一、引子:数组排序的「青铜时代」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 口诀 | 谁先动,最后就交换另一个指针的位置 |
九、总结
快排的本质 = 「分而治之」+ 「原地双指针交换」
三个关键认知
-
快的原因:
- pivot 二分把递归深度压到 O(log n)
- 双指针原地扫描每层只 O(n)
- 没有合并步骤,省内存
-
不稳定的原因:
- 双指针交换是「跳跃式」的,相等元素相对位置可能颠倒
- 要稳定可以加原始 index 作为 tie-breaker
-
代码实现的关键:
- partition 函数里
j先动,循环退出时i==j一定停在 ≤ pivot 的位置 - 记忆口诀:谁先动,最后就交换另一个指针的位置
- partition 函数里
快排是工程上最常用的排序算法,因为它平均速度最快、内存占用小、可读性好。