目录
引言
一、排序算法概述
排序算法简介
排序算法的分类
性能指标
二、十大排序算法详解
🍃冒泡排序
基本思想:
算法过程:
- 比较相邻元素:重复地走访需要排序的元素列表,依次比较两个相邻的元素。
- 交换元素:如果顺序(如从大到小或从小到大)错误,就交换这两个元素的位置。
- 重复进行:重复以上步骤,直到没有相邻的元素需要交换,则元素列表排序完成。
C语言实现代码:
//冒泡排序
void BubbleSort1(DataType* a, int size)//升序排序
{
for (int i = 0; i < size - 1; i++)//控制排序趟数
{
for (int j = 0; j < size - 1 - i; j++)//控制每次比较次数
{
if (a[j] > a[j + 1])//不满足升序就交换位置
{
DataType tmp = a[j];
a[j] = a[j + 1];
a[j + 1] = tmp;
}
}
}
}
void BubbleSort2(DataType* a, int size)//降序排序
{
for (int i = 0; i < size - 1; i++)//控制排序趟数
{
for (int j = 0; j < size - 1 - i; j++)//控制每次比较次数
{
if (a[j] < a[j + 1])//不满足降序就交换位置
{
DataType tmp = a[j];
a[j] = a[j + 1];
a[j + 1] = tmp;
}
}
}
}
🍃直接选择排序
基本思想:
算法过程:
- 初始化:未排序区间为数组的全部元素,即整个数组;已排序区间为空。
- 遍历:从未排序区间中遍历找到最小(或最大)的元素。
- 交换:将找到的最小(或最大)的元素与未排序区间的第一个元素进行交换,该元素即为未排序区间的最小值(或最大值),因此交换后,它就到了已排序区间的末尾。
- 缩小未排序区间:将未排序区间的第一个元素排除(因为它已经是排序好的了),继续在剩余的未排序区间中重复上述步骤,直到未排序区间为空。
下图以升序排序为例进行演示
C语言实现代码:
void swap(int* a, int* b)//交换函数
{
int tmp = *a;
*a = *b;
*b = tmp;
}
void SelectSort1(int* a, int n)//选择排序降序
{
for (int i = 0; i < n - 1; i++)//控制选择次数
{
int mini = i;
for (int j = i + 1; j < n; j++)//控制查找范围
{
if (a[j] < a[mini])
mini = j;
}
swap(&a[i], &a[mini]);
}
}
void SelectSort2(int* a, int n)//选择排序降序
{
for (int i = 0; i < n - 1; i++)//控制选择次数
{
int maxi = i;
for (int j = i + 1; j < n; j++)//控制查找范围
{
if (a[j] > a[maxi])
maxi = j;
}
swap(&a[i], &a[maxi]);
}
}
🍃直接插入排序
基本思想
算法过程
C语言实现代码
void InsertSort1(int* a, int n)//升序
{
for (int i = 1; i <= n-1 ; i++)//控制要插入的元素个数
{
int key = a[i];
int end = i - 1;
while (end >= 0 && a[end] > key)//移动元素
{
a[end + 1] = a[end];
end--;
}
a[end+1] = key;//满足大小关系后插入到指定位置
}
}
void InsertSort2(int* a, int n)//降序
{
for (int i = 1; i <= n - 1; i++)//控制要插入的元素个数
{
int key = a[i];
int end = i - 1;
while (end >= 0 && a[end] < key)//移动元素
{
a[end + 1] = a[end];
end--;
}
a[end + 1] = key;//满足大小关系后插入到指定位置
}
}
🍃希尔排序
基本思想:
实现步骤:
1.外循环进行多轮预排序
选择一个变量序列:
这个序列是逐渐减小的,gap的值较大时,数据可以更快的前后变动,但不容易"基本有序";gap较小时数据前后变动较慢,但更接近"基本有序"。 通常可以选取gap = n/3, gap = gap/3, ...,直到gap= 1。
但是要注意,如果直接每次都/3,可能面临的情况就是最后一组gap的值跳过了1,比如n=8时,gap第一次等于2,第二次等于0,解决方法也很简单,gap每次不是/3,而是gap=gap/3+1,就可以让gap最后一次一定会减小到1
2.第二层循环,每一轮预排序中进行分组
按gap进行分组:根据当前的变量gap,将待排序的数组元素下标按gap分组,总共可以分成gap组。比如gap为3时,每一组元素的首元素分别是0,1,2
3.第三层循环,分组之后,控制组里数据执行插入排序
每一组的数据有n/gap个,下标为0,gap, 2gap, 3gap,...的元素分为一组;下标为1,gap+1,2gap+1,3gap+1……的元素分为一组……
这一层循环一个需要注意的细节就是预防数组的越界:每一组分组数据的最后一个数据一般不会是数组的最后一个数据。每次选取的要插入的数据下标是end+gap,那么这个下标不能超过n-gap。比如数组有10个元素,gap为3,第一组数据最后一个数据的下标是9,要保证这一组数据访问到下标9之后,不再向后访问,因为下一次访问end为9,要插入的数据,9+gap的位置已经没有数据了。
4.第四层循环,实现插入排序的过程
每个数据向前扫描和移动,找到合适的位置后插入,直接在插入排序代码的基础上稍加修改即可
5.递减变量gap并重复上述分组排序过程:
每完成一轮按变量gap的分组排序后,将变量gap减小,然后重复分组排序过程,直到变量gap为1,此时整个数组恰好被分成一组,进行最后一次直接插入排序。
C语言实现代码
void ShellSort1(int* a, int n)//希尔排序升序
{
int gap = n;
while (gap > 1)//多组预排序,最后一组gap==1为直接插入排序
{
gap = gap / 3 + 1;
for (int i = 0; i <gap; i++)//控制分组的组数:gap组
{
for (int j = i; j < n - gap; j += gap)//控制每组的插入元素个数:n/gap个
{
int key = a[j+gap];
int end = j;
while (end >= 0 && a[end] > key)//比较和移动元素
{
a[end + gap] = a[end];
end -= gap;
}
a[end + gap] = key;//满足大小关系后插入到指定位置
}
}
}
}
void ShellSort2(int* a, int n)//希尔排序降序
{
int gap = n;
while (gap > 1)//多组预排序,最后一组gap==1为直接插入排序
{
gap = gap / 3 + 1;
for (int i = 0; i < gap; i++)//控制分组的组数:gap组
{
for (int j = i; j < n - gap; j += gap)//控制每组的插入元素个数:n/gap个
{
int key = a[j + gap];
int end = j;
while (end >= 0 && a[end] < key)//比较和移动元素
{
a[end + gap] = a[end];
end -= gap;
}
a[end + gap] = key;//满足大小关系后插入到指定位置
}
}
}
}
🍃快速排序
🍃堆排序
🍃归并排序
基本思想
算法过程:
子函数的任务(归并的核心):
🍃1. 分解
- 将当前区间一分为二,即找到中间位置mid = (left + right) / 2
- 递归地对左半部分left...mid进行归并排序。
- 递归地对右半部分mid+1...right进行归并排序。
🍃2. 解决
- 在归并排序中,“解决”步骤实际上是在递归调用中隐式完成的,即通过递归调用自身来实现对左右子数组的排序。
- 当递归调用达到基本情况(即子数组只有一个元素或为空时),由于一个元素的数组自然是有序的,因此不需要进行任何操作,递归开始返回。
🍃3. 合并(以升序为例):核心代码
- 使用两个变量i和j,分别指向左半部分和右半部分的起始位置。
- 比较左半部分和右半部分当前指针所指的元素,将较小的元素先存入temp数组,并移动对应的变量。
- 重复上述步骤,直到左半部分或右半部分的所有元素都被复制到temp数组中。
- 将左半部分或右半部分中剩余的元素(一定是某部分的元素先复制完成)直接复制到temp数组的末尾。
- 将temp数组中的元素复制回原数组,以完成合并过程。
C语言实现代码
// 归并排序递归实现升序
void Merge1(int left,int right,int* a,int* tmp)//归并排序子函数
{
if (left >= right)//区间只有一个元素或不存在时不需要归并
return;
int mid = (left + right) / 2;//取得下标中间值
Merge1(left, mid, a, tmp);//递归左区间
Merge1(mid + 1, right, a, tmp);//递归右区间
int begin1 = left,end1 = mid;//左区间范围
int begin2 = mid + 1,end2 = right;//右区间范围
int begin = left;
while (begin1 <= end1 && begin2 <= end2)//二路归并
{
if (a[begin1] < a[begin2])
tmp[begin] = a[begin1++];
else
tmp[begin] = a[begin2++];
begin++;
}
//判断哪个区间元素没有遍历完,直接搬下来
while (begin1 <= end1)
tmp[begin++] = a[begin1++];
while (begin2 <= end2)
tmp[begin++] = a[begin2++];
memcpy(a + left, tmp + left, sizeof(int) * (right - left + 1));
//将归并和排序之后的数据拷贝至原数组
}
void MergeSort1(int* a, int n)//归并排序主调函数
{
int* tmp = (int*)malloc(sizeof(int) * n);//申请临时数组
if (tmp == NULL)
{
perror("Merge malloc\n");
return;
}
Merge1(0, n - 1, a, tmp);//调用归并函数
free(tmp);//排序完成释放空间
tmp = NULL;
}
🍃计数排序
基本原理
请看下图动图演示
实现步骤
1. 确定数据范围
首先,代码通过遍历待排序数组 a
,找出其中的最大值 max
和最小值 min
。这两个值用于确定计数数组 count
的大小,因为计数数组需要覆盖待排序数组中所有可能出现的值(在最小值和最大值之间)。
int min = INT_MAX;
int max = INT_MIN;
for (int i = 0; i < n; i++)//求得最大值和最小值
{
if (a[i] > max)
max = a[i];
if (a[i] < min)
min = a[i];
}
2. 初始化计数数组
根据最大值和最小值计算出的范围(max - min + 1
),代码使用 calloc
分配了一个足够大的整数数组 count
,并将所有元素初始化为 0。这个数组用于统计待排序数组中每个值出现的次数。使用 calloc
而不是 malloc
加初始化是为了确保所有元素都初始化为 0,因为计数排序需要这些初始值来正确统计。
int size = max - min + 1;
int* count = (int*)calloc(size, sizeof(int));//根据数据范围申请空间
if (count == NULL)
{
perror("calloc fail\n");
return;
}
3. 统计元素频率
接下来,代码再次遍历待排序数组 a
,这次是为了统计每个元素出现的次数。对于数组 a
中的每个元素 a[i]
,代码通过 count[a[i] - min]++
将 a[i]
的出现次数记录在 count
数组的相应位置上。这里减去 min
是为了将 a
中的值映射到 count
数组的有效索引范围内。
for (int i = 0; i < n; i++)//遍历原数组,将数据出现的次数写入count数组对应的位置
{
count[a[i] - min]++;//核心代码1
}
4. 排序
代码遍历 count
数组,并根据每个值出现的次数,将对应的值依次放回原数组 a
中。这里使用了一个双层循环,外层循环遍历 count
数组的每个索引(即待排序数组中的每个可能值),内层循环(通过 while
循环实现)则根据 count[j]
的值(即该值出现的次数)将 j + min
(即原始值)放回原数组 a
中。每次放回一个值后,count[j]
递减,直到该值的所有出现都被放回原数组。
int i = 0;
for (int j = 0; j < size; j++)//遍历count数组,根据数据出现次数,将数据写入原数组对应的位置
{
while (count[j]--)//每写入一次,次数--
{
a[i++] = j + min;//核心代码2
}
}
5. 清理资源
最后,代码释放了 count
数组占用的内存,并将其指针设置为 NULL
,以避免野指针问题。
free(count);
count = NULL;
C语言实现代码
void CountSort1(int* a, int n)//计数排序升序
{
int min = INT_MAX;
int max = INT_MIN;
for (int i = 0; i < n; i++)//求得最大值和最小值
{
if (a[i] > max)
max = a[i];
if (a[i] < min)
min = a[i];
}
int size = max - min + 1;
int* count = (int*)calloc(size, sizeof(int));//根据数据范围申请空间
if (count == NULL)
{
perror("calloc fail\n");
return;
}
for (int i = 0; i < n; i++)//遍历原数组,将数据出现的次数写入count数组对应的位置
{
count[a[i] - min]++;//核心代码1
}
int i = 0;
for (int j = 0; j < size; j++)//遍历count数组,根据数据出现次数,将数据写入原数组对应的位置
{
while (count[j]--)//每写入一次,次数--
{
a[i++] = j + min;//核心代码2
}
}
free(count);
count = NULL;
}
🍃桶排序
基本思想:
算法过程
- 确定桶的数量:根据待排序数据的范围来确定桶的数量。
- 分配元素到各个桶:遍历待排序数组,将每个元素分配到对应的桶中。
- 对每个桶进行排序:可以使用不同的排序算法对每个桶中的元素进行排序,也可以使用递归的桶排序。
- 合并桶中的数据:将各个桶中的数据有序地合并成一个有序数组。
C语言实现代码
#define MAX_NUM 100 // 假设数据最大为100
#define BUCKET_SIZE (MAX_NUM / 5) // 假设桶的数量是数据范围的1/5
void insertionSort(int arr[], int n) {
int i, key, j;
for (i = 1; i < n; i++) {
key = arr[i];
j = i - 1;
/* 将arr[i]插入到已排序序列arr[0..i-1]的适当位置 */
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j = j - 1;
}
arr[j + 1] = key;
}
}
void bucketSort(int arr[], int n) {
int i, j;
int bucket[BUCKET_SIZE][n]; // 假设最坏情况下每个桶都有n个元素
int count[BUCKET_SIZE] = {0}; // 每个桶中的元素数量
// 1. 分配元素到各个桶
for (i = 0; i < n; i++) {
int bi = arr[i] / (MAX_NUM / BUCKET_SIZE); // 桶的索引
bucket[bi][count[bi]++] = arr[i];
}
// 2. 对每个桶进行排序
for (i = 0; i < BUCKET_SIZE; i++) {
insertionSort(bucket[i], count[i]);
}
// 3. 合并桶中的数据
int index = 0;
for (i = 0; i < BUCKET_SIZE; i++) {
for (j = 0; j < count[i]; j++) {
arr[index++] = bucket[i][j];
}
}
}
🍃基数排序
基本思想:
算法过程
- 找出待排序数组中的最大数,以确定最大位数。
- 从最低位开始,依次进行一次排序。
- 分配:根据当前位数,将元素分配到不同的桶中。
- 收集:将桶中的元素按顺序收集起来,形成新的数组。
- 重复上述过程,直到最高位。
C语言实现代码
以下是一个基于LSD的基数排序的C语言实现,假设我们排序的是非负整数,并且每个数字的最大位数是已知的(或者可以动态计算)。为了简化,这里假设所有数字都是单个字符(即0-9的数字),并存储在字符数组中。
#define MAXD 10 // 假设最大位数是10(实际情况可能需要根据数据范围确定)
#define RADIX 10 // 基数为10,因为处理的是十进制数
// 用于基数排序的桶
int count[RADIX];
char output[MAXD][1000]; // 假设最多有1000个数字
// 获取字符串num的最低位(这里简化为取最后一个字符代表的数字)
int getDigit(char num[], int d) {
return num[strlen(num) - 1 - d] - '0';
}
// 基数排序函数
void radixSort(char arr[][10], int n) {
// 找到最大数的位数
int m = 0;
for (int i = 0; i < n; i++)
if (strlen(arr[i]) > m)
m = strlen(arr[i]);
// 从最低位到最高位进行排序
for (int d = 0; d < m; d++) {
// 初始化桶
memset(count, 0, sizeof(count));
// 将元素分配到桶中
for (int i = 0; i < n; i++)
count[getDigit(arr[i], d)]++;
// 更改count[i],使其包含实际位置信息
for (int r = 1; r < RADIX; r++)
count[r] += count[r - 1];
// 将元素收集到输出数组中
for (int i = n - 1; i >= 0; i--) {
output[count[getDigit(arr[i], d)] - 1] = arr[i];
count[getDigit(arr[i], d)]--;
}
// 将输出数组的内容复制回原数组
for (int i = 0; i < n; i++)
strcpy(arr[i], output[i]);
}
}
三、排序算法的性能比较与适用场景分析
性能比较
排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 最好时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n^2) | O(n^2) | O(n) | O(1) | 稳定 |
选择排序 | O(n^2) | O(n^2) | O(n^2) | O(1) | 不稳定 |
插入排序 | O(n^2) | O(n^2) | O(n) | O(1) | 稳定 |
希尔排序 | O(nlogn) | O(n^s) (s为常数) | O(n) | O(1) | 不稳定 |
快速排序 | O(nlogn) | O(n^2) | O(nlogn) | O(logn) | 不稳定 |
归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 |
堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不稳定 |
计数排序 | O(n+k) | O(n+k) | O(n+k) | O(k) | 稳定 |
桶排序 | O(n+k) | O(n^2) | O(n) | O(n+k) | 稳定(平均)/不稳定(最坏) |
基数排序 | O(d*(n+k)) | O(d*(n+k)) | O(d*(n+k)) | O(n+k) | 稳定 |
适用场景分析
- 冒泡排序:
- 适用场景:适用于数据量较小的情况,如简单的数据排序练习或小规模数据的排序。
- 缺点:在数据量较大时效率极低。
- 选择排序:
- 适用场景:与冒泡排序类似,适用于数据量较小的情况。
- 缺点:由于需要多次遍历未排序部分以找到最小(或最大)元素,因此效率较低。
- 插入排序:
- 适用场景:适用于部分有序或数据量较小的数据排序。对于几乎已经排序的数据,插入排序的效率很高。
- 优点:在数据规模较小时,效率较高;对于部分有序的数据集,效率也较高。
- 希尔排序:
- 适用场景:适用于中等规模的数据排序,特别是在数据基本有序时,效率较高。
- 优点:通过引入增量序列,减少了数据比较和移动的次数,提高了排序效率。
- 快速排序:
- 适用场景:适用于大规模数据的排序,是实际应用中最常用的排序算法之一。
- 优点:平均情况下效率高,且可以通过随机选择基准元素来避免最坏情况的发生。
- 归并排序:
- 适用场景:适用于大规模数据的排序,特别是当需要稳定排序时。
- 优点:稳定排序,且时间复杂度为O(nlogn),效率较高。但空间复杂度较高,不适合内存受限的环境。
- 堆排序:
- 适用场景:适用于需要快速选择最大(或最小)元素的场景,如堆数据结构的应用。
- 优点:时间复杂度为O(nlogn),效率较高;且不需要额外的存储空间(除了递归所需的栈空间)。
- 计数排序:
- 适用场景:适用于整数数据范围较小的情况,如考试成绩、年龄等数据的排序。
- 优点:在数据范围较小时,效率极高;且稳定排序。
- 桶排序:
- 适用场景:适用于数据分布均匀的情况,如正态分布的数据。
- 优点:在数据分布均匀时,效率较高;且稳定排序(平均情况下)。
- 基数排序
- 适用场景:数据范围大但位数较少,关键字可以分割成独立的数字,数据分布较均匀
- 优点:在数据分布均匀时,效率较高;且稳定排序(平均情况下)。
四、总结
在探讨完十大经典排序算法后,我们可以从多个维度对这些算法进行总结,以便更好地理解它们的特点、适用场景以及性能差异。
性能总结
- 时间复杂度:
- 最优情况:冒泡排序、插入排序、选择排序在最优情况下时间复杂度为O(n),但这通常发生在数据已经接近有序的情况下。快速排序、归并排序、堆排序在最优情况下可以达到O(n log n)。桶排序和计数排序在特定条件下(如数据分布均匀)可以达到O(n)。
- 平均情况:大多数比较排序算法(如冒泡排序、插入排序、选择排序、快速排序、归并排序、堆排序)的平均时间复杂度为O(n^2)或O(n log n)。非比较排序(如桶排序、计数排序、基数排序)在平均情况下也能达到O(n)。
- 最差情况:冒泡排序、插入排序、选择排序的最差时间复杂度为O(n2)。快速排序在最差情况下(如每次分区都选择到最大或最小元素)也会退化到O(n2),但通过随机化选择基准元素可以显著降低这种情况的发生概率。归并排序和堆排序的最差时间复杂度始终为O(n log n)。
- 空间复杂度:
- 冒泡排序、插入排序、选择排序等原地排序算法的空间复杂度为O(1),即它们只使用常量额外空间。
- 归并排序、快速排序等在某些实现中可能需要额外的空间来存储递归调用栈或临时数组,其空间复杂度为O(log n)或O(n)。
- 桶排序、计数排序、基数排序等非比较排序算法通常需要额外的空间来存储桶、计数数组或基数队列,其空间复杂度取决于数据的分布和范围。
稳定性与适用性
- 稳定性:排序算法的稳定性指的是在排序过程中,如果两个元素相等,它们的相对位置在排序前后保持不变。归并排序、插入排序、冒泡排序是稳定的排序算法,而快速排序、选择排序、堆排序等则是不稳定的。
- 适用性:
- 对于小数据集或几乎有序的数据集,插入排序和冒泡排序通常表现良好。
- 当数据量较大时,快速排序、归并排序和堆排序等更高效的算法更为适用。
- 如果数据分布具有特定模式(如大量重复元素或有限取值范围),则可以考虑使用计数排序、桶排序或基数排序等非比较排序算法。
本文完