递归是很多人学算法时的第一个坎。
循环很好理解,for 从 1 跑到 n,代码一步一步执行。但递归不一样,它会出现"函数调用自己"的情况,看起来像绕了一圈。
这篇文章用两个小例子把递归讲清楚:
- 求
1 + 2 + 3 + ... + n - 手写数组扁平化
flatten
这两个例子都不复杂,但很适合理解递归的核心:把一个大问题拆成更小的同类问题。

一、先用循环求和
如果要求:
1 + 2 + 3 + ... + n
最容易想到的是循环。
ini
function sum(n) {
let total = 0;
for (let i = 1; i <= n; i++) {
total += i;
}
return total;
}
console.log(sum(5)); // 15
这段代码很好理解。
当 n = 5 时:
makefile
total = 0
total += 1
total += 2
total += 3
total += 4
total += 5
最后得到:
15
循环是从小到大累加。
递归的思路不一样。
二、递归怎么想
还是求:
1 + 2 + 3 + ... + n
我们可以换一种写法:
scss
sum(n) = n + sum(n - 1)
比如:
scss
sum(5) = 5 + sum(4)
sum(4) = 4 + sum(3)
sum(3) = 3 + sum(2)
sum(2) = 2 + sum(1)
sum(1) = 1
这就是递归的味道。
一个大问题:
scss
sum(5)
被拆成了一个更小的同类问题:
scss
sum(4)
继续拆:
scss
sum(3)
sum(2)
sum(1)
直到遇到最小问题:
scss
sum(1) = 1
三、递归求和代码
scss
function sum(n) {
if (n === 1) {
return 1;
}
return n + sum(n - 1);
}
console.log(sum(5)); // 15
这段代码有两个重点。
第一个是递归出口:
ini
if (n === 1) {
return 1;
}
第二个是递归公式:
bash
return n + sum(n - 1);
递归一定要有出口。
如果没有出口,函数会一直调用自己,最后栈溢出。
四、递归执行过程
看这行代码:
scss
sum(5)
它会展开成:
scss
sum(5)
= 5 + sum(4)
= 5 + 4 + sum(3)
= 5 + 4 + 3 + sum(2)
= 5 + 4 + 3 + 2 + sum(1)
= 5 + 4 + 3 + 2 + 1
= 15
如果从函数调用角度看,是这样的:
scss
sum(5)
sum(4)
sum(3)
sum(2)
sum(1)
到 sum(1) 时,递归停止。
然后结果再一层一层返回:
scss
sum(1) 返回 1
sum(2) 返回 2 + 1
sum(3) 返回 3 + 3
sum(4) 返回 4 + 6
sum(5) 返回 5 + 10
最后得到 15。
这就是递归里常说的:
先递,再归
递,是不断往下拆。
归,是结果一层一层返回。
五、递归三要素
写递归时,我会先问自己三个问题。
1. 这个问题能不能拆成同类小问题
比如:
scss
sum(n) = n + sum(n - 1)
sum(n) 和 sum(n - 1) 是同一类问题,只是规模变小了。
2. 递归出口是什么
求和问题里,最小情况是:
scss
sum(1) = 1
所以出口是:
ini
if (n === 1) return 1;
3. 每次递归有没有让问题变小
scss
sum(n - 1)
每次都让 n 减 1。
这样最终一定能走到 n === 1。
如果问题规模没有变小,递归就会停不下来。
六、数组扁平化是什么
接着看第二个例子:数组扁平化。
比如有一个嵌套数组:
ini
const arr = [1, [2, [3, 4, 5, [5, 6]]]];
我们希望把它变成:
csharp
[1, 2, 3, 4, 5, 5, 6]
这种操作就叫数组扁平化。
JavaScript 原生提供了 flat 方法:
ini
const arr = [1, [2, [3, 4, 5, [5, 6]]]];
console.log(arr.flat(Infinity));
输出:
csharp
[1, 2, 3, 4, 5, 5, 6]
Infinity 表示不管嵌套多少层,都全部展开。
但如果想练递归,手写一版 flatten 更有价值。
七、手写 flatten
ini
const 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, [5, 6]]]];
console.log(flatten(arr)); // [1, 2, 3, 4, 5, 5, 6]
这段代码的核心判断是:
javascript
Array.isArray(item)
如果当前元素还是数组,就继续递归展开。
如果当前元素不是数组,就直接放进结果数组。
八、flatten 的递归思路
数组:
csharp
[1, [2, [3, 4, 5, [5, 6]]]]
从左到右遍历。
第一个元素是:
1
不是数组,直接放进结果:
ini
result = [1]
第二个元素是:
csharp
[2, [3, 4, 5, [5, 6]]]
它还是数组,所以不能直接 push。
应该继续调用:
scss
flatten([2, [3, 4, 5, [5, 6]]])
这就是递归。
因为里面还是同一个问题:
把一个数组拍平
只是数组规模更小了。
九、为什么用 concat
代码里有一行:
ini
result = result.concat(flatten(item));
这里不能简单写成:
ini
result.push(flatten(item));
因为 flatten(item) 返回的是一个数组。
如果用 push,会把整个数组作为一个元素塞进去。
比如:
ini
const result = [1];
result.push([2, 3]);
console.log(result); // [1, [2, 3]]
这不是扁平化。
而 concat 会把两个数组连接起来:
ini
const result = [1];
const next = result.concat([2, 3]);
console.log(next); // [1, 2, 3]
所以递归展开数组时,用 concat 更合适。
十、递归版 flatten 的执行过程
以这个数组为例:
csharp
[1, [2, [3, 4, 5, [5, 6]]]]
执行过程大概是:
scss
flatten([1, [2, [3, 4, 5, [5, 6]]]])
遇到 1,放入 result
遇到 [2, [3, 4, 5, [5, 6]]],继续 flatten
flatten([2, [3, 4, 5, [5, 6]]])
遇到 2,放入 result
遇到 [3, 4, 5, [5, 6]],继续 flatten
flatten([3, 4, 5, [5, 6]])
遇到 3,放入 result
遇到 4,放入 result
遇到 5,放入 result
遇到 [5, 6],继续 flatten
flatten([5, 6])
遇到 5,放入 result
遇到 6,放入 result
返回 [5, 6]
最后所有结果一层层合并,得到:
csharp
[1, 2, 3, 4, 5, 5, 6]
十一、递归和树结构
递归很适合处理树状结构。
嵌套数组其实就像一棵树:
csharp
[1, [2, [3, 4]]]
可以想成:
markdown
数组
├── 1
└── 数组
├── 2
└── 数组
├── 3
└── 4
只要数据结构里存在"里面还有同类结构"的情况,就很容易用递归。
比如:
数组里面还有数组
目录里面还有目录
评论下面还有回复
树节点下面还有子节点
这些都适合用递归思考。
十二、递归和循环怎么选
不是所有问题都必须用递归。
像 1 + 2 + ... + n,循环其实更简单,也更省性能。
但递归的价值在于,它能更自然地表达某些结构。
比如数组扁平化、树遍历、文件目录扫描,这类问题用递归会更顺。
简单判断:
线性重复:优先循环
嵌套结构:优先考虑递归
当然,递归也有成本。
每一次函数调用都会进入调用栈。
如果递归层级太深,可能会栈溢出。
十三、容易写错的地方
1. 忘记递归出口
错误写法:
bash
function sum(n) {
return n + sum(n - 1);
}
这会一直调用下去。
正确写法:
bash
function sum(n) {
if (n === 1) return 1;
return n + sum(n - 1);
}
2. 递归时问题规模没有变小
错误写法:
bash
function sum(n) {
if (n === 1) return 1;
return n + sum(n);
}
这里一直调用 sum(n),n 没有变化。
正确写法:
bash
return n + sum(n - 1);
3. flatten 里把数组 push 进去
错误写法:
ini
result.push(flatten(item));
这样会得到嵌套数组。
正确写法:
ini
result = result.concat(flatten(item));
4. 不判断是不是数组
如果没有:
javascript
Array.isArray(item)
就无法区分当前元素应该继续展开,还是直接放入结果。
十四、总结
递归不是魔法。
它的本质就是:
把一个问题,拆成更小的同类问题
写递归时,先想三件事:
递归公式是什么
递归出口是什么
问题规模有没有变小
求和问题里:
scss
sum(n) = n + sum(n - 1)
sum(1) = 1
数组扁平化里:
sql
如果元素是数组,就继续 flatten
如果元素不是数组,就放进 result