数据结构:快速排序

前面我们讲了交换排序中的冒泡排序,接下来让我们一起进入快速排序的学习当中。

快速排序

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

我们随机取一个数作为基准值。

然后将比基准值小的值放到左边的序列中,将比基准值大的值放到右边的序列中。

然后从左右序列中再找基准值,继续划分左右序列:

直到划分完成为止。

从图中我们可以很直观地看出,左右序列的划分和二叉树结构很像,因此我们可以通过递归分出左右序列。问题就在于,基准值如何找?

关于找基准值我们有好几种方法,我们将找基准值封装为一个函数,用 keyi 来接收基准值。

比如我们上面找到的基准值,位置是下标 4。

然后我们递归去找它左右序列的基准值,就需要把左右序列的区间传过去。因此形参要改一下,改为 int* arr、int left、int right。left 是左区间,right 是右区间,调用该函数递归去找左右区间的基准值。

因为 keyi 在下标为 4 的位置,一开始传过来的 left 是数组开头也就是下标为 0 的位置,right 是数组末尾位置 n - 1。

所以左序列区间应该是 left, keyi-1,右序列区间应该是 keyi+1, right。我们就可以递归去找左右序列的基准值了。

需要注意的是,当我们的左右序列划分到只有一个数据或没有数据,比如以下这种情况。

这时基准值 keyi 位置是 1,它的左序列区间为 0,0,左右区间相等,也就是 left == right 的情况。只有一个数据时,这个数据肯定就是基准值了,左右序列也不存在,所以在 left == right 时不用再去递归找基准值了。

我们再来看右序列,右序列的区间是 2,1,也就是 right < left 的情况,这种情况下右序列都不存在了。

综上所述,当 left >= right 时,左右序列不存在或者说只剩一个数据,就不用再去找基准值了。

快速排序的代码大致就写完了,接下来我们介绍几种找基准值的方法。

Hoare左右指针

Hoare左右指针的基本思想是这样的,我们定义三个变量, keyi 是基准值, left 是左区间, right 是右区间,我们一般把 left 位置作为基准值,然后 left++,指向下一个位置。

可以看出,left 和 right 需要传参过来,指向数组的指针也是,所以找基准值的函数参数如下:

int Partition(int* arr,int left,int right)

思路

Hoare左右指针具体找基准值的思路是这样的:

right 从右往左找,找到比基准值小的数据。 left 从左往右找,找比基准值大的数据。

找到后将 left 和 right 指向的数据进行交换,也就是把比基准值小的数据放到了左边,比基准值大的数据放在了右边。然后按规则继续找。

找到后继续交换。

然后 right 继续从右往左找比基准值小的数据,right-- 找到了 3

然后 left 从左往右找,找到了 5,但此时 right < left。

这时 left 和 right 就停止查找,交换 keyi 和 right 中的数据。此时基准值存放在 right 位置,把 right 返回去,基准值就找到了。

这就是Hoare左右指针找基准值的办法。

代码实现

我们先定义好 keyi ,一般取 left 位置为基准值,然后 left ++指向下一个位置。

我们让 right 先从右往左找比基准值小的数据,然后 left 从左往右找比基准值大的数据,都找到后交换数据。这个过程循环往复,直到 left > right 为止。因为 right 和 left 在查找的过程中也有可能越界,所以要加上 left ≤ right 这个限制条件。

(当 left == right 的时候,也要将这个位置的值和基准值作比较,如果进入循环条件写成 left < right,当 left == right 的时候就不会进入循环,会把这次与基准值比较漏掉,直接交换 right 和 keyi 的值导致错误)

这里 right 和 left 找到和基准值相等的数据要进行交换,原因一会儿再说。

找到后交换 left 和 right 的值,同样也要保证 left ≤ right。

交换完 left 和 right 后直接移一个位置,是为了防止 left 和 right 都指向和基准值相同的值,从而变成死循环一直交换。

整体结束后我们让 right 和 keyi 处的数据进行交换,返回 right 基准值的位置就找到了。

我们简单测试一下:

与预期结果符合,说明代码没什么问题。

为什么和基准值相等的时候也要进行交换

我们来看以下数组:

我们用Hoare版本的找基准值来排序一下该数组,假设基准值相同我们也不进行交换:

首先是 right 指针从右往左遍历去获取比基准值小的数,我们会发现 right 会一直 -- 来到 keyi 的位置,此时 left > right,跳出循环,将 keyi 和 right 的值进行交换,返回 right 的位置。

这时基准值把原数组分为了左右序列,左序列不存在,分出右序列:

这时又开始找基准值,right 从右往左遍历找比基准值小的数据,发现找不到,又来到了 keyi 的位置,此时 left > right,跳出循环,交换 keyi 和 right 处的数据,将 right 作为基准值下标返回来。

这时又分出左右序列,左序列不存在,分出右序列:

我们会发现,如果基准值相同的时候我们不进行交换,每次 right 都会走到 keyi 的位置,然后在此分出右序列。

对于递归树,我们最理想的情况下是每次基准值位置都在数组偏中间的位置,这样我们递归树结构就类似于二叉树,递归次数是logn。

