文章目录
一、排序的概念和运用
1. 排序的概念
什么是排序:所谓排序,就是使一串记录按照其中的某个或某些关键字的大小,按递增或递减方式排列起来的操作。
排序的稳定性:假定在待排序的记录序列中存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i] = r[j],且 r[i] 在 r[j] 之前,而在排序后的序列中,r[i] 仍在 r[j] 之前,则称这种排序算法是稳定的;否则称该排序算法是不稳定的。
为什么要关注排序的稳定性:
这里以高考成绩排名的例子来阐述,我们知道,由于高考人数众多,出现几个人或者几十个人成绩相同的情况是完全有可能的,那么对成绩相同的同学我们如何进行排名呢?答案是按单科目成绩进行排名。
假设我们规定,当高考成绩相同时,语文成绩高的排前面;那么我们在对高考成绩进行排名时,就可以先按所有考生的语文成绩进行一次排序,将语文成绩高的排在前面,然后再按总成绩进行一次排序,得出最终排名,如果我们使用的排序算法有稳定性,那么相同成绩的人一定是语文成绩高的排在前面,如果我们使用的排序算法不稳定,那么总成绩相同的两个人的排名就可能出现错误。
所以,排序算法的稳定性在特殊场景下,对于结构体的排序是有价值的,对于整数排序没有任何实际意义。
内部排序:数据元素全部存放在内存中的排序;
外部排序:由于待排序的记录太多,不能同时存放在内存中,而是需要将待排序的记录存储在外存中,待排序时再把数据一部分一部分地调入内存进行排序,在排序过程中需要多次进行内存和外存之间的交换;这种排序方法就称为外部排序;归并排序既可以用于内部排序,也可以用于外部排序。
比较排序:通过比较两个元素的大小来确定元素在内存中的次序,选择排序、插入排序、交换排序与归并排序都属于比较排序。
非比较排序:与传统的比较排序不同,它并不依赖于比较两个元素的大小来进行排序,而是利用其他技巧和特性来确定元素之间的相对顺序。非比较排序算法通常比比较排序算法更有效率,因为它不涉及元素之间的比较,但是并不适用于所有类型的数据,比较排序算法则更加通用。常见的非比较排序有基数排序、计数排序与桶排序。
2. 排序的运用
排序在我们日常生活中是非常常见的,比如我们在京东淘宝上购物时,可以选择按销量排序、按价格排序、按好评排序,又比如世界500强企业,中国排名前50的高校等等,这些地方都需要用到排序。
二、常见排序算法的实现
以下是几种常见的排序算法:
注意:以下所有排序都以升序为例来实现
1. 直接插入排序
1.1 基本思路
直接插入排序是一种简单的插入排序法,其基本思想是:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。
在实际生活中,我们斗地主理牌时,就运用了插入排序的思想:
- 动画演示
如图:当我们插入第 i (i>=1)个元素时,前面的array[0],array[1],…,array[i-1]已经排好序,此时用array[i]的排序码按顺序与 array[i-1],array[i-2],…的排序码进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移。
1.2 代码实现
void InsertSort(int* nums, int numsSize)
{
for (int i = 1; i < numsSize; i++)
{
int temp = nums[i];//待排序的元素
int j = i - 1;
for (j = i - 1; j >= 0; j--) //已排序的元素
{
if (temp < nums[j])
{
nums[j + 1] = nums[j];
}
else
{
break;
}
}
nums[j + 1] = temp;
}
}
- 注意事项
当 tmp 中的元素小于数组中的所有元素时,为了避免数组越界,内层 for 循环的 j 应该大于等于 0,此情况下退出循环时 j = -1,tmp 被放入 nums[0],程序正常。
1.3 复杂度和稳定性分析
时间复杂度
最坏情况:当待排序的数组为逆序时,我们第一次插入需要挪动1个数据,第二次插入需要挪动2个数据,… … 第n次插入需要挪动n-1个数据,所以需要挪动的次数是一个等差数列,嵌套得到时间复杂度为 O(N^2)
;
最好情况:当待排序的数组为顺序时,每一次插入都不需要挪动数据,时间复杂度为 O(N)
;
所以直接插入排序的时间复杂度为:O(N^2)
。
空间复杂度
直接插入排序没有额外的空间消耗,空间复杂度为:O(1)
稳定性
很明显可以得到,直接插入排序每次只会挪动比待插入元素大的数据,遇到相同的数据直接放在后面,不会改变相对顺序,因此具有稳定性。
1. 4 特性总结
- 元素集合越接近有序,直接插入排序算法的时间效率越高;
- 时间复杂度:O(N^2);
- 空间复杂度:O(1);
- 稳定性:稳定
2. 希尔排序
2.1 基本思路
希尔排序是插入排序的一种,是对直接插入排序的优化。前面提到直接插入排序在元素集合接近有序的情况下效率很高,所以希尔排序就在直接插入排序前多加一步预排序,使得元素集合接近有序,再使用直接插入排序可以提高效率。希尔排序法的基本思路是:先选定一个整数,把待排序文件中所有记录分成 gap 个组,所有距离为 gap 的记录分在同一组内,并对每一组内的记录进行直接插入排序,然后重复上述分组和排序的工作,最后再整体进行一次直接插入排序,使得文件内所有记录达到有序。
2.2 代码实现
先进行一次gap = 3 的预排序,使得数组接近有序,再进行一次gap = 1 的直接插入排序
void ShellSort(int* nums, int numsSize)
{
//预排序
int gap = 3;
for(int k = 0; k < gap; k++) //k代表组号
{
for (int i = gap + k; i < numsSize; i += gap) //遍历每一组中的元素
{
//每一组内进行直接插入排序
int temp = nums[i];
int j = i - gap;
for (j = i - gap; j >= k; j -= gap)
{
if (temp < nums[j])
{
nums[j + gap] = nums[j];
}
else
{
break;
}
}
nums[j + gap] = temp;
}
}
//直接插入排序
gap = 1;
for (int k = 0; k < gap; k++)
{
for (int i = gap + k; i < numsSize; i += gap)
{
int temp = nums[i];
int j = i - gap;
for (j = i - gap; j >= k; j -= gap)
{
if (temp < nums[j])
{
nums[j + gap] = nums[j];
}
else
{
break;
}
}
nums[j + gap] = temp;
}
}
}
- 可以优化的地方
1、关于 gap 的取值:我们知道,gap 越大,大的数据就能越快的到后面去,小的数据能越快的到前面来,但是预排后数据越不接近有序;gap 越小,大的数据到后面的速度越慢,但是预排后数据越接近有序;所以综合二者考虑,怎样才能选出最优方案呢,Knuth提出了 gap 随数据个数和排序次数变化而变化的思路,即 gap 最开始是数据个数 n 的1 / 3,之后每预排一组数据,gap 就减小3倍,直到最后一次 gap = 1后结束;
2、对于上面的版本来说,我们每次只排序一组数据,当这一组排完之后再排序下一组数据,所以我们需要用两层 for 循环嵌套来保证每一组数据都被排序,我们完全不用这么做,我们可以每次让 i 加1,即让所有组数据同时进行排序 (第一组插入一个元素后让第二组插入一个元素,然后第三组,… …,当所有组都插入一个元素后再插入第一组的下一个元素,… …),所以只需要使用一个 for 循环。
3、当 gap 等于1时,相当于对整体进行直接插入排序 ;
4、为了保证无论数据个数 n 为奇数还是偶数,gap 经过不断缩小3倍后,最后一次 gap 一定等于1,需要让gap 每次除3后都加1。
- 完全体代码
void ShellSort(int* nums, int numsSize)
{
int gap = numsSize;
while (gap > 1)
{
gap = gap / 3 + 1;
for (int i = gap; i < numsSize; i++)
{
int temp = nums[i];
int j = i - gap;
for (j = i - gap; j >= 0; j -= gap)
{
if (temp < nums[j])
{
nums[j + gap] = nums[j];
}
else
{
break;
}
}
nums[j + gap] = temp;
}
}
}
2.3 复杂度和稳定性分析
时间复杂度
在希尔排序中,因为 gap 的取值方法不唯一,导致其时间复杂度很难去计算,因此在一些优秀的数据结构书籍中给出的希尔排序的时间复杂度也都不固定:
因为我们的 gap 是按照Knuth提出的方式取值的,而且Knuth进行了大量的实验统计,我们暂时就按照蓝色框的时间复杂度来算,可以大概记忆为:O(N1.3);
空间复杂度
希尔排序没有额外的内存消耗,空间复杂度为:O(1);
稳定性
和直接插入排序不同,希尔排序是不稳定的,因为在预排序过程中,数值相同的元素可能会被分配到不同组中,不同组进行插入排序之后,数值相同的元素的相对位置就可能会发生改变,所以希尔排序不具有稳定性。
2. 4 特性总结
- 希尔排序是对直接插入排序的优化;
- 当 gap > 1时都是预排序,目的是让数组更接近于有序;当gap == 1时,数组已经接近有序,这使得最后一次的直接插入排序效率很高,从而达到优化整体效率的效果;
- 时间复杂度:O(N1.3) (不准确);
- 空间复杂度:O(1);
- 稳定性:不稳定
3. 直接选择排序
3.1 基本思路
直接选择排序就是每一次从待排序的数据元素中选出最小(或最大)的元素,放在序列的起始位置,直到全部待排序数据元素排完;还可以对其做一些简单的优化:每次选出两个数,最小的放在前面,最大的放在后面。
- 动图演示 (未优化)
在元素集合array[i]–array[n-1]中选择关键码最小的数据元素,若它不是这组元素中第一个元素,则将它与这组元素中的第一个元素交换,然后在剩余的 array[ i + 1 ] – array [ n - 1 ] 集合中,重复上述步骤,直到集合剩余1个元素
3.2 代码实现
// 交换两个数据
void Swap(int* e1, int* e2)
{
assert(e1 && e2);
int tmp = *e1;
*e1 = *e2;
*e2 = tmp;
}
void SelectSort(int* nums, int numsSize)
{
int begin = 0;
int end = numsSize - 1;
int mini = 0;
int maxi = end;
while (begin < end)
{
for (int i = begin; i <= end; i++)
{
if (nums[i] < nums[mini])
{
mini = i;
}
if (nums[i] > nums[maxi])
{
maxi = i;
}
}
Swap(&nums[mini], &nums[begin]); //把最小的数据交换到最前面
if (maxi == begin) //如果改变了最大的数据的位置,需要修正
{
maxi = mini;
}
Swap(&nums[maxi], &nums[end]); //把最大的数据交换到最后面
mini = ++begin;
maxi = --end;
}
}
- 注意事项
优化后的直接选择排序存在一个隐藏的 bug:当最大的数位于数组最前面 (maxi == begin) 时,nums[begin] 和 nums [ mini ] 交换后会把最大数 nums[ begin ] 的下标变为 mini,此时我们需要修正 maxi,使得程序能够选出最大的数。
3.3 复杂度和稳定性分析
时间复杂度
不同于插入排序,数据有序或者无序并不会影响选择排序的效率,因为它始终是通过遍历比较数组元素来求得最大值或最小值,所以时间复杂度恒为:O(N2);
空间复杂度
直接选择排序没有额外的内存消耗,空间复杂度为:O(1);
稳定性
直接选择排序给我们的直观感受是稳定的,因为它每次选择最小元素时,会选到第一个最小的元素,并把它交换到左边,所以相等元素间的相对位置不会发生改变,但其实这里思考得不够全面,因为最小的元素交换到左边的同时会把左边的元素交换到右边,可能会破坏其他元素的相对顺序,比如 6 9 6 1 1 在一次选择排序后变成 1 9 6 1 6,两个 6 的相对顺序发生了变化,所以说直接选择排序其实是不稳定的。(注:这里为了方便理解,我们以未优化的直接选择排序为例)
3. 4 特性总结
- 直接选择排序的思想非常容易理解,但是效率不高,实际中很少使用;
- 时间复杂度:O(N2)
- 空间复杂度:O(1)
- 稳定性:不稳定
4. 堆排序
4.1 基本思路
堆排序是指利用堆这种数据结构所设计的一种排序算法,它是选择排序的一种;它通过堆来进行选择数据;需要注意的是排升序要建大堆,排降序建小堆。
4.2 代码实现
关于堆排序我前面在介绍堆的文章中已经实现过了,具体参照:【数据结构】堆
4.3 复杂度和稳定性分析
时间复杂度
堆排序建堆的时间复杂度为 O(N),选数的时间复杂度为 O(N*logN),所以堆排序的时间复杂度为:O(N*logN);
空间复杂度
堆排序直接在原数组上进行建堆和选数操作,没有额外的空间消耗,空间复杂度为:O(1);
稳定性
由于建堆过程中相同的数据做父节点还是孩子节点,都是随机的,哪个先被选择出来也是随机的,所以堆排序是不稳定的。
4. 4 特性总结
- 相比于直接选择排序,堆排序使用堆来选数,效率提高了很多;
- 时间复杂度:O(N * logN);
- 空间复杂度:O(1);
- 稳定性:不稳定;
5. 冒泡排序
5.1 基本思路
冒泡排序是一种交换排序,所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置。交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的首部移动。
冒泡排序是交换排序中最简单的一种排序方法,它的基本思想是对所有相邻记录的关键字值进行比较,如果nums [ i ] > nums [ i + 1 ],则将其交换,最终达到有序的目的。
由于冒泡排序本身并不知道待排序的数据是否是有序的,所以即便目标数据已经有序,它还是会继续进行比较,直到比较到最后两个数据;所以为了提高效率,我们可以对冒泡排序进行简单的优化,增加一个有序判断,使得当目标数据有序时冒泡排序能够跳出循环,停止排序。
- 动画演示
5.2 代码实现
void BubbleSort(int* nums, int numsSize)
{
for (int i = 1; i < numsSize; i++) //控制趟数
{
int flag = 0; //有序的标志
for (int j = 0; j < numsSize - i; j++) //控制每一趟需要比较的次数
{
if (nums[j] > nums[j + 1])
{
Swap(&nums[j], &nums[j + 1]);
flag = 1; //无序的标志
}
}
if (flag == 0) //排完一趟还是有序的标志
{
break;
}
}
}
5.3 复杂度和稳定性分析
时间复杂度
最坏情况:数据是逆序的,第一趟排序要交换 n - 1个数据,第二趟要交换 n - 2个数据,… …,最后一趟要交换1个数据,所以交换的次数是一个等差数列,时间复杂度为 O(N2);
最好情况:对于未优化的冒泡排序来说,数据是否有序并不会影响排序的效率,时间复杂度都为 O(N2);但对于优化后的冒泡排序来说,当数据有序的情况下,只需要一趟比较 n - 1 次数据就可以跳出循环,其时间复杂度可以达到 O(N);
所以时间复杂度为:O(N2);
空间复杂度
冒泡排序没有额外的内存消耗,空间复杂度为:O(1);
稳定性
冒泡排序每趟的排序过程中,只有当前一个元素大于后一个元素时才会发生交换,当二者相等时并不会发生交换,所以相等元素间的相对顺序不会发生改变,所以冒泡排序具有稳定性。
5. 4 特性总结
- 冒泡排序是一种非常简单且容易理解的排序;
- 时间复杂度:O(N2);
- 空间复杂度:O(1);
- 稳定性:稳定;
6. 快速排序
6.1 基本思路
快速排序是 Hoare 于1962年提出的一种二叉树结构的交换排序算法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该基准值将待排序序列分割成两个子序列,左边的元素均小于基准值,右边的元素均大于基准值,然后对其左右子序列重复以上过程,直到所有元素都排列在相应位置上。
6.2 代码实现
递归版本
在了解快排的思想之后,我们会发现快速排序的排序过程是这样的:先选定一个 key 做基准值,经过单趟排序后 key 左边位置的元素都小于 key,右边位置的元素都大于 key,这就使得 key 被放在了正确的位置,即一趟排序可以确定一个元素的位置;现在我们只需要对 key 的左区间和右区间再进行单趟排序即可;
经过上面的分析我们发现,快排的排序过程和二叉树的前序遍历十分相似,我们每趟排序可以确定一个元素的位置,然后我们就只需要排序该位置的左右区间即可,左右区间又可以被划分为左右区间,即不断被划分为子问题,这就是递归的思想。
//单趟排序
int PartSort1(int* nums, int begin, int end)
{
int keyi = begin;
int left = begin;
int right = end;
while (left < right)
{
while (left < right && nums[right] >= nums[keyi])//找小
{
right--;
}
while (left < right && nums[left] <= nums[keyi])//找大
{
left++;
}
Swap(&nums[left], &nums[right]);
}
Swap(&nums[keyi], &nums[left]);
return left;
}
void QuickSort(int* nums, int numsSize)
{
if (numsSize <= 1)
{
return;
}
int keyi = PartSort1(nums, 0, numsSize - 1); //一次快排确定中间位置
QuickSort(nums, keyi);
QuickSort(nums + keyi + 1, numsSize - keyi - 1);
}
关于快排的单趟排序,现在主流的主要有三种方法:hoare 法、挖坑法以及前后指针法
hoare 法
hoare 法的算法思路是这样的:我们取最左边的元素做 key,然后定义两个指针 L 和 R,L 指向区间第一个元素,R 指向区间最后一个元素,然后让 R 先走,当 R 遇到小于 key 的元素时就停下来,然后让 L 走,当 L 遇到大于 key 的数时就停下来,然后让 L 和 R 所指的元素进行交换;重复上面的步骤,直到 L 和 R 相遇;二者相遇位置所对应的值一定是小于等于 key 的,这时候再交换 key 和 L/R 即可。
int PartSort1(int* nums, int begin, int end)
{
int keyi = begin;
int left = begin;
int right = end;
while (left < right)
{
while (left < right && nums[right] >= nums[keyi])//找小
{
right--;
}
while (left < right && nums[left] <= nums[keyi])//找大
{
left++;
}
Swap(&nums[left], &nums[right]);
}
Swap(&nums[keyi], &nums[left]);
return left;
}
- 注意事项
我们需要保存的是 key 的下标 keyi,因为在 partsort 函数中,key 只是形参,而形参的改变不会影响数组元素 nums [ keyi ];
在写循环条件时,需要加上 left < right,这是为了避免当 keyi 右边的元素全部大于 key 时,R 不会停止而造成数组越界;同时也为了避免 L 在往右走的过程中直接越过 R,而不会在相遇点停止;
另外,当 L 和 R 遇到等于 key 的元素时,也不要停留,避免出现 nums [ keyi ] == nums [ left ] == nums [ right ] 这种情况从而导致 L 和 R 在交换后仍然会在此停留,造成程序死循环。
无论是最开始还是 L 和 R 已经交换数据后,都要让 R 先走,目的是为了保证 L 和 R 最后相遇点一定小于等于 key。我们知道,L 和 R 相遇只可能是两种情况:L 撞 R 和 R 撞 L
当 L 撞 R 时,由于 R 是先走的,所以 R 下标对应的元素一定是小于 key 的,结论成立;
当 R 撞 L 时,又分为两种情况:1、L 一直没动过,最后 R 与 L 在 keyi 处相遇,相遇点等于key;2、L 动过,而 L 动过后,R 在动之前一定与 L 之间发生了交换,所以此时 L 下标对应的值大于 key,结论也成立。
排序优化
经过上面的努力我们已经成功实现了快速排序,但是我们可以对其中的两个逻辑进行优化以提高效率:一是选 key 逻辑,二是递归小区间。
- 优化选 key
当前我们是选择区间的第一个位置作为 keyi,然后通过单趟排序确定该元素的位置,那么最好的情况就是我们每次选出的 key 都是序列的中位数,这样我们就能不断二分,递归层数最少,效率最高。
那么最坏的情况就应该是当数组有序或者接近有序的时候,在这种情况下我们可以认为 key 就处于最左边,这样每次递归左区间长度为0,右区间长度为 n-1,那么递归的深度就为 n,即一共要建立 n 层栈帧,但是栈区的空间是非常小的,只有 8M 左右,当数据量较大,比如10 W、100 W 的时候就会发生栈溢出,导致程序崩溃。
针对数组有序或接近有序造成程序栈溢出的情况,有人对选 key 的逻辑提出了以下三种优化方法:
随机选数 – 这样使得每次 key 都为最小值的概率变得很低;
选中间下标做 keyi – 专门针对有序序列进行优化;
三数取中 – 取 left、right、mid 三个下标对应数值的中间值做 key,然后把它与首元素进行交换,keyi 还是 0 ,但是 key 一定不是最小值
这里采用三数取中法
int GetKeyi(int* nums, int begin, int end)
{
int midi = (begin + end) / 2;
int mini = begin;
int maxi = end;
if (nums[mini] > nums[midi])
{
Swap(&mini, &midi);
}
if (nums[maxi] < nums[midi])
{
Swap(&maxi, &midi);
}
if (nums[mini] > nums[midi])
{
Swap(&mini, &midi);
}
return midi;
}
优化后的单趟排序
int PartSort1(int* nums, int begin, int end)
{
int keyi = GetKeyi(nums, begin, end);
//把选到的 key 换到最前面
Swap(&nums[keyi], &nums[begin]);
keyi = begin;
int left = begin;
int right = end;
while (left < right)
{
while (left < right && nums[right] >= nums[keyi])//找小
{
right--;
}
while (left < right && nums[left] <= nums[keyi])//找大
{
left++;
}
Swap(&nums[left], &nums[right]);
}
Swap(&nums[keyi], &nums[left]);
return left;
}
- 优化递归小区间
在完全二叉树中,最后一层的节点数大约占总结点数的 1/2,倒数第二层的节点数大约占总结点数的 1/4,倒数第三层大约占1/8,也就是说,完全二叉树最后三层的递归调用次数大约占总递归调用次数的 87.5%;
对于快排来说,虽然我们递归下来的树不是完全二叉树 (不是每一次的 key 都为中位数),但也差不多;而且快排递归有一个特点,那就是最后几层的元素很少 (倒数第三层有8个数左右,倒数第二层有4个数左右,倒数第一层有2个数左右),并且它们都是接近有序的,所以当区间长度小于等于8时我们可以直接使用直接插入排序,而不是让其继续递归分割子区间,从而提高效率。
优化后的递归函数
void QuickSort(int* nums, int numsSize)
{
if (numsSize <= 1)
return;
//当递归到区间元素个数小于等于10时,为了提高效率直接使用插入排序
if (numsSize <= 10)
{
InsertSort(nums, numsSize);
}
else
{
int keyi = PartSort1(nums, 0, numsSize - 1);
//递归左区间
QuickSort(nums, 0, keyi - 1);
//递归右区间
QuickSort(nums, keyi + 1, numsSize - 1);
}
}
挖坑法
首先,我们利用三数取中筛选出适合数值,然后让其与最左边的元素进行交换,然后定义两个变量 L 和 R,分别指向区间首部和末尾,与 hoare 法不同的是,挖坑法会多增加一个变量 piti,用来记录坑的位置。如下图,在数组最左边挖一个坑,把坑的值交给 key 保管,然后让 R 先走,当 R 找到比 key 小的值后用这个值去填坑,并让 R 的位置作为新的坑;然后让 L 走,找比 key 大的值,找到后也用这个值去填坑,于是 L 的位置形成了新的坑;不断重复上述过程,直到 L 与 R 相遇,此时直接用 key 填掉最后一个坑。
int PartSort2(int* nums, int begin, int end)
{
int keyi = GetKeyi(nums, begin, end);
Swap(&nums[keyi], &nums[begin]);
int key = nums[begin]; //挖出第一个坑,位于最左边
int left = begin;
int right = end;
int piti = begin;
while (left < right)
{
while (left < right && nums[right] >= key) //从右边找一个大于key的值去填左边的坑
{
right--;
}
nums[piti] = nums[right]; //填坑
piti = right; //更新坑位
while (left < right && nums[left] <= key) //从左边找一个小于于key的值去填右边的坑
{
left++;
}
nums[piti] = nums[left]; //填坑
piti = left; //更新坑位
}
nums[piti] = key; //最后一次填坑
return piti; //返回坑位
}
前后指针法
首先,最开始还是和前面两种方法一样,利用三数取中法来优化选 key 的逻辑,但是后面的步骤就有较大的区别了;首先定义两个变量:prev = begin 和 cur = begin + 1,作为前后指针;
先让 cur 走,当找到小于 key 的元素时停下来,然后先让 prev++,再交换两指针对应元素的值,重复前面的步骤,直到 cur > right,最后交换 nums[ keyi ] 和 nums[ prev ] 即可
int PartSort3(int* nums, int begin, int end)
{
int keyi = GetKeyi(nums, begin, end);
Swap(&nums[keyi], &nums[begin]);
keyi = begin;
int pre = begin;
int cur = begin + 1;
while (cur <= end)
{
if (nums[cur] <= nums[keyi] && ++pre != cur)
{
Swap(&nums[pre], &nums[cur]);
}
cur++;
}
Swap(&nums[keyi], &nums[pre]);
return pre;
}
- 注意事项
1、因为 prev 是从 keyi 的位置开始的,而 keyi 在循环结束时才进行交换,所以我们需要先让 prev++,再观察 prev 是否等于 cur,如果相等也没有交换的必要了;
2、如果不相等,由于 cur 在 prev 的下一个位置开始走,并且 cur 只有遇到小于 key 的值才停下来,所以prev 和 cur 之间的值都大于 key, nums [ prev ] 也一定大于 key,二者进行交换;
3、当 cur > right 跳出循环后,prev 并没有再次进行自增,那么 nums[ prev ] 一定是小于 key 的,所以直接把它和首元素交换即可。
递归版本完整代码,以 hoare 为例
void Swap(int* e1, int* e2)
{
int tmp = *e1;
*e1 = *e2;
*e2 = tmp;
}
//选 key
int GetKeyi(int* nums, int begin, int end)
{
int midi = (begin + end) / 2;
int mini = begin;
int maxi = end;
if (nums[mini] > nums[midi])
{
Swap(&mini, &midi);
}
if (nums[maxi] < nums[midi])
{
Swap(&maxi, &midi);
}
if (nums[mini] > nums[midi])
{
Swap(&mini, &midi);
}
return midi;
}
int PartSort1(int* nums, int begin, int end)
{
int keyi = GetKeyi(nums, begin, end);
Swap(&nums[keyi], &nums[begin]);
keyi = begin;
int left = begin;
int right = end;
while (left < right)
{
while (left < right && nums[right] >= nums[keyi])//找小
{
right--;
}
while (left < right && nums[left] <= nums[keyi])//找大
{
left++;
}
Swap(&nums[left], &nums[right]);
}
Swap(&nums[keyi], &nums[left]);
return left;
}
void QuickSort(int* nums, int numsSize)
{
if (numsSize <= 1)
return;
//当递归到区间元素个数小于等于10时,为了提高效率直接使用插入排序
if (numsSize <= 10)
{
InsertSort(nums, numsSize);
}
else
{
int keyi = PartSort1(nums, 0, numsSize - 1);
//递归左区间
QuickSort(nums, keyi);
//递归右区间
QuickSort(nums + keyi + 1, numsSize - 1 - keyi);
}
}
非递归版本
经过上面的学习,我们已经知道如何使用递归的方式来实现快速排序,但是大家仔细观察就会发现,上面递归的过程其实是数组区间变化的过程 (先是整个数组,然后是左右区间,左右区间又被划分为左右区间),我们只要知道了所有的区间的 begin 和 end,就可以不通过递归的方式来实现快速排序。
那么怎样得到所有的区间的 begin 和 end呢,这就需要借助另外一个数据结构 – 栈:首先我们将数组的左右边界 入栈,然后取出栈顶的 begin 和 end (取出元素的同时进行 pop),此时,我们就可以用 begin 和 end 来代表一个区间并对此区间进行一次单趟排序,排序完成后会得到一个 keyi ,利用它再得到此区间的左右区间并进行入栈操作;重复上述步骤,直到栈为空即可。
非递归版本存在的意义:首先,对于未优化的快排,当数据元素有序或接近有序时递归深度为N,而递归调用函数的栈帧是在栈区上开辟的,而栈区本身很小,只有8M左右,所以当递归调用深度超过栈区的范围就会栈溢出,而非递归版本不会在栈区压栈,而是在堆区开辟栈这种数据结构,堆区的空间远远大于栈区,不用担心空间不够用的情况;其次,对于优化后的快排来说,三数取中只能保证我们每次选出的 key 值不是最小的数,而不能保证不是倒数第二小的,在这种情况下如果我们的数据量非常大,比如几亿,那么也是有可能发生栈溢出的,所以说在某些极端场景下优化后的快排也是需要使用非递归的。
void QuickSortNonR(int* nums, int numsSize)
{
if (numsSize <= 1)
{
return;
}
Stack S;
InitStack(&S); //初始化栈
int begin = 0;
int end = numsSize - 1;
EnStack(&S, end); //第一个区间边界入栈
EnStack(&S, begin);
while (!IsEmptyStack(&S))
{
begin = StackTop(&S);
DeStack(&S);
end = StackTop(&S);
DeStack(&S);
int keyi = PartSort1(nums, begin, end); //一次快排得到 keyi
if (end > keyi + 1)
{
EnStack(&S, end); //左区间入栈
EnStack(&S, keyi + 1);
}
if (begin < keyi - 1)
{
EnStack(&S, keyi - 1); //右区间入栈
EnStack(&S, begin);
}
}
DestroyStack(&S);
}
- 注意事项
由于我们这里使用了数据结构中的栈,所以需要将我们之前写的的 Stack.h 和 Stack.c 两个源文件添加到当前项目中来,并且在 Sort.h 中包含 Stack.h;
6.3 复杂度和稳定性分析
时间复杂度
递归版本:快排递归的深度大约是 log2N,每一层的元素个数大约是 N,所以时间复杂度为:O(N * logN);
非递归版本:非递归和递归情况类似,入栈出栈的时间复杂度为 O(N),然后左右子区间差不多可以划分 logN 次,所以时间复杂度为:O(N * logN);
空间复杂度
递归版本会压栈,非递归版本在堆区开辟栈,都有额外的空间消耗,空间复杂度为:O(logN) ~ O(N);
稳定性
由于快速排序选出的 key 值是不确定的,而且会随意交换数据,所以是不稳定的;
6. 4 特性总结
- 快速排序的综合性能很高,使用场景比较广,所以被称之为快速排序,可谓名不虚传;
- 时间复杂度:O(N * logN);
- 空间复杂度:O(logN) ~ O(N)
- 稳定性:不稳定;
7. 归并排序
7.1 基本思路
归并排序是建立在归并操作上的一种高效的排序算法,该算法采用了分治的算法思想(Divide and Conquer)。
要想整个序列有序,先要让左右子序列有序,然后将有序的子序列归并,得到完全有序的序列。
- 动画演示
7.2 代码实现
递归版本
如上图所示:如果说快速排序递归实现相当于二叉树的前序遍历的话,那么归并排序的递归实现就可以认为是二叉树的后序遍历;因为归并思想实质上就是将两个有序数组合并成一个有序数组。归并的实现也十分简单,就是不断取小的元素尾插;困难的是如何达到归并的条件:被归并的两个区间里面的元素必须是有序的;这时候我们就需要用到递归的思想了,我们需要不断将待排序的区间分为左右两个子区间进行递归,直到左右子区间区间的大小为1,然后再进行归并 (只有一个元素的区间必定是有序的);归并之后返回至上一层,待上一层的右区间也变成有序后再归并并返回上一层,直到第一层的左右区间有序;大家可以类比二叉树后序遍历中的先访问左子树,再访问右子树,最后访问根节点来理解。
具体实例如下:下图中经过归并后的 6 10、1 7、3 9、2 4实际上是会返回至上一层覆盖掉原来的 10 6、7 1、3 9、4 2的,后面的 1 6 7 10、2 3 4 9,以及最后的 1 2 3 4 6 7 9 10也是一样,图中只是形象的画法。
void _MergeSort(int* nums, int begin, int end, int* temp)
{
if (begin >= end)
{
return;
}
int mid = (begin + end) / 2;
_MergeSort(nums, begin, mid, temp);
_MergeSort(nums, mid + 1, end, temp);
//左右序列有序后进行归并
int p1 = begin;
int p2 = mid + 1;
int i = begin; //下标与原数组对应
while (p1 <= mid && p2 <= end) //归并的两个数组都不能越界
{
if (nums[p1] <= nums[p2]) //选出较小或相等的数放入临时数组
{
temp[i++] = nums[p1++];
}
else
{
temp[i++] = nums[p2++];
}
}
while (p1 <= mid)
{
temp[i++] = nums[p1++]; //其中一个数组越界后,直接把另一个数组拷贝到临时数组
}
while (p2 <= end)
{
temp[i++] = nums[p2++];
}
//最后用排好序的临时数组覆盖原数组
memcpy(nums + begin, temp + begin, sizeof(int) * (end - begin + 1));
}
void MergeSort(int* nums, int numsSize)
{
//开辟一个与原数组等大的数组来接收归并的数据
int* temp = (int*)malloc(sizeof(int) * numsSize);
if (temp == NULL)
{
perror("Merge");
exit(-1);
}
_MergeSort(nums, 0, numsSize - 1, temp);
free(temp);
temp = NULL;
}
- 注意事项
在每一次归并完成之后,我们都需要将 tmp 数组中归并的结果拷贝回原数组,这里需要特别注意的是进行拷贝的区间,因为 tmp 中保存的是一部分小区间归并后的结果,所以我们拷贝的时候也应该拷贝到原数组的对应区间中。
非递归版本
与快排的递归不同,归并排序的左右区间是严格进行二分的,所以归并排序递归下来是一颗完全二叉树,所以递归的深度为 logN,那么自然就不存在栈溢出的问题,毕竟当数据量为10亿的时候,递归的深度也才30层,也就是说,归并排序非递归的价值其实不大;但是,由于归并排序非递归版本涉及到的边界问题比较复杂,有的公司会用它来考察我们的编程能力,本着技多不压身的原则,我们还是来学习一下它。
归并排序的非递归是不能使用栈来实现的,因为最先用到的区间是最底层的小区间,没办法通过压栈的方式从底层推出上层的区间,只能像斐波那契数列一样,通过循环迭代的方式由前面的区间来得到后面的区间
如上图,我们定义一个 gap 变量,用于指定每次进行排序的一组数据元素的个数,然后定义循环,让相邻两组数据进行归并,待数组所有组都两两归并之后,再让 gap *= 2,让其归并更大组的数据,直到 gap > n 时,数组有序;
但是上述方法只能用于排序拥有 2n 个数据的数组,当数组元素个数不满足这个条件时,就会发生越界访问:
- 越界情况
第一组 end 越界:此时,第二组一定全部越界;这种情况下只有一组数据,不需要归并
第二组 begin 越界,这种情况下也只有一组数据,不需要归并
第二组 end 越界,此时存在两组数据,需要将第二组的 end 修正为数组末尾,然后继续进行归并;
void MergeSortNonR(int* nums, int numsSize)
{
int* temp = (int*)malloc(sizeof(int) * numsSize);
if (temp == NULL)
{
perror("MergeSortNonR");
exit(-1);
}
for (int gap = 1; gap < numsSize; gap *= 2)
{
//每次gap个数据和gap个数据进行归并
for (int i = 0; i < numsSize; i += 2 * gap)
{
int begin1 = i; //begin1 一定不会越界
int begin2 = begin1 + gap;
int end1 = begin1 + gap - 1;
int end2 = begin2 + gap - 1;
if (end1 >= numsSize)
{
break;
}
else if (begin2 >= numsSize)
{
break;
}
else if (end2 >= numsSize)
{
end2 = numsSize - 1; //修正 end2
}
//归并两组数据
int j = i;
while (begin1 <= end1 && begin2 <= end2)
{
if (nums[begin1] <= nums[begin2])
{
temp[j++] = nums[begin1++];
}
else
{
temp[j++] = nums[begin2++];
}
}
while (begin1 <= end1)
{
temp[j++] = nums[begin1++];
}
while (begin2 <= end2)
{
temp[j++] = nums[begin2++];
}
//归并一次,回写一次
memcpy(nums + i, temp + i, sizeof(int) * (end2 - i + 1));
}
}
free(temp);
temp = NULL;
}
- 注意事项
一定不要整个数组归并后统一回写覆盖原数组,因为如果有越界的情况,会有一组数据没有归并或者少归并,这样 temp 数组的对应位置就是随机值,如果回写覆盖原数组就会产生错误
7.3 复杂度和稳定性分析
时间复杂度
对于递归版本的归并排序来说,递归的深度为 logN,每一层待排序的元素个数都为 N,所以时间复杂度是严格的 O(N * logN);对于非递归版本来说,gap 每次增加两倍,每次 gap 中待排序的数据等于或者小于 N,所以非递归的时间复杂度也是 O(N * logN);
空间复杂度
归并排序需要额外开辟一个与原数组同等大小的数组用于归并,所以空间复杂度为:O(N);
稳定性
归并排序的稳定性取决于单次归并过程中判断条件是nums[begin1] < nums[begin2]
,还是nums[begin1] <= nums[begin2]
,第二种判断条件才是稳定的,只要能够有办法保证稳定性那么这种算法就可以称为稳定的算法。
7. 4 特性总结
- 归并排序的缺点在于需要O(N)的空间复杂度,所以它更多的是解决在磁盘中的外排序问题;
- 时间复杂度:O(N * logN);
- 空间复杂度:O(N);
- 稳定性:稳定。
8. 计数排序
8.1 基本思路
计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用,属于非比较排序;上面的七大排序都是比较排序。计数排序使用一个额外的数组C,其中第 i 个元素是待排序数组 A 中值等于 i 的元素的个数。计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。
- 绝对映射
先遍历一遍原数组,找出原数组中最大的元素,然后开辟一个大小比此元素大1的新数组,接着我们将原数组的值与新数组的下标相映射,最后我们遍历新数组,根据新数组的下标可以确定值,根据存储的值可以确定个数,把它们输出到原数组完成排序
- 相对映射
很容易可以发现绝对映射存在两个缺陷:
- 当数据值很大时,空间消耗过大;比如我们要对数组 a = { 10000, 9999, 9991,9996} 进行计数排序,我们需要开辟一个大小为 10001 的新数组,这相当浪费;
- 绝对映射不能排序负数,因为数组下标不可能为负数。
为了弥补这些缺陷,我们可以采用相对映射,不再根据数组的最大元素来开辟空间,而是根据数组中最大元素与最小元素的差值来开辟空间;比如上面的数组 a 中最大元素为10000,最小元素为9991,那么我们就只需要开辟 10000 - 9991 + 1 即 10 个整型的空间;相应的,我们在进行映射时也不再将元素值直接映射到对应的新数组的下标中,而是让元素值减去最小值之后再进行映射;这样,即使数组元素为负数,那么当其减去最小值之后也会映射到大于等于 0 的下标中去;最后,当我们取出元素覆盖原数组时,再让其加上最小值即可。
8.2 代码实现
void CountSort(int* nums, int numsSize)
{
int min = nums[0];
int max = nums[0];
for (int i = 1; i < numsSize; i++)
{
if (nums[i] > max)
{
max = nums[i];
}
if (nums[i] < min)
{
min = nums[i];
}
}
int* temp = (int*)calloc(max - min + 1, sizeof(int));
if (temp == NULL)
{
perror("CountSort");
exit(-1);
}
for (int i = 0; i < numsSize; i++)
{
temp[nums[i] - min]++; //相对映射
}
int j = 0;
for (int i = 0; i < max - min + 1; i++)
{
while (temp[i]--)
{
nums[j++] = min + i; //回写时要加上最小值 min
}
}
free(temp);
temp = NULL;
}
8.3 复杂度和稳定性分析
时间复杂度
映射时间复杂度为 O(N),取数的时间复杂度为 O (k) (k是数据范围),所以总时间复杂度为O(N + k)
空间复杂度
额外开辟的新数组大小取决于原数组数据范围大小,所以空间复杂度为 O(k) (k是数据范围)
稳定性
对于具有相同关键字的元素,它们会被放在排序结果的同一个位置,因此相等元素的相对顺序保持不变,这使得计数排序成为一种稳定的排序算法。
8.4 特性总结
- 数列最大元素和最小元素差距过大时,不适用于计数排序;除整数外的其他数据类型,不适用于计数排序
- 时间复杂度:O(N + k);
- 空间复杂度:O(k);
- 稳定性:稳定
三、排序算法的比较和总结
排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 最好时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
插入排序 | O(N2) | O(N2) | O(N) | O(1) | 稳定 |
希尔排序 | O(N * logN) ~ O(N2) | O(N2) | O(N1.3) | O(1) | 不稳定 |
选择排序 | O(N2) | O(N2) | O(N2) | O(1) | 不稳定 |
堆排序 | O(N * logN) | O(N * logN) | O(N * logN) | O(1) | 不稳定 |
冒泡排序 | O(N2) | O(N2) | O(N) | O(1) | 稳定 |
快速排序 | O(N * logN) | O(N2) | O(N * logN) | O(logN) ~ O(N) | 不稳定 |
归并排序 | O(N * logN) | O(N * logN) | O(N * logN) | O(N) | 稳定 |
- 测试排序算法运行时间的代码
void TestOP()
{
srand((unsigned int)time(NULL));
const int N = 100000;
int* a1 = (int*)malloc(sizeof(int) * N);
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);
int* a8 = (int*)malloc(sizeof(int) * N);
if (a1 == NULL)
{
perror("malloc fail");
return;
}
for (int i = 0; i < N; ++i)
{
a1[i] = rand();
a2[i] = a1[i];
a3[i] = a1[i];
a4[i] = a1[i];
a5[i] = a1[i];
a6[i] = a1[i];
a7[i] = a1[i];
a8[i] = a1[i];
}
int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
int begin3 = clock();
HeapSort(a3, N);
int end3 = clock();
int begin4 = clock();
QuickSort(a4, N);
int end4 = clock();
int begin5 = clock();
QuickSortNonR(a5, N);
int end5 = clock();
int begin6 = clock();
MergeSort(a6, N);
int end6 = clock();
int begin7 = clock();
MergeSortNonR(a7, N);
int end7 = clock();
int begin8 = clock();
CountSort(a8, N);
int end8 = clock();
printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
printf("HeapSort:%d\n", end3 - begin3);
printf("QuickSort:%d\n", end4 - begin4);
printf("QuickSortNonR:%d\n", end5 - begin5);
printf("MergeSort:%d\n", end6 - begin6);
printf("MergeSortNonR:%d\n", end7 - begin7);
printf("CountSort:%d\n", end8 - begin8);
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
free(a7);
free(a8);
}
可以看到计数排序不愧是线性复杂度的排序算法,相比于其他比较排序遥遥临先,处于第二梯队的就是快速排序和归并排序,这两个排序兼顾效率和使用场景,是最常用的排序算法,希尔排序和堆排序紧随其后,效率差不了太多,也是很常用的算法。
完整代码参见:八大排序完整代码