快排学习笔记 —— 分而治之,O(nlogn) 的奥秘

5 阅读8分钟

一、引子:数组排序的「青铜时代」O(n²)

在读快排之前,先看三种基础排序 —— 它们的时间复杂度都是 O(n²)。

排序核心动作一句话理解
冒泡两两相邻位置比较像水里的气泡,大的「冒」到末尾
选择选择最小的元素放到当前位置每轮挑最小的塞前面
插入从当前位置开始与前面的元素比较,直到找到合适位置像整理扑克牌,把当前牌插到合适位置

1.1 为什么 O(n²) 是「青铜」?

n 个元素 × 每轮扫 n 次 = n² 次比较
  • 10 个元素:100 次比较,无所谓
  • 10000 个元素:1 亿次比较,CPU 顶不住
  • 1,000,000 个元素:1 万亿次比较,爆炸

1.2 快排为什么能跳出 O(n²)?

维度三种基础排序快排
比较次数每次扫 n 个,扫 n 轮 → n²每轮 O(n),但只扫 log n 层 → n·log n
关键突破pivot 二分:每轮把问题规模减半

10000 个元素:n² = 1 亿,n·log n ≈ 13 万,快了 700+ 倍


二、快排的核心思想:分而治之

四步流程

Step 1:选一个 pivot(基准值)
        通常取数组第一个元素 nums[left]

Step 2:分区(Partition)
        用双指针把数组重排成:
        [ 比 pivot 小的 ]  pivot  [ 比 pivot 大的 ]

Step 3:递归
        对左子数组再做快排
        对右子数组再做快排

Step 4:触底退出
        子数组长度 ≤ 1 时直接返回

2.1 关键术语速记

术语含义
pivot 基准值一轮排序中作为分界的「标杆元素」
partition 分区把数组重排成「左小 pivot 右大」的过程
双指针i 从左往右、j 从右往左,相向而行,遇到错位的元素就交换
原地交换不开新数组,直接在原数组上 swap,节省内存

三、递归 vs 分治:经常混淆的两个概念

  • 递归是代码的实现方式(函数自身调用)
  • 分治是一种算法思想,绝大多数用递归实现

3.1 一句话区分

概念是什么关心什么
递归实现手段「函数自己调用自己」—— 怎么写
分治算法思想「大问题切成独立小问题」—— 怎么想

3.2 分治的三步:分 + 治 + 合

分:把大问题切成互相独立的小问题
治:对小问题求解
合:把小问题的解合并起来

对应到快排:

步骤快排里干啥
选 pivot,把数组切成左右两半(partition)
对左右两半分别递归排序
?—— 快排里其实没有「合」这一步!

3.3 一个反直觉点:快排没有「合」步骤

分治的标准模板是「分-治-合」(比如归并排序):

  • 归并排序:先递归排左、排右,最后合并两个有序数组 → 有「合」
  • 快排:因为 partition 时元素已经在原数组里就位了,左右两边各自排好后整个数组就自动有序了,不需要合并步骤

这是快排比归并排序省内存的关键:没有合并阶段 = 不需要额外数组 = 真正的原地排序(in-place)。


四、实战代码:快排的完整实现

快排由两个函数组成:partition(分区) + quickSort(主流程递归)。

4.1 标准 Hoare 分区(partition)

function partition(nums, left, right) {
    let i = left, j = right;
    const pivot = nums[left];          // ① 选第一个元素作为基准值

    while (i < j) {
        while (i < j && nums[j] >= pivot) j--;  // ② j 先动:从右往左找 < pivot
        while (i < j && nums[i] <= pivot) i++;  // ③ 再动 i:从左往右找 > pivot

        if (i < j) {                             // ④ 找到一对错位元素就交换
            [nums[i], nums[j]] = [nums[j], nums[i]];
        }
    }
    // ⑤ 循环退出时 i == j,且一定停在「小于等于 pivot」的位置
    //    把 pivot 放到它正确的位置(分区点)
    [nums[i], nums[left]] = [nums[left], nums[i]];
    return i;
}

4.2 为什么「j 先动」?—— Hoare 分区的核心不变量

这是 Hoare 分区最关键的设计:

  • 谁先动,相遇点的性质就由谁决定
  • j 先动 → j 是最后一个找到「小于 pivot」的指针 → 当 i 追上 j 时,i==j 指向的一定是 ≤ pivot 的元素 → 和 nums[left](pivot)交换后,大数不会被放到左边。

🔑 记忆口诀:谁先动,最后就交换另一个指针的位置

  • j 先动 → 最后交换 nums[i]
  • i 先动 → 最后交换 nums[j]

4.3 主流程(quickSort)

function quickSort(nums, left, right) {
    if (left >= right) {                        // ① 触底退出:子数组长度 ≤ 1
        return;
    }
    const pivot = partition(nums, left, right); // ② 分:拿到 pivot 的最终位置
    quickSort(nums, left, pivot - 1);           // ③ 治:递归左边
    quickSort(nums, pivot + 1, right);          // ③ 治:递归右边
}

// 调用入口
const arr = [2, 4, 1, 0, 3, 5];
quickSort(arr, 0, arr.length - 1);
console.log(arr);   // [0, 1, 2, 3, 4, 5]

