本文所有的排序都是将数据排为升序,文章较长,读者自行选择感兴趣的部分进行观看。
排序算法,是每个程序员免不了要面对的一道坎。它们像一只无情的怪兽,总是在不断地折磨着我们的思维。特别是在我们的代码出现了性能问题时,这个怪兽就像是从脑海中跳出来,对我们狠狠地扑了一记。那么,如何驯服这只怪兽,让它乖乖服从我们的命令呢?其实,只需要熟练掌握一些排序算法的实现细节,加上一点幽默的心态,我们就可以轻松地应对这个怪兽了!
在这里请允许我先简单介绍一下,每一个种排序算法我会写一些什么
- 算法的实现思路
- 算法代码的实现
- 不易发现的小陷阱
- 算法的性能分析
对于算法的性能分析,有一些概念需要读者先行了解:
了解完这些概念,最后再请读者复制下面一段代码。
之后我将用TestOP()这个函数比较不同数据量下,函数的性能的优劣。
(注意:测试结果不具备统计学意义,且测试是在debug下测试)
void TestOP()
{
srand(time(0));
const int N = 10000;//数据量
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);
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];
}
int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
int begin3 = clock();
SelectSort(a3, N);
int end3 = clock();
int begin4 = clock();
HeapSort(a4, N);
int end4 = clock();
int begin5 = clock();
QuickSort(a5, 0, N - 1);
int end5 = clock();
int begin6 = clock();
MergeSort(a6, N);
int end6 = clock();
int begin7 = clock();
BubbleSort(a7, N);
int end7 = clock();
printf("InsertSort:%dms\n", end1 - begin1);
printf("ShellSort:%dms\n", end2 - begin2);
printf("SelectSort:%dms\n", end3 - begin3);
printf("HeappSort:%dms\n", end4 - begin4);
printf("QuickSort:%dms\n", end5 - begin5);
printf("MergeSort:%dms\n", end6 - begin6);
printf("BubbleSort:%dms\n", end7 - begin7);
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
}
下面我们进入正题
插入排序包括:直接插入排序和希尔排序
算法的实现思路
直接插入排序是一种简单的插入排序法,其基本思想是把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列。
用一个形象的比喻:摸牌与插牌。
在斗地主的摸牌阶段,当我们从牌堆中取出一张牌时,我们必须将它插入到我们手中的牌中,使得手中的牌是有序的。比如下图中,我摸了一张7,我将其与手牌中的最后一张10对比,小于10,则7应该在10前面;再与10前面的5进行对比,大于5,则7应该在5的后面;OK,此时我应该将7插入5和10之间。
下面我进行动图演示
void InsertSort(int* a, int sz) {
for (int i = 1; i < sz; i++) {
int end = i - 1;
int tmp = a[i];
while (end >= 0) {
if (tmp < a[end]) {
a[end + 1] = a[end];
end--;
}
else {
break;
}
}
a[end + 1] = tmp;
}
}
插入排序比较简单,唯一值得说的就是判断条件:end >= 0,因为插入是在从后往前第一个比tmp小或等于的数后面插入,存在一种情况插入的位置是在a[0]处,这就需要end = -1
算法的性能
算法时间复杂度:最好 O(n) | 最坏 O(n^2)
算法的空间复杂度:O(1)
稳定性:稳定
总结一句:原始数据越接近有序,直接插入排序越优秀。
【因为如果接近有序(本文的有序指的是升序),后面的数往往可以直接插入在最后面】
上文提到,原始数据越接近有序,直接插入排序越优秀。于是有一位名叫Shell(希尔)的大佬,对插入排序进行了改良,而改良后的排序就是下面要介绍的希尔排序。
算法的实现思路
希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数gap,把待排序文件中所有数据分成gap个组,所有距离为gap的数据分在同一组内,并对每一组内的数据进行排序。然后,取 gap = gap/2,重复上述分组和排序的工作。当到达gap=1时,数据将排好序.
直接插入排序最坏的情况是你要排成升序,但我给你的数组刚好是个降序,这种情况,你往后的每个数据都需要移到最前面。希尔就想了一个办法,直接插入排序是一个接一个的比较,他选择相隔距离为gap的元素进行比较。读者可能感到很疑惑,这难道就能更快吗?别急,下面我用动图来解释。
每一趟排完后,数据都相比原来更加有序,更加接近于直接插入排序的最好情况。
这里演示只是排序中的一趟,在整个排序中gap的值是在变化的,至于如何变化,取决于读者自己,但要满足最后一趟的gap值是1(即最后一趟就是直接插入排序)。
一般情况gap的变化有下面两种形式:
gap = gap / 2;
gap = gap / 3 + 1;
代码实现
void ShellSort(int* a, int sz) {
int gap = sz;
while (gap > 1) {
gap /= 2;
//gap = gap/3 + 1
for (int i = 0; i + gap < sz; i++) {
int end = i;
int tmp = a[end + gap];
while (end >= 0) {
if (tmp < a[end]) {
a[end + gap] = a[end];
end -= gap;
}
else {
break;
}
}
a[end + gap] = tmp;
}
}
}
注意事项:
算法性能分析
算法的空间复杂度:O(1)
稳定性:不稳定(不能保证相同元素的相对位置不变)
时间复杂度: 希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在不同的书中给出的答案不固定。
大致可以认为它的时间复杂度比O(nlogn)大一点。
用TestOP()来测一下,可以看出希尔排序比直接插入排序强了不少
选择排序包括直接选择排序和堆排序。
一般我们把直接选择排序简称为选择排序
算法实现思路
每次从剩余数中选出最小的数,与已排序的数组后面一个元素换位置。
void SelectSort(int* a, int sz) {
int end = 0;
while (end < sz-1) {
int mini = end;
for (int i = end + 1; i <= sz-1; i++) {
if (a[i] < a[mini]) {
mini = i;
}
}
Swap(&a[end], &a[mini]);
end++;
}
}
void SelectSort(int* a, int sz) {
int left = 0;
int right = sz - 1;
while (left < right) {
int maxi, mini;
maxi = mini = left;
for (int i = left; i <= right; i++) {
if (a[i] > a[maxi]) {
maxi = i;
}
if (a[i] < a[mini]) {
mini = i;
}
}
Swap(&a[left], &a[mini]);
//存在情况:left指向的数就是目前区间的最大值
//即maxi和left指向同一个元素
//此时下标为left的与下标为mini的值交换,maxi指向的值就变了
//如果是每次只换一边就不用担心这个问题,解决方法如下
if (left == maxi) {
maxi = mini;
}
Swap(&a[right], &a[maxi]);
++left;
--right;
}
}
算法性能分析:
时间复杂度:最好和最坏情况都是O(n^2)
空间复杂度:O(1)
稳定性:不稳定
总结一句:选择排序,狗都不用!
不管你给的数组顺序如何,它每次都要遍历剩余的元素,找出最大或最小,不像直接插入排序,懂得偷懒。
虽然直接选择排序捞的不行,但选择排序家族还是有强者的,下面让我开始学习本文到现在第一个时间复杂度为O(nlogn)的排序算法 - 堆排序。
堆排序不像其它排序,你先要了解一下堆这种数据结构。(这里的堆可不是内存里的堆区)详情可见我的这篇博客堆排序
下面是它的代码:
void AdjustDown(int* a, int size, int father) {
int child = 2 * father + 1;
while (child < size) {
int MaxChild = child;
//判断是否具有右孩子
if (child + 1 < size) {
MaxChild = (a[child] >= a[child + 1] ? child : child + 1);
}
//判断最大的孩子是否大于父亲; >号表示建大堆
if (a[MaxChild] > a[father]) {
Swap(&a[MaxChild], &a[father]);
father = MaxChild;
child = father * 2 + 1;
}
else {
return;
}
}
}
void HeapSort(int* a, int sz) {
//建大堆
for (int i = (sz-1-1) / 2; i >= 0; i--) {
AdjustDown(a, sz, i);
}
//排升序
for (int i = 0; i < sz; i++) {
Swap(&a[0], &a[sz - 1 - i]);
AdjustDown(a, sz-1-i, 0);
}
}
算法性能分析:
算法的时间复杂度:O(nlogn)
算法的空间复杂度:O(1)
稳定性:不稳定
这里我们调用TestOP()来测一下(直接插入排序和选择排序这里将它们屏蔽掉了,它们已经不配与HeapSort同台竞技了,唯有ShellSort可与之一战)
震惊!堆排序落败了。堂堂O(nlogn)竟如此不堪,其实这个问题就像我开头说得一样,我能喝酒,但并不代表我酒量好。堆排序虽然属于O(nlogn)这个量级,但并不代表它就足以秒杀一众对手。当然,如果数据量在增个几百亿,HeapSort就可能强于ShellSort。
冒泡排序就不用过多介绍,下面为它的动图演示
思路:冒泡排序从数组的第一个元素开始,依次比较相邻的两个元素(比较大小), 如果它们的顺序不对,就交换它们的位置,使得较大的元素“冒泡”到数组的末端。 这样一趟排序之后,最大的元素就会被放置到数组的最后一个位置上。
void BubbleSort(int* a, int sz) {
int flag = 1;
for (int i = 1; i <= sz-1; i++) {
//一趟排序
for (int j = 1; j <= sz-i; j++) {
if (a[j - 1] > a[j]) {
Swap(&a[j - 1], &a[j]);
flag = 0;
}
}
if (flag) {
break;
}
}
}
算法的时间复杂度:O(n^2)
算法的空间复杂度:O(1)
稳定性:稳定
下面的两个排序就是这7种排序最不好解决的排序了,准备开始啃硬菜。
算法的实现思路
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
上面是Hoare大佬的原版思路,但有些人认为Hoare的方法有很多小坑,于是又有许多人提出了快速排序的其他实现思路,如挖坑法,前后指针法,下面我们一一道来
直接上动图:
再上递归展开图:和二叉树的前序遍历差不多
当你看完了这两个动图后,快速排序直接手到擒来——那怎么可能,好歹也是排序中的王者,不搞些小陷阱来坑我们,那也说不过去了。
1.0版本:
void Swap(int* a, int* b) {
int tmp = *a;
*a = *b;
*b = tmp;
}
int PartSort1(int* a, int left, int right) {
int begin = left, end = right;
int keyi = left;
while (left < right) {
while (left < right && a[right] >= a[keyi]) {
right--;
}
while (left < right && a[left] <= a[keyi]) {
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyi]);
keyi = left;
//[begin, keyi-1] keyi [keyi+1, end]
return keyi;
}
void QuickSort(int* a, int left, int right) {
if (left >= right) {
return;
}
int keyi = PartSort1(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi+1, right);
}
注意点如下:
记住上面这三个关键点。
在这个代码上面我写了一个1.0版本,因为这并不是真正的最终成品。相信听过快速排序的读者应该知道快速排序的时间复杂度是O(nlogn).如下:
它维持在O(nlogn)的核心在于key每次都将数组分为了两部分,且这分的两部分长度越接近,效率越高。(因为分的越平均,越接近二分)
但如果分的越不平均,比如key的正确位置刚好就是某一端(即key是最大或最小),那分了跟没分一样。
其中最极端的情况就数组是降序,让你排升序,这将使时间复杂度变为O(n^2).这就很难搞了,这不和选择排序一样fw了吗。但好消息是快速排序是可以优化的,上面的问题选的key不对,那我不选择最左边的值做key,随机选择一个数,那它是key刚好最大或最小的可能就变小了。
这就有了第一种选key的方法:随机选key (为了防止因key选的方式变了,代码也要跟着大改,我们将选到的key与最左边的值换一下位置)
但有人认为随机还是不太行,于是有了第二种方式:三数选中 :选择左,中,右这三个数位于正中间的数。(GetMidNumi需要自己写)
最后我们采用三数取中来完成代码 :
2.0版本
int GetMidNumi(int* a, int left, int right)
{
int mid = (left + right) / 2;
if (a[left] > a[mid]) {
if (a[mid] > a[right]) {
return mid;
}
else if (a[left] > a[right]) {
return right;
}
else {
return left;
}
}
else {// a[left] < a[mid]
if (a[mid] < a[right]) {
return mid;
}
else if (a[left] < a[right]) {
return right;
}
else {
return left;
}
}
}
void Swap(int* a, int* b) {
int tmp = *a;
*a = *b;
*b = tmp;
}
int PartSort1(int* a, int left, int right) {
int begin = left, end = right;
//随机找keyi
/*int keyi = rand() % (right-left);
Swap(&a[left], &a[keyi]);
keyi = left;*/
//三数取中
int midi = GetMidNumi(a, left, right);
if (midi != left) {
Swap(&a[left], &a[midi]);
}
int keyi = left;
while (left < right) {
while (left < right && a[right] >= a[keyi]) {
right--;
}
while (left < right && a[left] <= a[keyi]) {
left++;
}
Swap(&a[left], &a[right]);
}
Swap(&a[left], &a[keyi]);
keyi = left;
//[begin, keyi-1] keyi [keyi+1, end]
return keyi;
}
void QuickSort(int* a, int left, int right) {
if (left >= right) {
return;
}
int keyi = PartSort1(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi+1, right);
}
这样已经就很不错了,但还可以继续优化。
从这你可以想到什么?如果快速排序递归到区间比较小时,我们可不可以不用递归了,直接用直接插入排序将这些小区间排好序,是不是还能节省一些时间。
只用将QuickSort()改一下
void QuickSort(int* a, int left, int right) {
if (left >= right) {
return;
}
//区间一般设为13或14
if ((right - left + 1) > 13) {
int keyi = PartSort1(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
else {
InsertSort(a+left, right - left+1);
}
}
算法性能分析:
算法时间复杂度:O(nlogn)
算法空间复杂度:O(logn)
稳定性:不稳定
用TestOP测一下,直接封神
下面两种比原版容易多了
int PartSort2(int* a, int left, int right) {
int midi = GetMidNumi(a, left, right);
if (midi != left) {
Swap(&a[left], &a[midi]);
}
int key = a[left];
int hole = left;
while (left < right) {
while (left < right && a[right] >= key) {
right--;
}
a[hole] = key;
key = a[hole];
while (left < right && a[left] <= key) {
left++;
}
a[hole] = key;
key = a[hole];
}
a[hole] = key;
// [begin, hole-1] hole [hole+1, end]
return hole;
}
//前后制作法
//cur找小,++prev,Swap,cur++ ; 不小, cur++
int PartSort3(int* a, int left, int right) {
int midi = GetMidNumi(a, left, right);
if (midi != left) {
Swap(&a[left], &a[midi]);
}
int cur , prev, keyi;
prev = keyi = left;
cur = prev + 1;
while (cur <= right) {
if (a[cur] < a[keyi] && ++prev != cur) {
Swap(&a[++prev], &a[cur]);
}
cur++;
}
Swap(&a[keyi], &a[prev]);
keyi = prev;
return keyi;
}
快速排序除了递归这种方法,还可以使用非递归来实现。
有人就很好奇,为什么还要用非递归?因为只有使用了递归,就可能存在一种风险 - 栈溢出。
虽然它的空间复杂度只有O(logn),但当数据量很大时,仍有栈溢出的风险。
那么如何实现非递归呢?让我们先想想我们递归到底是在递归什么,
答案是区间。每次递归都是在做区间缩小的操作,递归返回就在做区间增大的操作。
除了认识到区间,我们还要认识到一点,递归与栈的关系。(这里的栈指的是数据结构,不是内存)我们想一想,每次递归返回时,第一个返回的是第一个递归的吗?不,是递归的末端先返回。这不就是栈的先进后出(FILO)记住。
一般递归改非递归
- 改循环
- 使用栈来辅助
void QuickSortNonR(int* a, int left, int right) {
ST st;
StackInit(&st);
StackPush(&st, right);
StackPush(&st, left);
while (!StackEmpty(&st)) {
int begin = StackTop(&st);
StackPop(&st);
int end = StackTop(&st);
StackPop(&st);
//选哪个PartSort都一样
int keyi = PartSort3(a, begin, end);
if (keyi + 1 < end) {
StackPush(&st, end);
StackPush(&st, keyi + 1);
}
if (begin < keyi - 1) {
StackPush(&st, keyi - 1);
StackPush(&st, begin);
}
}
StackDestroy(&st);
}
算法的实现思路
基本思想:归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide andConquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
文字描述还是有点抽象,下面用动图演示。
形象来讲:就是将数组拆成一个个元素,再逐渐将它们组合起来。这符合递归的特性,下面我们通过递归展开图来看。
看到这个动图后,我相信你应该已经知道该如何写代码了。
且慢,在敲键盘前先静静思考一下归并的过程。
递归展开图虽然看起来像把数组里的元素拆成了一个个新数组,但我们必须要清晰的认识到这些元素还是在原数组里。明白了这点,让我们来好好思考一下如何归并。
有人说:交换一下就行了。比如上面最下面一层10 和 6归并回去的时候,大的和小的交换一下位置,不就有序了。但倒数第二层呢?4个数互相交换吗?这就有点复杂化了。
比较好的做法是创建一个临时数组tmp,将需要归并的元素通过直接插入排序插入到临时数组里面,再把临时数组拷贝回去。如下图
归并的过程我们也想清楚了,下面开始写代码……
再次且慢,还要一个注意点:这个临时数组你是打算一开始直接创建一个同a数组一样大小的数组,还是再每次归并时创建要求大小的数组(如归并10和6时,创建一个大小为sizeof(int)*2大小的临时数组)。
推荐:一开始就创建一个和a同样大小的临时数组tmp。
正因如此我们将归并排序(MergeSort)分为两部分:
代码如下:
void _MergeSort(int* a, int left, int right, int* tmp) {
if (left >= right) {
return;
}
int mid = left + (right - left) / 2;
//左递归
_MergeSort(a, left, mid, tmp);
//右递归
_MergeSort(a, mid + 1, right, tmp);
//归并 区间【left, mid】【mid+1,right】
//别和快速排序一样把mid给舍弃了
int left1 = left, right1 = mid;
int left2 = mid+1, right2 = right;
int end = left;
while (left1 <= right1 && left2 <= right2) {
if (a[left1] <= a[left2]) {
tmp[end++] = a[left1++];
}
else {
tmp[end++] = a[left2++];
}
}
//假如1有剩
while (left1 <= right1) {
tmp[end++] = a[left1++];
}
//假如2有剩
while (left2 <= right2) {
tmp[end++] = a[left2++];
}
//拷贝tmp到a,注意是a+left,tmp+left,不是从0开始
memcpy(a + left, tmp + left, sizeof(int) * (right-left+1));
return;
}
void MergeSort(int* a, int sz) {
int* tmp = (int*)malloc(sizeof(int) * sz);
if (tmp == NULL) {
perror("malloc fail");
return;
}
_MergeSort(a, 0, sz - 1, tmp);
//记得要free
free(tmp);
}
注意事项已在代码中标注。
注意其中关键点
算法的性能分析:和快速排序类似
算法的时间复杂度:O(nlogn)
算法的空间复杂度:O(n) 【O(n)是堆上的临时数组,O(logn)是函数栈帧,两者取大,所以是O(n)】
稳定性:稳定
调用TestOP()来测一下,归并排序还是挺好的。
和快速排序一样,我们也要学习一下它的非递归实现。毕竟栈区上的函数栈帧,空间复杂度是O(logn),有栈溢出的风险。 那么我们该如何实现它的非递归呢.
我们来分析一下它用递归的目的:将整个数组分成一个个元素,然后返回。
那我们可以这样,直接从递归的返回阶段开始。具体如下图
看起来也挺简单呀,但事实上,这看似简单的过程隐藏着许多陷阱。
我们先按照上面的思路写一个段代码,之后我们来分析其问题(注意以下内容通过画图来理解更轻松)。
请读者根据下面的代码以及自己画图来领悟
void MergeSortNonR(int* a, int sz) {
int* tmp = (int*)malloc(sizeof(int) * sz);
if (tmp == NULL) {
perror("malloc fail");
return;
}
int gap = 1;
while (gap < sz) {
//注意gap和i的变化
for (int i = 0; i < sz; i += 2*gap) {
int left1 = i, right1 = i + gap - 1;
int left2 = i + gap, right2 = i + 2 * gap - 1;
int end = i;
while (left1 <= right1 && left2 <= right2) {
if (a[left1] <= a[left2]) {
tmp[end++] = a[left1++];
}
else {
tmp[end++] = a[left2++];
}
}
//假如1有剩
while (left1 <= right1) {
tmp[end++] = a[left1++];
}
//假如2有剩
while (left2 <= right2) {
tmp[end++] = a[left2++];
}
//拷贝tmp到a
memcpy(a + i, tmp + i, sizeof(int) * (end-i));
}
gap *= 2;
}
free(tmp);
}
你认为这就行了吗?让我们来运行一下。
错误,怎么回事呢?这就是归并排序非递归真正困难的地方。
你以为想明白left1,right1,i,gap……之间的关系就可以写出来,但这只不过是跨过一个陷阱进入下一个陷阱中,而这个陷阱便是边界问题。
那么我们该如何去找出并解决问题呢?调试。但这个地方并不好调,它有好几层循环,即使通过打断点也不容易。这里我们可以用到一个小技巧 - 打印。
再次运行
原数组有10个元素,下标最大为9,但上面的数可有很多超过9的数呀。这代表什么?代表它在疯狂越界。那怎么办?应该修正。那怎么修正呢?让我们一步步分析。
//归并排序的非递归
void MergeSortNonR(int* a, int sz) {
int* tmp = (int*)malloc(sizeof(int) * sz);
if (tmp == NULL) {
perror("malloc fail");
return;
}
int gap = 1;
while (gap < sz) {
for (int i = 0; i < sz; i += 2*gap) {
int left1 = i, right1 = i + gap - 1;
int left2 = i + gap, right2 = i + 2 * gap - 1;
int end = i;
//printf("[%d %d][%d %d] ", left1, right1, left2, right2);
if (left2 >= sz) {
break;
}
if (right2 >= sz) {
right2 = sz - 1;
}
while (left1 <= right1 && left2 <= right2) {
if (a[left1] <= a[left2]) {
tmp[end++] = a[left1++];
}
else {
tmp[end++] = a[left2++];
}
}
//假如1有剩
while (left1 <= right1) {
tmp[end++] = a[left1++];
}
//假如2有剩
while (left2 <= right2) {
tmp[end++] = a[left2++];
}
//拷贝tmp到a
memcpy(a + i, tmp + i, sizeof(int) * (end-i));
}
gap *= 2;
//printf("\n");
}
free(tmp);
}
它的效率与递归版本没什么区别,这里就不测试了。
时间复杂度:O(n) 【这里就只有在堆上开辟的临时数组】