深圳幻海软件技术有限公司 欢迎您!

八大排序算法之快速排序(下篇)(快排的优化+非递归快排的实现)

2023-04-13

目录一.前言1.快速排序的实现:快速排序的单趟排序(排升序)(快慢指针法实现):​2.未经优化的快排的缺陷二.快速排序的优化1.三数取中优化优化思路:2.小区间插入排序优化小区间插排优化的递归快排:三.非递归快速排序的实现1.快排一个难以避免的缺陷(暂不考虑三指针单趟排序优化)2.非递归快排的实现思

目录

一.前言

1.快速排序的实现:

快速排序的单趟排序(排升序)(快慢指针法实现):​

2.未经优化的快排的缺陷

二.快速排序的优化

1.三数取中优化

优化思路:

2. 小区间插入排序优化

小区间插排优化的递归快排:

三.非递归快速排序的实现

1.快排一个难以避免的缺陷(暂不考虑三指针单趟排序优化)

2.非递归快排的实现思路

数据结构栈模拟系统栈算法思想:​

非递归快排代码实现:


一.前言

1.快速排序的实现:

🤪快排的详细实现原理参见青菜的博客🤪:http://t.csdn.cn/0bf1ghttp://t.csdn.cn/0bf1g下面简单回顾一下快排的核心思想:

快速排序的单趟排序(排升序)(快慢指针法实现):

  1. int PartionV3(int* arr, int left, int right)//完成1个元素的排序,同时分割数组
  2. {
  3. assert(arr);
  4. int key = left;//选取数组左端元素作为key值
  5. int slow = left; //slow从left开始
  6. int fast = left + 1; //fast从left+1开始遍历数组
  7. while (fast <= right)
  8. {
  9. if (arr[fast] < arr[key])//fast找到比key小的值
  10. {
  11. ++slow; //slow指向下一个位置
  12. if (slow != fast)//fast和slow相等没必要交换
  13. {
  14. swap(&arr[slow], &arr[fast]);//交换slow和fast所指向的值
  15. }
  16. }
  17. ++fast; //fast遍历数组
  18. }
  19. swap(&arr[key], &arr[slow]); //最后交换key和slow所指向的变量
  20. return slow; //返回slow位置下标
  21. }

  • 🤪假设乱序数组有N个元素,则需要进行N趟单趟排序(每次单趟排序可以完成一个key元素在有序序列中的归位)
  • 🤪直接循环N-1次单趟排序的时间复杂度为O(N^2)
  • 🤪为了降低排序的时间复杂度的数量级,我们采用分治递归思想来完成这N次单趟排序
  • 🤪基本思想是:每次单趟排序完成后,被归位的key元素为划分点,将数组划分为在排序意义上互不关联两个子数组(左子数组中每个元素都比key小,右子数组中每个元素都比key大),从而使得后续每次单趟排序需要遍历的元素个数呈指数式递减,总体排序的时间复杂度也因此降阶,递归过程中数组被逐步划分过程的图示:(划分结构逻辑上类似于二叉树)
  • 上图中每个数组拆分层次中(一个拆分层次中多个子数组),单趟排序所需遍历的总的元素个数的数量级为O(N),拆分层次的数量级为O(logN),因此上述情形下,快排的总体时间复杂度为O(NlogN).

  • 😜这里我们假设所处理的数组都是逆序数极大乱序序列:这种情况下,每次单趟排序所确定的数组分割点有极大的可能是在数组的中间位置。

  • 😜对应上图完成排序的分治递归代码

    1. //每完成一次数组分割,就进行左右子数组分治递归完成每一个元素的排序
    2. void QuickSort2(int* arr, int left, int right)
    3. {
    4. assert(arr);
    5. if (left>=right) //子数组只剩1个元素时(或left和right错开时)停止递归
    6. {
    7. return;
    8. }
    9. int breakpoint = PartionV3(arr, left, right);//找到数组分割点(同时也完成了一个元素的排序)
    10. //左右子数组分治递归
    11. QuickSort(arr, left, breakpoint - 1); //处理左子数组
    12. QuickSort(arr, breakpoint + 1, right); //处理右子数组
    13. }
  • 😜注意递归结束条件的控制😜

2.未经优化的快排的缺陷

  • 单趟排序时,我们将子数组的左端元素作为key,因此在处理有序或接近有序以及倒序或完全倒序的序列时,大多数情况下经过单趟排序后,数组的分割点(key元素的最终位置)会停留在数组左端(倒序的情况则停留在数组右端),于是整个排序过程中,数组被逐步划分过程的图示:
  • 此时划分层次数量级为O(N),每个划分层次中单趟排序所需遍历的元素个数等差数列递减,此时分治递归的时间复杂度计算公式为等差数列求和式,数量级升阶为O(N^2);
  • 为了避免这种情况出现,我们需要对key元素的选取方式进行优化

