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

这个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 的区间一次确定,不参与后续递归
相关推荐
§ꦿCFོ༉13 分钟前
嵌入式八股文—C语言入门学习(四)
c语言·开发语言·学习
2603_969694852 小时前
【JavaWeb学习记录】MyBatis核心知识点与实战笔记:从入门到动态SQL
笔记·sql·学习·mysql·mybatis·javaweb·学习方法
唐墨1233 小时前
linux kernel 比较数据大小的宏定义
linux·算法
拂拉氏4 小时前
【知识讲解-题目讲解】 统计满足K约束的子字符串数量2 (滑动窗口+前缀和+二分查找+分析)
算法·前缀和·二分查找·滑动窗口
北京云帆互联科技8 小时前
云帆培训考试系统更新说明:v9.3.0
java·学习
通信小呆呆9 小时前
电子战抗干扰技术:当“不被干扰”成为一种系统
算法·通信·mimo·isac
老歌老听老掉牙9 小时前
案例 7:二维 Rosenbrock 函数最小值(香蕉函数)
c++·pso
笑容掩饰我的失落9 小时前
区块链从小白到精通(八):从零实现一个标准 ERC-20 代币(完整可运行代码)
算法·区块链·php·智能合约·哈希算法
Severus_black9 小时前
【C++初阶】Stack、Queue算法题大练兵!
c语言·c++·算法·stl