八大排序算法之快速排序(下篇)(快排的优化+非递归快排的实现)
目录
1.快速排序的实现:
快速排序的单趟排序(排升序)(快慢指针法实现):
2.未经优化的快排的缺陷
二.快速排序的优化
1.三数取中优化
优化思路:
2. 小区间插入排序优化
小区间插排优化的递归快排:
三.非递归快速排序的实现
1.快排一个难以避免的缺陷(暂不考虑三指针单趟排序优化)
2.非递归快排的实现思路
数据结构栈模拟系统栈算法思想:
非递归快排代码实现:
一.前言
1.快速排序的实现:
🤪快排的详细实现原理参见青菜的博客🤪: http://t.csdn.cn/0bf1ghttp://t.csdn.cn/0bf1g下面简单回顾一下快排的核心思想:
快速排序的单趟排序(排升序)(快慢指针法实现):
int PartionV3(int* arr, int left, int right)//完成1个元素的排序,同时分割数组 { assert(arr); int key = left; //选取数组左端元素作为key值 int slow = left; //slow从left开始 int fast = left + 1; //fast从left+1开始遍历数组 while (fast <= right) { if (arr[fast] < arr[key]) //fast找到比key小的值 { ++slow; //slow指向下一个位置 if (slow != fast) //fast和slow相等没必要交换 { swap(&arr[slow], &arr[fast]);//交换slow和fast所指向的值 } } ++fast; //fast遍历数组 } swap(&arr[key], &arr[slow]); //最后交换key和slow所指向的变量 return slow; //返回slow位置下标 }
- 🤪假设乱序数组有N个元素,则需要进行N趟单趟排序(每次单趟排序可以完成一个key元素在有序序列中的归位)
- 🤪直接循环N-1次单趟排序的时间复杂度为O(N^2)
- 🤪为了降低排序的时间复杂度的数量级,我们采用分治递归思想来完成这N次单趟排序
- 🤪基本思想是:每次单趟排序完成后,以被归位的key元素为划分点,将数组划分为在排序意义上互不关联的两个子数组(左子数组中每个元素都比key小,右子数组中每个元素都比key大),从而使得后续每次单趟排序需要遍历的元素个数呈指数式递减,总体排序的时间复杂度也因此降阶,递归过程中数组被逐步划分过程的图示:(划分结构逻辑上类似于二叉树)
上图中每个数组拆分层次中(一个拆分层次中有多个子数组),单趟排序所需遍历的总的元素个数的数量级为O(N),拆分层次的数量级为O(logN),因此上述情形下,快排的总体时间复杂度为O(NlogN).
😜这里我们假设所处理的数组都是逆序数极大的乱序序列:这种情况下,每次单趟排序所确定的数组分割点有极大的可能是在数组的中间位置。
😜对应上图完成排序的分治递归代码:
//每完成一次数组分割,就进行左右子数组分治递归完成每一个元素的排序 void QuickSort2(int* arr, int left, int right) { assert(arr); if (left>=right) //子数组只剩1个元素时(或left和right错开时)停止递归 { return; } int breakpoint = PartionV3(arr, left, right); //找到数组分割点(同时也完成了一个元素的排序) //左右子数组分治递归 QuickSort(arr, left, breakpoint - 1); //处理左子数组 QuickSort(arr, breakpoint + 1, right); //处理右子数组 }
- 😜注意递归结束条件的控制😜
2.未经优化的快排的缺陷
- 单趟排序时,我们将子数组的左端元素作为key,因此在处理有序或接近有序以及倒序或完全倒序的序列时,大多数情况下经过单趟排序后,数组的分割点(key元素的最终位置)会停留在数组左端(倒序的情况则停留在数组右端),于是整个排序过程中,数组被逐步划分过程的图示:
- 此时划分层次数量级为O(N),每个划分层次中单趟排序所需遍历的元素个数呈等差数列递减,此时分治递归的时间复杂度计算公式为等差数列求和式,数量级升阶为O(N^2);
- 为了避免这种情况出现,我们需要对key元素的选取方式进行优化
二.快速排序的优化
1.三数取中优化
优化思路:
- 为了避免序列有序或接近有序时,分治递归过程中不断在数组区间端点处对数组进行划分(从而导致数组划分总次数数量级为O(N)),我们采用三数取中的方式优化key的选取:
- 设计一个接口,确定数组的首元素,尾元素和中间位置元素三者中的中位数(中间大小的数),并返回其下标.接口首部:
int GetMid(int* arr,int left,int right)
接口实现:
int GetMid(int* arr,int left,int right) { int mid = left + ((right - left) >> 2); //在arr[left],arr[mid],arr[right]三者中取中间值作为key,返回key的下标 if (arr[left] < arr[right]) { if (arr[left] < arr[mid] && arr[mid] < arr[right]) { return mid; } else if (arr[mid] > arr[right]) { return right; } else { return left; } } else { if (arr[left] > arr[mid] && arr[mid] > arr[right]) { return mid; } else if (arr[mid] > arr[left]) { return left; } else { return right; } } }
确定数组的首元素,尾元素和中间位置元素三者中的中位数后,将其交换到arr[left]的位置,后续的单趟排序过程我们依然选择arr[left]作为key元素
三数取中优化后的单趟排序:
int PartionV3(int* arr, int left, int right)//完成1个元素的排序,同时分割数组 { assert(arr); swap(&arr[left], &arr[GetMid(arr, left, right)]); int key = left; //在arr[left],arr[mid],arr[right]三者中取中间值作为key int slow = left; //slow从left开始 int fast = left + 1; //fast从left+1开始遍历数组 while (fast <= right) { if (arr[fast] < arr[key]) //fast找到比key小的值 { ++slow; //slow指向下一个位置 if (slow != fast) //fast和slow相等没必要交换 { swap(&arr[slow], &arr[fast]);//交换slow和fast所指向的值 } } ++fast; //fast遍历数组 } swap(&arr[key], &arr[slow]); //最后交换key和slow所指向的变量 return slow; //返回slow位置下标 }
经过三数取中后的单趟排序,面对有序序列时,数组分割点(key元素的最终位置)会确定在数组的中间位置,因此分治递归时,函数栈帧的逻辑分布会呈现出满二叉树的结构(递归深度最小),也就意味着,在面对有序(或接近有序)的序列时,三数取中优化后的快排有着最高的排序效率(相比于排序其余乱序序列的情况).这一点大大增加了快排在各种实际场景中的适用性
(每个子数组代表一个函数栈帧)
同时三数取中优化也让快排的单趟排序在处理各种乱序序列时,令数组的分割点(key元素的最终位置)尽可能地接近数组中间位置
2. 小区间插入排序优化
- 分治递归过程中的函数栈帧逻辑分布(每一个子数组代表了一次函数递归调用):
- 根据二叉树的结构特点(满二叉树最后一层结点个数占总结点个数的一半)可知,元素个数为1个(以及2个3个等等)的子数组个数最多(一个子数组代表一次函数递归调用),因此我们可以修改递归的结束条件:当子数组的元素个数小于10(可以在一定范围内任意设定)时,对子数组进行插入排序后结束递归
- 插入排序接口:
void InsertSort(DataType* arr, int size) { assert(arr); for (int end = 0; end < size; end++) //用end来维护数组前end个元素构成的有序序列 { int x = arr[end]; //x为待插入有序序列的数据 int insert = end; //用变量insert来确定x要插入的位置 while (insert>0) //通过元素比较确定x要插入的位置 { if (x < arr[insert-1]) //说明insert不是x要插入的位置 { arr[insert] = arr[insert-1]; //往后挪动数据为x留出空位 insert--; //令insert指向序列前一个元素 } else { break; //有序序列中x>=arr[insert-1]说明insert是x要插入的位置 } } //最坏的情况下x会被插入到数组的首地址处(此时数据比较和挪动了end次) arr[insert] = x; //完成元素x的插入(继续插入下一个元素) } }
小区间插排优化的递归快排:
//每完成一次数组分割,就进行左右子数组分治递归完成每一个元素的排序 void QuickSort(int* arr, int left,int right) { assert(arr); if (right - left+1 <=10) //子数组只剩10个元素时(小区间插排)停止递归 { InsertSort(arr + left, right - left+1); return; } int breakpoint = PartionV3(arr, left, right); //找到数组分割点(同时也完成了一个元素的排序) //左右子数组分治递归 QuickSort(arr, left, breakpoint - 1); //处理左子数组 QuickSort(arr, breakpoint + 1, right); //处理右子数组 }
- 经过上述小区间插入排序处理后的分治快排,函数的递归调用次数减少了一半以上,这对于递归优化比较落后的编译平台是非常友好的
附:由于现代编译器对于递归函数的时间效率会进行大幅程度的优化,因此快排的小区间插排优化实测效果并不明显
三.非递归快速排序的实现
1.快排一个难以避免的缺陷(暂不考虑三指针单趟排序优化)
- 当序列为常序列(所有元素相同)或序列中有大量(绝大部分)元素相同时,快排递归中数组的划分过程也会出现下面类似的情况:
- 其原因与前面未经优化的快排面对有序序列时时间复杂度升阶的原因是一致的(常序列是特殊的有序序列),并且面对常序列或大量(绝大部分)元素相同的序列时,三数取中优化是起不到任何作用的(三个元素都是相同的,取中位数没有任何意义)
- 因此使用递归快排时,如果处理的序列为常序列或大量(绝大部分)元素相同的序列,会有一定的栈溢出风险(上图中的递归深度为O(N),即递归过程中会有O(N)数量级个函数栈帧同时存在)(此时快排的时间复杂度为O(NlogN)
- (在不考虑三指针单趟排序优化的情形下)常序列(或大量(绝大部分)元素相同的序列)要避免使用快排进行处理
- 实际运用中为了节省系统栈空间,我们需要用数据结构栈来模拟系统栈,利用循环结构实现快排.
2.非递归快排的实现思路
- 在快排递归的过程中,每个函数栈帧中存储的关键变量是待排序的子数组的左右端下标(left和right)
- 函数栈帧进出系统栈的规则满足后进先出
- 因此我们可以用数据结构中的整形栈来存储每个待排序的子数组的左右端点下标,借助数据结构栈我们就可以利用循环结构来模拟递归的过程
函数首部:
void QuickSortNonR(int* arr, int begin, int end)
begin和end是待排序的数组左端下标和右端下标(两个下标都指向有效元素)(闭区间)
- 为了方便我们借助C++STL的栈对象来完成模拟递归,快排递归(前序递归)遍历次序:
数据结构栈模拟系统栈算法思想:
- 先将原数组的区间左右端下标压入栈中,接着开始执行循环:
- 首先,利用left和right变量来接收栈顶区间
- 对[left,right]子数组进行单趟排序,完成单个元素排序的同时确定数组的分割点下标breakpoint
- 将[left,right]数组的右子数组[breakpoint+1,right]区间和左子数组[left,breakpoint-1]区间压入栈中(注意右子数组区间要先入栈,左子数组区间要后入栈,保证后续左子数组先出栈被处理)(左右子数组区间入栈的前提是区间中的元素个数大于或等于2)
- 重复上述步骤直到栈为空,则完成了分治递归的模拟过程(相当于模拟了前序遍历的递归过程)
- 算法gif:
非递归快排代码实现:
void QuickSortNonR(int* arr, int left, int right) { assert(arr); stack<int> range; if (right > left) //区间中有两个以上元素时,区间入栈 { range.push(right); //取的时候先取左边界,因此左边界后入栈(令左边界压在右边界上面) range.push(left); //初始区间入栈; } while (!range.empty()) { int left = range.top(); //令栈顶区间出栈 range.pop(); int right = range.top(); range.pop(); int breakpoint = PartionV3(arr, left, right); //确定区间分割点,同时完成一个数组元素的排序 //栈是后进先出,递归是先向左子数组递归,因此做左子数组区间后入栈 if (right>breakpoint+1) //若子区间元素个数大于一个则区间入栈 { range.push(right); range.push(breakpoint + 1); } if(breakpoint-1>left) { range.push(breakpoint - 1); range.push(left); } } }
- 区间入栈时注意左右端下标入栈顺序
代码与艺术: 优质好文,博主的文章细节很到位,兼顾实用性和可操作性,感谢博主的分享,期待博主持续带来更多好文 也欢迎您来逛逛我的博客互三哦~
做而论道_CS: 计算机根本就不用原码和反码,只用补码。 所以,原码反码,都不需要讨论。 对补码的理解,也不用弄这么复杂。 所谓的补码,不过就是一个小学的知识点而已。 你看十进制,两位数:0~99。 可以有:27 + 99 = (一百) 26 也可以:27 - 1 = 26 如果你忽略进位,依旧保持两位数, 这两种算法的功能,就是相同的。 即,当你舍弃了进位: 正数,就可以当负数使用、用加法,就能实现减法运算。 在计算机中舍弃进位,会怎样? 计算机中,就全是正数了。 没有了负数,减法也不存在了,减法器,当然也没用了。 计算机有一个加法器,就能横行天下! ================== 舍弃进位,就是补码的来历和存在意义。 ================== 在两位十进制时,舍弃进位,就是减去一百。 那么,加上 99,再减去 100,当然就是 “-1 ” 了。 八位二进制数是:0000 0000 ~ 1111 1111。 也就是十进制的:0 ~ 255。 如果出现进位,就是:2^8 = 256。 那么,加上 1111 1111 (255),再减 256,也就是-1。 由此,计算机专家就发明了:-1 的补码是 1111 1111。 同样,-2 的补码就是:1111 1110 (254)。 还有,-3 的补码就是:1111 1101 (253)。 。。。 最后,-128 的补码是:1000 0000 (128)。 转换公式:负数的补码 = 2^8 + 该负数。 同样还有:正数的补码 = 2^8 + 该正数。 但是,正数加上 256,就会出现进位。 那就舍弃吧。 于是就有:正数的补码 = 该正数。 这就证明了:零和正数的补码,就是其本身。 例:求-31 的补码是多少? 解:256-31 = 225 = 1110 0001 (二进制)。 这不就完事了吗? 哪里还用:机器数真值原码反码取反加一符号位不变模 ... 老外脑子不够用,算术不灵,才 “发明” 了这许多的谎言。 谁要是跟老外学算术,立刻、马上,直接就掉沟里去了!
daiyanyun: 传过来的参数是数组的个数,没有减一把。
daiyanyun: 哪个两个有序序列的合并为什么不加上边界,left <= right, 传过来的参数是right - 1 吗
努力的青菜: 谢谢兄弟