面试官:"手写一个快排。" 你:"好的。" 然后沉默了。如果你也曾在面试中被问到快排,或者是一直以来对快排一知半解,看这篇文章就对啦。
快速排序(Quick Sort)是面试出场率最高的排序算法,没有之一。它基于分治策略,平均时间复杂度 O(nlogn),是大多数语言内置排序的底层实现。
本文带你从三种 O(n^2) 排序出发,理解快排为什么快、怎么实现、以及它"不稳定"的本质原因。
一、排序算法的进化:从 O(n^2) 到 O(nlogn)
在讲快排之前,先回顾三种基础排序——它们都是 O(n^2) 级别:
| 算法 | 核心思想 | 时间复杂度 |
|---|---|---|
| 冒泡排序 | 两两相邻位置比较,大的往后冒 | O(n^2) |
| 选择排序 | 每轮选择最小元素,放到当前位置 | O(n^2) |
| 插入排序 | 从当前位置开始,与前面元素比较,找到合适位置插入 | O(n^2) |
这三种算法的共同问题:每轮只确定一个元素的最终位置,效率低下。
快排的突破在于:每轮确定一个基准值的最终位置,同时把数组分成两部分,然后递归处理。这就是分治策略。
二、快排核心思想:分而治之
2.1 三步走
分(Divide)→ 选一个基准值(pivot),把数组分成两部分
治(Conquer)→ 分别对左右两部分排序
合(Combine)→ 不需要额外合并,原地排序已完成
2.2 一轮操作的具体过程
假设数组 [2, 4, 1, 0, 3, 5],选第一个元素 2 作为基准值:
初始: [2, 4, 1, 0, 3, 5]
↑
pivot = 2
第一步:从右往左找第一个比 2 小的 → 0
第二步:从左往右找第一个比 2 大的 → 4
第三步:交换 4 和 0
[2, 0, 1, 4, 3, 5]
↑ ↑
i=1 j=3
第四步:继续,从右找比 2 小的 → 1(但 i 已经 >= j,停止)
第五步:将基准值交换到分界线位置
[1, 0, 2, 4, 3, 5]
↑
pivot 最终位置
结果:pivot=2 到了正确位置,左边 [1, 0] 都比它小,右边 [4, 3, 5] 都比它大
一轮之后,基准值的最终位置就确定了。然后对左右两部分分别递归执行同样的操作。
三、递归 vs 分治:别再搞混了
很多人把递归和分治当成一回事,其实它们是不同层面的概念:
| 维度 | 递归 | 分治 |
|---|---|---|
| 本质 | 代码实现方式 | 算法设计思想 |
| 定义 | 函数自身调用自身 | 把大问题拆成独立的小问题 |
| 方向 | 自顶向下,缩小问题规模 | 分 → 治 → 合 三步 |
| 关系 | 分治绝大多数用递归实现 | 递归不一定是分治 |
快排是分治思想 + 递归实现的经典案例。
四、完整代码实现
4.1 partition 函数:治(分区)
function partition(nums, left, right) {
let i = left, j = right; // 左右双指针
while (i < j) {
// 右指针:找第一个比基准值小的元素
while (i < j && nums[j] >= nums[left]) {
j--;
}
// 左指针:找第一个比基准值大的元素
while (i < j && nums[i] <= nums[left]) {
i++;
}
// 交换这两个元素
[nums[i], nums[j]] = [nums[j], nums[i]];
}
// 将基准值交换到分界线位置
[nums[i], nums[left]] = [nums[left], nums[i]];
return i; // 返回基准值的最终位置
}
逐行解析:
| 代码 | 作用 |
|---|---|
let i = left, j = right | 初始化左右双指针 |
nums[j] >= nums[left] | 右侧比基准值大的,留在右边,j 继续左移 |
nums[i] <= nums[left] | 左侧比基准值小的,留在左边,i 继续右移 |
[nums[i], nums[j]] = [nums[j], nums[i]] | 找到一对"该换的",交换 |
[nums[i], nums[left]] = [nums[left], nums[i]] | 最后把基准值放到分界线 |
return i | 返回基准值位置,作为递归分界 |
4.2 quickSort 函数:递归调度
function quickSort(nums, left, right) {
if (left >= right) {
return; // 递归终止条件:子数组长度为 0 或 1
}
// 分区:获取基准值位置
let pivot = partition(nums, left, right);
// 递归:分别处理左右子数组
quickSort(nums, left, pivot - 1);
quickSort(nums, pivot + 1, right);
}
4.3 调用
const arr = [2, 4, 1, 0, 3, 5];
quickSort(arr, 0, arr.length - 1);
console.log(arr); // [0, 1, 2, 3, 4, 5]
五、快排为什么快?
5.1 两个关键因素
| 因素 | 说明 | 复杂度贡献 |
|---|---|---|
| pivot 基准值分区 | 每轮将问题规模减半(类似二分) | O(logn) 层递归深度 |
| 原地交换(双指针) | 不需要额外数组,直接在原数组上交换 | O(n) 每层操作 |
两者结合:O(logn) 层 × O(n) 每层 = O(nlogn)
5.2 对比其他排序
| 算法 | 时间复杂度(平均) | 空间复杂度 | 是否稳定 |
|---|---|---|---|
| 冒泡排序 | O(n^2) | O(1) | 稳定 |
| 选择排序 | O(n^2) | O(1) | 不稳定 |
| 插入排序 | O(n^2) | O(1) | 稳定 |
| 归并排序 | O(nlogn) | O(n) | 稳定 |
| 快速排序 | O(nlogn) | O(logn) | 不稳定 |
快排在时间复杂度和空间复杂度上都做到了优秀平衡。
六、快排为什么不稳定?
稳定排序:相等元素的相对位置在排序前后保持不变。
快排是不稳定排序。来看一个例子:
原始数组:[3a, 2, 3b, 1] (3a 和 3b 是两个相等的元素)
第一轮:pivot = 3a
- 右指针找到 1(比 3a 小)
- 左指针找到 3b(比 3a 大?不,3b == 3a,不满足 > 条件,i 不移动)
- 但 j 继续左移,最终 3a 和 1 交换
结果:[1, 2, 3b, 3a]
3a 和 3b 的相对位置颠倒了 → 不稳定
本质原因:分区过程中,相等元素可能被交换到基准值的另一侧,导致相对位置改变。
七、总结
快速排序的核心知识点:
- 快的原因:pivot 基准值分区(O(logn)) + 原地交换双指针(O(n))= O(nlogn)
- 不稳定的原因:分区过程中相等元素的相对位置会被打乱
- 核心策略:分治——分、治、合三步
- 实现方式:递归——函数自身调用,自顶向下缩小问题规模
面试写快排,记住口诀:选基准、双指针、左右扫、交换、递归。
本文代码基于原生 JavaScript,兼容所有现代浏览器和 Node.js。