被面试官问到快排,我用5行代码让他当场点头
前言
面试官:「手写一个快速排序吧。」
我:「好的。」
然后我写出了这个:
function quickSort(nums, left, right) {
if (left >= right) return;
let pivot = partition(nums, left, right);
quickSort(nums, left, pivot - 1);
quickSort(nums, pivot + 1, right);
}
面试官看完点了点头:「可以,讲讲原理。」
如果你也曾在面试中被问到快排,或者一直对这个"最快的排序"一知半解,这篇文章就是为你写的。
一、为什么快排这么"快"?
先看一组数据:
| 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|
| 冒泡排序 | 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²) 的算法快了一个量级。
当 n = 10000 时:
- O(n²) = 1亿次操作
- O(n log n) ≈ 13万次操作
差了将近1000倍!
但快排之所以"快",不只是因为时间复杂度低,更因为它的原地排序特性——不需要额外的数组空间,直接在原数组上交换元素。
二、分治思想:大事化小,小事化了
快排的核心思想是分治策略(Divide and Conquer)。
分治 = 分 + 治 + 合
- 分:把大问题切成互相独立的小问题
- 治:递归解决每个小问题
- 合:合并小问题的结果(快排这步是隐式的,不需要额外操作)
举个例子:
原始数组:[10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
第一轮 partition:
pivot = 10(选第一个元素作为基准值)
结果:[1, 9, 8, 7, 6, 5, 4, 3, 2, 10]
↑ pivot就位
左边:[1, 9, 8, 7, 6, 5, 4, 3, 2] → 继续递归
右边:[10] → 已排好
第二轮递归左边:
pivot = 1
结果:[1, 9, 8, 7, 6, 5, 4, 3, 2]
...以此类推,直到所有元素就位
三、代码逐行拆解
3.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;
}
这段代码在干什么?
想象你是一个图书馆管理员,要把书架上的书按大小排列:
- 你选了一本书作为"基准线"(pivot)
- 从右边开始,找到一本比基准线小的书
- 从左边开始,找到一本比基准线大的书
- 把这两本书交换位置
- 重复步骤2-4,直到左右指针相遇
- 把基准线那本书放到最终位置
关键点:
nums[left]作为基准值(pivot),所以j从右往左找比它小的,i从左往右找比它大的- 找到后交换,这样比基准值小的都在左边,大的都在右边
- 最后把基准值放到分界线位置
3.2 quickSort函数——递归分治
function quickSort(nums, left, right) {
if (left >= right) return; // 递归终止条件
let pivot = partition(nums, left, right); // 分区
quickSort(nums, left, pivot - 1); // 递归排左边
quickSort(nums, pivot + 1, right); // 递归排右边
}
就3行核心代码,是不是比想象中简单?
四、递归 vs 分治,别再搞混了
很多人分不清递归和分治,其实它们是两个层面的概念:
| 概念 | 是什么 | 类比 |
|---|---|---|
| 递归 | 代码实现方式(函数调用自身) | 像俄罗斯套娃,一层套一层 |
| 分治 | 算法设计思想(分+治+合) | 像分而治之的管理策略 |
- 递归是手段,分治是目的
- 分治几乎都用递归实现,但递归不一定都是分治
快排的递归调用栈:
quickSort([10,9,8,7,6,5,4,3,2,1], 0, 9)
→ partition → pivot=1 (位置0)
→ quickSort([1,9,8,7,6,5,4,3,2], 0, 8)
→ partition → pivot=1 (位置0)
→ quickSort([], 0, -1) → return
→ quickSort([9,8,7,6,5,4,3,2], 1, 8)
→ ...继续递归
→ quickSort([10], 1, 9) → return
五、快排的两个"致命伤"
5.1 不稳定排序
什么是不稳定?举个例子:
原数组:[3a, 3b, 1, 2]
排序后:[1, 2, 3a, 3b] 或 [1, 2, 3b, 3a]
3a和3b的相对位置可能颠倒,这就是"不稳定"。
原因: partition过程中,相等元素的交换会改变相对顺序。
5.2 最坏情况O(n²)
当数组已经有序时(比如[1,2,3,4,5]),每次partition只排好一个元素,递归深度变成n,时间复杂度退化为O(n²)。
解决方案:
- 随机选择pivot
- 三数取中法(取左、中、右三个元素的中位数)
六、完整代码+测试
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;
}
function quickSort(nums, left, right) {
if (left >= right) return;
let pivot = partition(nums, left, right);
quickSort(nums, left, pivot - 1);
quickSort(nums, pivot + 1, right);
}
// 测试
const arr = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1];
quickSort(arr, 0, arr.length - 1);
console.log(arr); // [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
总结
| 问题 | 答案 |
|---|---|
| 快排为什么快? | 基准值分治(O(log n)) + 原地交换(O(n)) |
| 为什么不稳定? | 相等元素在partition中相对位置会颠倒 |
| 核心思想是什么? | 分治策略:分→治→合 |
| 面试怎么讲? | 先说思想,再写代码,最后分析复杂度 |
记住这3点,面试官问到快排你就能侃侃而谈了。
写在最后
快排是算法面试的"必考题",理解它的分治思想比死记代码更重要。
如果你觉得有帮助,点个赞👍支持一下,后续还会分享更多算法面试干货~
有什么问题欢迎在评论区讨论!