10. 递归函数:递归原理,递归与迭代的对比,经典案例(阶乘、斐波那契)

好,咱们今天聊递归。说实话,递归这个概念,我当年学的时候也觉得有点绕。但后来在项目中用多了,发现它其实是个很优雅的工具——前提是你得理解它的脾气。

递归的原理:自己调用自己

递归,说白了就是一个函数在执行过程中,又调用了它自己。嗯,听起来有点“套娃”的意思。但关键在于,每次调用自己时,问题的规模都在缩小,直到遇到一个“不用再递归”的边界条件。

我习惯把递归拆成两个部分来看:

  • 递推关系:当前问题的解,依赖于更小规模问题的解。
  • 终止条件:当问题规模小到一定程度时,直接返回结果,不再递归。

你想想看,如果没有终止条件,递归就会无限进行下去,最终栈空间耗尽,程序崩溃。我在项目中就见过同事写的递归忘了加终止条件,结果一跑就段错误,排查了半天。

核心要点:递归函数必须包含两个要素——递推公式 + 终止条件。缺一不可。

递归的调用过程:栈的视角

为什么递归能工作?这得感谢函数调用栈。每次函数调用,系统都会在栈上分配一块空间,用来存放局部变量、参数和返回地址。递归调用也不例外,每次自己调用自己,都会在栈上压入一个新的栈帧。

我画了一张图,帮你理解这个过程:

递归调用栈示意图(以阶乘 fact(4) 为例) fact(4) 调用 等待 fact(3) 返回 fact(3) 调用 等待 fact(2) 返回 fact(2) 调用 等待 fact(1) 返回 fact(1) 调用 直接返回 1 ← 返回 6 ← 返回 2 ← 返回 1 ← 返回 1 栈增长方向 图例 递推阶段(压栈) 终止条件(触底) 回归阶段(出栈)

从图上你能看到,递归分两个阶段:递推(一路向下压栈)和回归(从底部开始向上返回)。当 fact(1) 触底返回 1 后,栈帧依次弹出,每层计算自己的结果,最终回到最顶层。

我的经验:调试递归函数时,我习惯在函数入口打印参数值,在返回前打印返回值。这样能清晰看到每一层的调用和返回过程,比光靠脑子想靠谱多了。

经典案例一:阶乘

阶乘是递归的“Hello World”。数学定义很直白:n! = n × (n-1)!,且 0! = 1。翻译成代码就是:

#include <stdio.h>

long long factorial(int n) {
    // 终止条件
    if (n <= 1) {
        return 1;
    }
    // 递推关系
    return n * factorial(n - 1);
}

int main() {
    int n = 5;
    printf("%d! = %lld\n", n, factorial(n));
    return 0;
}

运行结果:5! = 120。这个例子很简单,但我想提醒你注意一点:参数 n 每次减 1,最终一定会到达 1,所以递归是收敛的。如果你写的是 factorial(n+1),那就永远到不了终止条件了——我曾经在代码审查时抓到过这种 bug。

经典案例二:斐波那契数列

斐波那契数列的定义:F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2)。递归实现非常直观:

#include <stdio.h>

long long fib(int n) {
    if (n <= 1) {
        return n;
    }
    return fib(n - 1) + fib(n - 2);
}

int main() {
    for (int i = 0; i <= 10; i++) {
        printf("F(%d) = %lld\n", i, fib(i));
    }
    return 0;
}

这段代码能跑,但我要给你提个醒:它的效率非常低。为什么?因为存在大量重复计算。你算一下 F(5),它需要算 F(4) 和 F(3);算 F(4) 又需要算 F(3) 和 F(2)……F(3) 被算了两次。随着 n 增大,重复计算呈指数级增长。

避坑指南:我曾经在一个嵌入式项目中,看到同事用递归斐波那契算第 40 项,结果程序卡死了好几分钟。后来改成迭代,瞬间出结果。递归虽好,但别滥用。

递归 vs 迭代:怎么选?

咱们来做个对比,我整理了一张表:

对比维度 递归 迭代
代码可读性 简洁,贴近数学定义 稍显繁琐,但逻辑直白
内存开销 每次调用压栈,深度大时可能栈溢出 通常只占用固定栈空间
执行效率 函数调用有额外开销,可能重复计算 通常更快,无调用开销
适用场景 树形结构、分治算法、数学定义明确的问题 线性遍历、累加累乘、简单循环

我个人习惯是:能用迭代解决的问题,优先用迭代。但遇到树遍历、汉诺塔、快速排序这类天然适合递归的问题,我不会硬写成迭代——那是在跟自己过不去。

递归的优化:尾递归

有一种特殊的递归叫尾递归——递归调用是函数的最后一个操作,并且返回值直接返回,不再参与额外计算。比如阶乘可以改写成尾递归形式:

long long factorial_tail(int n, long long acc) {
    if (n <= 1) {
        return acc;
    }
    return factorial_tail(n - 1, n * acc);
}

// 调用时:factorial_tail(5, 1)

尾递归的好处是:某些编译器可以优化成循环,从而避免栈溢出。但注意,C 标准并不强制要求编译器做尾递归优化,所以别完全依赖它。我在嵌入式开发中,一般还是老老实实写迭代。

递归的经典应用场景

除了阶乘和斐波那契,递归在以下场景中非常常见:

  • 树的遍历:前序、中序、后序遍历,递归写法极其优雅
  • 快速排序:分治思想,递归实现最自然
  • 汉诺塔:不用递归,代码会变得非常复杂
  • 文件系统遍历:目录嵌套深度未知,递归是天然选择

总结一下:递归是一种强大的编程思想,核心是“将大问题分解为同构的小问题”。理解递归的关键在于:相信你的函数能正确处理子问题,不要陷入“人肉递归”的陷阱。你只需要定义好递推关系和终止条件,剩下的交给计算机。

嗯,递归就聊到这儿。记住一句话:递归是思维工具,不是万能钥匙。该用的时候别犹豫,不该用的时候别逞强。


公众号:蓝海资料掘金营,微信deep3321