
很多人学C语言的时候函数指针、结构体、链表这些好歹能靠画图硬啃下来唯独“递归”这个坎一上来就懵。函数怎么还能调用自己调用自己不就死循环了吗再一看汉诺塔、青蛙跳台阶这类经典题不少人直接放弃治疗。其实递归没那么玄乎你缺的不是智商而是把大问题拆成小问题的思维习惯以及一张能把调用过程画出花的纸。我这篇就把函数递归从底层原理到经典题目一次讲透尤其是汉诺塔和青蛙跳台阶每一步怎么推、代码怎么写、有哪些坑全部摊开聊。适合刚学完循环和函数、准备进阶C语言的学习者也适合正在刷题却总在递归上卡壳的朋友。1. 递归的本质函数自己调用自己但远不止“调用自己”这么简单1.1 递归的“三件套”终止条件、递推公式、状态变化我之前带过不少新手发现大家学递归最大的障碍就是陷入细节里出不来总想一步步跟踪函数到底怎么执行。说实话人脑的栈容量很有限硬跟三层以上的递归调用基本就要宕机。正确的打开方式是记住递归的三要素终止条件base case递归到什么时候结束这是防止死循环的命门。递推公式recursive relation把规模为n的问题转化成规模更小的问题。状态变化state change每次递归调用参数必须朝着终止条件方向变化。给个最经典的例子计算n的阶乘int factorial(int n) { // 终止条件 if (n 1) { return 1; } // 递推公式 状态变化 return n * factorial(n - 1); }这个函数里n每次减1一路减到1触发终止条件返回。所以计算5的阶乘时实际发生的调用链是factorial(5) - 5 * factorial(4) - 4 * factorial(3) - 3 * factorial(2) - 2 * factorial(1) - 1算完后逐层返回1 - 2 - 6 - 24 - 120。很多教材把这个过程叫“递推与回归”我个人更愿意把它理解成“把球扔出去再一层层接住弹回来”。递是往下拆解问题归是带着答案往上回溯。只要你能建立起“函数调用一次就开辟一个独立的世界”这个认知递归基本就入门了。1.2 函数调用栈递归能跑起来的底层逻辑为什么递归不会互相干扰是因为每次函数调用系统都会在内存的栈区上分配一块独立的栈帧stack frame用来存放该次调用的局部变量、参数和返回地址。递归调用本质上是嵌套调用每层调用都有自己独立的一份局部变量互不覆盖。我用一个例子说明。运行factorial(3)时栈的变化大致是调用 factorial(3)压入栈帧 n3 调用 factorial(2)压入栈帧 n2 调用 factorial(1)压入栈帧 n1 返回 1弹出 n1 的栈帧 返回 2弹出 n2 的栈帧 返回 6弹出 n3 的栈帧这个机制也解释了为什么递归层数太深会报栈溢出stack overflow。每层调用都要占用栈空间系统给栈的区域大小是有限的Windows上默认一般是1MB左右Linux通常也是8MB左右。如果递归几十万层栈帧把栈空间占满了程序就会崩溃。这也是后面要讲为什么有些问题能用递归却最好别用递归的原因之一。1.3 递归的数学根基数学归纳法递归之所以让人头疼是因为它天然依赖一种“跳跃式”的信任感我假设factorial(n-1)的结果是对的那么factorial(n)就是n * factorial(n-1)。这个假设步骤其实就是数学归纳法里的归纳假设。用数学归纳法设计递归函数通常三步验证 n 取最小规模时比如 n0 或 n1函数成立这对应终止条件。假设 n-1 时成立推导 n 时也成立这对应递推公式。保证从 n 递归到 n-1 能不断逼近最小规模。我见过不少写不出递归的同学卡住的原因不是不会写代码而是脑子里没有“归纳假设”这个概念。写递归时不需要跟着执行路径走到底你只需要相信两件事终止条件是对的递推公式把规模缩小了。就这么点信任感很多代码就豁然开朗了。2. 青蛙跳台阶从题目建模到递归公式的完整推导2.1 先看懂题目在说什么青蛙跳台阶是递归里最具代表性的入门题剑指Offer里也有网上各种面试题中也是常客。题目描述大概是一只青蛙一次可以跳上1级台阶也可以跳上2级台阶。求青蛙跳上一个n级台阶总共有多少种跳法。举个例子n3时1111221一共3种所以答案应该是3。注意“12”和“21”是两种不同的跳法因为跳的顺序不同。2.2 找出递推关系这题的精华所在解这道题的突破口是思考青蛙“最后一步”是怎么跳的。跳上n级台阶的最后一步只有两种可能从第 n-1 级跳1级上来从第 n-2 级跳2级上来。所以跳到第n级的跳法总数等于跳到第 n-1 级的跳法总数加上跳到第 n-2 级的跳法总数。令 f(n) 表示跳上n级台阶的跳法数f(n) f(n-1) f(n-2)已知f(1) 1 f(2) 2这样递推公式和终止条件都有了。它本质上就是斐波那契数列Fibonacci的变体只是初始项不同。如果你在做算法题时发现某个问题可以建模成“当前状态 前一步状态 前两步状态”恭喜你已经抓到动态规划入门题的命门了青蛙跳台阶就是最简单的例子。2.3 递归代码实现与逐层推演直接按照公式写代码#include stdio.h int jump(int n) { // 终止条件 if (n 0) { return 0; } if (n 1) { return 1; } if (n 2) { return 2; } // 递推公式 return jump(n - 1) jump(n - 2); } int main(void) { int n; printf(请输入台阶数: ); scanf(%d, n); printf(跳法总数 %d\n, jump(n)); return 0; }这段代码能跑但只适合 n 很小的时候。你如果输入 n 50程序会卡到怀疑人生甚至直接算不完。为什么因为递归树膨胀得非常快。拿 jump(5) 举例调用过程是jump(5) - jump(4) jump(3) - (jump(3) jump(2)) (jump(2) jump(1)) - ((jump(2) jump(1)) 2) (2 1) - ((2 1) 2) (2 1) - 8看着还好但 jump(40) 的递归调用次数大约是 1.6 亿次这个量级的操作普通电脑也得等上好一阵。更别说 jump(50)指数级爆炸等到天黑都未必出结果。这是纯递归解法最大的硬伤也是面试官特别喜欢追问的点如果不用递归你怎么算2.4 性能优化记忆化递归和迭代法解决重复计算的思路很简单既然每次都要重复算 jump(3)、jump(4) 这些子问题那我搞个数组把它们存起来下次直接用不再重新递归。这就是“记忆化搜索”也叫带备忘录的递归#include stdio.h #define MAX_N 1000 long long memo[MAX_N 1]; long long jump_memo(int n) { if (n 0) { return 0; } if (n 1) { return 1; } if (n 2) { return 2; } // 如果之前算过直接返回 if (memo[n] ! 0) { return memo[n]; } // 没算过就递归算然后把结果存起来 memo[n] jump_memo(n - 1) jump_memo(n - 2); return memo[n]; } int main(void) { int n; printf(请输入台阶数: ); scanf(%d, n); printf(跳法总数 %lld\n, jump_memo(n)); return 0; }加了这一行if (memo[n] ! 0) return memo[n];复杂度立刻从指数级降到了 O(n)。这就是“用空间换时间”的典型思路。那有没有不递归的办法当然有。既然递推公式是 f(n) f(n-1) f(n-2)我完全可以不用函数自动调用自己而是在循环里一次次迭代long long jump_iterative(int n) { if (n 0) { return 0; } if (n 1) { return 1; } if (n 2) { return 2; } long long a 1; // f(1) long long b 2; // f(2) long long result 0; for (int i 3; i n; i) { result a b; a b; b result; } return result; }这个迭代版本既不爆栈也没有重复计算实际工程里我会优先用它。不过话说回来递归版本用来理解问题结构、梳理递推关系效率虽然差教学价值却一点不低。你先会用递归建模再想着怎么优化这条路子比一上来就背迭代写法要扎实得多。3. 汉诺塔真正体现“化归思维”的硬核递归题3.1 题目规则与背后的历史汉诺塔Hanoi Tower这题几乎每个学递归的人都会遇到。规则很简单有三根柱子记为 A、B、C。A柱上有 n 个大小不同的圆盘按从下往上依次变大的顺序叠放。要求把所有圆盘从 A 移到 C每次只能移动一个圆盘并且大盘不能压在小盘上面。很多同学看完规则就傻了这怎么用代码写三根柱子、一堆盘子的移动代码该怎么表达“移动”这个动作其实这正是递归的神奇之处——你不需要把每一步都描述出来只需要找到“把n个盘子从A移到C”和“把n-1个盘子从A移到B”之间的关系。3.2 递归思路三步走的化归把“将 n 个盘子从 A 借助 B 移到 C”这个问题记为hanoi(n, A, B, C)。整个过程可以拆成三步先把上面 n-1 个盘子从 A 借助 C 移到 B。把最大的第 n 个盘子从 A 直接移到 C。再把 B 上的 n-1 个盘子借助 A 移到 C。你仔细品一下第一步把 n-1 个盘子从 A 挪到 B 时C 是辅助柱子第三步把 n-1 个盘子从 B 挪到 C 时A 是辅助柱子。每个大问题都能拆成两个规模为 n-1 的小问题外加一次直接移动。这就是化归思想问题规模一点点缩小直到 n1只需要直接移动一次。这里有个初学者经常绕不出来的点明明是三根棍子为什么函数参数里有两根柱子位置要互换其实参数顺序不是固定的“A、B、C”三根柱子而是“源柱、辅助柱、目标柱”三个角色。你在代码里传参时交换位置本质上就是角色互换。理解了这一点汉诺塔的递归代码就只是套公式了。3.3 C语言代码实现从定义到打印移动步骤写汉诺塔代码先明确函数功能hanoi(n, from, aux, to)表示“把 n 个盘子从 from 柱借助 aux 柱移到 to 柱”。然后代码非常短#include stdio.h void move(char from, char to) { printf(%c - %c\n, from, to); } void hanoi(int n, char from, char aux, char to) { // 终止条件只剩一个盘子时直接从from移到to if (n 1) { move(from, to); return; } // 第一步将上面n-1个盘子从from借助to移到aux hanoi(n - 1, from, to, aux); // 第二步将最大的盘子从from移到to move(from, to); // 第三步将aux上的n-1个盘子借助from移到to hanoi(n - 1, aux, from, to); } int main(void) { int n; printf(请输入盘子数量: ); scanf(%d, n); hanoi(n, A, B, C); return 0; }我分别跑一下 n1、2、3输出如下n1时A - Cn2时A - B A - C B - Cn3时A - C A - B C - B A - C B - A B - C A - C你数一下n3 时刚好7步。如果你手头有一个小号的汉诺塔玩具可以按这个步骤操作一遍会发现自己确实能完成而且规律明显。3.4 移动次数推导为什么是 2^n - 1有一个古老的传说说梵天创造世界时做了三根金刚石柱把64个金盘从上到下由小到大摞在一起命令僧侣把所有盘子移到另一根柱子上移完就是世界末日。那移完需要多少步从递归公式可以看出T(n) 2 * T(n-1) 1也就是“先把 n-1 个盘子移走再把最大的移一次最后把 n-1 个盘子移回来”。展开计算T(1) 1T(2) 2×1 1 3T(3) 2×3 1 7T(4) 2×7 1 15规律很明显T(n) 2^n - 1。所以64个盘子需要 2^64 - 1 步约等于 1.8×10^19 步。假设僧侣每秒移动一次不吃不喝不睡觉一年约3153.6万秒大概要5800亿年。这个数字远超宇宙目前的年龄所以“世界末日”只是个纯数学玩笑不必当真。这也是一个天然的复杂度警钟汉诺塔的时间复杂度是 O(2^n)n 超过 30程序输出移动步骤的耗时就会非常明显n 到 40输出就得上亿行了。所以汉诺塔题目的递归代码适合用来理解和演示实际使用时要注意 n 的取值范围。3.5 汉诺塔递归的常见坑与调试经验我踩过的坑主要有两个第一个是参数顺序搞混。hanoi(n-1, from, to, aux)和hanoi(n-1, aux, from, to)这两行初学者特别容易写错。写错的表现是移动次数对但具体步骤不符合“大盘不能压小盘”的规则。排查办法很简单拿 n3 手动推一遍对照正确输出基本一眼就能看出哪一行角色写反了。第二个是递归逻辑看着对但print出来的步骤颠三倒四。这时候我建议不要硬调而是在纸上用三根柱子画个小图把 n2 的调用过程一步步画出来。你放心画一遍之后汉诺塔的递归结构就深深刻在你脑子里了比盯着代码看半天有用得多。4. 递归的边界什么时候该用递归什么时候该换迭代4.1 递归不是万能的递归 vs 迭代对比聊完两个经典题该泼点冷水了。递归的代码简洁、逻辑清晰尤其适合处理树形结构、分治策略、回溯搜索这类问题。但它有两个很现实的软肋栈空间有限、重复计算多。而这些问题恰恰是迭代可以规避的。我做个粗略的表格对比维度递归迭代代码可读性表达自然接近数学定义需要自己维护状态变量略抽象栈空间占用每层调用都占栈帧层数深容易溢出只需要几个变量几乎不占额外空间重复计算容易重复调用相同子问题指数级膨胀可以通过循环避免重复计算调试难度跟踪调用链比较痛苦变量状态相对可控更容易打印调试适用场景树的遍历、分治、回溯、递归定义的数据结构线性递推、大数计算、高性能要求场景我的判断标准很简单能轻松写成尾递归的直接改迭代问题规模可能很大的优先迭代实在表达复杂、用迭代会把自己绕晕的才用递归而且尽量加记忆化。4.2 尾递归优化递归的高性能形态有一种特殊情况叫尾递归tail recursion。它要求递归调用是函数体中最后执行的操作并且递归调用的返回值直接返回给上层不再参与任何后续计算。尾递归之所以特殊是因为编译器可以做优化复用当前函数的栈帧使得递归深度不再线性消耗栈空间。举个例子用尾递归求阶乘int factorial_tail(int n, int acc) { if (n 1) { return acc; } return factorial_tail(n - 1, n * acc); }调用时初始 acc 传 1factorial_tail(5, 1)。第一次调用计算 5×acc第二次计算 4×上一步结果一路算到 1。这里的递归调用是整个函数的最后一步所以理论上可以被优化为循环栈空间保持常量。可惜的是C语言标准并没有强制要求编译器必须做尾递归优化。GCC 在较高优化级别比如-O2下对简单场景会做但不是所有编译器都可靠。所以我的建议是把尾递归当作一种优化思路理解但工程代码里关键路径我一般直接写迭代避免把命运交给编译器的“心情”。4.3 递归实战避坑指南栈溢出、死循环与全局变量的坑说几个实际写代码时非常容易翻车的点。第一个是栈溢出。递归没写终止条件或者终止条件永远到达不了程序就会无限递归直到栈空间耗尽报Segmentation fault或者Stack overflow。比如把factorial的终止条件写成if (n 1)却用factorial(0)调用那就会一直减到负数也到不了1直接爆栈。所以终止条件里建议写成n 1把边界情况一起兜住。第二个是全局变量污染。我见过有人把汉诺塔的移动计数变量声明成全局变量然后多次调用函数时忘记重置导致计数和输出对不上。这个问题的根源是递归中每次调用共享同一份全局状态一旦逻辑复杂很容易出现“改了这个变量影响那个递归分支”的意外。解决方案很简单计数变量放到函数参数里返回或者每次调用前显式重置。除非迫不得已我一般不推荐在递归里依赖全局变量。第三个是重复计算失控。青蛙跳台阶和斐波那契这类问题纯递归不优化n稍微一大就卡成PPT。遇到这种情况我的习惯是先判断子问题是否有重叠有重叠就上记忆化或者迭代。这个习惯在刷题时非常值钱因为很多看似复杂的动态规划题本质就是“带备忘录的递归”。第四个是返回值类型精度问题。汉诺塔移动次数、青蛙跳台阶的跳法数一旦 n 超过 40int 类型立刻溢出因为 2^40 已经超过 21 亿。这时候要么用 long long要么在题目要求下对大数取模。很多新手写了int然后计算 n50结果出来一个负数还以为是递归写错了其实是数值溢出。排错时先检查数据类型往往比盯代码更快。4.4 调试递归的独家心法打印缩进与手工验证小规模输入递归出了问题最笨也最有效的方法就是加打印语句。我这里分享一个独家技巧在递归函数入口和出口都打印并且用缩进体现层数。拿汉诺塔举例void hanoi(int n, char from, char aux, char to) { printf(%*s[hanoi] n%d, %c - %c (aux%c)\n, (MAX_N - n) * 2, , n, from, to, aux); if (n 1) { move(from, to); return; } hanoi(n - 1, from, to, aux); move(from, to); hanoi(n - 1, aux, from, to); printf(%*s[exit] n%d\n, (MAX_N - n) * 2, , n); }%*s用来输出指定宽度的空字符串层数越深缩进越多。这样跑一次输出你就能很直观地看到递归一层层进去、再一层层出来的过程。这个方法我用了很多年比任何调试器都顺手。另外一个经验是手算小规模输入。递归出 bug 时千万不要拿 n100 去试要先用 n1、n2、n3 这些能手动验证的规模确认逻辑无误后再放大数据。手动推演 n3 的汉诺塔输出应该是7步方向全部符合规则如果不符就把错误步骤对应的调用参数打出来基本能定位是哪个参数位置写反了。5. 两个经典题的变体与面试延伸从“会做”到“做透”5.1 青蛙跳台阶的变体一次可跳1到m级面试官问完青蛙跳台阶十有八九会紧跟一句“如果青蛙一次可以跳1级、2级、3级……直到m级那跳法总数怎么算”思路还是一样的但递推公式会变成f(n) f(n-1) f(n-2) ... f(n-m)如果 m 大于等于 n也就是青蛙一次能跳任意级那么 f(n) 可以用数学归纳法推出来答案是 f(n) 2^(n-1)。推导也不难跳上 n 级台阶最后一步可能是从 0、1、2……n-1 任意一级跳上来所以f(n) f(0) f(1) f(2) ... f(n-1)这其实就是 f(n) 2 × f(n-1)初始 f(1)1所以 f(n) 2^(n-1)。很多公司笔试题喜欢考这个看似变体、实则难度跳跃的题目如果你只会背原题很容易懵。但有了递推建模的能力这种变体就是多写几行公式的事。5.2 汉诺塔的变体统计移动次数与状态打印汉诺塔的变体也很多常见的有不打印具体移动步骤只返回移动次数限制某根柱子不能直接放盘子求最小移动次数或者要求输出第k步的具体移动方案。这些变体有一个共同点只要你会写基础版本的递归稍加改造就能对付。比如只统计次数可以这样long long hanoi_count(int n) { if (n 1) { return 1; } return 2 * hanoi_count(n - 1) 1; }如果面试官要求优化你直接推导出公式return (1LL n) - 1;但要注意 n 太大时左移结果会溢出 long long。这就是“会基础版、能变体、有复杂度意识”的三个层次面试官通常是用这样的递进式提问来摸你底细的。5.3 递归在真实项目中的应用目录遍历与树形结构聊到这里可能有读者要问这些题目看起来挺数学的实际工作里真的用得上递归吗答案是太常用了。举几个我实际见过的场景第一个是遍历文件目录。你在终端里执行tree命令或者用代码扫描某个文件夹下所有文件本质上就是对目录树做先序遍历或者深度优先遍历。每个目录就是一个节点每个节点下还有子目录这种天然嵌套的结构用递归处理最自然。第二个是处理树形菜单、组织结构、商品分类这类数据。JSON 数据一到三层你可以写多层嵌套循环如果嵌套十层以上呢只能递归了。前几年我处理过一个电商分类接口分类层级不固定最深能嵌套到十几层当时就是想清楚了“递归遍历树 栈记录路径”的思路代码写起来非常省事。第三个是编译器领域的语法分析。表达式求值、抽象语法树AST的遍历基本全是递归。你写一个简单的计算器解析1 2 * (3 - 4)也要用递归下降解析来处理嵌套括号。C语言中的C语言语法本身远比这个复杂但只要抓住“递归下降”思想就能一点一点啃下来。5.4 刷题建议递归怎么练才能形成肌肉记忆不少读者会问递归思想到底怎么练我个人的建议分三步走。第一步把学过的基础递归题吃透阶乘、斐波那契、字符串逆序、数组求和、链表反转这些题目用递归写一遍再用迭代写一遍对比两种写法的差异。第二步啃树形结构二叉树的前序、中序、后序遍历计算树的高度求树的结点总数。树天生就是递归定义的做这类题会让你对“递推公式”有肌肉记忆。第三步才是挑战递归级别的难题比如汉诺塔、八皇后、全排列、背包问题这些回溯类题目。每个题目都要做到“能用手推小规模答案”和“能画出递归树”两个标准。做到这层你的递归能力就不再是死记硬背而是真正理解它了。我个人在实际操作中的一个感受是递归思维一旦建立学动态规划、回溯、分治这些进阶算法都会顺很多。所以别嫌这些题目简单也别只停留在“看得懂”的阶段多动手画递归树、多亲手改bug比看一百篇教程都管用。最后再分享一个小技巧每次写完一个递归函数先问自己三个问题——如果没有终止条件会怎样如果递归参数不变会怎样如果返回值类型放不下会怎样这三个问题过一遍大多数递归都能写稳。