二.快速排序的优化

1.三数取中优化

优化思路:

  • 为了避免序列有序或接近有序时,分治递归过程中不断在数组区间端点处对数组进行划分(从而导致数组划分总次数数量级为O(N)),我们采用三数取中的方式优化key的选取:
  1. 设计一个接口,确定数组的首元素,尾元素中间位置元素三者中的中位数(中间大小的数),并返回其下标.接口首部:
    int GetMid(int* arr,int left,int right)

    接口实现: 

    1. int GetMid(int* arr,int left,int right)
    2. {
    3. int mid = left + ((right - left) >> 2); //在arr[left],arr[mid],arr[right]三者中取中间值作为key,返回key的下标
    4. if (arr[left] < arr[right])
    5. {
    6. if (arr[left] < arr[mid] && arr[mid] < arr[right])
    7. {
    8. return mid;
    9. }
    10. else if (arr[mid] > arr[right])
    11. {
    12. return right;
    13. }
    14. else
    15. {
    16. return left;
    17. }
    18. }
    19. else
    20. {
    21. if (arr[left] > arr[mid] && arr[mid] > arr[right])
    22. {
    23. return mid;
    24. }
    25. else if (arr[mid] > arr[left])
    26. {
    27. return left;
    28. }
    29. else
    30. {
    31. return right;
    32. }
    33. }
    34. }
  2. 确定数组的首元素,尾元素中间位置元素三者中的中位数后,将其交换到arr[left]的位置,后续的单趟排序过程我们依然选择arr[left]作为key元素

  3. 三数取中优化后的单趟排序:

    1. int PartionV3(int* arr, int left, int right)//完成1个元素的排序,同时分割数组
    2. {
    3. assert(arr);
    4. swap(&arr[left], &arr[GetMid(arr, left, right)]);
    5. int key = left;//在arr[left],arr[mid],arr[right]三者中取中间值作为key
    6. int slow = left; //slow从left开始
    7. int fast = left + 1; //fast从left+1开始遍历数组
    8. while (fast <= right)
    9. {
    10. if (arr[fast] < arr[key])//fast找到比key小的值
    11. {
    12. ++slow; //slow指向下一个位置
    13. if (slow != fast)//fast和slow相等没必要交换
    14. {
    15. swap(&arr[slow], &arr[fast]);//交换slow和fast所指向的值
    16. }
    17. }
    18. ++fast; //fast遍历数组
    19. }
    20. swap(&arr[key], &arr[slow]); //最后交换key和slow所指向的变量
    21. return slow; //返回slow位置下标
    22. }
  4. 经过三数取中后的单趟排序,面对有序序列时,数组分割点(key元素的最终位置)会确定在数组的中间位置,因此分治递归时,函数栈帧的逻辑分布会呈现出满二叉树的结构(递归深度最小),也就意味着,在面对有序(或接近有序)的序列时,三数取中优化后的快排有着最高的排序效率(相比于排序其余乱序序列的情况).这一点大大增加了快排在各种实际场景中适用性

     (每个子数组代表一个函数栈帧)

  5. 同时三数取中优化也让快排的单趟排序在处理各种乱序序列时,令数组的分割点(key元素的最终位置)尽可能地接近数组中间位置

2. 小区间插入排序优化

  • 分治递归过程中的函数栈帧逻辑分布(每一个子数组代表了一次函数递归调用):
  • 根据二叉树的结构特点(满二叉树最后一层结点个数总结点个数的一半)可知,元素个数为1个(以及2个3个等等)的子数组个数最多(一个子数组代表一次函数递归调用),因此我们可以修改递归的结束条件:当子数组的元素个数小于10(可以在一定范围内任意设定)时,对子数组进行插入排序结束递归
  • 插入排序接口:
    1. void InsertSort(DataType* arr, int size)
    2. {
    3. assert(arr);
    4. for (int end = 0; end < size; end++) //用end来维护数组前end个元素构成的有序序列
    5. {
    6. int x = arr[end];//x为待插入有序序列的数据
    7. int insert = end;//用变量insert来确定x要插入的位置
    8. while (insert>0)//通过元素比较确定x要插入的位置
    9. {
    10. if (x < arr[insert-1]) //说明insert不是x要插入的位置
    11. {
    12. arr[insert] = arr[insert-1]; //往后挪动数据为x留出空位
    13. insert--; //令insert指向序列前一个元素
    14. }
    15. else
    16. {
    17. break; //有序序列中x>=arr[insert-1]说明insert是x要插入的位置
    18. }
    19. }//最坏的情况下x会被插入到数组的首地址处(此时数据比较和挪动了end次)
    20. arr[insert] = x; //完成元素x的插入(继续插入下一个元素)
    21. }
    22. }

    小区间插排优化的递归快排:

    1. //每完成一次数组分割,就进行左右子数组分治递归完成每一个元素的排序
    2. void QuickSort(int* arr, int left,int right)
    3. {
    4. assert(arr);
    5. if (right - left+1 <=10)//子数组只剩10个元素时(小区间插排)停止递归
    6. {
    7. InsertSort(arr + left, right - left+1);
    8. return;
    9. }
    10. int breakpoint = PartionV3(arr, left, right);//找到数组分割点(同时也完成了一个元素的排序)
    11. //左右子数组分治递归
    12. QuickSort(arr, left, breakpoint - 1); //处理左子数组
    13. QuickSort(arr, breakpoint + 1, right); //处理右子数组
    14. }

  • 经过上述小区间插入排序处理后的分治快排,函数的递归调用次数减少了一半以上,这对于递归优化比较落后的编译平台是非常友好的

