递归算法:从原理到实战,一次讲透

递归是编程世界中最优雅、也最容易让人"绕晕"的思维方式。很多人学完递归后,一遇到新题就发懵;或者写得出代码,却说不清为什么对。这篇文章,我们就把递归这件事彻底拆开讲透。

一、什么是递归

简单来说:递归就是函数自己调用自己

听起来很简单,但递归真正的核心不在"调用自己",而在于:把一个大问题,拆成规模更小、但解法相同的子问题

举个例子,假设你要数一排队伍有多少人。你不知道总数,但你可以问前面的人:"你前面有几个人?" 前面的人再去问他前面的人,如此传递,直到第一个人(他没有前面的人了,直接返回 0),答案再一层层累加回来。

这就是递归的精髓:

  • 大问题 = 数一整排队伍的人数
  • 小问题 = 数前面那一截队伍的人数(逻辑完全一样,只是规模变小)
  • 终止条件 = 排在第一个的人,前面没人,返回 0

二、递归的三个核心要素

王争老师在《算法训练营》中提到,判断一个问题能不能用递归来做,主要看三点:

  1. 规模更小的问题,跟规模大的问题,解决思路相同、仅规模不同。 也就是说,大问题和小问题是"同构"的,只是数据规模不一样。
  2. 子问题的解可以组合得到原问题的解。 拆分出来的小问题的答案,能够拼起来还原出大问题的答案。
  3. 存在最小子问题,可以直接返回结果(存在递归终止条件)。 必须有一个"到底"的点,不能再无限拆下去。

这三条,是判断"能不能递归"的试金石。以后遇到一个新问题,先拿这三条去套,符合就大胆用递归。

三、递归的正确编写姿势

很多人写递归时,脑子里会忍不住去模拟整个"递"和"归"的完整执行过程------第一层调用谁,第二层又调用谁,返回时怎么一层层回来......

这其实是一个巨大的思维误区。

王争老师有一句很经典的话:

注意:千万不要试图想清楚整个递和归的执行过程,实际上这是进入了一种思维误区。

为什么?因为递归的层级可能很深,人脑的栈空间是有限的,你不可能在脑子里把几十层调用都模拟清楚。一旦陷进去,就会越想越乱。

正确姿势是什么?

正确的写递归的方式是:假设子问题已经被解决了,在此基础上去思考怎么解决原问题。

具体分三步:

  1. 假设子问题 B、C 已经解决,在此基础上去思考,如何组合 B、C 的解来解决原问题 A。
  2. 基于这一思路,找出递推公式 + 终止条件
  3. 把递推公式和终止条件,翻译成代码

这听起来有点抽象,我们用一个经典例子来说明。

四、经典案例:爬楼梯

问题描述

假设有 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 迭代

递归能解决的问题,理论上都可以改写成迭代(循环)。那到底该用哪个?

维度 递归 迭代
代码可读性 通常更简洁、更接近问题本质 有时更冗长
性能 有函数调用开销,可能栈溢出 性能更好,无栈溢出风险
适用场景 问题天然有递归结构(树、分治、回溯) 简单线性遍历、性能敏感场景

经验法则:问题本身有明显的递归结构时,优先用递归;性能要求高或递归深度大时,改写成迭代。

八、递归的常见应用场景

  1. 树和图的遍历:二叉树的前中后序遍历、DFS 深度优先搜索,天然就是递归结构。
  2. 分治算法:归并排序、快速排序,把数组拆成两半分别处理,再合并。
  3. 回溯算法:全排列、八皇后、数独,本质是递归 + 状态回退。
  4. 动态规划:很多 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
}

这里的 respathused 全是跨层共享的状态。壳函数负责初始化这些状态、启动递归、最后返回结果;内部 helper 负责真正做递归和回溯。

3. 判断口诀

到底要不要壳函数,记住一个口诀:

递归只靠参数就能自洽 → 不用壳;递归需要额外携带共享状态(结果列表、缓存、路径、深度等)→ 用壳函数包一层。

更本质地说,壳函数解决的是两个问题:

  1. 对外接口干净:对外暴露的函数签名保持简单,把复杂的状态管理藏在内部。
  2. 状态初始化与复用:缓存、结果容器这些状态需要在一处初始化,再被所有递归层共享,壳函数是天然的初始化位置。

所以下次看到别人代码里 outerinner 的写法,不要觉得多余------那是因为递归过程中确实有需要跨层携带的状态。如果你自己写的递归不需要任何额外状态,那一个函数就够了,不必硬套壳。

十、总结

最后,用几句话把递归这件事收一下:

  1. 递归的本质 = 把大问题拆成同构的小问题,直到遇到可以直接解决的最小子问题。
  2. 判断能不能递归:小问题与大问题解法相同、子问题解可组合、存在终止条件。
  3. 写递归的正确姿势 :假设子问题已解决 → 找递推公式和终止条件 → 翻译成代码。不要去模拟整个执行过程。
  4. 注意两个坑:栈溢出和重复计算,前者控制深度,后者加缓存。

递归不是玄学,它是一种"信任递推公式"的思维方式。一旦你习惯了"假设子问题已解决"这个角度,你会发现大量看似复杂的算法题,其实就是一个递推公式加一个终止条件而已。

希望这篇文章能帮你真正想通递归。如果你觉得有帮助,欢迎点赞收藏,也欢迎在评论区交流你的递归学习心得。

相关推荐
CYLAM20251 小时前
STC32G12K128单片机实现高精度算法及常用初等函数值的计算
算法
掘金者阿豪1 小时前
ChatGPT Plus、Pro 5x、Pro 20x 到底有多少额度?聊聊 Codex 那个让人看不懂的“周限额”
后端
用户608186527901 小时前
Avalonia 控件模板实战:从 WPF 迁移自定义 Button 样式的完整指南
后端
奶人五毛拉人一块1 小时前
动态规划--子数组类型
算法·动态规划·子数组问题
HugoStudio_SWAN1 小时前
洛谷 P1321 单词覆盖还原——从蜡板上的密信到数字水印
c++·学习·程序人生·算法
明月_清风2 小时前
GPT-6 Astra 与 AGI 的门槛:我们到底在争论什么?
人工智能·后端·openai
jyOverQ2 小时前
RabbitMQ 延迟消息怎么实现?TTL 与死信队列
分布式·后端·rabbitmq·ruby
元界metalite2 小时前
Java通用枚举驱动下拉框-元数据接口与前端契约
后端
Csvn2 小时前
🐍 Day 10: 依赖管理 — 从 requirements.txt 到 pyproject.toml
后端·python