用代码拆解“函数调用自己”的奇妙思维
你有没有拆过俄罗斯套娃?打开一个,里面还有一个,再打开,又一个……直到最小的那个。递归(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 = 120Python 实现
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>
递归就像编程中的“盗梦空间”——只要你信任每一层都能完成自己的任务,整个问题就会像多米诺骨牌一样优雅倒塌。希望这篇文章能帮你打开这扇思维之门。
欢迎在评论区留下你对递归的理解或疑惑,我会抽空回复。
如果你喜欢这种带代码的技术科普,别忘了点赞和关注,我们下期见!