用代码拆解"函数调用自己"的奇妙思维
你有没有拆过俄罗斯套娃?打开一个,里面还有一个,再打开,又一个......直到最小的那个。递归(Recursion)就是这种"自相似"的思维方式------函数在执行过程中调用自身,像一面镜子照出另一面镜子。
本文会带你从零理解递归,并附上可直接运行的代码示例,让你真正掌握这个编程核心技巧。
什么是递归?
递归 = 递推 + 回归
-
递推:把大问题分解成形式相同但规模更小的子问题
-
回归:子问题解决后,逐层返回结果,拼出原问题的答案
一句话:把大象装进冰箱需要几步? 递归的回答是:1. 先放进去一只小象;2. 剩下的事和之前一样。
递归的两个关键要素
-
基线条件(Base Case) :问题小到可以直接解决,不再调用自身。没有它,递归会无限循环(就像没有底的套娃)。
-
递归条件(Recursive Case):把问题拆小,并调用自身去处理子问题。
💡 经验法则:写递归前,先想清楚"最简单的情况是什么",再想"如何把复杂情况变简单"。
经典案例:阶乘(n!)
阶乘的定义:
-
0! = 1
-
n! = n × (n-1)!
这本身就是递归定义。
JavaScript 实现
javascript
function factorial(n) {
// 基线条件
if (n === 0 || n === 1) {
return 1;
}
// 递归条件
return n * factorial(n - 1);
}
console.log(factorial(5)); // 120
// 执行过程:
// 5 * factorial(4)
// 5 * 4 * factorial(3)
// 5 * 4 * 3 * factorial(2)
// 5 * 4 * 3 * 2 * factorial(1)
// 5 * 4 * 3 * 2 * 1 = 120
Python 实现
python
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)
print(factorial(5)) # 120
另一个经典:斐波那契数列
数列:0, 1, 1, 2, 3, 5, 8, 13......
规律:F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2)
javascript
function fibonacci(n) {
if (n === 0) return 0;
if (n === 1) return 1;
return fibonacci(n - 1) + fibonacci(n - 2);
}
// 输出前10项
for (let i = 0; i < 10; i++) {
console.log(`F(${i}) = ${fibonacci(i)}`);
}
⚠️ 这种写法简单易懂,但效率极低(指数级重复计算)。实际开发中可用记忆化递归 或循环优化。
递归 vs 循环:一个直观对比
任务:计算 1 到 n 的和
循环(迭代)方式
javascript
function sumLoop(n) {
let total = 0;
for (let i = 1; i <= n; i++) {
total += i;
}
return total;
}
递归方式
javascript
function sumRecur(n) {
if (n === 1) return 1;
return n + sumRecur(n - 1);
}
| 维度 | 递归 | 循环 |
|---|---|---|
| 代码简洁度 | 更贴近数学定义 | 需要显式控制变量 |
| 性能 | 函数调用有开销 | 通常更快 |
| 内存风险 | 可能栈溢出(见下文) | 可控 |
| 适用场景 | 树/图遍历、分治算法 | 简单线性重复 |
小心"栈溢出":递归的代价
每次函数调用,系统都会在"调用栈"上压入一个帧(包含参数、局部变量)。递归过深(比如 factorial(100000))会导致栈空间耗尽,程序崩溃。
如何避免?
-
设定合理的递归深度限制(通常几千层以内安全)
-
改用尾递归优化(部分语言支持)
-
用循环或显式栈重写
尾递归示例(仅限支持优化的环境)
javascript
// 尾递归:最后一步只调用自身,不附加运算
function factorialTail(n, accumulator = 1) {
if (n <= 1) return accumulator;
return factorialTail(n - 1, n * accumulator);
}
// 某些JS引擎(如Safari)会优化这种写法,避免栈增长
递归的威力:遍历树状结构
递归最擅长的就是处理未知深度、天然嵌套的数据。比如文件目录、评论回复、组织架构。
示例:遍历文件夹(Node.js)
javascript
const fs = require('fs');
const path = require('path');
function listAllFiles(dirPath, indent = '') {
const files = fs.readdirSync(dirPath);
for (let file of files) {
const fullPath = path.join(dirPath, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
console.log(`${indent}📁 ${file}`);
listAllFiles(fullPath, indent + ' ');
} else {
console.log(`${indent}📄 ${file}`);
}
}
}
listAllFiles('./my-project');
递归 vs 分治:一对好搭档
递归是实现分治算法的自然方式:
-
分割:把问题分成几个相同形式的子问题
-
解决:递归地解决子问题(子问题足够小就直接解)
-
合并:把子问题的结果合并成原问题的解
经典案例:归并排序、快速排序、二分查找。
二分查找的递归写法
javascript
function binarySearch(arr, target, left = 0, right = arr.length - 1) {
if (left > right) return -1; // 没找到
const mid = Math.floor((left + right) / 2);
if (arr[mid] === target) return mid;
if (arr[mid] > target) return binarySearch(arr, target, left, mid - 1);
return binarySearch(arr, target, mid + 1, right);
}
const sorted = [2, 5, 8, 12, 16, 23, 38, 56];
console.log(binarySearch(sorted, 23)); // 5
总结:什么时候该用递归?
✅ 适合递归的场景
-
问题天然是递归定义的(如树、图、汉诺塔)
-
需要深度优先遍历(如解析JSON、渲染嵌套UI组件)
-
想让代码高度贴合数学公式(如阶乘、斐波那契)
❌ 不适合递归的场景
-
只需要简单重复(用循环更清晰)
-
递归深度可能超过数千(小心栈溢出)
-
性能要求极高(函数调用有开销)
动手挑战一下
实现一个函数 flattenArray,将任意嵌套的数组展开成一维数组:
javascript
// 输入:[1, [2, [3, 4], 5], 6]
// 输出:[1, 2, 3, 4, 5, 6]
function flattenArray(arr) {
// 你的递归代码在这里
}
<details> <summary>参考实现(点击展开)</summary>
javascript
function flattenArray(arr) {
let result = [];
for (let item of arr) {
if (Array.isArray(item)) {
result = result.concat(flattenArray(item));
} else {
result.push(item);
}
}
return result;
}
</details>
递归就像编程中的"盗梦空间"------只要你信任每一层都能完成自己的任务,整个问题就会像多米诺骨牌一样优雅倒塌。希望这篇文章能帮你打开这扇思维之门。
欢迎在评论区留下你对递归的理解或疑惑,我会抽空回复。
如果你喜欢这种带代码的技术科普,别忘了点赞 和关注,我们下期见!