递归是编程世界中最优雅、也最容易让人"绕晕"的思维方式。很多人学完递归后,一遇到新题就发懵;或者写得出代码,却说不清为什么对。这篇文章,我们就把递归这件事彻底拆开讲透。
一、什么是递归
简单来说:递归就是函数自己调用自己。
听起来很简单,但递归真正的核心不在"调用自己",而在于:把一个大问题,拆成规模更小、但解法相同的子问题。
举个例子,假设你要数一排队伍有多少人。你不知道总数,但你可以问前面的人:"你前面有几个人?" 前面的人再去问他前面的人,如此传递,直到第一个人(他没有前面的人了,直接返回 0),答案再一层层累加回来。
这就是递归的精髓:
- 大问题 = 数一整排队伍的人数
- 小问题 = 数前面那一截队伍的人数(逻辑完全一样,只是规模变小)
- 终止条件 = 排在第一个的人,前面没人,返回 0
二、递归的三个核心要素
王争老师在《算法训练营》中提到,判断一个问题能不能用递归来做,主要看三点:
- 规模更小的问题,跟规模大的问题,解决思路相同、仅规模不同。 也就是说,大问题和小问题是"同构"的,只是数据规模不一样。
- 子问题的解可以组合得到原问题的解。 拆分出来的小问题的答案,能够拼起来还原出大问题的答案。
- 存在最小子问题,可以直接返回结果(存在递归终止条件)。 必须有一个"到底"的点,不能再无限拆下去。
这三条,是判断"能不能递归"的试金石。以后遇到一个新问题,先拿这三条去套,符合就大胆用递归。
三、递归的正确编写姿势
很多人写递归时,脑子里会忍不住去模拟整个"递"和"归"的完整执行过程------第一层调用谁,第二层又调用谁,返回时怎么一层层回来......
这其实是一个巨大的思维误区。
王争老师有一句很经典的话:
注意:千万不要试图想清楚整个递和归的执行过程,实际上这是进入了一种思维误区。
为什么?因为递归的层级可能很深,人脑的栈空间是有限的,你不可能在脑子里把几十层调用都模拟清楚。一旦陷进去,就会越想越乱。
正确姿势是什么?
正确的写递归的方式是:假设子问题已经被解决了,在此基础上去思考怎么解决原问题。
具体分三步:
- 假设子问题 B、C 已经解决,在此基础上去思考,如何组合 B、C 的解来解决原问题 A。
- 基于这一思路,找出递推公式 + 终止条件。
- 把递推公式和终止条件,翻译成代码。
这听起来有点抽象,我们用一个经典例子来说明。
四、经典案例:爬楼梯
问题描述
假设有 n 级台阶,你每次可以爬 1 级或 2 级。请问爬到第 n 级有多少种不同的爬法?
按正确姿势来写
第一步:假设子问题已解决,推导递推公式
你想爬到第 n 级,最后一步只有两种情况:
- 最后一步走 1 级,那么在此之前你已经爬到了第 n-1 级 → 这种情况下的爬法数 =
f(n-1) - 最后一步走 2 级,那么在此之前你已经爬到了第 n-2 级 → 这种情况下的爬法数 =
f(n-2)
根据"子问题的解可以组合得到原问题的解",总数就是:
scss
text
f(n) = f(n-1) + f(n-2)
第二步:找终止条件
f(1) = 1(只有一级台阶,只有一种爬法)f(2) = 2(两级台阶,可以一步一步爬,也可以一次爬两级,共两种)
第三步:翻译成代码
go
go
func climbStairs(n int) int {
// 终止条件
if n == 1 {
return 1
}
if n == 2 {
return 2
}
// 递推公式
return climbStairs(n-1) + climbStairs(n-2)
}
你看,我们并没有去模拟"第 5 层调第 4 层、第 4 层调第 3 层......"的完整过程。我们只是假设 climbStairs(n-1) 和 climbStairs(n-2) 已经算出来了 ,然后把它们加起来。这就是递归的精髓------相信递推公式,相信终止条件,代码自然就对。
五、再看一个例子:斐波那契数列
爬楼梯本质就是斐波那契数列。斐波那契的定义本身就是一个递推公式:
scss
text
f(n) = f(n-1) + f(n-2)
f(1) = 1
f(2) = 1
几乎一模一样的代码:
go
go
func fib(n int) int {
if n == 1 || n == 2 {
return 1
}
return fib(n-1) + fib(n-2)
}
你会发现,只要你能写出递推公式和终止条件,代码几乎是机械翻译出来的。这就是为什么"找公式 + 找终止条件"才是递归的关键,而不是去模拟执行过程。
六、递归的隐患:栈溢出与重复计算
递归虽然优雅,但有两个常见坑,必须知道。
1. 栈溢出
函数每次调用自己,都会在内存的调用栈上压入一帧。如果递归层级太深(比如几万层),就会把栈空间撑爆,报 StackOverflow。
解决办法:
- 控制递归深度
- 或改写成迭代(循环)版本
2. 重复计算
以爬楼梯为例,f(5) 会计算 f(4) 和 f(3),而 f(4) 又会计算 f(3) 和 f(2)------f(3) 被重复计算了多次。数据规模一大,递归的指数级重复计算会让程序慢得无法接受。
解决办法是加缓存(记忆化递归):
go
go
func climbStairs(n int) int {
memo := make(map[int]int)
var helper func(k int) int
helper = func(k int) int {
if v, ok := memo[k]; ok {
return v
}
if k == 1 {
return 1
}
if k == 2 {
return 2
}
memo[k] = helper(k-1) + helper(k-2)
return memo[k]
}
return helper(n)
}
这样每个子问题只计算一次,时间复杂度从 O(2n)O(2^n)O(2n) 降到了 O(n)O(n)O(n)。
七、递归 vs 迭代
递归能解决的问题,理论上都可以改写成迭代(循环)。那到底该用哪个?
| 维度 | 递归 | 迭代 |
|---|---|---|
| 代码可读性 | 通常更简洁、更接近问题本质 | 有时更冗长 |
| 性能 | 有函数调用开销,可能栈溢出 | 性能更好,无栈溢出风险 |
| 适用场景 | 问题天然有递归结构(树、分治、回溯) | 简单线性遍历、性能敏感场景 |
经验法则:问题本身有明显的递归结构时,优先用递归;性能要求高或递归深度大时,改写成迭代。
八、递归的常见应用场景
- 树和图的遍历:二叉树的前中后序遍历、DFS 深度优先搜索,天然就是递归结构。
- 分治算法:归并排序、快速排序,把数组拆成两半分别处理,再合并。
- 回溯算法:全排列、八皇后、数独,本质是递归 + 状态回退。
- 动态规划:很多 DP 问题都可以先用递归 + 记忆化写出解法,再优化。
可以说,递归是理解树、分治、回溯、DP 的共同基础。把递归想通了,这一整片算法的地基就稳了。
九、为什么有些递归需要「壳函数」,有些不需要
写过一段时间递归的人一定会发现一个现象:有些递归函数直接写就行,有些却非得在外面再包一层「壳函数」(wrapper / helper)。比如力扣上很多树的题目,题解里经常出现一个 outer 调一个 inner 的结构。这到底是为什么?
答案的核心只有一句话:看递归过程中,除了"输入参数"之外,还需要不需要额外携带状态。
1. 不需要壳函数的情况
如果递归只需要依赖当前层传入的参数就能算出结果,那直接写一个递归函数就够了,不需要壳。
爬楼梯、斐波那契就是典型:
go
go
func climbStairs(n int) int {
if n == 1 {
return 1
}
if n == 2 {
return 2
}
return climbStairs(n-1) + climbStairs(n-2)
}
climbStairs(n) 的结果只取决于 n,不依赖任何外部变量。每一层递归拿到的 n 都是自包含的,算完就返回,干净利落。这种情况就不需要壳函数。
判断标准:递归的输入参数,已经足够描述"当前这一层要算什么" ,不需要任何额外的累积状态。
2. 需要壳函数的情况
一旦递归过程中需要额外携带一些状态 (比如:已经收集的结果列表、当前路径、当前深度、记忆化缓存),如果把这些状态直接塞进递归函数的参数里,又要求函数对外暴露的签名是干净的(比如力扣题目规定函数签名必须是 def dfs(root):),就会产生矛盾。
这时候的标准做法是:用一个壳函数对外提供干净的接口,在内部定义一个带额外参数的辅助递归函数。
例子 A:带全局缓存变量的爬楼梯
这个例子来自王争老师的一道经典例题,原话是:
当有些全局变量需要定义的时候,我们可以在递归函数外面嵌套一个非递归的壳。
场景是这样的:爬楼梯递归为了加速要加记忆化,而记忆化缓存 mem 是一个全局变量 (或成员变量),它需要在每次调用前重新初始化。如果让调用者自己去初始化这个全局变量再调递归,既不优雅也容易出错。于是我们在递归函数外面包一个非递归的壳,由壳负责初始化全局变量、调用递归,对外只暴露一个干净的接口。
先定义全局变量和递归 helper(对应图中的 f_r),它依赖全局 mem:
go
go
var mem []int // 全局缓存变量
// 递归 helper:依赖全局变量 mem
func climbStairsR(n int) int {
if n == 1 {
return 1
}
if n == 2 {
return 2
}
if mem[n] != 0 { // 命中缓存,直接返回
return mem[n]
}
mem[n] = climbStairsR(n-1) + climbStairsR(n-2)
return mem[n]
}
再写壳函数(对应图中的 f),它不递归,只负责初始化全局变量 mem,然后转发调用 helper:
go
go
// 壳函数:初始化全局变量,调用递归 helper,对外签名干净
func climbStairs(n int) int {
mem = make([]int, n+1) // 每次调用前初始化全局变量
return climbStairsR(n)
}
为什么这里需要壳?因为 mem 是个全局变量,它的生命周期不在递归函数内部,而是由外部管理。如果没有壳,调用者就得自己写两行:
scss
go
mem = make([]int, n+1)
climbStairsR(n)
这等于把"记忆化需要先初始化全局变量"这个实现细节漏给了外部。而壳的作用,就是把这步初始化封装起来,对外只暴露 climbStairs(n) 一个干净的入口。
这正好印证了那句话:当递归依赖一个需要外部初始化的全局变量时,壳函数就是用来包住"初始化 + 调用"这组动作的。 壳本身不参与递归,它只是递归的"启动器"。
例子 B:树遍历收集结果
遍历二叉树把所有节点值收集到一个列表,结果列表 res 是一个需要跨层共享的状态:
go
go
type TreeNode struct {
Val int
Left *TreeNode
Right *TreeNode
}
func inorderTraversal(root *TreeNode) []int {
res := []int{} // 需要被递归过程共享的状态
var dfs func(node *TreeNode)
dfs = func(node *TreeNode) {
if node == nil {
return
}
dfs(node.Left)
res = append(res, node.Val)
dfs(node.Right)
}
dfs(root)
return res
}
如果不用壳函数,就得把 res 也作为参数传进递归:
scss
go
func dfs(node *TreeNode, res *[]int) {
if node == nil {
return
}
dfs(node.Left, res)
*res = append(*res, node.Val)
dfs(node.Right, res)
}
这种写法功能上没问题,但对外暴露的函数签名变脏了,调用者还要自己先建一个空切片传进去。所以实践中,遇到需要初始化一个容器/缓存再递归的场景,大家更倾向于套一个壳函数,把脏活留在内部。
例子 C:回溯中的「路径」状态
回溯算法里,当前走的「路径」path 是一个必须跨层维护、并且要在递归返回时「撤销」的状态。这种场景几乎都会用壳函数 + helper:
go
go
func permute(nums []int) [][]int {
res := [][]int{}
used := make([]bool, len(nums))
var backtrack func(path []int)
backtrack = func(path []int) {
if len(path) == len(nums) {
cp := make([]int, len(path))
copy(cp, path) // 注意拷贝,否则会被后续回溯修改
res = append(res, cp)
return
}
for i := 0; i < len(nums); i++ {
if used[i] {
continue
}
path = append(path, nums[i])
used[i] = true
backtrack(path)
path = path[:len(path)-1] // 回溯:撤销选择
used[i] = false
}
}
backtrack([]int{})
return res
}
这里的 res、path、used 全是跨层共享的状态。壳函数负责初始化这些状态、启动递归、最后返回结果;内部 helper 负责真正做递归和回溯。
3. 判断口诀
到底要不要壳函数,记住一个口诀:
递归只靠参数就能自洽 → 不用壳;递归需要额外携带共享状态(结果列表、缓存、路径、深度等)→ 用壳函数包一层。
更本质地说,壳函数解决的是两个问题:
- 对外接口干净:对外暴露的函数签名保持简单,把复杂的状态管理藏在内部。
- 状态初始化与复用:缓存、结果容器这些状态需要在一处初始化,再被所有递归层共享,壳函数是天然的初始化位置。
所以下次看到别人代码里 outer 套 inner 的写法,不要觉得多余------那是因为递归过程中确实有需要跨层携带的状态。如果你自己写的递归不需要任何额外状态,那一个函数就够了,不必硬套壳。
十、总结
最后,用几句话把递归这件事收一下:
- 递归的本质 = 把大问题拆成同构的小问题,直到遇到可以直接解决的最小子问题。
- 判断能不能递归:小问题与大问题解法相同、子问题解可组合、存在终止条件。
- 写递归的正确姿势 :假设子问题已解决 → 找递推公式和终止条件 → 翻译成代码。不要去模拟整个执行过程。
- 注意两个坑:栈溢出和重复计算,前者控制深度,后者加缓存。
递归不是玄学,它是一种"信任递推公式"的思维方式。一旦你习惯了"假设子问题已解决"这个角度,你会发现大量看似复杂的算法题,其实就是一个递推公式加一个终止条件而已。
希望这篇文章能帮你真正想通递归。如果你觉得有帮助,欢迎点赞收藏,也欢迎在评论区交流你的递归学习心得。