附:由于现代编译器对于递归函数的时间效率会进行大幅程度的优化,因此快排的小区间插排优化实测效果并不明显

三.非递归快速排序的实现

1.快排一个难以避免的缺陷(暂不考虑三指针单趟排序优化)

  • 当序列为常序列(所有元素相同)序列中有大量(绝大部分)元素相同时,快排递归中数组的划分过程也会出现下面类似的情况:
  • 其原因与前面未经优化的快排面对有序序列时时间复杂度升阶的原因是一致的(常序列特殊的有序序列),并且面对常序列大量(绝大部分)元素相同的序列时,三数取中优化起不到任何作用的(三个元素都是相同的,取中位数没有任何意义)
  • 因此使用递归快排时,如果处理的序列为常序列大量(绝大部分)元素相同的序列,会有一定的栈溢出风险(上图中的递归深度为O(N),即递归过程中会有O(N)数量级个函数栈帧同时存在)(此时快排的时间复杂度为O(NlogN)
  • (在不考虑三指针单趟排序优化的情形下)常序列(或大量(绝大部分)元素相同的序列)要避免使用快排进行处理
  • 实际运用中为了节省系统栈空间,我们需要用数据结构栈模拟系统栈,利用循环结构实现快排.

2.非递归快排的实现思路

  • 快排递归的过程中,每个函数栈帧中存储的关键变量待排序的子数组的左右端下标(left和right)
  • 函数栈帧进出系统栈的规则满足后进先出
  • 因此我们可以用数据结构中的整形栈来存储每个待排序的子数组左右端点下标,借助数据结构栈我们就可以利用循环结构模拟递归的过程

函数首部: 

void QuickSortNonR(int* arr, int begin, int end)

begin和end是待排序的数组左端下标和右端下标(两个下标都指向有效元素)(闭区间)

  • 为了方便我们借助C++STL的栈对象来完成模拟递归,快排递归(前序递归)遍历次序:

数据结构栈模拟系统栈算法思想:

  • 先将原数组的区间左右端下标压入栈中,接着开始执行循环:
  1. 首先,利用left和right变量接收栈顶区间
  2. [left,right]子数组进行单趟排序,完成单个元素排序的同时确定数组的分割点下标breakpoint
  3. [left,right]数组右子数组[breakpoint+1,right]区间左子数组[left,breakpoint-1]区间压入栈中(注意右子数组区间要先入栈,左子数组区间要后入栈,保证后续左子数组先出栈被处理)(左右子数组区间入栈的前提是区间中的元素个数大于或等于2)
  • 重复上述步骤直到栈为空,则完成了分治递归的模拟过程(相当于模拟了前序遍历的递归过程)
  • 算法gif:

非递归快排代码实现:

  1. void QuickSortNonR(int* arr, int left, int right)
  2. {
  3. assert(arr);
  4. stack<int> range;
  5. if (right > left) //区间中有两个以上元素时,区间入栈
  6. {
  7. range.push(right); //取的时候先取左边界,因此左边界后入栈(令左边界压在右边界上面)
  8. range.push(left); //初始区间入栈;
  9. }
  10. while (!range.empty())
  11. {
  12. int left = range.top(); //令栈顶区间出栈
  13. range.pop();
  14. int right = range.top();
  15. range.pop();
  16. int breakpoint = PartionV3(arr, left, right); //确定区间分割点,同时完成一个数组元素的排序
  17. //栈是后进先出,递归是先向左子数组递归,因此做左子数组区间后入栈
  18. if (right>breakpoint+1) //若子区间元素个数大于一个则区间入栈
  19. {
  20. range.push(right);
  21. range.push(breakpoint + 1);
  22. }
  23. if(breakpoint-1>left)
  24. {
  25. range.push(breakpoint - 1);
  26. range.push(left);
  27. }
  28. }
  29. }
  • 区间入栈时注意左右端下标入栈顺序

文章知识点与官方知识档案匹配,可进一步学习相关知识
算法技能树首页概览43944 人正在系统学习中
="//sdk.51.la/js-sdk-pro.min.js">