4.4 一张图看明白快排递归过程

                    ┌─────────────────────────┐
                    │  输入:[5, 3, 8, 1, 2]   │
                    │  pivot = nums[0] = 5     │
                    └────────────┬─────────────┘
                                 │
                                 ▼
                          ┌─────────────┐
                          │ partition   │  ← 双指针扫描 + 交换
                          └──────┬──────┘
                                 │
                                 ▼
                ┌──────────────────────────────────┐
                │  [2, 3, 1]  5  [8]               │
                │   左小 ← pivot → 右大             │
                └──────────────────────────────────┘
                         /                 \
                   ┌─────▼─────┐         ┌──▼──┐
                   │ 递归左边   │         │递归右│
                   │ pivot=2   │         │长度1 │
                   └─────┬─────┘         └──┬──┘
                         │                  │
                   ┌─────▼─────┐         触底返回
                   │ [1] 2 [3] │
                   └─────┬─────┘
                         │
                   ┌─────▼─────┐
                   │ 递归继续   │
                   └───────────┘
                    ...
              最终:[1, 2, 3, 5, 8]

五、为什么快排是 O(n log n)?

快的原因:

  • pivot 基准值 O(logn)
  • 原地交换 O(n)
  • 交换使用双指针

5.1 时间复杂度拆解

快排的总工作量 = 每层工作量 × 递归深度

每层工作量:partition 用双指针扫描一遍数组 = O(n)
递归深度:每次 pivot 把数组对半分 = O(log n)

总:O(n) × O(log n) = O(n log n)  ✅

5.2 递归树可视化

                    [n 个元素]            ← 第 1 层,扫 n 次
                    /         \
              [n/2]            [n/2]      ← 第 2 层,扫 n 次(两边加起来)
              /  \              /  \
         [n/4] [n/4]       [n/4] [n/4]     ← 第 3 层,扫 n 次
         ...
         [1] [1] [1] ... [1]                ← 第 log n 层

关键洞察:每层加起来都是扫 n 个元素,一共 log n 层,所以总比较次数是 n × log n

5.3 最坏情况 O(n²) 怎么来的?

如果 pivot 每次都选到最大或最小的元素,递归树退化成「链表」:

        [n]            ← 第 1 层,扫 n 次
       /
     [n-1]            ← 第 2 层,扫 n-1 次
    /
  [n-2]               ← 第 3 层,扫 n-2 次
  ...
[1]                    ← 第 n 层

总比较次数:n + (n-1) + ... + 1 = O(n²),退化成冒泡了。

5.4 怎么避免最坏情况?

策略说明
随机选 pivot随机选一个元素作为基准,避免特定输入触发最坏
三数取中取 left / mid / right 三者的中位数
小数组切到插入排序子数组 < 10 时改用插入排序(常数因子更小)

六、为什么快排「不稳定」?

不稳定的原因:相等元素相对位置颠倒,完美体现快排不稳定。

6.1 什么是「稳定排序」?

如果两个元素值相等,排序后它们的相对顺序保持不变,就是稳定排序。

6.2 一句话证明快排不稳定

双指针交换是「跳跃式」的,两个相等的元素可能在分区时被错位交换,导致相对顺序颠倒。

举个反例:原数组 [3a, 3b, 2, 1](3a 和 3b 都等于 3,但 a 在 b 前面)。 一轮 partition 后 pivot=3a 被换到后面,最终排序结果里 3b 跑到了 3a 前面 → 相对位置颠倒 → 不稳定。

6.3 稳定 vs 不稳定排序对比

排序算法是否稳定实际影响
冒泡、插入、归并✅ 稳定业务排序首选(按价格排、价格相同保持上架时间顺序)
快排、选择、堆❌ 不稳定性能好,但相等元素会被打乱

工程上需要稳定的话,可以给每个元素加一个「原始 index」作为 tie-breaker,值相同时按 index 升序排,强制变稳定


七、快排 vs 其他排序总览表

排序平均时间最坏时间空间稳定特点
冒泡O(n²)O(n²)O(1)简单,适合小数据
选择O(n²)O(n²)O(1)交换次数少
插入O(n²)O(n²)O(1)小数据最快
快排O(n log n)O(n²)O(log n)平均最快,工程首选
归并O(n log n)O(n log n)O(n)稳定但费内存
堆排O(n log n)O(n log n)O(1)最坏也是 n log n

现代工程实践:V8 的 Array.prototype.sort()TimSort(归并+插入的混合),追求稳定;而 C++ STL 的 std::sort 默认是快排+堆排+插入的混合(Introsort)。


八、核心知识点速记表

概念一句话理解
快排核心选 pivot → 分区 → 递归左右两边
pivot 基准值作为分界的标杆元素,通常取 nums[left]
partition 分区用双指针把数组重排成「左小 pivot 右大」
双指针交换i 从左、j 从右相向而行,遇到错位元素就 swap
原地排序不开新数组,空间 O(log n)(仅递归栈)
递归代码实现方式,函数自己调用自己
分治算法思想,分 + 治 + 合三步
快排没有「合」partition 时元素已经就位,不需要合并步骤
快的原因每层 O(n) × 共 O(log n) 层 = O(n log n)
最坏 O(n²)pivot 每次选成最大/最小,递归退化成链表
不稳定双指针跳跃交换打乱相等元素的相对顺序
Hoare 口诀谁先动,最后就交换另一个指针的位置

九、总结

快排的本质 = 「分而治之」+ 「原地双指针交换」

三个关键认知

  1. 快的原因

    • pivot 二分把递归深度压到 O(log n)
    • 双指针原地扫描每层只 O(n)
    • 没有合并步骤,省内存
  2. 不稳定的原因

    • 双指针交换是「跳跃式」的,相等元素相对位置可能颠倒
    • 要稳定可以加原始 index 作为 tie-breaker
  3. 代码实现的关键

    • partition 函数里 j 先动,循环退出时 i==j 一定停在 ≤ pivot 的位置
    • 记忆口诀:谁先动,最后就交换另一个指针的位置

快排是工程上最常用的排序算法,因为它平均速度最快、内存占用小、可读性好。