当前位置: 首页 > news >正文

【数据结构初阶】排序算法(中)快速排序专题

文章目录

  • 1. 快排主框架
  • 2. 快排的不同实现
    • 2. 1 hoare版本
    • 2. 2 挖坑法
    • 2. 3 lomuto前后指针法
    • 2. 4 快排的非递归版本
  • 3. 快排优化
    • 3. 1 快排性能的关键点分析:
    • 3. 1 三路划分
    • 3. 2 introsort自省排序


1. 快排主框架

快速排序是Hoare于1962年提出的一种二叉树结构交换排序方法。
其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。

简单地说,就是将数组分成左右两个部分,左部分都大于(或小于)中间的基准值,右部分都小于(或大于)中间的基准值,然后不断重复上述过程,直到数组完全有序。

//快排主框架
void QuickSort(int* a, int left, int right)
{if (left >= right)return;int mid = PartSort(a, left, right);QuickSort(a, left, mid - 1);QuickSort(a, mid + 1, right);
}

那么显然快排最关键的就是PartSort这个函数怎么实现了,这个函数要实现:找到基准值并将数据按照大小划分到基准值的两侧

  1. 时间复杂度:O(nlogn)
  2. 空间复杂度:O(logn)

2. 快排的不同实现

这里的PartSort函数的实现有很多种,这里介绍3种。

2. 1 hoare版本

算法思路

  1. 创建左右指针,确定基准值
  2. 从右向左找出比基准值小的数据,从左向右找出比基准值大的数据,左右指针数据交换,进入下次循环
  3. 跳出循环后,交换rightkey

提问1:为什么跳出循环后right位置的值一定不大于key?
left > right 时,即right走到left的左侧,而left扫描过的数据均不大于key,因此right此时指向的数据一定不大于key

提问2:当left==right时要不要跳出循环?
不能,因为此时不知道leftright同时指向的这个值的大小,必须让right或者left额外多走一步。

hoare
我们通过这三个场景来分析提问1并详细解释hoare版本的思路:
首先我们选定第一个元素为基准值(实际上选择基准值还有其他更好地方式,但这里先简化),然后将left=1,right=numsSize-1,也就是除了第一个元素外,数组的头和尾。

注:以升序为例。
场景一:
left<key,left++right > keyright不动。
left++,left++,left指向7。
leftright交换数据,数组变为:

int a[]={6,1,2,3,9,7}; 

left++,right++,left==right,right指向9。
此时right大于key,肯定不能直接交换,必须再进行一次交换,让left > right,而因为此时right已经走到了left的左边,left的左边一定小于key

rightkey交换,第一次快排结束,此时数组为:

int a[]={3,1,2,6,9,7}; 

并将right返回,right就是基准值。

这里调用时,PartSort的返回值就是midleftright依然是传入的leftright

QuickSort(a, left, mid - 1);
QuickSort(a, mid + 1, right);

接下来就是继续递归,leftright(在递归传参时改变)最终会在循环之前就left >= right,递归停止。

场景二:
第一次快排在交换rightkey之前的结果是这样的:

int a[]={6,1,2,3,6,7}; 

leftright相遇在6。
且此时left == rightright指向3。right位置的值不大于key

场景三:
第一次快排在交换rightkey之前的结果是这样的:
leftright相遇在4。

int a[]={6,1,2,3,4,7};	//实际上的步骤和场景二是一样的,只是right位置的值不一样

且此时left == rightright指向3。right位置的值不大于key

为什么不用leftright交换?
我们来看这个数组:

int a[]={6,1,2,3,7};

leftright在7相遇,left++right--之后,left指向的位置就已经越界了。
但是right就不会有这个问题,因为我们选中的基准值在最左边,即使在第二个数字相遇,right--之后也不会越界。

代码:

// 快速排序Hoare版本
int PartSort1(int* a, int left, int right)
{int keyi = left;	//设定key为最左边的值left++;while (left <= right){while (left <= right && a[left] < a[keyi])left++;while (left <= right && a[right] > a[keyi])right--;if(left <= right)	//注意这里要加这个判断,因为上面的两个循环可能是因为left>right结束的Swap(&a[left++], &a[right--]);}Swap(&a[right], &a[keyi]);	//将right的值与keyi交换return right;
}

2. 2 挖坑法

