快速排序的学习理解:从基础分治到三路划分优化

这个up主代码分析的很直白,浅显易懂,但是代码优化不够,可以作为参考

https://www.bilibili.com/video/BV1vP411g7J3/?spm_id_from=333.337.search-card.all.click

一、普通快排:分治思想与复杂度

快速排序的总体思路是「分而治之」。每一轮先选择一个基准元素 pivot,然后通过划分操作,把小于 pivot 的元素放到左侧,把大于 pivot 的元素放到右侧,pivot 最终落位。接下来对左右两个子区间分别递归执行同样的操作,直到子区间只剩一个元素或为空。

由这个递归树可以看到,如果每一轮划分都能把序列近似对半切开,递归深度约为 log n,每一层总体的比较和交换开销约为 O(n),因此最好情况和平均情况的时间复杂度为 O(n log n)。但快速排序并不是稳定的原地划分,当划分严重不均衡时,递归深度会接近 n,最坏时间复杂度会退化到 O(n²)。

基础二路快排实现

下面是一段常见的二路划分快排代码,其中已经加入了随机 pivot,为了防止是有序数组的情况下快排时间复杂度变成O(n2):

cpp 复制代码
class Solution {
public:
    int patition(vector<int>& a, int low, int high)
    {
        int idx = low + rand() % (high - low + 1);
        swap(a[low], a[idx]);
        int pivot = a[low];
        while (low < high)
        {
            while (low < high && a[high] >= pivot)
            {
                high--;
            }
            a[low] = a[high];
            while (low < high && a[low] <= pivot)
            {
                low++;
            }
            a[high] = a[low];
        }
        a[low] = pivot;
        return high;
    }
    void qwe(vector<int>& nums, int low, int high)
    {
        if (low < high)
        {
            int pos = patition(nums, low, high);
            qwe(nums, low, pos - 1);
            qwe(nums, pos + 1, high);
        }
    }
    vector<int> sortArray(vector<int>& nums)
    {
        qwe(nums, 0, nums.size() - 1);
        return nums;
    }
};

二、有序输入为什么会退化

如果输入本身已经有序,并且每次固定选择最左端元素作为 pivot,那么划分会出现非常不均衡的情况:pivot 已经把整个序列扫描了一遍,结果却只切出一个元素,剩下的元素仍然留在同一侧。于是递归实际上变成了每次只减少一个元素,递归深度接近 n。

在这种情况下,每一层的扫描长度依次是 n、n-1、n-2......,总代价约为 1 加到 n,也就是 O(n²)。所以快排最怕「已经有序的序列 + 固定选端点为 pivot」这种组合。

我们暂时把代码中的随机 pivot 去掉,固定选择第一个元素:

复制代码
int pivot = a[low];

假设数组已经有序:

复制代码
[1, 2, 3, 4, 5, 6, 7]

第一次选择 1 作为 pivot。

复制代码
[1] [2, 3, 4, 5, 6, 7]
 ↑          ↑
pivot    下一次递归

由于 1 是最小值, 这段代码:

复制代码
while(low < high && a[high] >= pivot)
{
    high--;
}

会让 high 从最右端一直移动到最左端。

最后 pivot 被放回原位。

cpp 复制代码
[1] [2, 3, 4, 5, 6, 7]
 ↑          ↑
pivot    下一次递归

本来希望分成 3 个和 3 个,现在却分成了 0 个和 6 个。

接着递归右边:

cpp 复制代码
第一次:[1] [2, 3, 4, 5, 6, 7]
第二次:[2] [3, 4, 5, 6, 7]
第三次:[3] [4, 5, 6, 7]
第四次:[4] [5, 6, 7]
第五次:[5] [6, 7]
第六次:[6] [7]

每次只能确定一个元素的位置。

所以总操作量约为:

cpp 复制代码
(n-1)+(n-2)+......+1

这是等差数列:

cpp 复制代码
n(n-1)/2

因此时间复杂度退化成:

O(n2)

这就是有序数组会让固定首元素 pivot 的快速排序退化的根本原因。

三、随机 pivot 的作用(我的理解是破坏有序数组)与局限

针对有序输入退化,一个简单有效的优化是随机选择 pivot:在 low 到 high 范围内随机取一个下标,再把这个位置上的元素换到最左端,继续使用原来的划分逻辑。

这样做之后,输入是否有序不再直接决定 pivot 的位置,划分更有可能接近均匀的期望效果,平均复杂度稳定在 O(n log n)。上面的第一段代码就通过「low + rand() % (high - low + 1)」实现了随机 pivot。

不过随机 pivot 也有局限:它降低的是最坏情况出现的概率,并不能从理论上消除最坏情况;同时,对于

大量重复元素的场景,仅靠随机 pivot 仍然可能退化,因为问题已经不在 pivot 的选择上,而在划分方式上。

