递归到底怎么理解?从数组扁平化到快速排序,讲透分治算法

4 阅读11分钟

递归到底怎么理解?从数组扁平化到快速排序,讲透分治算法

递归不是玄学,它只是把一个大问题,拆成“和原问题长得一样的小问题”。

很多同学学算法时,会在两个地方卡住。

第一个地方是递归:函数为什么可以调用自己?什么时候停?为什么代码看起来只有几行,执行过程却绕来绕去?

第二个地方是快速排序:为什么它比冒泡、选择、插入这些排序更快?pivot 到底在干什么?双指针交换为什么能让基准值归位?

这两个问题其实可以放在一起理解。

因为快速排序就是递归和分治思想的经典应用。先搞懂递归,再看快排,很多抽象概念会自然落地。


一、先从递归说起

递归最朴素的定义是:一个函数在执行过程中调用它自己。

但这个定义太像背书了。更适合写代码的理解是:

递归 = 把当前问题,转化成一个规模更小、结构相同的问题

比如求数组扁平化:

const arr = [1, [2, [3, 4]], 5]

目标是得到:

[1, 2, 3, 4, 5]

如果数组里遇到普通元素,就直接放进结果;如果遇到的还是数组,就继续对这个子数组做同样的事。

这个“继续做同样的事”,就是递归。


二、数组扁平化:最适合入门递归的例子

笔记里的递归练习是一个 flatten 函数:

const flatten = (arr) => {
  let res = []

  arr.forEach((item) => {
    if (Array.isArray(item)) {
      res = res.concat(flatten(item))
    } else {
      res.push(item)
    }
  })

  return res
}

看起来很短,但里面已经包含递归最核心的三个要素。

1. 递归函数要解决什么问题?

flatten(arr) 的语义非常清楚:把传入的数组拍平成一维数组。

这个语义很重要。写递归时,不要一上来就钻进每一层调用栈,而是先相信函数定义:

flatten(arr) 会返回 arr 的扁平化结果

当你在 [2, [3, 4]] 上调用 flatten 时,它也会返回这个子数组的扁平化结果。

2. 什么时候继续递归?

item 还是数组时:

res = res.concat(flatten(item))

这说明当前元素不是最终答案的一部分,它还需要继续拆。

3. 什么时候停止?

item 不是数组时:

res.push(item)

这就是递归的出口。递归一定要让问题规模越来越小,最终走到一个不需要继续调用自己的情况。

如果没有出口,递归就会无限调用,直到栈溢出。


三、递归和循环有什么区别?

很多场景递归和循环都能做。

比如遍历数组,用 for 循环当然可以。但当数据结构天然有嵌套关系时,递归会更贴合问题本身。

数组扁平化的结构是:

数组里可能有数组
数组里的数组里还可能有数组

这就是一个自相似结构。递归写起来很自然。

你可以把递归理解成一种“自顶向下缩小问题规模”的写法:

大数组 flatten
  -> 子数组 flatten
    -> 更小的子数组 flatten
      -> 普通元素,返回

循环更像是在同一层级里不断推进;递归更像是进入下一层结构,处理完再回来。


四、递归不是分治,但分治常用递归实现

笔记里提到一个非常重要的区分:

递归是代码实现方式
分治是算法思想

递归关注的是:函数怎么调用自己。

分治关注的是:问题怎么拆、怎么解决、怎么合并。

经典分治通常有三步:

分:把大问题拆成若干个小问题
治:分别解决这些小问题
合:把小问题的结果合并成大问题的答案

比如归并排序是非常标准的分治:

分:把数组一分为二
治:递归排序左右数组
合:把两个有序数组合并

快速排序也用分治,但它的“合”更隐蔽。

快排不是先把左右两边排序完再显式合并,而是在 partition 阶段就把 pivot 放到了最终位置:

左边都比 pivot 小
pivot 在最终位置
右边都比 pivot 大

所以左右两边递归排好之后,整个数组自然就有序了。


五、为什么要学快速排序?

排序是算法里最基础、也最容易被低估的主题。

刚开始学排序时,我们通常会先接触三种 O(n^2) 排序:

  • 冒泡排序:相邻元素两两比较,把大的不断往后冒;
  • 选择排序:每轮选出最小值,放到当前正确位置;
  • 插入排序:把当前元素插入到前面已经有序的部分。

