最近在做 LeetCode 的深度对象过滤题(2823)时,我遇到了一个困扰很久的问题:虽然知道递归是"函数调用自己",也能照着模板写出代码,但每次遇到递归都要在脑子里"展开"整个调用过程------factorial(3) 调用 factorial(2),factorial(2) 又调用 factorial(1)......稍微复杂一点就会晕。
后来我意识到,这种思维方式本身就是错的。真正的递归思维不是"展开脑补",而是"相信递归"。这个转变让我重新理解了递归的本质。
这篇文章是我重新学习递归的总结,不是权威教程,而是从识别、模式、实战、思想四个维度的完整梳理。如果你也在递归上遇到困难,希望这篇文章能帮到你。
一、准确识别:什么时候需要递归?
在学习具体技巧之前,我发现最重要的能力是:快速判断一个问题是否需要递归。
递归问题的三大特征
根据我的观察,递归问题通常有以下特征:
特征 1:问题具有"自相似性"
大问题 = 小问题 + 小问题 + ...(结构相同)
例子:
- 计算阶乘:n! = n × (n-1)!
- 遍历文件夹:遍历文件夹 = 遍历文件 + 遍历子文件夹
- 汉诺塔:移动 n 个盘子 = 移动 (n-1) 个盘子 + 移动 1 个盘子 + 移动 (n-1) 个盘子
这种"自相似性"是递归最核心的特征------问题可以分解成和自己结构相同但规模更小的子问题。
特征 2:数据结构是递归的
javascript
// 环境: JavaScript
// 场景: 典型的递归数据结构
// 树结构:节点包含子节点
const tree = {
value: 1,
children: [
{
value: 2,
children: [
{ value: 4, children: [] },
{ value: 5, children: [] }
]
},
{ value: 3, children: [] }
]
};
// 嵌套对象:对象包含对象
const nested = {
user: {
profile: {
settings: {
theme: 'dark'
}
}
}
};
// 嵌套数组:数组包含数组
const comments = [
{
id: 1,
text: '评论',
replies: [
{ id: 2, text: '回复', replies: [] }
]
}
];
关键特征:
- 不知道有多少层
- 每一层结构相同
- 循环无法处理(层数未知)
特征 3:有明确的"最小单元"(终止条件)
能回答这两个问题,就是递归问题:
问题 1:最简单的情况是什么?
阶乘:n = 0 或 n = 1 时,直接返回 1
遍历树:节点没有子节点时,停止遍历
问题 2:如何缩小问题规模?
阶乘:n! = n × (n-1)!,问题从 n 缩小到 n-1
遍历树:处理当前节点,然后遍历每个子节点
识别清单:一眼判断
遇到问题时,我会用这个清单快速判断:
✅ 强烈提示需要递归:
- 题目出现"深度"、"嵌套"、"层级"、"树"、"图"
- 数据结构是嵌套对象/嵌套数组/树/图
- 题目要求"遍历所有可能"(组合、排列、路径)
- 不知道数据有多少层
⚠️ 可能需要递归:
- 题目涉及"分治"思想(二分、归并)
- 需要"回溯"(尝试-撤销-再尝试)
- 问题可以对半分解
❌ 不需要递归:
- 简单的顺序遍历(一个 for 循环搞定)
- 已知固定层数(几个嵌套循环搞定)
- 数组/字符串的简单处理(map/filter/reduce)
常见递归问题分类
我把常见的递归问题归为四类:
| 分类 | 应用场景 |
|---|---|
| 数/图遍历 | 1) DOM 树遍历(查找特定元素) 2) 文件系统遍历(统计文件大小) 3) 组织架构遍历(查找员工) 4) 评论楼层(无限嵌套评论) 5) 菜单树(多级菜单展开) |
| 数据转换 | 1) 深拷贝/深度克隆 2) 扁平化嵌套数组/对象 3) 对象深度过滤/映射 4) JSON 格式转换 5) 数据清洗(移除 null/undefined) |
| 搜索/回溯 | 1) 路径查找(面包屑导航) 2) 组合生成(选择题所有答案) 3) 排列生成(密码破解) 4) 迷宫求解 |
| 分治算法 | 1) 二分查找(搜索优化) 2) 归并排序(大数据排序) 3) 快速排序 4) 计算次方(快速幂) |
识别练习
这是我整理的一些练习题,可以测试一下判断能力:
markdown
1. 反转字符串 → ❌ 用 reverse() 或循环
2. 计算嵌套对象的最大【深度】 → ✅ 递归(不知道多少层)
3. 数组求和 → ❌ 用 reduce
4. 查找【二叉搜索树】中的节点 → ✅ 递归(树结构)
5. 打印 1-100 → ❌ 循环
6. 获取【所有子孙评论】(无限嵌套) → ✅ 递归(层数未知)
7. 对象数组按某字段排序 → ❌ 用 sort
8. 【遍历】 React 组件树找某个组件 → ✅ 递归(组件树)
9. 扁平化三层嵌套数组 → ❌ 已知层数,可以循环
10. 扁平化【任意深度】数组 → ✅ 递归(深度未知)
判断的关键 :如果能用固定次数的循环 解决,就不需要递归。递归是为了处理"层数未知"的情况。
二、常用模式:递归函数库
通过整理常见问题,我发现大部分递归都可以归纳为几个固定模式。掌握这些模式,就能快速写出递归代码。
核心模板
模板 1:单路径递归(最常见)
javascript
// 环境: JavaScript
// 场景: 处理线性递归问题
function recursion(data) {
// 1. 终止条件(base case)
if (isBaseCase(data)) {
return baseResult;
}
// 2. 处理当前层(可选)
const currentResult = process(data);
// 3. 递归子问题
const subResult = recursion(getSubProblem(data));
// 4. 合并结果
return combine(currentResult, subResult);
}
// 例子:计算阶乘
function factorial(n) {
if (n === 0) return 1; // 终止条件
return n * factorial(n - 1); // 递归 + 合并
}
模板 2:多路径递归(树/图)
javascript
// 环境: JavaScript
// 场景: 处理树形结构
function recursion(node) {
// 1. 终止条件
if (!node) return baseResult;
// 2. 处理当前节点
const currentResult = process(node);
// 3. 递归所有子节点
const childResults = node.children.map(child =>
recursion(child)
);
// 4. 合并结果
return combine(currentResult, ...childResults);
}
// 例子:计算树的最大深度
function maxDepth(node) {
if (!node) return 0;
const depths = node.children.map(child => maxDepth(child));
return 1 + Math.max(...depths, 0);
}
模板 3:回溯递归(搜索)
javascript
// 环境: JavaScript
// 场景: 生成所有可能的组合
function backtrack(path, choices, result) {
// 1. 终止条件:路径完整
if (isComplete(path)) {
result.push([...path]);
return;
}
// 2. 遍历所有选择
for (const choice of choices) {
// 做选择
path.push(choice);
// 递归(缩小选择范围)
backtrack(path, getNextChoices(choices, choice), result);
// 撤销选择(回溯)
path.pop();
}
}
// 例子:生成所有子集
function subsets(nums) {
const result = [];
function backtrack(path, start) {
result.push([...path]);
for (let i = start; i < nums.length; i++) {
path.push(nums[i]);
backtrack(path, i + 1);
path.pop();
}
}
backtrack([], 0);
return result;
}
实用递归函数库
下面是我整理的 5 个实用递归函数,几乎覆盖了日常开发的所有场景:
函数 1:深度遍历(DFS)
javascript
// 环境: JavaScript
// 场景: 遍历树/图的所有节点
function dfs(node, visit) {
if (!node) return;
// 访问当前节点
visit(node);
// 递归访问子节点
if (node.children) {
node.children.forEach(child => dfs(child, visit));
}
}
// 使用:统计所有节点
let count = 0;
dfs(tree, () => count++);
console.log(count);
// 使用:查找特定节点
let found = null;
dfs(tree, node => {
if (node.id === targetId) found = node;
});
函数 2:深度映射(Deep Map)
javascript
// 环境: JavaScript
// 场景: 转换嵌套结构的每个值
function deepMap(obj, fn) {
// 终止条件:基础类型
if (typeof obj !== 'object' || obj === null) {
return fn(obj);
}
// 数组:递归映射
if (Array.isArray(obj)) {
return obj.map(item => deepMap(item, fn));
}
// 对象:递归映射
const result = {};
for (const key in obj) {
result[key] = deepMap(obj[key], fn);
}
return result;
}
// 使用:所有数字乘以 2
const data = { a: 1, b: { c: 2, d: [3, 4] } };
const doubled = deepMap(data, value =>
typeof value === 'number' ? value * 2 : value
);
console.log(doubled);
// { a: 2, b: { c: 4, d: [6, 8] } }
函数 3:深度过滤(Deep Filter)
javascript
// 环境: JavaScript
// 场景: 过滤嵌套结构,移除不符合条件的值(LeetCode 2823)
function deepFilter(obj, fn) {
// 终止条件:基础类型
if (typeof obj !== 'object' || obj === null) {
return fn(obj) ? obj : undefined;
}
// 数组:递归过滤
if (Array.isArray(obj)) {
const filtered = obj
.map(item => deepFilter(item, fn))
.filter(item => item !== undefined);
return filtered.length > 0 ? filtered : undefined;
}
// 对象:递归过滤
const result = {};
for (const key in obj) {
const filtered = deepFilter(obj[key], fn);
if (filtered !== undefined) {
result[key] = filtered;
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
// 使用:移除所有负数
const data = { a: 1, b: -2, c: { d: 3, e: -4 } };
const positive = deepFilter(data, x => x > 0);
console.log(positive); // { a: 1, c: { d: 3 } }
函数 4:深度克隆(Deep Clone)
javascript
// 环境: JavaScript
// 场景: 完全复制嵌套对象(处理循环引用)
function deepClone(obj, map = new WeakMap()) {
// 处理循环引用
if (map.has(obj)) return map.get(obj);
// 终止条件:基础类型
if (typeof obj !== 'object' || obj === null) {
return obj;
}
// 特殊对象
if (obj instanceof Date) return new Date(obj);
if (obj instanceof RegExp) return new RegExp(obj);
// 数组/对象:递归克隆
const clone = Array.isArray(obj) ? [] : {};
map.set(obj, clone);
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clone[key] = deepClone(obj[key], map);
}
}
return clone;
}
// 使用:克隆复杂对象
const original = {
date: new Date(),
regex: /test/g,
nested: { a: 1 }
};
const cloned = deepClone(original);
cloned.nested.a = 999;
console.log(original.nested.a); // 1(没有被修改)
函数 5:路径查找(Find Path)
javascript
// 环境: JavaScript
// 场景: 找到目标节点的路径
function findPath(tree, targetId, path = []) {
// 找到目标
if (tree.id === targetId) {
return [...path, tree.id];
}
// 递归查找子节点
if (tree.children) {
for (const child of tree.children) {
const found = findPath(child, targetId, [...path, tree.id]);
if (found) return found;
}
}
return null;
}
// 使用:查找组织架构中的员工路径
const org = {
id: 1, name: 'CEO',
children: [
{
id: 2, name: 'CTO',
children: [
{ id: 4, name: '研发经理' }
]
}
]
};
console.log(findPath(org, 4)); // [1, 2, 4]
递归优化技巧
掌握了基础模式后,还需要了解一些优化技巧:
技巧 1:记忆化(Memoization)
javascript
// 环境: JavaScript
// 场景: 避免重复计算
// 斐波那契(慢,大量重复计算)
function fib(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
console.time('fib(40)');
fib(40); // 几秒钟
console.timeEnd('fib(40)');
// 记忆化版本(快)
function fibMemo(n, memo = {}) {
if (n <= 1) return n;
if (memo[n]) return memo[n]; // 已计算过,直接返回
memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
return memo[n];
}
console.time('fibMemo(40)');
fibMemo(40); // 瞬间完成
console.timeEnd('fibMemo(40)');
技巧 2:转迭代(避免栈溢出)
javascript
// 环境: JavaScript
// 场景: 深度很深的情况,转为迭代
// 递归版本(深度 > 10000 可能栈溢出)
function traverseRecursive(tree, visit) {
visit(tree);
if (tree.children) {
tree.children.forEach(child => traverseRecursive(child, visit));
}
}
// 迭代版本(用栈模拟递归)
function traverseIterative(tree, visit) {
const stack = [tree];
while (stack.length > 0) {
const node = stack.pop();
visit(node);
if (node.children) {
// 注意:倒序入栈,保证顺序一致
for (let i = node.children.length - 1; i >= 0; i--) {
stack.push(node.children[i]);
}
}
}
}
三、真实场景:递归在业务中的应用
理论总是抽象的,让我看看实际开发中哪些地方会用到递归。根据我的经验,前端开发中递归最常见的场景是处理树形数据。
场景 1:无限层级评论系统
这可能是我遇到最多的递归场景。
javascript
// 环境: React
// 场景: 递归渲染评论列表
// 数据结构
const comments = [
{
id: 1,
text: '一级评论',
author: 'Alice',
replies: [
{
id: 2,
text: '二级评论',
author: 'Bob',
replies: [
{
id: 3,
text: '三级评论',
author: 'Charlie',
replies: []
}
]
}
]
}
];
// 递归组件
function CommentList({ comments, level = 0 }) {
return (
<div style={{ marginLeft: level * 20 }}>
{comments.map(comment => (
<div key={comment.id}>
<div className="comment">
<strong>{comment.author}</strong>: {comment.text}
</div>
{/* 递归渲染子评论 */}
{comment.replies && comment.replies.length > 0 && (
<CommentList
comments={comment.replies}
level={level + 1}
/>
)}
</div>
))}
</div>
);
}
// 常见操作:统计总评论数
function countComments(comments) {
let count = comments.length;
comments.forEach(comment => {
if (comment.replies) {
count += countComments(comment.replies); // 递归
}
});
return count;
}
场景 2:组织架构树
javascript
// 环境: JavaScript
// 场景: 处理公司组织架构
const org = {
id: 1,
name: 'CEO',
title: '首席执行官',
children: [
{
id: 2,
name: 'CTO',
title: '技术总监',
children: [
{ id: 4, name: '张三', title: '研发经理', children: [] }
]
}
]
};
// 操作:查找某个员工的上级链路
function findManagerChain(org, employeeId, chain = []) {
if (org.id === employeeId) {
return [...chain, org.name];
}
if (org.children) {
for (const child of org.children) {
const found = findManagerChain(
child,
employeeId,
[...chain, org.name]
);
if (found) return found;
}
}
return null;
}
console.log(findManagerChain(org, 4));
// ['CEO', 'CTO', '张三']
// 操作:扁平化组织架构(用于导出 Excel)
function flattenOrg(org, level = 0, result = []) {
result.push({
id: org.id,
name: org.name,
title: org.title,
level: level
});
if (org.children) {
org.children.forEach(child => {
flattenOrg(child, level + 1, result);
});
}
return result;
}
场景 3:菜单权限过滤
javascript
// 环境: JavaScript
// 场景: 根据用户权限过滤菜单树
const allMenus = [
{
id: 1,
name: '用户管理',
permission: 'user',
children: [
{ id: 2, name: '用户列表', permission: 'user.list' },
{ id: 3, name: '添加用户', permission: 'user.add' }
]
},
{
id: 5,
name: '系统设置',
permission: 'system',
children: [
{ id: 6, name: '角色管理', permission: 'system.role' }
]
}
];
// 递归过滤菜单
function filterMenus(menus, permissions) {
return menus
// 1. 过滤当前层
.filter(menu => permissions.includes(menu.permission))
// 2. 递归过滤子菜单
.map(menu => ({
...menu,
children: menu.children
? filterMenus(menu.children, permissions)
: undefined
}))
// 3. 移除没有子菜单的父菜单
.filter(menu =>
!menu.children || menu.children.length > 0
);
}
// 使用
const userPermissions = ['user', 'user.list', 'user.add'];
const visibleMenus = filterMenus(allMenus, userPermissions);
什么时候不该用递归?
在实际开发中,我也踩过一些坑:
javascript
// 环境: JavaScript
// 场景: 不适合递归的情况
// ❌ 错误示例:深度很深的数据
function createDeepNesting() {
let comment = { id: 1, replies: [] };
let current = comment;
// 创建 10000 层嵌套
for (let i = 2; i <= 10000; i++) {
current.replies[0] = { id: i, replies: [] };
current = current.replies[0];
}
return comment;
}
const deepComment = createDeepNesting();
// countComments([deepComment]); // 栈溢出!
// ✅ 正确做法:转为迭代
function countCommentsIterative(comments) {
const stack = [...comments];
let count = 0;
while (stack.length > 0) {
const comment = stack.pop();
count++;
if (comment.replies) {
stack.push(...comment.replies);
}
}
return count;
}
console.log(countCommentsIterative([deepComment])); // 正常运行
判断标准:
- 数据深度 < 100 层 → 安全使用递归
- 数据深度 100-1000 层 → 谨慎使用,考虑加深度限制
- 数据深度 > 1000 层 → 必须转为迭代
四、递归思想:从"展开脑补"到"相信递归"
在掌握了递归的识别、模式、实战之后,我发现最大的障碍其实是思维方式。
初学者的误区:"展开脑补"
这是我之前的思考方式:
javascript
// 计算 factorial(3)
function factorial(n) {
if (n === 0) return 1;
return n * factorial(n - 1);
}
// 我的思路(错误):
// factorial(3) 调用 factorial(2)
// factorial(2) 调用 factorial(1)
// factorial(1) 调用 factorial(0)
// factorial(0) 返回 1
// factorial(1) = 1 * 1 = 1
// factorial(2) = 2 * 1 = 2
// factorial(3) = 3 * 2 = 6
// 问题:
// 1. 脑子里要记住整个调用栈
// 2. 稍微复杂一点就晕了
// 3. 根本不理解递归的本质
正确的思维:"相信递归"
后来我意识到,应该这样思考:
javascript
// 正确的思路:
// factorial(3) = 3 * factorial(2)
//
// 我不需要知道 factorial(2) 内部怎么实现
// 我只需要"相信"它会返回正确结果(2)
// 所以 factorial(3) = 3 * 2 = 6
//
// 这就是"信任"的力量
关键洞察:
写递归时,不要试图"展开"整个过程,只需要:
- 定义最简单的情况(终止条件)
- 假设递归调用会正确处理子问题
- 用子问题的结果构造当前问题的解
这种"信任"让复杂问题变简单
递归与数学归纳法
我发现递归和高中学的数学归纳法本质上是一回事:
数学归纳法:
证明命题 P(n) 对所有 n 成立:
- 基础:证明 P(1) 成立
- 归纳:假设 P(k) 成立,证明 P(k+1) 也成立
递归:
解决问题 Problem(n):
- 终止条件:处理最简单的情况
- 递归调用:假设 Problem(n-1) 已解决,构造 Problem(n) 的解
例子:
javascript
// 环境: JavaScript
// 场景: 数组求和
// 数学归纳法思路:
// 基础:空数组的和是 0
// 归纳:假设 sum(arr[1:]) 正确
// 则 sum(arr) = arr[0] + sum(arr[1:])
// 递归实现:
function sum(arr) {
// 基础情况
if (arr.length === 0) return 0;
// 归纳步骤:相信 sum(arr.slice(1)) 会返回正确结果
return arr[0] + sum(arr.slice(1));
}
console.log(sum([1, 2, 3, 4])); // 10
递归的局限与权衡
虽然递归很优雅,但也有代价:
javascript
// 环境: JavaScript
// 场景: 斐波那契数列
// 朴素递归(慢)
function fib(n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
// 问题:大量重复计算
// fib(5) = fib(4) + fib(3)
// = (fib(3) + fib(2)) + fib(3)
// fib(3) 被计算了两次!
console.time('fib(40)');
fib(40); // 几秒钟才能算完
console.timeEnd('fib(40)');
// 优化:记忆化
function fibMemo(n, memo = {}) {
if (n <= 1) return n;
if (memo[n]) return memo[n];
memo[n] = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
return memo[n];
}
console.time('fibMemo(40)');
fibMemo(40); // 瞬间完成
console.timeEnd('fibMemo(40)');
性能权衡:
| 维度 | 递归 | 迭代 |
|---|---|---|
| 可读性 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 性能 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 内存 | ⭐⭐ | ⭐⭐⭐⭐⭐ |
| 适用场景 | 嵌套结构 | 所有场景 |
何时选择:
适合递归:
✅ 问题天然递归(树、图、嵌套数据)
✅ 代码清晰度 > 性能
✅ 数据规模不大
✅ 可以用记忆化优化
不适合递归:
❌ 简单循环能解决
❌ 深度很深(栈溢出风险)
❌ 性能关键路径
❌ 大量重复计算且无法记忆化
延伸思考
在整理这些内容的过程中,我产生了一些新的疑问:
1. 尾递归优化在 JavaScript 中的支持?
javascript
// 环境: JavaScript (严格模式)
// 场景: 尾递归优化
'use strict';
// 普通递归
function sum(n) {
if (n === 0) return 0;
return n + sum(n - 1); // 不是尾递归
}
// 尾递归
function sumTail(n, acc = 0) {
if (n === 0) return acc;
return sumTail(n - 1, acc + n); // 最后一步是递归调用
}
// 问题:
// ES6 规范要求支持尾递归优化
// 但实际上只有 Safari 支持
// Chrome、Firefox 都不支持
// 所以在生产环境中,尾递归优化靠不住
我的理解是,虽然尾递归优化很优雅,但在 JavaScript 中实用价值有限。如果真的需要处理深度很深的递归,还是转为迭代更靠谱。
2. 递归与函数式编程
递归是函数式编程的基石。在 JavaScript 中如何平衡递归的优雅性和实用性?这是一个值得深入思考的问题。
小结
通过这次系统梳理,我对递归有了全新的认识:
核心要点:
- 识别递归问题:三大特征、识别清单、四大分类
- 掌握递归模式:三个核心模板、实用函数库、优化技巧
- 真实场景应用:评论系统、组织架构、菜单权限等
- 理解递归思想:"信任"比"展开"更重要
递归不只是一种编程技巧,更是一种思维方式。从"展开脑补"到"信任递归",这个转变需要时间和练习。
如果这篇文章对你有帮助,欢迎交流讨论。如果你有不同的理解或补充,也请不吝赐教。
参考资料
- MDN - 递归函数 - 递归的基础定义
- JavaScript.info - Recursion and stack - 递归与调用栈的关系
- LeetCode - 递归专题 - 递归练习题集
- Eloquent JavaScript - Recursion - 递归的深入讲解