四、大量重复元素为什么也会退化

普通二路划分通常把元素分成「小于等于 pivot」和「大于等于 pivot」两类。当数组中存在大量与 pivot 相等的元素时,这些相等元素既可能被分到左边,也可能被分到右边,划分结果变得非常不均衡:相等的元素没有形成一个可以确定不动的整体,很多相等元素还会继续进入下一层递归。

于是,即使选择了随机 pivot,大量重复元素仍然会让递归区间难以快速收敛,严重时时间复杂度退化到 O(n²)。要解决这个问题,需要把等于 pivot 的部分单独划成一个区间,这正是三路划分的思想。

五、三路划分:lt、i、gt 指针过程

三路划分把数组分成三个部分:小于 pivot、等于 pivot、大于 pivot。这里使用三个指针来维护区间边界:

  • lt:表示小于 pivot 区间的右边界。区间 low, lt-1 中的元素都小于 pivot。
  • i:当前正在检查的元素位置。区间 lt, i-1 中的元素都等于 pivot。
  • gt:表示大于 pivot 区间的左边界。区间 gt+1, high 中的元素都大于 pivot。

算法从 i = low + 1 开始扫描,直到 i 超过 gt:

  1. 如果 numsi 小于 pivot,交换 numslt 和 numsi,然后 lt 和 i 同时右移。
  2. 如果 numsi 大于 pivot,交换 numsi 和 numsgt,然后 gt 左移;注意此时 i 不前进,因为交换过来的是一个新的未检查元素。
  3. 如果 numsi 等于 pivot,只让 i 右移。

一轮结束后,low, lt-1 是小于区,lt, gt 是等于区,gt+1, high 是大于区。等于区已经确定且不需要参与后续递归,只需要递归处理小于区和大于区即可。

六、完整 C++ 三路快排实现

以下是结合随机 pivot 和三路划分的完整 C++ 实现:

cpp 复制代码
class Solution {
public:
    void quickSort(vector<int>& nums, int low, int high)
    {
        if (low >= high)
            return;
        // 随机选择 pivot
        int idx = low + rand() % (high - low + 1);
        swap(nums[low], nums[idx]);
        int pivot = nums[low];
        int lt = low;       // [low, lt-1] < pivot
        int i = low + 1;    // [lt, i-1] == pivot
        int gt = high;      // [gt+1, high] > pivot
        while (i <= gt)
        {
            if (nums[i] < pivot)
            {
                swap(nums[lt], nums[i]);
                lt++;
                i++;
            }
            else if (nums[i] > pivot)
            {
                swap(nums[i], nums[gt]);
                gt--;
            }
            else
            {
                i++;
            }
        }
        // 等于 pivot 的部分不需要递归
        quickSort(nums, low, lt - 1);
        quickSort(nums, gt + 1, high);
    }
    vector<int> sortArray(vector<int>& nums)
    {
        if (!nums.empty())
        {
            quickSort(nums, 0, (int)nums.size() - 1);
        }
        return nums;
    }
};

七、复杂度对比表

把前面讨论的几种情况汇总如下:

场景 时间复杂度 说明
最好情况、平均情况 O(n log n) 每次划分比较接近均衡,递归深度约为 log n
最坏情况(有序序列 + 固定首元素 pivot) O(n²) 每次仅划分出一个元素,递归深度接近 n
随机 pivot 平均情况 O(n log n) 降低有序输入导致退化的概率
大量重复元素 + 普通二路划分 O(n²) 相等的元素无法被有效分到两侧,递归迟迟不收敛
三路划分(含大量重复元素) 平均 O(n log n),大量重复时接近 O(n) 等于 pivot 的区间一次确定,不参与后续递归
相关推荐
m4Rk_1 小时前
【论文阅读】Agent记忆机制(109):MemComposer——用强化学习学会选哪些记忆、选多少以及怎样排序
论文阅读·人工智能·学习·开源·github
叼烟扛炮1 小时前
Linux 学习笔记(四):文件检索与管道重定向
linux·笔记·学习
乐迪信息1 小时前
AI算法盒子船体烟火检测:港口火灾早预警
人工智能·深度学习·算法
cu1431 小时前
细谈IT6563的具体功能与其应用
c语言·c++·单片机·嵌入式硬件
王夏奇1 小时前
AI工具学习
网络·学习
lisw051 小时前
发酵算法两大应用方向解析
算法·数学建模
布莱克6051 小时前
C++ 内存泄漏全解析:常见场景、成因与规避实践
开发语言·c++·内存泄露
Warolitbos1 小时前
【数据库】SQL进阶15题,窗口函数/索引/事务一页速查
数据库·sql·学习·mysql·面试
jimy12 小时前
lvalue、xvalue、prvalue(一):三者的分类
开发语言·c++