这些算法很好理解,但当数据量变大时,O(n^2) 的性能会迅速变差。

快速排序的平均时间复杂度是 O(n log n)。它之所以快,核心不在于某个神奇语法,而在于两个设计:

  • 用 pivot 一轮扫描完成分区;
  • 用递归继续处理左右两个更小的区间。

这就是分治思想在排序问题上的威力。


六、快排的核心:pivot 和 partition

快速排序的核心动作叫 partition,也就是分区。

它做的事情非常明确:

选一个 pivot
把比 pivot 小的元素放左边
把比 pivot 大的元素放右边
最后让 pivot 落在它最终应该在的位置

假设数组是:

[5, 2, 9, 1, 6]

如果选择 5 作为 pivot,一轮分区之后,数组会变成类似:

[1, 2, 5, 9, 6]

此时 5 的位置已经确定。左边 [1, 2] 都比它小,右边 [9, 6] 都比它大。

注意,这一轮并没有保证左右两边内部有序。它只保证 pivot 归位。

接下来递归处理左边和右边:

quickSort([1, 2])
quickSort([9, 6])

当每一层 pivot 都归位,整个数组就有序了。


七、用 JavaScript 写一个原地快排

下面是一个更接近面试和工程训练的原地快速排序实现。

它不会每次创建新的 left/right 数组,而是在原数组上通过交换完成 partition。

function quickSort(arr, left = 0, right = arr.length - 1) {
  if (left >= right) return arr

  const pivotIndex = partition(arr, left, right)

  quickSort(arr, left, pivotIndex - 1)
  quickSort(arr, pivotIndex + 1, right)

  return arr
}

function partition(arr, left, right) {
  const pivot = arr[right]
  let i = left

  for (let j = left; j < right; j++) {
    if (arr[j] < pivot) {
      swap(arr, i, j)
      i++
    }
  }

  swap(arr, i, right)
  return i
}

function swap(arr, i, j) {
  const temp = arr[i]
  arr[i] = arr[j]
  arr[j] = temp
}

const nums = [5, 2, 9, 1, 6]
console.log(quickSort(nums)) // [1, 2, 5, 6, 9]

这里选择最右侧元素作为 pivot。

partition 里有两个指针:

  • j 负责从左到右扫描;
  • i 负责记录“小于 pivot 区域”的下一个位置。

arr[j] < pivot 时,就把它交换到左侧区域,并让 i 往后移动。

扫描结束后,i 左边都小于 pivot,i 右边到 right - 1 都大于等于 pivot。最后把 pivot 和 arr[i] 交换,pivot 就归位了。


八、手动走一轮 partition

[5, 2, 9, 1, 6] 举例,选择最后一个元素 6 作为 pivot。

初始状态:

arr = [5, 2, 9, 1, 6]
pivot = 6
i = 0
j = 0

开始扫描:

j = 0, arr[j] = 5 < 6
交换 arr[0] 和 arr[0]
i = 1

j = 1, arr[j] = 2 < 6
交换 arr[1] 和 arr[1]
i = 2

j = 2, arr[j] = 9 不小于 6
不交换
i = 2

j = 3, arr[j] = 1 < 6
交换 arr[2] 和 arr[3]
arr = [5, 2, 1, 9, 6]
i = 3

扫描结束后,交换 arr[i] 和 pivot:

交换 arr[3] 和 arr[4]
arr = [5, 2, 1, 6, 9]

此时 6 已经在最终位置。

接下来只需要递归排序:

左区间 [5, 2, 1]
右区间 [9]

右区间只有一个元素,不用处理。左区间继续 partition。

这就是快排的执行过程。


九、快排为什么快?

笔记里总结了一个关键点:

pivot 基准值 + 原地交换 O(n)

每一轮 partition 只需要线性扫描当前区间,所以单层成本是 O(n)。

如果 pivot 选得比较均匀,数组每次都被拆成接近一半:

n
n / 2 + n / 2
n / 4 + n / 4 + n / 4 + n / 4
...

递归层数大约是 log n,每层总扫描量大约是 n,所以平均时间复杂度是:

