从递归到快速排序:用 JavaScript 把分治思想讲明白

快速排序刚开始看起来容易绕:一会儿是递归,一会儿是分区,一会儿又冒出一个 pivot。如果只是背代码,很容易写出边界错误。

这篇文章换一个顺序来讲:先用简单例子建立递归直觉,再看递归如何处理嵌套结构,最后把这个思路放到快速排序里。

先说结论:

  • 递归是一种代码实现方式:函数自己调用自己。
  • 分治是一种算法思想:把大问题拆成小问题,分别解决,再合并结果。
  • 快速排序是一种典型的分治算法,通常用递归实现。

读完本文,你应该能理解三件事:

  • 递归代码为什么一定要有终止条件。
  • 快速排序为什么可以通过一次次分区完成排序。
  • 快排的边界为什么不能随便混用。

先从递归开始

递归不只是"函数调用自己"。更重要的是:把一个问题拆成规模更小但结构相同的问题。

例如要求:

txt 复制代码
1 + 2 + 3 + ... + n

先看最熟悉的迭代写法:

js 复制代码
function sum(n) {
  let total = 0;

  for (let i = 1; i <= n; i++) {
    total += i;
  }

  return total;
}

console.log(sum(5)); // 15

换成递归思考时,可以把 sum(5) 看成:

txt 复制代码
sum(5) = sum(4) + 5
sum(4) = sum(3) + 4
sum(3) = sum(2) + 3
sum(2) = sum(1) + 2

所以递归公式是:

txt 复制代码
sum(n) = sum(n - 1) + n

问题不能一直拆下去,需要有一个最小问题作为终止条件:

txt 复制代码
sum(1) = 1# 从递归到快速排序:用 JavaScript 把分治思想讲明白

快速排序刚开始看起来容易绕:一会儿是递归,一会儿是分区,一会儿又冒出一个 `pivot`。如果只是背代码,很容易写出边界错误。

这篇文章换一个顺序来讲:先用简单例子建立递归直觉,再看递归如何处理嵌套结构,最后把这个思路放到快速排序里。

先说结论:

- 递归是一种代码实现方式:函数自己调用自己。
- 分治是一种算法思想:把大问题拆成小问题,分别解决,再合并结果。
- 快速排序是一种典型的分治算法,通常用递归实现。

读完本文,你应该能理解三件事:

- 递归代码为什么一定要有终止条件。
- 快速排序为什么可以通过一次次分区完成排序。
- 快排的边界为什么不能随便混用。

## 先从递归开始

递归不只是"函数调用自己"。更重要的是:把一个问题拆成规模更小但结构相同的问题。

例如要求:

```txt
1 + 2 + 3 + ... + n

先看最熟悉的迭代写法:

js 复制代码
function sum(n) {
  let total = 0;

  for (let i = 1; i <= n; i++) {
    total += i;
  }

  return total;
}

console.log(sum(5)); // 15

换成递归思考时,可以把 sum(5) 看成:

txt 复制代码
sum(5) = sum(4) + 5
sum(4) = sum(3) + 4
sum(3) = sum(2) + 3
sum(2) = sum(1) + 2

所以递归公式是:

txt 复制代码
sum(n) = sum(n - 1) + n

问题不能一直拆下去,需要有一个最小问题作为终止条件:

txt 复制代码
sum(1) = 1

最终代码就是:

js 复制代码

最终代码就是:

js 复制代码
function sum(n) {
  if (n === 1) return 1;
  return sum(n - 1) + n;
}

console.log(sum(5)); // 15

写递归时,先别急着敲代码,可以先问两个问题:

  • 递归公式:当前问题如何由更小的问题得到。
  • 终止条件:递归什么时候停止。

没有终止条件,递归会一直调用下去,最终导致调用栈溢出。

再看一个更像递归的问题:数组扁平化

数组扁平化就是把多层嵌套数组展开成一维数组。

JavaScript 原生提供了 flat 方法:

js 复制代码
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]

如果自己实现一个完全扁平化函数,递归会非常自然:

js 复制代码
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]

这段代码的判断只有两种情况:

  • 如果当前元素是数组,继续扁平化它。
  • 如果当前元素不是数组,直接放入结果数组。
  • 每一层递归返回自己的扁平化结果。

数组嵌套数组,本身就是一层套一层的结构,这类问题通常很适合递归处理。

递归和分治的区别

理解快速排序之前,需要先把递归和分治分清楚。它们经常一起出现,但不是同一类概念。

递归是实现方式,强调的是「函数调用自身」。

分治是算法思想,强调的是「拆分问题」:

txt 复制代码
分:把大问题拆成多个规模更小的问题
治:分别解决这些小问题
合:把小问题的结果合并成原问题的结果

大多数分治算法会用递归实现,但不是所有递归代码都属于分治。

例如 sum(n) = sum(n - 1) + n 是递归,但它不算典型分治,因为它每次只得到一个更小的子问题。

快速排序就更接近标准的分治过程:

  • 先选一个基准值 pivot
  • 把小于等于基准值的元素放左边,大于基准值的元素放右边。
  • 再分别对左右两边继续排序。

快速排序到底在做什么

快速排序的核心是一次分区操作。

假设有数组:

js 复制代码
const nums = [2, 4, 1, 0, 3, 5];

选择第一个元素 2 作为基准值,也就是 pivot。经过一次分区后,数组会变成类似这样的结构:

txt 复制代码
[比 2 小或等于 2 的元素] 2 [比 2 大的元素]

这时 2 已经到了最终排序结果中应该处于的位置。接下来只需要分别处理它左边和右边的子数组。

这就是快速排序的核心:一次分区确定一个基准值的位置,并把一个大数组拆成两个更小的区间。

可以把它理解成下面这个过程:

txt 复制代码
quickSort([2, 4, 1, 0, 3, 5])
  -> 把 2 放到正确位置
  -> 继续排序 2 左边的部分
  -> 继续排序 2 右边的部分

原地快速排序实现

下面是一个原地排序版本。它不会额外创建 leftright 两个数组,而是通过双指针直接在原数组中交换元素。

js 复制代码
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) 为例:

js 复制代码
const pivotValue = nums[left];
let i = left;
let j = right;

这里选择当前区间的第一个元素作为基准值。接下来用两个指针做扫描:

  • i 从左往右走,寻找大于基准值的元素。
  • j 从右往左走,寻找小于等于基准值的元素。

右指针先从右往左找,找到第一个小于等于基准值的元素:

js 复制代码
while (i < j && nums[j] > pivot本文用 Node.js 示例讲清 Tokenization 与 Embedding:文本如何变成 token id,语义如何变成向量,并说明 token 计费、上下文、余弦相似度和 RAG 检索的核心关系Value) {
  j--;
}

左指针再从左往右找,找到第一个大于基准值的元素:

js 复制代码
while (i < j && nums[i] <= pivotValue) {
  i++;
}

如果两个指针还没有相遇,就交换这两个位置:

js 复制代码
if (i < j) {
  [nums[i], nums[j]] = [nums[j], nums[i]];
}

ij 相遇时,说明当前区间已经被分成两部分。最后把基准值放到分界位置:

js 复制代码
[nums[left], nums[i]] = [nums[i], nums[left]];

此时 i 就是基准值的最终位置。

[2, 4, 1, 0, 3, 5] 举例,一次分区后的结果可能是:

txt 复制代码
原数组: [2, 4, 1, 0, 3, 5]
基准值: 2
分区后: [0, 1, 2, 4, 3, 5]

此时 2 左边的元素都小于等于它,右边的元素都大于它。注意,左边和右边内部还不一定有序,所以还需要继续递归排序。

另一种常见写法:Hoare 分区

快速排序还有一种常见写法:Hoare 分区。它通常会选中间位置作为基准值,并返回右侧分界点。

这一版和前面的写法有一个关键区别:它不一定把某个基准值固定到最终位置,而是把数组划分成两个区间。

因此它的递归边界也不一样:

js 复制代码
quickSortHoare(nums, left, j);
quickSortHoare(nums, j + 1, right);

不要把它和上一版的边界混用:

js 复制代码
quickSort(nums, left, pivotIndex - 1);
quickSort(nums, pivotIndex + 1, right);

完整代码如下:

js 复制代码
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]

这一版代码也很常见,尤其是在算法题题解里。重点不是记住哪一种更好,而是记住:不同分区方式返回的含义不同,递归边界必须跟着变。

时间复杂度

快速排序的平均时间复杂度是:

txt 复制代码
O(nlogn)

可以从两个角度理解:

  • 每一层分区操作需要扫描当前区间,整体是 O(n)
  • 平均情况下,每次分区能把数组拆得比较均匀,递归层数约为 O(logn)

所以平均复杂度是:

txt 复制代码
O(n) * O(logn) = O(nlogn)

但快速排序不是任何情况下都是 O(nlogn)

如果每次基准值都选得很差,例如数组已经有序且总是选择第一个元素作为基准值,递归会退化成单边递归:

txt 复制代码
n -> n - 1 -> n - 2 -> ... -> 1

这时最坏时间复杂度会变成:

txt 复制代码
O(n^2)

空间复杂度

上面的实现是原地交换,不会额外创建左右数组,所以额外空间主要来自递归调用栈。

调用栈空间取决于递归深度:

  • 平均空间复杂度:O(logn)
  • 最坏空间复杂度:O(n)

如果写成下面这种创建新数组的版本,理解起来更简单,但空间消耗更大:

js 复制代码
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 排序:

js 复制代码
[
  { value: 2, name: "a" },
  { value: 1, name: "b" },
  { value: 2, name: "c" }
]

稳定排序会保证排序后 "a" 仍然排在 "c" 前面。快速排序因为存在交换操作,通常不保证这一点。

如果需要稳定排序,可以考虑归并排序。归并排序在合并两个有序数组时,如果相等元素优先取左边数组的元素,就可以保持稳定性。

小结

从递归到快速排序,核心其实只有一条线:把大问题变成小问题。

递归解决问题时,重点是找出递归公式和终止条件。分治解决问题时,重点是把大问题拆成小问题,再合并结果。

快速排序把递归和分治结合得很典型:

  • 选基准值。
  • 通过分区把数组拆成左右两部分。
  • 递归排序左右子数组。
  • 平均时间复杂度为 O(nlogn)
  • 原地版本平均空间复杂度为 O(logn)
  • 通常是不稳定排序。

理解了递归和分治,再看快速排序,就不会只是背代码,而是能看懂它为什么这样写、为什么这样拆边界。

相关推荐
Darling噜啦啦1 小时前
快速排序与递归思维:从分治策略到数组扁平化——面试必考算法全解析
面试·排序算法
小兔崽子去哪了1 小时前
Vue3 + Pinia 集成 IGV.js 实现 BAM 文件在线浏览
javascript·vue.js·后端
小月土星2 小时前
JavaScript 快速排序:从 pivot、双指针到分治思想
javascript·算法·面试
沉默王二2 小时前
Agent底层原理连问8道,从ReAct到记忆压缩,PaiCLI项目实战拆解
面试·agent·ai编程
小月土星2 小时前
JavaScript 递归入门:从 1 到 n 求和,再到数组扁平化
javascript·算法·面试
蝎子莱莱爱打怪2 小时前
XZLL-IM干货系列 04|Netty 长连接实战:Pipeline 怎么排、心跳怎么跳、连接怎么管
后端·微服务·面试
还有多久拿退休金3 小时前
一个 var 让整个团队加班到凌晨——JS 闭包的那些暗坑
前端·javascript
weedsfly3 小时前
用了 React/Vue 之后,这些 DOM 操作的坑你踩过几个?
前端·javascript
Asize3 小时前
Ajax 入门:从 JSON 序列化到 XMLHttpRequest
前端·javascript·前端框架