【C/排序算法】:快速排序和冒泡排序
目录
- 一,快速排序
- 1. 挖坑法
- 2. 快速排序的优化
- 3. Hoare法
- 4. 前后"指针"法
- 二,快速排序总结:
- 三,冒泡排序
一,快速排序
快速排序是一种比较复杂的排序算法,它总共有4种实现方式,分别是挖坑法,左右"指针"法,前后"指针"法,以及非递归的快速排序 (本文只讲述递归实现,非递归实现以后有专门的文章) ,并且这些算法中也会涉及多种优化措施,比如三数取中,小区间优化,下面都会一一介绍。
由于它效率极高的缘故,快速排序也是日常开发中使用最多的,最重要的排序算法。
1. 挖坑法
1.1 基本思想:
任取待排序元素序列中的某元素(一般选最左边或最右边的元素)作为基准值(也叫做 key 关键字),按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
1.2 一趟排序图解如下:
给定一无序数组,选第一个元素为关键字 key = 6
我们选定关键字 key = 6后,就说明6的位置就可以被覆盖了,所以我们就说左边形成了一个****坑,用pivot 表示。
左边有坑,右边的 end 要从最后一个元素开始找比 key 小的数,找到后放到左边的坑里,所以5放进了坑中
5被拿走之后,右边它原来所在的位置就形成了一个新坑,此时,左边的 begin 要开始找比 key 大的数,找到后放到右边的坑里,所以7放进了坑中
7被拿走后,左边又形成了一个新坑,此时,end 又要开始找比 key 小的数放到左边的坑里,所以4放进了坑中
此时,右边又形成了新坑,begin 要开始找比 key 大的数,找到后放到右边的坑里,所以9放进了坑中
左边又形成了坑,右边 end 开始找,找到了3,放入坑中
最后一次 begin++ 后,begin 和 end 重叠了,并且它们一定相遇在坑中,此时,把 key 放入坑中即可。
上述操作只是第一趟排序,只排好了一个数,此时第一个基准 key = 6已经在它合适的位置上了(排好序后的位置),后面对左右子序列排序时6不动。并且已经把数组分成了两个子序列,以 key 为基准,左边的元素都比它小,右边的元素都比它大。
1.3 单趟排序的代码实现如下:
注意:第二个和第三个 while 中的 begin 不能缺少,要防止在找大和找小的时候 begin 和 end 错开或是在极端情况下(比如已经升序时)end一直减导致越界。
int PartSort1(int* arr, int sz) { int begin = 0; int end = sz -1; int key = arr[begin]; int pivot = begin; //这是排一趟,只排好了一个数 while (begin key) { end--; } //小的放到了左边的坑里,右边end自己形成了新的坑 arr[pivot] = arr[end]; pivot = end; //右边有坑,左边end找比key大的 while (begin1.4 整体排序
要利用分治递归思想。第一趟排序把整个数组分割成了左子序列和右子序列,如果左右子序列都有序了,那么整个数组就有序了,所以再递归使用前面的挖坑算法,再找出关键字,再把左右子序列分割成子序列…… 直到关键字的左右两边只有一个数据不可再递归,或者是关键字的左序列,右序列都是有序,那么整体就有序了。
如图所示:
1.5 整体排序过程代码实现如下:
注意:因为是左右子序列,所以要控制一个区间。
void QuickySort(int* arr, int left,int right) { //当左右子区间不存在,或只有一个元素时, //就不需要递归了,排序完成 if (left >= right) { return; } int begin = left; int end = right; int key = arr[begin]; int pivot = begin; //这是排一趟,只排好了一个数 while (begin key) { end--; } //小的放到了左边的坑里,右边end自己形成了新的坑 arr[pivot] = arr[end]; pivot = end; //右边有坑,左边end找比key大的 while (begin2. 快速排序的优化
2.1 三数取中
上文快排的算法思想有一个致命的缺陷:那就是当数据为有序时,其时间复杂度为O(N*N)。
原因:这是因为在取关键字 key 的值时,一直都是选最左边(或最右边)的数据。当数组本为升序时,每次关键字的右子序列的值都比它大,再次递归调用时,右子序列的子序列也是如此(降序同理)。
所以这个缺陷的原因就是 key 的取值。
那该如何取 key的值呢?一个比较好的方法是三数取中。
三数取中:并不是指取所有数据中间的那数,而是指在三个数中取那个不大不小的中间数,这个数可能在最左边,也可能在最右边。
通过这种类似随机选数的方法,就能保证一定不是数据中最大或最小的值做 key。
2.1.1 三数取中的代码的实现:
//三数取中 int GetMidIndex(int* arr, int left, int right) { //右移有除2的效果 int mid = (left + right) >> 1; if (arr[mid] > arr[left]) { if (arr[mid] arr[right]) { return left; } else { return right; } } else //arr[mid] arr[right]) { return mid; } else if (arr[left]但是挖坑算法中我们习惯拿 begin 作为 key ,为了保持挖坑算法不被改变,我们把 begin 指向的值和通过三数取中选出的数的指向的值进行交换,确保 key 仍是begin指向的值。
代码实现为:
void Swap(int* p1, int* p2) { int tmp = *p1; *p1 = *p2; *p2 = tmp; } void QuickySort(int* arr, int left,int right) { //当左右子区间不存在,或只有一个元素时, //就不需要递归了,排序完成 if (left >= right) { return; } int begin = left; int end = right; int index = GetMidIndex(arr, left, right); Swap(&arr[index], &arr[left]);//交换一下,保证key还是最左边的数 int key = arr[begin]; int pivot = begin; //这是排一趟,只排好了一个数 while (begin key) { end--; } //小的放到了左边的坑里,右边end自己形成了新的坑 arr[pivot] = arr[end]; pivot = end; //右边有坑,左边end找比key大的 while (begin2.2 小区间优化
我们知道在函数调用的过程中会在内存中建立栈帧,栈帧的建立也是需要时间和空间的。假设用上述代码排100W个数据,则大致有20层的递归调用,但是在最后几层中就大概调用了80多万次函数,它占用了栈帧的绝大多数空间和时间。
那么有人就会想,能不能把最后几层的函数递归调用消除呢?
官方给出的一种方法是小区间优化法,用于减少递归调用次数。
就是在排序的过程中当左右子序列中的数据个数大于某个数量时,不进行递归了,而是选用其他排序算法进行排序。这里一般用插入排序。
2.2.1 小区间优化的代码实现:
(注意:插入排序的算法这里没有给出,想要了解的请前往我的主页。)
//小区间优化法:减少递归调用次数 // keyindex - 1 - left 指子序列中的元素个数 // > 10是我们控制的一个界限 if (keyindex - 1 - left > 10) { QuickySort(arr, left, keyindex - 1); } else { // arr + left 是指这时的子序列不一定从第一个元素开始 //keyindex - 1 - left + 1 是指元素的个数 InsertSort(arr + left, keyindex - 1 - left + 1); } if (right - (keyindex + 1) > 10) { QuickySort(arr, keyindex + 1, right); } else { InsertSort(arr + keyindex + 1, right - (keyindex + 1) + 1); }但是由于小区间优化所带来的效率提升并不显著,而且它是与我们所控制的那个界限有关,所以平时并没有过于注重这个优化。
3.挖坑法的完整排序代码
void Swap(int* p1, int* p2) { int tmp = *p1; *p1 = *p2; *p2 = tmp; } //三数取中 int GetMidIndex(int* arr, int left, int right) { //右移有除2的效果 int mid = (left + right) >> 1; if (arr[mid] > arr[left]) { if (arr[mid] arr[right]) { return left; } else { return right; } } else //arr[mid] arr[right]) { return mid; } else if (arr[left] key) { end--; } //小的放到了左边的坑里,右边end自己形成了新的坑 arr[pivot] = arr[end]; pivot = end; //右边有坑,左边end找比key大的 while (begin = right) { return; } int keyindex = PartSort1(arr, left, right); // [left] keyindex [right] // [left keyindex -1] keyindex [keyindex +1 right] // 左子区间和右子区间有序,整体就有序了 QuickySort(arr, left, keyindex - 1); QuickySort(arr, keyindex + 1, right); //或是 /*if (keyindex - 1 - left > 10) { QuickySort(arr, left, keyindex - 1); } else { // arr + left 是指这时的子序列不一定从第一个元素开始 //keyindex - 1 - left + 1 是指元素的个数 InsertSort(arr + left, keyindex - 1 - left + 1); } if (right - (keyindex + 1) > 10) { QuickySort(arr, keyindex + 1, right); } else { InsertSort(arr + keyindex + 1, right - (keyindex + 1) + 1); }*/排序结果为:
3.1 时间复杂度与稳定性
挖坑法的时间复杂度是O(N*logN),是不稳定的排序。
3. Hoare法
3.1 算法思想:
与挖坑法类似,一般也要用三数取中法选一个关键字做 key,最终也是把整个数组分割成左右两个子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值。
只是实现的方式不同,左右"指针"法是分别从数组的最左边和最右边开始找数,左边的 begin 找比 key大的数,右边的 end 找比 key 小的数,找到后把这两个位置上的数交换,直到分割成左右两个子序列,然后左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
3.2 单趟排序的图解如下:
给定一无序数组,选第一个元素为关键字 keyi = 6,这里的keyi是数组的下标
begin++ 找比 keyi 大的数,end – 找比 keyi 小的数,找到后停下来交换
重复上述操作
最后当 begin 和 end 相遇时,把相遇位置上的数与关键字 keyi所在位置的数交换:
有人可能会想,为什么相遇位置的数据一定比keyi位置的数据小呢?
答案:这是右边先走保证的。
最终排完第一趟后,以 keyi所指向的数6为基准,左边的元素都比它小,右边的元素都比它大。
递归图解如下:
3.3 单趟排序的代码实现:
注意:
1.代码中的三数取中函数与交换函数在上文,此处就直接调用
2.第二个和第三个while中的 begin
4.复杂度和稳定性的分析
1.最坏:有序或接近有序时,时间复杂度为O(N^2) 数据为随机值时,时间复杂度为O(N*logN)。
2.空间复杂度:由于递归要建立栈帧,O(logN)。
2.快速排序算法是不稳定的。
三,冒泡排序
1.基本思想:
从序列的一端开始往另一端冒泡,依次比较相邻的两个数的大小。
设数组长度为N。
1.每轮比较相邻的前后两个数据,如果前面数据大于(或者小于)后面的数据,就将这两个个数据交换。
2.这样每轮对数组的第0个数据到N-1个数据进行一次遍历后,最大或者最小的一个数据就到数组第N-1个位置。
3.第一轮比较到下标为N-1的数据(最后一个),以后每次比较都-1。
2.图解冒泡排序:
以 [ 8,2,5,9,7 ] 这组数字来做示例:
从左往右依次冒泡,将小的往右移动(排降序)
第一轮冒泡:
首先比较第一个数和第二个数的大小,我们发现 2 比 8 要小,那么保持原位,不做改动。位置还是 8,2,5,9,7 。指针往右移动一格,接着比较:
比较第二个数和第三个数的大小,发现 2 比 5 要小,所以位置交换,交换后数组更新为:[ 8,5,2,9,7 ]。
指针再往右移动一格,继续比较:
比较第三个数和第四个数的大小,发现 2 比 9 要小,所以位置交换,交换后数组更新为:[ 8,5,9,2,7 ]。同样,指针再往右移动,继续比较:
比较第 4 个数和第 5 个数的大小,发现 2 比 7 要小,所以位置交换,交换后数组更新为:[ 8,5,9,7,2 ]。
下一步,指针再往右移动,发现已经到底了,则本轮冒泡结束,处于最右边的 2 就是已经排好序的数字。
通过这一轮不断的对比交换,数组中最小的数字移动到了最右边。
重复上述步骤,得到的最终结果是:
3.代码实现冒泡排序如下:
void Swap(int* p1, int* p2) { int tmp = *p1; *p1 = *p2; *p2 = tmp; } void BubbleSort(int* arr, int sz) { for (int j = 0; j4.冒泡排序的小优化:
假设我们要排降序,如果数组此时就是降序,那么在第一轮比较过后数据并没有发生交换,那就不要再进行多于的后续比较了,直接跳出循环即可。
void BubbleSort(int* arr, int sz) { for (int j = 0; j arr[i]) { //前一个比后一个大,就交换 Swap(&arr[i - 1], &arr[i]); //如果不是有序的就发生了交换,exchange=1 exchange = 1; } } //如果一趟比较过后发现是有序的,就直接跳出循环 if (exchange == 0) { break; } } }5.时间复杂度和稳定性的分析
最好:就是顺序时,时间复杂度为O(N)
乱序时:时间复杂度为O(N*N)
所以冒泡排序的时间复杂度是O(N*N)。
冒泡排序算法是稳定的。






