O(n log n)

这就是快排比冒泡、选择、插入这些 O(n^2) 排序更适合大数据量的原因。


十、快排最坏情况为什么是 O(n^2)?

快排不是永远 O(n log n)。

如果 pivot 每次都选得很差,比如数组本来已经有序,而你总是选择最右侧元素作为 pivot:

[1, 2, 3, 4, 5]

每一轮 partition 只能确定一个元素的位置,剩下的问题规模只减少 1:

n
n - 1
n - 2
n - 3
...

这样递归树会退化成一条链,总时间复杂度变成:

O(n^2)

解决办法通常有几种:

  • 随机选择 pivot;
  • 三数取中选择 pivot;
  • 小数组切换到插入排序;
  • 对递归深度做限制,退化时改用堆排序。

实际工程里的高质量排序实现,通常不会是教科书里最朴素的快排,而会做很多优化。


十一、快排是不稳定排序

笔记里还有一个点很重要:快排不稳定。

所谓稳定排序,是指两个相等元素在排序前后的相对顺序不变。

例如:

[
  { name: 'A', score: 90 },
  { name: 'B', score: 90 },
  { name: 'C', score: 80 }
]

如果按 score 排序,稳定排序会保证 AB 仍然保持原来的相对顺序。

但快排在 partition 过程中会交换元素,相等元素的相对顺序可能被打乱,所以它通常是不稳定排序。

这不是 bug,而是算法特性。

面试里如果问“快排稳定吗”,答案要明确:

普通快速排序不稳定,因为 partition 的交换过程可能改变相等元素的相对顺序。

十二、递归快排的几个易错点

1. 忘记递归终止条件

快排一定要有:

if (left >= right) return arr

否则区间缩小到 0 或 1 个元素时还继续递归,就会出问题。

2. partition 返回值不清楚

partition 返回的是 pivot 最终所在的位置,不是 pivot 的值。

所以递归时要排除 pivot:

quickSort(arr, left, pivotIndex - 1)
quickSort(arr, pivotIndex + 1, right)

如果写成包含 pivotIndex 的区间,可能导致重复处理甚至死循环。

3. 把递归和分治混为一谈

递归是一种写法,分治是一种思想。

不是所有递归都是分治。比如链表递归遍历,更多只是顺着结构往下走,不一定有“分、治、合”的完整过程。

也不是所有分治都必须用递归,只是递归通常最自然。

4. 只背复杂度,不理解原因

快排平均 O(n log n),不是因为“递归就是 log n”,而是因为:

每层 partition 总成本约 O(n)
递归层数平均约 O(log n)

这两个条件同时成立,才得到 O(n log n)。


十三、从递归到快排,应该怎么练?

如果你刚开始学算法,可以按这个顺序练。

先练递归结构:

  • 数组扁平化;
  • 阶乘;
  • 斐波那契;
  • 二叉树前序、中序、后序遍历;
  • 深拷贝对象。

再练分治思想:

  • 二分查找;
  • 归并排序;
  • 快速排序;
  • 求数组最大值;
  • 分治求逆序对。

最后再回头看复杂度:

  • 每次问题规模缩小多少;
  • 每一层做了多少额外工作;
  • 递归深度是多少;
  • 是否有重复计算;
  • 空间复杂度来自调用栈还是额外数组。

算法不是靠背模板学会的,而是靠不断把“问题规模如何缩小”这件事想清楚。


十四、总结

递归解决的是代码表达问题:当一个问题可以拆成结构相同的小问题时,让函数调用自己,是最自然的写法。

分治解决的是算法设计问题:把大问题拆小,分别解决,再合并结果。

快速排序则是两者结合的典型案例:

选择 pivot
  -> partition 分区
  -> pivot 归位
  -> 递归排序左右区间

快排快,是因为每一层通过一次线性扫描完成分区,平均只需要 log n 层递归,所以平均复杂度是 O(n log n)。

快排也有代价:pivot 选不好会退化到 O(n^2),普通快排因为交换元素而不稳定。

如果你能从数组扁平化理解递归,从 partition 理解 pivot 归位,再从递归树理解 O(n log n),快速排序就不再是一段需要硬背的模板,而是一套可以自己推出来的算法思维。