而在这种情况下,每次都只减少了一个数据,比如说一开始数据个数为 n ,下次递归分出的右序列数据为 n - 1 ,一直往下递归,递归次数来到了最坏的 n 次,在时间效率上就是大打折扣,因此遇到基准值相同的情况我们也要交换,避免这种情况的发生。

时间复杂度

对于代码来说,虽然是嵌套了两层while循环,但实际上外层循环为有限次,因为主要是内部循环中 right 和 left 进行移动,外层循环实际上是判断 left ≤ right 是否成立,这两个指针必定会在有限步中相遇,所以整体时间复杂度为

O(n)。

对于递归次数,理想状态下递归树是一个二叉树结构,递归次数来到了 logn ,但对于已有序数组,递归次数会来到最坏的 n 次,和上个问题类似。

所以最好情况下Hoare版本的快排时间复杂度为 O(nlogn),最坏情况下为 O(n2)。

挖坑法

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

按图来说是这样的:

我们取 left 位置为 hole 坑, left++。

right 从右往左找比基准值小的数据,找到后将 right 处的值放到 hole 中,然后 right 变为新的 hole。

然后 left 从左往右找比基准值大的数据,找到后将 left 处的数据放到 hole 中, left 位置变为新的 hole 。

然后 right 继续从右往左找比基准值小的数据,找到后将 right 处的数据放入 hole 中,right 处成为新的 hole。

然后 left 从左往右找比基准值大的数据,放入 hole 中, left 处成为新的 hole。

right 再从右往左找比基准值小的数据。

此时发现 left == right ,则查找结束,将基准值放入 hole 中,然后返回来 hole 的位置,基准值就找到了。

根据思想我们可以看出来需要一个变量来存储基准值,因为值会有覆盖问题。

根据代码我们可以看出来,当 right 或 left 处的数据和基准值相等时不交换,因为如果交换的话条件就是 arrright > tmp,这样因为和基准值相等,所以 right 指针就不会往左走,一直进行交换,从而变成死循环。

挖坑法用的很少,了解一下即可。

lomuto前后指针

思路

创建前后指针,将后面比基准值小的值放在前面。

同样地,我们将 left(这里下标为0)处作为 基准值 keyi ,然后创建一个前指针 prev ,指向 left 位置,后指针 cur 指向 prev 的下一个位置。

具体思路是这样的, cur++ 去找比基准值小的数据,找到后和 prev++ 的位置去交换,直到 cur 遍历完整个序列为止。

cur 现在指向的是2,比基准值小,和 ++prev 位置交换,但此时 cur == prev ,相当于自己跟自己交换,所以我们选择不交换。

cur++ 继续去找比基准值小的数据,找到后和 ++prev 位置交换。

然后 cur++ 继续往下走,找到后继续和 ++prev 位置交换。

然后 cur++ ,最后把序列遍历完了。

这时我们将 keyi 和 prev 位置处的数据进行数据交换,把 prev 位置返回来,基准值就找到了。

这就是lomuto前后指针找基准值的办法。

代码实现

我们先初始化创建变量。

然后 cur 遍历序列去找比基准值小的数据,找到了和 ++prev 交换数据。

跳出循环后交换 prev 和 keyi 处的数据,把 prev 返回来。

这个代码就写好了,实现也是很简单。我们来简单测试一下。

结果符合预期,代码没什么问题。

注:对于 cur 遇到和基准值相同的值要不要交换呢?其实交不交换都一样,如果原序列数据都相同,我们 cur 和基准值相同要交换的话,递归就会从最右边开始分,递归 n 次;反之就是从最左边开始分,也是递归 n 次,无法解决时间效率上的问题,所以交不交换都一样。

时间复杂度

lomuto前后指针的方法很明显就一个循环,所以时间复杂度为O(n),至于递归次数和Hoare版本、挖坑法都表现出了一样的缺陷,最好情况下是 logn ,最坏情况下是n次。

因此lomuto版本的时间复杂度最好是O(nlogn),最坏是O(n2)。

相关推荐
闻缺陷则喜何志丹15 分钟前
【计算几何 第十一章】凸包:混合物
数学·算法·计算几何·凸包·混合物·凸组合
土司大王16 分钟前
LeetCode hot100——二叉树的最大深度
算法·leetcode·职场和发展
高亦真23 分钟前
今天是学习嵌入式的第31天
linux·学习·算法
让学习成为一种生活方式33 分钟前
黄花蒿LHC基因家族的串联重复驱动扩张及其UV-B胁迫适应性--BMC Plant Biology
人工智能·算法·机器学习
ECT-OS-JiuHuaShan41 分钟前
共轭互逆链路论,彻底打击庸俗辩证法和不可知论
数据库·人工智能·算法·机器学习·数学建模
薛定e的猫咪44 分钟前
【大模型量化】使用 llama.cpp 完成量化、本地推理与服务化部署
人工智能·深度学习·算法·llama
薛定e的猫咪1 小时前
【源码解读版】ContraBAR:用 CPC 对比学习替代变分推断做贝叶斯元 RL
人工智能·深度学习·学习·算法·机器学习
lzx_0021 小时前
list(全)
数据结构·list
绿算技术2 小时前
大模型本地化的经济账:DeepSeek V4 Flash 部署实测
人工智能·科技·算法·spark