快速排序刚开始看起来容易绕:一会儿是递归,一会儿是分区,一会儿又冒出一个 pivot。如果只是背代码,很容易写出边界错误。
这篇文章换一个顺序来讲:先用简单例子建立递归直觉,再看递归如何处理嵌套结构,最后把这个思路放到快速排序里。
先说结论:
- 递归是一种代码实现方式:函数自己调用自己。
- 分治是一种算法思想:把大问题拆成小问题,分别解决,再合并结果。
- 快速排序是一种典型的分治算法,通常用递归实现。
读完本文,你应该能理解三件事:
- 递归代码为什么一定要有终止条件。
- 快速排序为什么可以通过一次次分区完成排序。
- 快排的边界为什么不能随便混用。
先从递归开始
递归不只是“函数调用自己”。更重要的是:把一个问题拆成规模更小但结构相同的问题。
例如要求:
1 + 2 + 3 + ... + n
先看最熟悉的迭代写法:
function sum(n) {
let total = 0;
for (let i = 1; i <= n; i++) {
total += i;
}
return total;
}
console.log(sum(5)); // 15
换成递归思考时,可以把 sum(5) 看成:
sum(5) = sum(4) + 5
sum(4) = sum(3) + 4
sum(3) = sum(2) + 3
sum(2) = sum(1) + 2
所以递归公式是:
sum(n) = sum(n - 1) + n
问题不能一直拆下去,需要有一个最小问题作为终止条件:
sum(1) = 1# 从递归到快速排序:用 JavaScript 把分治思想讲明白
快速排序刚开始看起来容易绕:一会儿是递归,一会儿是分区,一会儿又冒出一个 `pivot`。如果只是背代码,很容易写出边界错误。
这篇文章换一个顺序来讲:先用简单例子建立递归直觉,再看递归如何处理嵌套结构,最后把这个思路放到快速排序里。
先说结论:
- 递归是一种代码实现方式:函数自己调用自己。
- 分治是一种算法思想:把大问题拆成小问题,分别解决,再合并结果。
- 快速排序是一种典型的分治算法,通常用递归实现。
读完本文,你应该能理解三件事:
- 递归代码为什么一定要有终止条件。
- 快速排序为什么可以通过一次次分区完成排序。
- 快排的边界为什么不能随便混用。
## 先从递归开始
递归不只是“函数调用自己”。更重要的是:把一个问题拆成规模更小但结构相同的问题。
例如要求:
```txt
1 + 2 + 3 + ... + n
先看最熟悉的迭代写法:
function sum(n) {
let total = 0;
for (let i = 1; i <= n; i++) {
total += i;
}
return total;
}
console.log(sum(5)); // 15
换成递归思考时,可以把 sum(5) 看成:
sum(5) = sum(4) + 5
sum(4) = sum(3) + 4
sum(3) = sum(2) + 3
sum(2) = sum(1) + 2
所以递归公式是:
sum(n) = sum(n - 1) + n
问题不能一直拆下去,需要有一个最小问题作为终止条件:
sum(1) = 1
最终代码就是:
最终代码就是:
function sum(n) {
if (n === 1) return 1;
return sum(n - 1) + n;
}
console.log(sum(5)); // 15
写递归时,先别急着敲代码,可以先问两个问题:
- 递归公式:当前问题如何由更小的问题得到。
- 终止条件:递归什么时候停止。
没有终止条件,递归会一直调用下去,最终导致调用栈溢出。
再看一个更像递归的问题:数组扁平化
数组扁平化就是把多层嵌套数组展开成一维数组。
JavaScript 原生提供了 flat 方法:
const arr = [1, [2, [3, 4, [5, 6]]]];
console.log(arr.flat()); // [1, 2, [3, 4, [5, 6]]]
console.log(arr.flat(2)); // [1, 2, 3, 4, [5, 6]]
console.log(arr.flat(Infinity)); // [1, 2, 3, 4, 5, 6]
如果自己实现一个完全扁平化函数,递归会非常自然:
function flatten(arr) {
let result = [];
arr.forEach((item) => {
if (Array.isArray(item)) {
result = result.concat(flatten(item));
} else {
result.push(item);
}
});
return result;
}
const arr = [1, 2, [3, 4, [5, 6]]];
console.log(flatten(arr)); // [1, 2, 3, 4, 5, 6]
这段代码的判断只有两种情况:
- 如果当前元素是数组,继续扁平化它。
- 如果当前元素不是数组,直接放入结果数组。
- 每一层递归返回自己的扁平化结果。
数组嵌套数组,本身就是一层套一层的结构,这类问题通常很适合递归处理。
递归和分治的区别
理解快速排序之前,需要先把递归和分治分清楚。它们经常一起出现,但不是同一类概念。
递归是实现方式,强调的是「函数调用自身」。
分治是算法思想,强调的是「拆分问题」:
分:把大问题拆成多个规模更小的问题
治:分别解决这些小问题
合:把小问题的结果合并成原问题的结果
大多数分治算法会用递归实现,但不是所有递归代码都属于分治。
例如 sum(n) = sum(n - 1) + n 是递归,但它不算典型分治,因为它每次只得到一个更小的子问题。
快速排序就更接近标准的分治过程:
- 先选一个基准值
pivot。 - 把小于等于基准值的元素放左边,大于基准值的元素放右边。
- 再分别对左右两边继续排序。
快速排序到底在做什么
快速排序的核心是一次分区操作。
假设有数组:
const nums = [2, 4, 1, 0, 3, 5];
选择第一个元素 2 作为基准值,也就是 pivot。经过一次分区后,数组会变成类似这样的结构:
[比 2 小或等于 2 的元素] 2 [比 2 大的元素]
这时 2 已经到了最终排序结果中应该处于的位置。接下来只需要分别处理它左边和右边的子数组。
这就是快速排序的核心:一次分区确定一个基准值的位置,并把一个大数组拆成两个更小的区间。
可以把它理解成下面这个过程:
quickSort([2, 4, 1, 0, 3, 5])
-> 把 2 放到正确位置
-> 继续排序 2 左边的部分
-> 继续排序 2 右边的部分
原地快速排序实现
下面是一个原地排序版本。它不会额外创建 left 和 right 两个数组,而是通过双指针直接在原数组中交换元素。
function partition(nums, left, right) {
const pivotValue = nums[left];
let i = left;
let j = right;
while (i < j) {
while (i < j && nums[j] > pivotValue) {
j--;
}
while (i < j && nums[i] <= pivotValue) {
i++;
}
if (i < j) {
[nums[i], nums[j]] = [nums[j], nums[i]];
}
}
[nums[left], nums[i]] = [nums[i], nums[left]];
return i;
}
function quickSort(nums, left = 0, right = nums.length - 1) {
if (left >= right) return nums;
const pivotIndex = partition(nums, left, right);
quickSort(nums, left, pivotIndex - 1);
quickSort(nums, pivotIndex + 1, right);
return nums;
}
const arr = [2, 4, 1, 0, 3, 5];
console.log(quickSort(arr)); // [0, 1, 2, 3, 4, 5]
这段代码只需要抓住两个函数:
partition:完成一次分区,并返回基准值最终所在的位置。quickSort:递归处理基准值左边和右边的子数组。
把分区过程拆开看
以 partition(nums, left, right) 为例:
const pivotValue = nums[left];
let i = left;
let j = right;
这里选择当前区间的第一个元素作为基准值。接下来用两个指针做扫描:
i从左往右走,寻找大于基准值的元素。j从右往左走,寻找小于等于基准值的元素。
右指针先从右往左找,找到第一个小于等于基准值的元素:
while (i < j && nums[j] > pivot本文用 Node.js 示例讲清 Tokenization 与 Embedding:文本如何变成 token id,语义如何变成向量,并说明 token 计费、上下文、余弦相似度和 RAG 检索的核心关系Value) {
j--;
}
左指针再从左往右找,找到第一个大于基准值的元素:
while (i < j && nums[i] <= pivotValue) {
i++;
}
如果两个指针还没有相遇,就交换这两个位置:
if (i < j) {
[nums[i], nums[j]] = [nums[j], nums[i]];
}
当 i 和 j 相遇时,说明当前区间已经被分成两部分。最后把基准值放到分界位置:
[nums[left], nums[i]] = [nums[i], nums[left]];
此时 i 就是基准值的最终位置。
用 [2, 4, 1, 0, 3, 5] 举例,一次分区后的结果可能是:
原数组: [2, 4, 1, 0, 3, 5]
基准值: 2
分区后: [0, 1, 2, 4, 3, 5]
此时 2 左边的元素都小于等于它,右边的元素都大于它。注意,左边和右边内部还不一定有序,所以还需要继续递归排序。
另一种常见写法:Hoare 分区
快速排序还有一种常见写法:Hoare 分区。它通常会选中间位置作为基准值,并返回右侧分界点。
这一版和前面的写法有一个关键区别:它不一定把某个基准值固定到最终位置,而是把数组划分成两个区间。
因此它的递归边界也不一样:
quickSortHoare(nums, left, j);
quickSortHoare(nums, j + 1, right);
不要把它和上一版的边界混用:
quickSort(nums, left, pivotIndex - 1);
quickSort(nums, pivotIndex + 1, right);
完整代码如下:
function quickSortHoare(nums, left = 0, right = nums.length - 1) {
if (left >= right) return nums;
let i = left - 1;
let j = right + 1;
const pivotValue = nums[Math.floor((left + right) / 2)];
while (i < j) {
do {
i++;
} while (nums[i] < pivotValue);
do {
j--;
} while (nums[j] > pivotValue);
if (i < j) {
[nums[i], nums[j]] = [nums[j], nums[i]];
}
}
quickSortHoare(nums, left, j);
quickSortHoare(nums, j + 1, right);
return nums;
}
const arr = [2, 4, 1, 0, 3, 5];
console.log(quickSortHoare(arr)); // [0, 1, 2, 3, 4, 5]
这一版代码也很常见,尤其是在算法题题解里。重点不是记住哪一种更好,而是记住:不同分区方式返回的含义不同,递归边界必须跟着变。
时间复杂度
快速排序的平均时间复杂度是:
O(nlogn)
可以从两个角度理解:
- 每一层分区操作需要扫描当前区间,整体是
O(n)。 - 平均情况下,每次分区能把数组拆得比较均匀,递归层数约为
O(logn)。
所以平均复杂度是:
O(n) * O(logn) = O(nlogn)
但快速排序不是任何情况下都是 O(nlogn)。
如果每次基准值都选得很差,例如数组已经有序且总是选择第一个元素作为基准值,递归会退化成单边递归:
n -> n - 1 -> n - 2 -> ... -> 1
这时最坏时间复杂度会变成:
O(n^2)
空间复杂度
上面的实现是原地交换,不会额外创建左右数组,所以额外空间主要来自递归调用栈。
调用栈空间取决于递归深度:
- 平均空间复杂度:
O(logn) - 最坏空间复杂度:
O(n)
如果写成下面这种创建新数组的版本,理解起来更简单,但空间消耗更大:
function quickSortSimple(nums) {
if (nums.length <= 1) return nums;
const pivotValue = nums[0];
const left = [];
const right = [];
for (let i = 1; i < nums.length; i++) {
if (nums[i] <= pivotValue) {
left.push(nums[i]);
} else {
right.push(nums[i]);
}
}
return quickSortSimple(left).concat(pivotValue, quickSortSimple(right));
}
console.log(quickSortSimple([2, 4, 1, 0, 3, 5])); // [0, 1, 2, 3, 4, 5]
这个版本适合入门理解,因为它把「左边」「右边」直接写成了两个数组。但它需要额外空间,实际场景里原地排序版本更常见。
快速排序稳定吗
快速排序通常是不稳定排序。
稳定排序指的是:如果两个元素的值相等,排序后它们的相对顺序不变。
快速排序在分区过程中会交换元素,相等元素的相对顺序可能被打乱。
例如排序下面这组数据时,如果只按 value 排序:
[
{ value: 2, name: "a" },
{ value: 1, name: "b" },
{ value: 2, name: "c" }
]
稳定排序会保证排序后 "a" 仍然排在 "c" 前面。快速排序因为存在交换操作,通常不保证这一点。
如果需要稳定排序,可以考虑归并排序。归并排序在合并两个有序数组时,如果相等元素优先取左边数组的元素,就可以保持稳定性。
小结
从递归到快速排序,核心其实只有一条线:把大问题变成小问题。
递归解决问题时,重点是找出递归公式和终止条件。分治解决问题时,重点是把大问题拆成小问题,再合并结果。
快速排序把递归和分治结合得很典型:
- 选基准值。
- 通过分区把数组拆成左右两部分。
- 递归排序左右子数组。
- 平均时间复杂度为
O(nlogn)。 - 原地版本平均空间复杂度为
O(logn)。 - 通常是不稳定排序。
理解了递归和分治,再看快速排序,就不会只是背代码,而是能看懂它为什么这样写、为什么这样拆边界。