思路:
创建左右指针。首先从右向左找出比基准小的数据,找到后立即放入左边坑中,当前位置变为新的"坑",然后从左向右找出比基准大的数据,找到后立即放入右边坑中,当前位置变为新的"坑",结束循环后将最开始存储的分界值放入当前的"坑"中,返回当前"坑"下标(即分界值下标)。

int a[]={6,1,2,7,9,3,4,5,10,8};

我们以这个数组为例,分析挖坑法的步骤:
首先将6挖成坑,并将6存成key

int hole = left;
int key = a[left];

先从右边开始遍历,到5时a[right] > key,停下遍历,将right的值放到坑中,并将right的位置挖成坑:

a[hole] = a[right];	//把right的值放进坑中
hole = right;		//把right挖成新的坑

再从左边遍历,到7时,停下遍历,将left的值放到坑中,并将left的位置挖成坑。
重复上述过程,直到left > right,把最开始的key放进现在的坑中。

挖坑法实际上和hoare版本的原理差不多,只不过是挖坑填坑这个动作代替了交换,并没有本质的变化。

代码:

// 快速排序挖坑法
int PartSort2(int* a, int left, int right)
{int hole = left;int key = a[left];while (left < right){//右边找while (left < right && a[right] >= key)right--;a[hole] = a[right];	//赋值与挖坑hole = right;while (left < right && a[left] <= key)left++;a[hole] = a[left];hole = left;}a[hole] = key;	//把key放进坑里return hole;	//最后的坑就是基准值的位置
}

2. 3 lomuto前后指针法

创建前后指针,从左往右找比基准值小的进行交换,使得小的都排在基准值的左边。

前指针prev在开始的时候指向left+1,后指针cur指向left也就是基准值。
prev开始遍历,如果发现cur的值小于key,就把prev++,再curprev指向的值交换,当遍历完成后,把prevleft指向的值交换。这样结果就是比基准值小的值都会在prev的左边,那么比基准值大的值就会都在prev右边,划分就完成了。

我们以这个数组为例,简单说明一下前后指针法的步骤:

int a[]={6,1,2,7,9,3,4,5,10,8};

一开始,prev指向6,cur指向1,key为6。
cur向后遍历,发现2比6小,prev++,并与cur交换,但这是我们会发现prevcur其实是一样的,那么交换就没有意义了,还会浪费性能,可以在这里添加一个判断。此时prevcur都指向1。
然后cur继续遍历到2,和1一样。此时prevcur都指向2。
接着cur继续遍历,直到cur指向3,prev++,然后与3进行交换,因为prev++之后指向的值是cur遍历过的,所以一定是大于key的。此时数组为:

int a[]={6,1,2,3,9,7,4,5,10,8};

prev指向3,cur指向7。
…………
可以发现,在这个过程中比基准值小的值不断地被交换到prevprev的左边,那么最终把基准值与prev交换,就可以实现划分了。

// 快速排序前后指针法
int PartSort3(int* a, int left, int right)
{int prev = left;int cur = left + 1;int key = a[left];while (cur <= right){if (a[cur] < key && ++prev != cur)	//先++,再比较Swap(&a[cur], &a[prev]);	//Swap函数自行实现即可cur++;}Swap(&a[left], &a[prev]);	//把基准值放到prev的位置return prev;
}

2. 4 快排的非递归版本

上面的三种方法都是通过递归实现的,那么有没有办法不使用递归实现快速排序呢?
当然有,只不过需要借助数据结构——栈。
leftright进行基准值的计算并划分,然后把本应递归的区间的新的leftright入栈,在入栈或出栈时判断leftright的大小是否合适,一直运行到栈为空,快速排序就完成了。

void QuickSortNonR(int* a, int left, int right)
{Stack st;StackInit(&st);Stack* tmp = &st;	//以上均为创建栈StackPush(tmp, right);	//将初始的left和right入栈,可以方便StackPush(tmp, left);while (!StackEmpty(tmp)){//出栈得到本次循环的left和rightint lefti = StackTop(tmp);StackPop(tmp);int righti = StackTop(tmp);StackPop(tmp);//采用前后指针法得到基准值并划分,也可以使用其他的办法int keyi = lefti;int prev = lefti;int cur = lefti + 1;while (cur <= righti){if (a[cur] < a[keyi] && prev++ != cur)Swap(&a[cur], &a[prev]);cur++;}Swap(&a[prev], &a[keyi]);keyi = prev;//入栈前判断if (keyi - 1 > lefti){StackPush(tmp, keyi - 1);StackPush(tmp, lefti);}if (keyi + 1 < righti){StackPush(tmp, righti);StackPush(tmp, keyi + 1);}}//销毁栈StackDestroy(tmp);
}

3. 快排优化

3. 1 快排性能的关键点分析:

决定快排性能的关键点是每次单趟排序后,key对数组的分割,如果每次选key基本二分居中,那么快排的递归树就是颗均匀的满二叉树,性能最佳。但是实践中虽然不可能每次都是二分居中,但是性能也还是可控的。但是如果出现每次选到最小值/最大值,划分为0个和N-1的子问题时,时间复杂度为O(N^2),数组序列有序时就会出现这样的问题,我们可以用三数取中(选取3个数,把大小在中间那个作为基准值)或者随机选key(使用随机数选基准值,这两种办法实际的效率提升不是很高,不单独介绍了)解决这个问题,但是现在还是有一些场景没解决,比如数组中有大量重复数据时,比如以下代码:

// 数组中有多个跟key相等的值
int a[] = { 6,1,7,6,6,6,4,9 };
int a[] = { 3,2,3,3,3,3,2,3 };
// 数组中全是相同的值
int a[] = { 2,2,2,2,2,2,2,2 };

这种时候快排的效率就会急剧下降,完全不如其他的较快的排序算法(如堆排序)。

3. 1 三路划分

当面对有大量跟key相同的值时,三路划分的核心思想有点类似hoare的左右指针和lomuto的前后指针的结合。核心思想是把数组中的数据分为三段【比key小的值】【跟key相等的值】【比key大的值】,所以叫做三路划分算法。结合步骤,理解一下实现思想:

  1. key默认取left位置的值。
  2. left指向区间最左边,right指向区间最后边,cur指向left+1位置。
  3. cur遇到比key小的值后跟left位置交换,换到左边,left++,cur++
  4. cur遇到比key大的值后跟right位置交换,换到右边,right--
  5. cur遇到跟key相等的值后,cur++
  6. 直到cur > right结束
int a[]={6,1,7,6,6,6,4,9};	//前
int a[]={1,4,6,6,6,6,9,7};	//后int b[]={6,6,6,6,6,6,6,6};	//前
int b[]={6,6,6,6,6,6,6,6};	//后

代码:

void QuickSortTreeWay(int* a, int left, int right)
{if (left >= right)return;int begin = left;int cur = left + 1;int end = right;int key = a[left];while (cur <= right){if (a[cur] > key){//把大于key的数放到右边Swap(&a[cur], &a[end--]);	//注意由于不确定被换过来的值的大小,cur不能直接++}else if (a[cur] == key){cur++;	//等于key的先不管}else{Swap(&a[cur++], &a[begin++]);	//小于key的放到begin的左边}}//递归时,begin-end的数不需要再调整QuickSortTreeWay(a, left, begin - 1);QuickSortTreeWay(a, end + 1, right);
}

3. 2 introsort自省排序

introsort是由David Musser在1997年设计的排序算法,C++ sgi STLsort中就是用的introspectivesort(内省排序)思想实现的。

内省排序可以认为不受数据分布的影响,无论什么原因划分不均匀导致递归深度太深,它都会转换为堆排painkiller,而堆排不受数据分布影响,具体可以看下面代码。
三路划分针对有大量重复数据时效率很好,其他场景就一般,而自省排序在任何场景都有优异的性能

introsort是introspective sort采用了缩写,他的名字其实表达了它的实现思路,也就是进行自我侦测和反省,快排递归深度太深(sgi stl中使用的是深度为2倍排序元素数量的对数值)那就说明在这种数据序列下,选key出现了问题,性能在快速退化,那么就不要再进行快排分割递归了,改换为堆排序进行排序。

代码:(堆排序与插入排序不再赘述)

void IntroSort(int* a, int left, int right, int depth, int defaultDepth)
{if (left >= right)return;// 数组⻓度小于16的小数组,换为插入排序,简单递归次数if (right - left + 1 < 16){InsertSort(a + left, right - left + 1);return;}// 当深度超过2*logN时改⽤堆排序if (depth > defaultDepth){HeapSort(a + left, right - left + 1);return;}depth++;	//深度++,方便判断int begin = left;int end = right;// 随机选key,相较于直接将第一个元素作为key,有一定的优势int randi = left + (rand() % (right - left + 1));Swap(&a[left], &a[randi]);	//把选中的key放到left上,和之前的快排尽可能保持相似//前后指针法int prev = left;int cur = prev + 1;int keyi = left;while (cur <= right){if (a[cur] < a[keyi] && ++prev != cur){Swap(&a[prev],&a[cur]);}++cur;}Swap(&a[prev], &a[keyi]);keyi = prev;// [begin, keyi-1] keyi [keyi+1, end]IntroSort(a, begin, keyi - 1, depth, defaultDepth);IntroSort(a, keyi + 1, end, depth, defaultDepth);
}
void QuickSort(int* a, int left, int right)
{int depth = 0;int logn = 0;int N = right - left + 1;//计算lognfor (int i = 1; i < N; i *= 2){logn++;}// introspective sort -- 自省排序IntroSort(a, left, right, depth, logn * 2);
}int* sortArray(int* nums, int numsSize, int* returnSize) {srand(time(0));QuickSort(nums, 0, numsSize - 1);*returnSize = numsSize;return nums;
}

谢谢你的阅读,喜欢的话来个点赞收藏评论关注吧!
我会持续更新更多优质文章

相关文章:

  • 人工智能实战用折线图解读产业GDP发展态势
  • 【自用】jlu 数据库 第一章 Introduction
  • 大模型分布式训练并行技术(九)-总结
  • kubernetes配置资源管理
  • Python 从入门到实战31(数据库编程接口)
  • 【个人笔记】数据一致性的解决方案
  • MySQL-数据库约束
  • 翻译:Recent Event Camera Innovations: A Survey
  • Anki 学习日记 - 卡片模版 - 单选ABCD(纯操作)
  • 《Linux运维总结:使用 MongoDB工具备份和恢复mongodb 7.0.14分片集群(方案一)》
  • yolov8环境安装
  • GPT+AI技术实战:构建多端智能虚拟数字人的创新与突破
  • WebRTC中的维纳滤波器实现详解:基于决策导向的SNR估计
  • 0基础学习CSS(六)字体
  • C++中的lower_bound函数详解
  • Google 是如何开发 Web 框架的
  • bootstrap创建登录注册页面
  • es6
  • Java IO学习笔记一
  • JavaScript设计模式系列一:工厂模式
  • JAVA之继承和多态
  • Linux中的硬链接与软链接
  • Sequelize 中文文档 v4 - Getting started - 入门
  • Synchronized 关键字使用、底层原理、JDK1.6 之后的底层优化以及 和ReenTrantLock 的对比...
  • Vue 2.3、2.4 知识点小结
  • vue从创建到完整的饿了么(18)购物车详细信息的展示与删除
  • webpack4 一点通
  • 更好理解的面向对象的Javascript 1 —— 动态类型和多态
  • 将回调地狱按在地上摩擦的Promise
  • 腾讯大梁:DevOps最后一棒,有效构建海量运营的持续反馈能力
  • 推荐一个React的管理后台框架
  • 3月7日云栖精选夜读 | RSA 2019安全大会:企业资产管理成行业新风向标,云上安全占绝对优势 ...
  • Java性能优化之JVM GC(垃圾回收机制)
  • 回归生活:清理微信公众号
  • ​DB-Engines 12月数据库排名: PostgreSQL有望获得「2020年度数据库」荣誉?
  • ​卜东波研究员:高观点下的少儿计算思维
  • ‌U盘闪一下就没了?‌如何有效恢复数据
  • # 手柄编程_北通阿修罗3动手评:一款兼具功能、操控性的电竞手柄
  • (03)光刻——半导体电路的绘制
  • (4)通过调用hadoop的java api实现本地文件上传到hadoop文件系统上
  • (delphi11最新学习资料) Object Pascal 学习笔记---第14章泛型第2节(泛型类的类构造函数)
  • (二)什么是Vite——Vite 和 Webpack 区别(冷启动)
  • (强烈推荐)移动端音视频从零到上手(下)
  • (转)EXC_BREAKPOINT僵尸错误
  • (转)http协议
  • (转)shell中括号的特殊用法 linux if多条件判断
  • ***原理与防范
  • .apk 成为历史!
  • .bat批处理(一):@echo off
  • .NET/C# 使用反射调用含 ref 或 out 参数的方法
  • .NET精简框架的“无法找到资源程序集”异常释疑
  • .Net中的集合
  • .php文件都打不开,打不开php文件怎么办
  • .skip() 和 .only() 的使用
  • @LoadBalanced 和 @RefreshScope 同时使用,负载均衡失效分析