上周线上面试,面试官让手写快速排序。我心想这还不简单,提笔就写双指针,结果跑完测试用例输出一半对一半乱,当场尬住。
回来之后我对着代码盯了半小时,越看越不服气。上学的时候学过冒泡、选择、插入,都是两层循环硬刚,时间复杂度 O (n²),数据量大了直接没法用。一直说快排是排序算法里的 "性能担当",平均 O (nlogn),但我始终没彻底搞懂它到底快在哪,每次写都是照着模板硬背,稍微变个基准选择方式就写错。
索性这次不背了,顺着思路一步步推,把踩过的坑全捋了一遍,终于算是把这点东西嚼透了。
快排的核心逻辑:选个基准,拆成两半
说穿了快排的思路特别朴素,完全是 "分而治之" 的路子。你想给一整个数组排序,不用从头到尾一个个比。先随便挑一个元素当 "基准",跑一轮下来,把比它小的全扔到左边,比它大的全扔到右边。这一轮结束,这个基准元素的最终位置就定死了,再也不用动。
剩下的事就简单了:左边那一半数组,再挑个基准,重复同样的操作;右边那一半也一样。拆到最后,每个子数组只剩一个元素的时候,自然就是有序的,整个数组也就排好了。
我当时拿排身高举例子想,一下就通了:班里同学乱站着,你随便拉一个人出来当参照,比他矮的站左边,高的站右边,这个人就站对位置了。然后左边、右边各自再拉一个人继续分,用不了几轮所有人都能站好。

说白了就是把 "排一个大数组" 这个难事,拆成了 "排无数个小数组" 的小事,小事解决了,大事自然就成了。这就是所谓的分治思想。
手搓一版能跑的代码
思路通了,代码写起来就顺了。核心就两个函数:一个负责分区(partition),把数组按基准拆开、返回基准的最终位置;一个负责递归,把左右子数组继续排。
我写了个最经典的双指针版本,选最左边的元素当基准,原地交换,不开额外空间:
javascript
// 分区函数:处理 [left, right] 区间,返回基准数的最终索引
function partition(nums, left, right) {
let i = left, j = right;
// 以最左侧元素为基准
// 别问我为什么下面先动 j 指针,踩了三次坑才记住
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 pivotIndex = partition(nums, left, right);
// 递归排左边
quickSort(nums, left, pivotIndex - 1);
// 递归排右边
quickSort(nums, pivotIndex + 1, right);
}
// 跑个测试用例
const nums = [5, 2, 4, 1, 0, 3];
quickSort(nums, 0, nums.length - 1);
console.log(nums); // 输出:[0, 1, 2, 3, 4, 5]
跑通的那一刻我还挺得意,觉得也就这么回事。结果后面换了几个测试用例,直接连踩三个坑。
我踩过的那几个经典坑
坑 1:指针移动顺序写反,排序直接乱套
这是我栽的第一个跟头。最开始写的时候,我想当然先写左指针、再写右指针,觉得反正都是往中间走,谁先谁后不一样?
结果一跑,数组直接排得乱七八糟。我对着输出看了半天,拿个小数组手动走了一遍流程才反应过来。
我们选的是最左边的元素当基准,最后要把基准和 i、j 相遇的位置交换。如果先动左指针,最后相遇的位置很可能是一个大于基准的数,你把大数换到最左边,那不乱套才怪。先动右指针就不一样,最后停下来的位置一定是小于等于基准的数,交换过去才合理。
划重点基准选左端点,就先移动右指针;基准选右端点,就先移动左指针。死记硬背很容易忘,自己拿个 3 个元素的小数组走一遍流程,马上就记牢了。
坑 2:有序数组直接跑,直接栈溢出
我当时觉得代码写对了,随手拿了个 [1,2,3,4,5] 测一下极限情况,结果控制台直接报 Maximum call stack size exceeded。
我第一反应是递归终止条件写错了,查了半天没问题。后来才拍脑袋想明白:本来就有序的数组,你还选最左边当基准,每一轮分区都是左边空、右边全是元素,等于每次只排好一个数,递归深度直接变成 n 层。
这时候快排不仅时间复杂度退化成了 O (n²),连空间都扛不住,直接栈溢出。说白了就是基准选得太烂,把快排干成冒泡了。
实际工程里没人会这么写,一般都会加个优化:随机选一个元素当基准,或者取左中右三个数的中位数当基准,最大概率避免最坏情况。
坑 3:我以前居然以为快排是稳定排序
没深入想之前,我一直觉得相等的元素不会互相交换,顺序应该不变。直到我拿 [2, 2, 1] 跑了一遍,才发现两个 2 的顺序反过来了。
原因也很简单:快排是远距离交换元素,两个相等的元素很可能在指针交换的过程中颠倒前后顺序。比如数组里第一个 2 和第三个元素交换位置,后面那个 2 就跑到前面去了,相对顺序直接乱掉。
这也是快排 "不稳定" 的根源。如果业务里要求相等元素保持原顺序,就别用快排,归并排序更合适。
别把分治和递归搞混了
捋到这我顺便把一个老误区给清了:之前我总觉得分治就是递归,递归就是分治,其实完全是两码事。
递归是写代码的一种方式,就是函数自己调用自己,一层层缩小问题规模,最后再逐层返回。它是实现手段,就像你用锤子钉钉子,锤子是工具。
分治是一种算法思想,核心是 "分 + 治 + 合":把一个大问题拆成几个互相独立的小问题,小问题各自解决,最后把结果拼起来就是大问题的答案。它是解决问题的思路,就像 "把大件拆成小件搬" 这个想法。
快排就是典型的分治思想,只不过我们用递归的方式来实现它而已。你要是愿意,用迭代 + 栈也能写出分治的快排,只是写起来麻烦点。
说直白点:分治是战略,递归是战术,别混为一谈。
再往深唠两句
很多人问快排为啥快,其实算笔账就清楚了。平均情况下,每一轮分区都把数组差不多对半拆,整个递归过程大概有 logn 层;每一层里,所有元素都要被遍历比较一次,开销是 O (n)。乘起来就是 O (nlogn),比起冒泡的 O (n²),数据量大的时候性能差了好几个量级。
而且快排是原地排序,不需要开额外的数组存结果,空间开销主要来自递归栈,平均情况是 O (logn),对缓存也很友好,这也是实际工程里它用得最多的原因。
但还是那句话,没有万能的算法。数据量特别小的时候,快排的递归开销反而不如插入排序;需要稳定排序的场景,它也不如归并。知道它好在哪,也知道它哪不行,才算真的搞懂了。
最后说几句
折腾这么一圈下来,我最大的感受有三个:第一,算法别死记硬背代码。之前我背了好多次快排模板,过段时间就忘。这次顺着分治的思路一步步推,连指针顺序这种细节都能自己推出来,根本不用背。第二,细节里全是坑。看着就十几行代码,真自己手写,指针顺序、递归边界、基准选择,哪一步错了都跑不通。第三,没有银弹。快排再经典,也有它搞不定的场景。知道什么时候该用、什么时候不该用,比会写代码本身更重要。
如果你也有手写快排踩坑的经历,或者有别的理解,欢迎在评论区聊聊。搞懂了的话也可以留个言,我看看有多少人和我走过一样的弯路