2. 常见排序算法的实现
2.0 十大排序算法
2.1 插入排序
2.1.1 基本思想
直接插入排序是一种简单的插入排序法:
基本思想
- 把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中。
- 直到所有的记录插入完为止,得到一个新的有序序列 。
比 + 挪 (+ 插)
实际中我们玩扑克牌时,就用了插入排序的思想。
插入排序的思想:已经有一个有序序列,再新加入一个数据,将这个新加入的数据插入到合适的位置,保持整个序列依旧保持有序。
2.1.2 直接插入排序
基本逻辑
当插入第i(i>=1)个元素时,前面的array[0],array[1],…,array[i-1]已经排好序,此时用array[i]的排序码与array[i-1],array[i-2],…的排序码顺序进行比较(依次进行比较),找到插入位置即将array[i]插入,原来位置上的元素顺序后移。
动图演示
算法步骤
1. 初始化:
- 将数组的第一个元素(arr[0])视为已排序部分。
- 其余部分(arr[1]到arr[n-1])视为未排序部分。
2. 遍历未排序部分:
- 从i=1到n-1(n为数组长度),依次取出arr[i]作为待插入元素 key(key = a[i])。
3. key从右向左比较,key较小则依次向后挪动路径上的已排好序的数据
- 初始化 j = i-1(即已排序部分的最后一个元素)。
- 如果 key < arr[j],则arr[j]向后移动一位(arr[j+1] = arr[j]),并继续向左比较(j--)
- 直到找到arr[j] ≤ key或j<0(即已比较完所有已排序元素)。
4. 将key插入到正确位置:
- 将key 插入到arr[j+1]的位置。(arr[j+1] = key)
5. 重复上述过程,直到所有未排序元素都被处理。
优化思路:
- 二分查找优化:在已排序部分使用二分查找快速找到插入位置,减少比较次数
(但仍需相等的挪动次数,优化程度有限,代码变复杂,不建议)
终止条件 :
- 外层循环终止:当所有未排序元素(i 从 1 到 n-1)都被处理完毕时,排序结束。
- 内层循环终止:
- 当 j < 0(即已比较完所有已排序元素)。
- 或 arr[j] ≤ key(找到正确插入位置)。
代码实现
建议排序算法部分的代码,先实现单趟,再实现整体——更好控制。
一上来就直接进行排序整体控制——不好控制。
先考虑单趟——把一个数插入一个有序数组 [0, end] end+1 。
end的起始位置在0,然后依次把后面一个元素当作end+1插入前面的有序数组
1. 先考虑单趟
- 先保存待插入数据,再把前面的数据后挪(否则数据被覆盖)。
- 注意比较逻辑,将算法控制为一个稳定算法。
- 两个循环结束条件,循环结束后插入tmp。
2.再考虑整体
- 每趟外循环给end、tmp赋值。
- end的结束位置在n-2,外循环的结束条件是i < n-1(若 i < n 会越界访问)。
//(直接)插入排序
void InsertSort(int* a, int n)
{
//2.再考虑循环
for (int i = 0; i < n - 1; i++) //若i<n则会导致插入一个随机数-858993460作首元素
{
//1.先考虑单趟——把一个数插入一个有序数组
//往[0, end]的有序数组中插入end+1
//每趟外循环给end赋值——end的起始位置在0
int end = i;
//然后依次把后面一个元素当作end+1插入前面的有序数组——end的结束位置在n-2
int tmp = a[end + 1]; //先保存待插入数据,再把前面的数据后挪(否则数据被覆盖)
while (end >= 0) //end小于0,循环比较截止,tmp插入到end后面
{
//如果tmp较小
if (tmp < a[end])
{
//排好的数据就往后挪,给tmp腾位置
a[end + 1] = a[end];
//迭代
--end;
}
//如果tmp较大或相等,前排数据就不后挪——>就可以做到稳定
else
{
break; //tmp较大或相等,循环比较截止,tmp插入到end后面
}
}
//tmp比某值大(相等) / 比全部小(end = -1),都tmp插入到end后面
a[end + 1] = tmp;
}
}
代码测试。
也可以在监视中观察每一趟排序的过程。
性能分析
时间复杂度——简单粗暴,两层循环就是 O(N^2)——错(不能只看循环次数)
最坏情况:逆序——1,2,3,......,n-1——>合计O( N^2 )
最好情况是多少:顺序有序或者接近有序 O(N)
有序为什么不是O(1)——没有排序能做到O(1),最好就是O(N)——极限情况下。
有序也得O(N)比完才知道有序。
正常最快就是O(N*logN)。
时间复杂度:
情况 时间复杂度 说明 最优情况(已排序数组) O(N) 每趟只需比较一次,无需移动元素 最坏情况(完全逆序) O(N^2) 每次插入需比较和移动所有已排序元素 平均情况(随机数组) O(N^2) 平均需要 (N^2) / 4次比较和移动 空间复杂度:
- 空间复杂度:需常数额外空间O(1)。
稳定性:
- 稳定
特性总结
直接插入排序的特性总结:
- 元素集合越接近有序,直接插入排序算法的时间效率越高。
- 时间复杂度:O(N^2)。
- 空间复杂度:O(1),它是一种稳定的排序算法。
- 稳定性:稳定。
2.1.3 希尔排序
基本逻辑(算法步骤)
希尔排序是以人名命名的。希尔发现插入排序虽然在O(N^2)这个量级,但是插入排序显著地快,因为插入排序具有很强的适应性,几乎每次都不是最坏,甚至都还不错。
而且要是有序或者接近有序,插入排序能接近线性时间复杂度——O(N)。
希尔排序就是让数据接近有序的插入排序。
故而希尔排序把排序分为两步:
1. 预排序:目标就是让数据接近有序——gap > 1
- 分组预排插入——目标:让在前面的大的数据更快换到后面的位置,在后面的小的数据更快地换到前面的位置。
2. 全排序:目标就是让数据全部有序——gap == 1
希尔排序法又称缩小增量排序法(递减增减排序)。
希尔排序是插入排序的一种更高效的改进版本。但希尔排序是非稳定排序算法。
希尔排序是基于插入排序的以下两点性质而提出改进方法的:
- 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率;
- 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位;
希尔排序法的基本思想是:
基本逻辑
- 先选定一个整数gap,把待排序文件中所有记录分成个组。
- 所有距离为gap的记录分在同一组内。(gap也相当于组数)
- 然后对每一组内的记录进行插入排序。(组内插入排序)
- 然后取gap = gap / 2,重复上述分组和排序的工作。
- 当到达gap = 1 时,所有记录在同一组内,进行最后一次插入排序。
误区
- gap=1并不是说1个元素一组,而是说相隔为1的所有元素组成一组,即整个数组
- 可以认为gap代表组数:
- gap越小,组数越少
- gap越大,分组越多
希尔排序的优势
第一趟,gap == 5,这样同组内的数据,挪动一次,就能相当于外面的5步。
第二趟结合第一趟,在最开头的数据9,挪动3次就来到最后了。
动图演示
代码实现
排序算法先完成单趟,再实现整体控制。
通过控制起始位置,完成不同颜色组别的插入排序的控制。
3层循环,很多人很怀疑这里的执行效率。
实际上考虑时间复杂度不能只看循环层数,主要看的还是算法思想。
每趟:3层循环→优化成2层循环。(效率没有差别)
1. 先考虑单趟
- 某一个gap值
- 中层for循环,遍历end = [0,n-gap),end是已排好序的最后一个元素
- 两次循环遍历的end = i,是两个不同的组。
- 组内执行a[end+gap]这个数的直接插入排序,过程中end每次往前迭代gap个元素。
2. 再考虑整体
- 每趟给不同的gap值。
- 最后一趟gap==1,全排序。
//希尔排序
void ShellSort(int* a, int n)
{
//给gap赋值数组长度——1个数据一组
int gap = n;
//3.多次逐次增加精度的预排序——>每一次都更接近有序
while (gap > 1)
{
//迭代——log2(N)次、log3(N)次——最外层while循环时间复杂度
//gap /= 2;
//选择除2:但是这样会导致预排序次数过多,就选择除3
//选择除3:但不能保证最后为1,就手动+1
gap = gap / 3 + 1;
//2.一组的直接插入排序 和 全数组预分组排序
for (int i = 0; i < n - gap; i++) //留出一个n-gap的空间
{
//1.一个数tmp的直接插入排序
//控制end 元素的位置 —— 一开始在0
int end = i;
//控制插入元素的位置 —— end+gap
//保存在临时变量中,避免挪动覆盖
int tmp = a[end + gap];
//直接插入排序
while (end >= 0)
{
//比较
if (tmp < a[end])
{
//1.挪动
a[end + gap] = a[end];
//2.end往前跳
end -= gap;
}
else
{
break;
}
}
//赋值
a[end + gap] = tmp;
}
//PrintArray(a, n); 每个gap,预排序后都打印观察一下效果——>越来越有序
}
}
代码测试。
这样一共3层循环(优化后),效率受到一些质疑。
性能分析
时间复杂度:
本实现的 gap / 3 + 1 序列平均性能优于传统的 n / 2 序列
最坏情况仍为O(n²),但实际运行中很少出现
当数据部分有序时,性能接近O(n log n)
空间复杂度:
- 仅使用常数个临时变量 O(1)
稳定性:
- 不稳定
简单分析时间复杂度:
外层while循环的时间复杂度——gap迭代次数。
log2(N)次、log3(N)次。
中层for循环的时间复杂度——第一个gap值下,基本上是n次,最后一个gap值下,基本上是n次,中间是一个变化的过程。
N——>a*N((a>1) ——>N。
总的时间复杂度
log3(N) * N——>log3(N) * a*N (a>1) ——>log3(N) * N。
总结:比log3(N) * N大,大约是 N ^ 1.3 = N^(0.3) * N。
对数增长 VS 指数增长。
所以严格来说,希尔排序时间复杂度比堆排、快排慢一个档。
但是只是在10万,100万慢,1000万个数据希尔比堆排快。
因为时间复杂度只反映一个最坏的情况,时间复杂度看的是最坏,如果它有多个项,它看的是影响最大的那个项,看的是它的量级。
但是很多具体细节会影响具体时耗——具体细节:
① 具体的算法思想:会不会每次都最坏(例子:插入 VS 冒泡)。
② 算法的其他消耗:比如数据量足够大时,建堆的时耗O(N)也不小。
③ 不同的数据样本:插入排序在接近有序时,效率高于堆排序。
(处理随机数据当然堆排序好用)
性能测试
void TestOP()
{
srand(time(0));//要产生随机需要一个种子,否则随机是写死的伪随机
const int N = 100000;
int* a1 = (int*)malloc(sizeof(int) * N); //创建7个无序数组,每个数组10万个元素
int* a2 = (int*)malloc(sizeof(int) * N);
int* a3 = (int*)malloc(sizeof(int) * N);
int* a4 = (int*)malloc(sizeof(int) * N);
int* a5 = (int*)malloc(sizeof(int) * N);
int* a6 = (int*)malloc(sizeof(int) * N);
int* a7 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a1[i] = rand(); //随机生成10万个元素,给7个数组
a2[i] = a1[i];
a3[i] = a1[i];
a4[i] = a1[i];
a5[i] = a1[i];
a6[i] = a1[i];
a7[i] = a1[i];
}
int begin1 = clock(); //系统启动到执行到此的毫秒数
InsertSort(a1, N);
int end1 = clock(); //系统启动到执行到此的毫秒数
int begin7 = clock();
//BubbleSort(a7, N);
int end7 = clock();
int begin3 = clock();
//SelectSort(a3, N);
int end3 = clock();
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
int begin4 = clock();
HeapSort(a4, N);
int end4 = clock();
//int begin5 = clock();
//QuickSortNonR(a5, 0, N - 1);
//int end5 = clock();
//int begin6 = clock();
//MergeSortNonR(a6, N);
//int end6 = clock();
printf("InsertSort:%d\n", end1 - begin1);
//printf("BubbleSort:%d\n", end7 - begin7);
printf("ShellSort:%d\n", end2 - begin2);
//printf("SelectSort:%d\n", end3 - begin3);
printf("HeapSort:%d\n", end4 - begin4);
//printf("QuickSort:%d\n", end5 - begin5);
//printf("MergeSort:%d\n", end6 - begin6);
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
free(a7);
}
int main()
{
//TestInsertSort();
//TestBubbleSort();
//TestShellSort();
//TestSelectSort();
//TestQuickSort();
//TestMergeSort();
//TestCountSort();
TestOP();
//MergeSortFile("sort.txt");
return 0;
}
测试结果——10万个数据下:
(gap /= 2 效率大概在12ms左右,比gap = gap/3+1稍微慢一些)
希尔排序的效率接近与堆排序O(N*logN)。
希尔排序远优于直接选择排序。
1000万个数据,希尔排序的效率就已经要优于堆排序了。
(时间复杂度只代表一个大概的量级,具体快慢还取决于一些细节因素)
n→∞,希尔排序的时间复杂度→O(N * (logN)^2),相较与堆排序的O(N^log^N)。
大概而言,希尔排序的时间复杂度在O(N^1.3)。
特性总结
希尔排序的特性总结:
1. 希尔排序是对直接插入排序的优化。
2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果(性能测试的对比)。
3. 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些树中给出的希尔排序的时间复杂度都不固定:
《数据结构(C语言版)》--- 严蔚敏
《数据结构-用面相对象方法与C++描述》--- 殷人昆
4. 稳定性:不稳定。