一、交换排序
基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置。交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
1.1 冒泡排序
说起冒泡排序,这也算是在我们学习编程时遇到的第一个排序算法,总体逻辑就是从待排序数组第一个一直向后遍历,遇到比自己大的就记录该值,遇到比自己小的就交换,直到到达待排序数组结尾,此时待排序数组长度--,依此逻辑每次都能将最大值移动到最后。直到待排序数组长度为0,此时数组已有序。
冒泡排序动态演示:
在实现代码时,还可以增加一个变量bool exchange = true
,如果一趟遍历下来,没有任何数据进行交换,则exchange
不变,代表此时数组已有序,那么便直接结束排序(if(exchange == true) break;
);如果发生数据交换,则改变exchange
值为false
,那么排序任然继续下一趟。 代码如下:
//冒泡排序 void BubbleSort(int* a, int n) { //排序趟数 for(int i = 0; i < n - 1; ++i) { bool exchange = true; //每趟需要排的元素 for(int j = 0; j < n - 1 - i; ++j) { //左大于右就交换 if(a[j] > a[j + 1]) { Swap(&a[j], &a[j + 1]) exchange = false; } } if(exchange == true) break; } }
冒泡排序的特性总结:
- 冒泡排序是一种非常容易理解的排序
- 时间复杂度:
O(N^2)
- 空间复杂度:
O(1)
- 稳定性: 稳定
1.2 快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
那么具体是如何实现的呢?可以参考如下过程:
事实上整体逻辑就是二叉树的前序遍历,即先处理当前数组,取基准值,以基准值为标准划分左右两部分,然后递归基准值左部分([begin, div - 1]
),最后在递归基准值右部分([div + 1, end]
)。 将begin >= end
作为结束条件,说明当前已经递归到的部分的数据个数<= 1
,return
回到上层递归。
整个递归逻辑的主体部分为partion()
函数,用来划分基准值左右两部分,并返回当前基准值的下标。 可以使用三种方法来实现partion()
函数,即初版hoare
法,挖坑法,前后指针法,后两者是对hoare
法的改进,下面将逐一介绍!
递归框架:
// 假设按照升序对a数组中[bedin, end]区间中的元素进行排序 void QuickSort(int* a, int begin, int end) { if(begin >= end) return; // 按照基准值对a数组的 [begin, end]区间中的元素进行划分 int div = partion(a, begin, end); // 划分成功后以div为边界形成了左右两部分 [begin, div - 1] 和 [div + 1, end] // 递归排[begin, div - 1] QuickSort(a, begin, div - 1); // 递归排[div + 1, end] QuickSort(a, div + 1, end); }
上述为快速排序递归实现的主框架,发现与二叉树前序遍历规则非常像,我们可以参照二叉树前序遍历(如有疑问请参考:【数据结构和算法】— 二叉树(3)–二叉树链式结构的实现(1))规则即可快速写出来,后序只需分析如何按照基准值来对区间中数据进行划分的方式即可。
将区间按照基准值划分为左右两半部分的常见方式有:
1.2.1 hoare法
hoare
法 动态演示:
hoare法主要思想:定义两个变量分别对应待排序数组首元素下标int left = begin,待排序数组尾元素下标int right = end。右边先向左找小于a[keyi]的值,找到后,左边在向右找大于a[keyi]的值,然后交换两数Swap(&a[left], &a[right])。需要注意的是,在寻找过程中要保证left < right。最后直到left == right,将基准值和相遇点的值交换Swap(&a[keyi], &a[left]),并返回基准值下标return keyi。
hoare
法代码:
//hoare法 int partion(int* a, int begin, int end) { int left = begin, right = end; //设基准值为首元素,keyi记录下标 int keyi = begin; while(left < right) { //右向左 找小于a[keyi]的值 while(left < right && a[keyi] <= a[right] --right; //左向右 找大于a[keyi]的值 while(left < right && a[keyi] >= a[left] ++left; //找完一次便交换, Swap(&a[left], &a[right]); } //left 和 right 相遇交换基准值和 < a[keyi] 的值(即a[left]) Swap(&a[left], &a[keyi]); return left; }
相信看完上面上面代码和演示,会有这么一个问题:为什么相遇的位置比a[keyi]
要小呢?
因为左边做keyi
,右边right
先走:
- 情况1:
right
先遇到left
。此时right
没有找到比a[keyi]
小的,一直走,直到遇到left
,相遇位置是a[left]
,一定比a[keyi]
小; - 情况2:
left
先遇到right
。因为是right
先走,找到小于a[keyi]
的元素便会停下,接着left
找大,没有找到,遇到了right
停下来了,相遇点是right
,比a[keyi]
要小。
当然如果右边做基准值,那么必须要左边left
先走,才能确保相遇点都大于a[keyi]
。
1.2.2 挖坑法
挖坑法 动态演示:
挖坑法主要思想:先取出基准值,并将其赋值给key ,此时这个位置就空出来了,称之为坑位,用holei来记录坑位的下标。 紧接着右边向左找小(即小于key的元素),若找到就将该元素填入坑位中a[holei] = a[end],并改变坑位为替换元素的下标holei = end,然后左边向右找大(即大于key的元素),同样填入坑位,并更新坑位。依次循环,直到左右相遇begin == end,这时将基准值填入坑中(a[holei] = key),并返回坑位return holei;。
挖坑法代码:
//挖坑法 int partion(int* a, int begin, int end) { //基准值记录 int key = a[begin]; //坑位记录 int holei = begin; while(begin < end) { //右边找小,填左坑,更新坑位 while(begin < end && a[end] >= key) --end; a[holei] = a[end]; holei = end; //左边找小,填右坑,更新坑位 while(begin < end && a[begin] <= key) ++begin; a[holei] = a[begin]; holei = begin; } //基准值填入坑位 a[holei] = key; return holei; }
1.2.3 前后指针法
前后指针法 动态演示:
前后指针法主要思想:事实上这里定义的并不是真正的指针,而是整形来模拟指针。 将初始位置定义为前指针int prev = begin;,后一个位置定义为后指针int cur = begin + 1;,并记录基准值int key = a[begin];。当后指针指向的元素大于key时,++cur;若找到了小于key的值,那么就++prev,在判断prev != cur之后,就交换前后指针对应的值(Swap(&a[prev], &a[cur]));若相等则跳过。最后在交换基准值和前指针对应的元素(Swap(&a[prev], &a[keyi])),并返回前指针对应元素下标(return prev;)。
cur
遇到比key
大的值,++cur
;cur
遇到比key
小的值,Swap(&a[prev], &a[cur])
,++cur
,++prev
。
前后指针法代码:
//前后指针法 int partion(int* a, int begin, int end) { int prev = begin, cur = begin + 1; int keyi = begin; //注意此处 <= while(cur <= end) { //将两种逻辑合二为一 if(a[cur] < a[keyi] && ++prev != cur) Swap(&a[prev], &a[cur]); ++cur; } Swap(&a[prev], &a[keyi]); return prev; }
1.3 快速排序优化
1.3.1 三数取中法选key
考虑下面这种情况:当数组已经有序或者极其接近有序时,再使用递归法写快速排序,时间复杂度如何?
此时基准值如果还选择待排序数组第一个元素的话,那么每层递归便缺少基准值左部分的递归(即begin >= end
),只有右部分, 这样待排序的数组只减少了一个元素,递归深度由原来的log(N)
变成了N
,时间复杂度也随之变成了O(N^2)
。
于是乎,一般会选择三数取中法来确定基准值,这样选出来的数既不会是最大值,也不会是最小值。三数指的是:待排序数组第一个元素a[begin],待排序数组最后一个元素a[end],待排序数组中间那个数a[(begin + end) / 2];取中指:取这三个数的中位数。 取到之后与初始值交换 Swap(&a[midi], &a[begin]);,确保第一个值为中位数。实现代码如下:
//三数取中 int GetMidi(int* a, int begin, int end) { int midi = (begin + end) / 2; //begin midi end 三个数选中位数 if(a[begin] < a[midi]) { if(a[midi] < a[end]) return midi; else if(a[begin] > a[end]) return begin; else return end; } else //a[begin] >= a[midi] { if(a[midi] > a[end]) return midi; else if(a[end] > a[bedin]) return begin; else return end; } } int midi = GetMidi(a, begin, end); Swap(&a[midi], &a[begin]);
1.3.2 递归到小的子区间使用插入排序
为什么说递归到小的子区间要使用插入排序呢? 根据这里写的递归的特性,可以看出整体的逻辑是一棵二叉树。说到二叉树,二叉树最后一层的节点数大约占整个二叉树的节点数的一半,这样一来时间复杂度便会升高。那么我们只需要想办法将最后两三层递归逻辑,使用其他效率高的排序给替换掉即可。 这样一来如果替换掉两层,就减少了大约75%的递归,替换三层,大约就减少了87.5%的递归。
小区间优化代码如下:
//小区间优化 void QuickSort(int* a, int begin, int end) { if(begin >= end) return; if(end - begin + 1 <= 10) { //end - begin + 1 <= 10 此逻辑大约优化了三层 //插入排序 InsertSort(a + begin, end - begin + 1); } else { int keyi = partion(a, begin, end); QuickSort(a, begin, keyi - 1); QuickSort(a, keyi + 1, end); } }
事实上,在如今的编译器上,此优化是微乎其微的,因为编译器已经帮助我们进行了很多优化,特别是在release版编译程序时。那么此处为什么选择直接插入排序?根据其特性,元素集合越接近有序,直接插入排序算法的时间效率越高。且此时待排序数组的元素个数较少,不适合希尔排序,且他是一种稳定的排序算法。
1.4 快排非递归版
根据递归版快排的特性,相当于二叉树的前序遍历,那么我们便可利用栈后进先出的特性,来模拟递归并实现排序,栈的实现还请参考:【数据结构和算法】— 栈。快排非递归整体逻辑大致如下:
在实现时我们要先创建栈并初始化,然后进栈一对数据(整个待排序数组的下标范围), 以!STEmpty(&s)作为循环条件,当栈中无节点时,便会结束。每进栈一次,便出栈顶两元素作为此次排序的范围,然后进栈div左右两部分的范围,当然只有范围中有一个数据以上才会进栈(即left < div - 1或right > div + 1)。
//非递归,栈模拟 void QuickSortNonR(int* a, int begin, int end) { ST s; STInit(&s); //先进栈一对数据 STPush(&s, end); STPush(&s, begin); while(!STEmpty(&s)) { //出栈一对数据,为此次排序范围 int left = STTop(&s); STPop(&s); int right = STTop(&s); STPop(&s); int div = partion(a, begin, end); //div 右部分进栈 if(right > div + 1) { STPush(&s, right); STPush(&s, div + 1); } //div 左部分进栈 if(left < div - 1) { STPush(&s, div - 1); STPush(&s, left); } } }
快速排序的特性总结:
- 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序;
- 时间复杂度:
O(N*logN)
- 空间复杂度:
O(logN)
- 稳定性: 不稳定
二、归并排序
2.1 归并排序
基本思想:
情况1:right
先遇到left
。此时right
没有找到比a[keyi]
小的,一直走,直到遇到left
,相遇位置是a[left]
,一定比a[keyi]
小此时基准值如果还选择待排序数组第一个元素的话,那么每层递归便缺少基准值左部分的递归(即begin >= end
),只有右部分, 这样待排序的数组只减少了一个元素,递归深度由原来的log(N)
变成了N
,时间复杂度也随之变成了O(N^2)
。
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide andConquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 归并排序核心步骤:
归并排序 动态演示:
2.1.1 递归版
递归版的归并排序,逻辑上与二叉树的后序遍历十分相似。可先找到待排序数组的中间那个数的下标int mid = (begin + end) / 2;
,将左部分[begin, mid]
作为左子树,右部分[mid + 1, end]
作为右子树,继续递归,直到begin >= end
(即当前元素个数小于等于一个)结束并返回,当左右部分递归完便开始合并。
此处合并即为两待排序数组[begin, mid]
和[mid + 1, end]
,向动态开辟的数组tmp
中拷贝并排序。至于合并的逻辑就十分简单,两待排序数组元素依次比较,小的拷贝进tmp
,直到一方拷贝完begin1 > end1
或begin2 > end2
,然后直接拷贝未拷贝完的一方,最后再使用memcpy()
函数将tmp
中数据(此时tmp
中元素已有序)拷回a
中。
代码如下:
//递归版 归并排序 void _MergeSort(int* a, int begin, int end, int* tmp) { if(begin >= end) return; int mid = (begin + end) / 2; //[begin, mid] [mid + 1, end] _MergeSort(a, begin, mid, tmp); _MergeSort(a, mid + 1, end, tmp); //归并 int begin1 = begin, end1 = mid; int begin2 = mid + 1, end2 = end; int i = begin; //将两个数组合并为一个,并排为升序 while(begin1 <= end1 && begin2 <= end2) { if(a[begin1] < a[begin2]) tmp[i++] = a[begin1++]; else tmp[i++] = a[begin2++]; } //拷贝剩余数据 while(begin1 <= end1) tmp[i++] = a[begin1++]; while(begin2 <= end2) tmp[i++] = a[begin2++]; //拷贝回原数组 memcpy(a + begin, tmp + begin, sizeof(int)* (end - begin + 1)); } void MergeSort(int* a, int n) { int* tmp = (int*)malloc(sizeof(int)* n); if(tmp == NULL) { perror("malloc fail"); return; } _MergeSort(a, 0, n - 1, tmp); free(tmp); }
2.1.2 非递归版
归并排序非递归版与递归相似,使用循环来模拟递归的合并逻辑。定义变量int gap
来表示所需合并的两数组的长度,动态开辟长度为n
的数组来存储合并后的数组,用i
来控制待合并数组的初始下标for(size_t i = 0; i < n; i += 2*gap)
(长度小于数组长度,一次跳过两gap
)。
根据i
确定好两待合并数组的首元素下标begin
,尾元素下标end
,然后将两个数组合并为一个,并排为升序。在确定begin
和end
时要注意边界条件的处理(即最后一对待排序数组下标可能超出n
),大致分为以下几种情况:
当情况1时,因为只有一个待排序数组[begin1, end1]
,且此数组已有序所以无需进行合并排序操作,直接break
即可;而情况2,是两个待排序数组,需要合并,但第二个数组可能超出了a
数组的范围,所以要缩小end2
(即end2 = n - 1
)。
代码如下:
//递归版 归并排序 void MergeSort(int* a, int n) { int* tmp = (int*)malloc(sizeof(int)* n); if(tmp == NULL) { perror("malloc fail"); return; } int gap = 1; while(gap < n) { for(size_t i = 0; i < n; i += 2*gap) { int begin1 = i, end1 = i + gap - 1; int begin2 = i + gap, end2 = i + 2*gap - 1; //边界处理 if(end1 >= n || begin2 >= n) break; if(end2 >= n) end2 = n - 1; //[begin1, end1] [begin2, end2] 归并 int j = begin1; //将两个数组合并为一个,并排为升序 while(begin1 <= end1 && begin2 <= end2) { if(a[begin1] < a[begin2]) tmp[j++] = a[begin1++]; else tmp[j++] = a[begin2++]; } //拷贝剩余数据 while(begin1 <= end1) tmp[j++] = a[begin1++]; while(begin2 <= end2) tmp[j++] = a[begin2++]; //拷贝回原数组 memcpy(a + i, tmp + i, sizeof(int)* (end2 - i + 1)); } gap *= 2; } free(tmp); }
在非递归的归并排序中,有这么两个问题值得思考:
- 对比栈实现快排的非递归,归并排序为什么不可以使用栈?
两种排序的非递归写法,本质上与二叉树遍历极其相似。区别在于快速排序的非递归相当于二叉树的前序遍历,可以利用栈后进先出的特性来实现;而归并排序相当于二叉树的后序遍历,只能用循环来模拟实现。 - 上面代码中
memcpy()
函数的拷贝操作与放在for
循环外对比!放在for
循环内(即每归并一小段,就放回原数组a
中),这样避免了随机值。因为当情况1时,break
了,后面的数据(最后一组)并没有放到动态开辟的数组tmp
中,从而导致访问到随机值(即因为拷贝操作放在for
循环外,全部数据,统一拷贝,最后一对数据memcpy()
时,会遇到随机值)
归并排序的特性总结:
- 归并的缺点在于需要
O(N)
的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。 - 时间复杂度:
O(N*logN)
- 空间复杂度:
O(N)
- 稳定性: 稳定