递归到底怎么理解?从数组扁平化到快速排序,讲透分治算法
递归不是玄学,它只是把一个大问题,拆成“和原问题长得一样的小问题”。
很多同学学算法时,会在两个地方卡住。
第一个地方是递归:函数为什么可以调用自己?什么时候停?为什么代码看起来只有几行,执行过程却绕来绕去?
第二个地方是快速排序:为什么它比冒泡、选择、插入这些排序更快?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 排序,稳定排序会保证 A 和 B 仍然保持原来的相对顺序。
但快排在 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),快速排序就不再是一段需要硬背的模板,而是一套可以自己推出来的算法思维。