1. 递归到底是个什么东西
很多人在学C语言的时候,学到函数这块就卡住了,尤其是递归。数组、指针、结构体好歹能看到实实在在的数据在内存里怎么摆,但递归这东西,代码看起来就那么几行,执行起来却像变魔术一样,让人摸不着头脑。
先给一个粗暴但准确的定义:递归就是函数自己调用自己。不是函数的拷贝,是同一个函数,在执行的过程中又调用了自己。这句话看起来简单,但很多初学者第一个疑惑就是:它调用自己,那不就无限循环了吗?程序不得爆掉?
问得好,这正是递归的核心问题。答案在于递归必须满足两个关键条件:递归出口和递推公式。没有出口的递归就是死循环,迟早把栈空间耗尽导致程序崩溃;没有递推关系的递归就是原地打转,毫无意义。
举个例子,计算n的阶乘:
int factorial(int n) { if (n <= 1) { return 1; // 递归出口 } return n * factorial(n - 1); // 递推公式 }你执行factorial(5)的时候,函数并不会立刻算出结果。它会先变成5 * factorial(4),然后变成5 * 4 * factorial(3),层层递进,一直到5 * 4 * 3 * 2 * factorial(1),碰到出口条件n <= 1返回1,然后再一层层"归"回来:5*4*3*2*1。
整个过程可以想象成查字典:你要查"久"字,发现解释里有个"遥"字不认识,于是去查"遥",结果"遥"的解释里又有"远"字不认识,再去查"远"……直到查到一个所有字都认识的字条,才能一层层倒回去,最终弄明白"久"是什么意思。
这个"从入口一路问到出口,再从出口一路带回答案"的过程,就是递归最核心的逻辑。
那什么样的问题适合用递归?理论上,只要一个问题能被拆成"规模更小但结构相同"的子问题,就适合递归。最典型的就在两个经典问题上:汉诺塔和青蛙跳台阶。这篇文章就把这两个问题从头到尾拆开揉碎讲清楚,顺带把递归的底层机制、性能陷阱、调试技巧一网打尽。
2. 递归背后的执行机制
在动手写代码之前,必须先把递归的底层执行机制搞明白,否则你写出来的递归就是碰运气,对了不知道为什么对,错了也不知道怎么改。
2.1 栈帧:函数调用的真相
C语言里每次函数调用,系统都会在内存的栈区(stack)分配一块空间,叫栈帧(stack frame)。这个栈帧里保存了三个关键信息:函数的局部变量、参数值,以及返回地址——也就是调用完这个函数后,该回到哪里继续执行。
每次调用新函数,就压入一个新栈帧;每次函数返回,就弹出栈顶帧。这和我们平时摞盘子一模一样,后放上去的先拿下来,这叫后进先出。
重点是:递归调用时,每次调用同一个函数,会创建不同的栈帧。你以为它们在"同一个函数"里,其实每一层的局部变量都独立存在,互不干扰。比如上面阶乘的例子,递归第5层的那个n,和第4层的n,虽然名字都叫n,但它们住的是不同的"房间"。
这就能解释一个很多新手都会犯的错:在递归函数里用了全局变量或静态变量来保存中间结果。这些变量是全函数共享的,不是每层独立的。你这一层的修改,下一层看得见,下一层改了,上一层也会受影响,最后结果全乱套。所以写递归,优先用参数和返回值传递数据,别依赖全局状态,这是第一条铁律。
2.2 递归的"递"与"归"
递归的执行过程可以拆成两个阶段:递——一层层调用下去,直到碰到出口;归——出口返回结果,结果一层层倒着传回来。
还是看阶乘,factorial(5)的完整过程:
递:factorial(5) → 5 * factorial(4) 递:factorial(4) → 4 * factorial(3) 递:factorial(3) → 3 * factorial(2) 递:factorial(2) → 2 * factorial(1) 递:factorial(1) → 1 // 到达出口 归:factorial(1) 返回 1 归:factorial(2) 返回 2 * 1 = 2 归:factorial(3) 返回 3 * 2 = 6 归:factorial(4) 返回 4 * 6 = 24 归:factorial(5) 返回 5 * 24 = 120看到没有,"递"的时候从大到小,"归"的时候从小到大,后调用的先返回。一句话:先深入,再回溯,这就是递归的本质节奏。
很多同学看完"递"就晕了,其实真正计算发生在"归"的阶段。你想,factorial(5)里的那个* factorial(4),要等factorial(4)算出结果才能执行乘法运算,所以真正干活的时机是"归"的过程。递归的代码写在调用自己之前,是"递"的时候干活;写在自己之后,是"归"的时候干活。这个理解对后面的汉诺塔特别关键——它的输出语句放在两次递归调用的中间,执行顺序非常反直觉。
2.3 递归深度和栈溢出
栈区空间是有限的,不同平台不一样,常见默认在1MB~8MB。每次函数调用占用多少栈帧?取决于局部变量大小,几十到几百字节很正常。所以一个递归能深入多少层,是有上限的。
如果递归深度太大,栈帧不断压入,最终超出栈的容量,就会触发栈溢出(stack overflow),程序直接崩溃。我在Linux上试过,一个不带额外局部变量的空递归函数,深度大概到几十万层就会段错误。
实操里我一般给你一个经验红线:递归深度在1万层以内比较安全,超过就要考虑改写成迭代或者深度优先搜索配显式栈。刷题网站上经常有斐波那契那种"记忆化递归"突然爆栈的问题,十有八九就是这个深度问题,不是你的逻辑错了。
3. 汉诺塔问题全拆解
汉诺塔(Hanoi Tower)应该是最能体现递归魅力的题目了,没有之一。初见时觉得巨难无比,搞懂之后会觉得递归真他妈优雅。
3.1 问题描述
有3根柱子,分别叫A(起始柱)、B(辅助柱)、C(目标柱)。A柱上从下往上按大小顺序摞着n个圆盘。要求把所有圆盘从A移到C,规则有两条:
- 每次只能移动一个圆盘
- 任何时候大盘不能压在小盘上面
问:n个圆盘时,最少需要移动多少次?每一步怎么移?
3.2 从最小的规模开始找感觉
面对这种问题,不要一上来就想着n个盘子。我们先从最简单的开始推。
n=1:一个盘子,直接从A移到C,完成。共1步。
n=2:两个盘子,小盘(1号)在大盘(2号)上面。
- 1号盘:A → B(先把小的挪开)
- 2号盘:A → C(大的直接去目标位)
- 1号盘:B → C(小的再挪到大的上面)
共3步。
n=3:三个盘子的时候情况就开始复杂了,但核心思路是:
- 先把上面2个盘子从A移到B(借助C)——这一步怎么移,就是上面n=2的过程,只是目标柱从C换成了B
- 再把最大的3号盘从A移到C
- 最后把B上的2个盘子移到C(借助A)——这又是一次n=2的移动
你发现规律了吗?不管多少个盘子,移动n个盘子的问题,总是可以拆成三步:
- 把上面的 n-1 个盘子从 A 移到 B(借助 C)
- 把最底下的第 n 个盘子从 A 移到 C
- 把 B 上的 n-1 个盘子从 C 移到目标 C(借助 A)
而"把n-1个盘子从某根柱移到另一根柱",又是一个规模更小、规则完全相同的汉诺塔问题。这不就是递归吗?
3.3 代码实现
#include <stdio.h> void hanoi(int n, char from, char tmp, char to) { if (n == 1) { // 只有一个盘子,直接移动 printf("第1个盘: %c -> %c\n", from, to); return; } // 第一步:把上面n-1个盘子从from移到tmp,借助to hanoi(n - 1, from, to, tmp); // 第二步:把第n个盘子从from移到to printf("第%d个盘: %c -> %c\n", n, from, to); // 第三步:把tmp上的n-1个盘子从tmp移到to,借助from hanoi(n - 1, tmp, from, to); } int main() { int n = 3; printf("移动 %d 个盘子的步骤:\n", n); hanoi(n, 'A', 'B', 'C'); return 0; }运行结果:
移动 3 个盘子的步骤: 第1个盘: A -> C 第2个盘: A -> B 第1个盘: C -> B 第3个盘: A -> C 第1个盘: B -> A 第2个盘: B -> C 第1个盘: A -> C正好7步。你可以拿纸和笔,拿三个硬币模拟一下,每一步都对得上。
这个函数的参数设计有一个细节需要注意:from、tmp、to三个参数表示的是角色,不是固定某根柱子。同一根柱子,在这一层调用里可能是from,在下一层调用里就变成了tmp。很多同学看递归看晕,就是没转过这个弯来——函数参数的含义是现场的、临时的,A/B/C是具体的,角色是会变化的。
3.4 为什么这个代码是对的
很多人第一次看到这个代码,最大的困惑是:不就三行调用吗?凭什么它能算出正确的移动步骤?
我们一层层看。
假设hanoi(3, 'A', 'B', 'C'):
- 第一步调用
hanoi(2, 'A', 'C', 'B'),意思是:我要把2个盘子从A移到B,用C做辅助。这本身就是一个子问题。- 它内部先调用
hanoi(1, 'A', 'B', 'C'),输出A→C(第1号盘先挪走) - 输出2号盘A→B
- 再调用
hanoi(1, 'C', 'A', 'B'),输出C→B
- 它内部先调用
- 回到外层,输出3号盘A→C
- 再调用
hanoi(2, 'B', 'A', 'C'),把2个盘子从B移到C,用A做辅助。- 内部先输出B→A
- 输出2号盘B→C
- 输出A→C
关键在于:每一层都只关心"怎么把当前这堆盘子当成一个整体来挪",至于挪的过程中内部怎么折腾,完全交给下一层递归处理。你不需要在脑子里把每一层每一步都展开,你只需要相信:只要子问题能被正确解决,那组合起来整个问题就解决了。这就是递归里的"相信过程"。
这种"大事化小、小事化了"的思路,在算法上有个正式名字叫分治法:把一个大问题分解成若干个独立的、规模更小的同类子问题,分别求解,再合并结果。
3.5 最少移动次数推导
汉诺塔问题还有一个经典变体:问n个盘子最少需要移多少次。设f(n)表示n个盘子的最少移动次数,根据前面拆解的三步:
- 移走上面n-1个盘子:
f(n-1)次 - 移最下面的大盘:1次
- 再把n-1个盘子移回来:
f(n-1)次
所以递推关系是:
f(n) = 2 * f(n-1) + 1 f(1) = 1展开一下:
f(1) = 1 f(2) = 2*1 + 1 = 3 f(3) = 2*3 + 1 = 7 f(4) = 2*7 + 1 = 15看出规律没有?f(n) = 2^n - 1。64个盘子的传说,需要的次数是2^64 - 1,按一秒移一次,得5849亿年,比宇宙年龄还长,这就是指数爆炸的威力。
这个推导过程也完美体现了递归思维的另一层用法:用递推公式描述问题规模的增长规律。很多时候你不需要真的去模拟每一步,用一个递推式就能分析出复杂度。
3.6 汉诺塔的常见变体和坑
变体1:目标柱不同的汉诺塔。比如要求从A移到B,C是辅助,那么调用hanoi(n, 'A', 'C', 'B')就行,逻辑不用改。
变体2:返回步数。不改打印功能,加一个返回值:
int hanoi_count(int n, char from, char tmp, char to) { if (n == 1) { printf("第1个盘: %c -> %c\n", from, to); return 1; } int count = 0; count += hanoi_count(n - 1, from, to, tmp); printf("第%d个盘: %c -> %c\n", n, from, to); count++; count += hanoi_count(n - 1, tmp, from, to); return count; }坑1:打印语句的顺序。汉诺塔的打印语句夹在两个递归调用中间,这意味着:"归"的时候先执行完第一个递归,打印当前层,再执行第二个递归。这个顺序一旦写反,整个移动步骤就是错的。正确逻辑必须是:先把小的移到辅助柱,再动大盘,最后把小的移到目标柱。三大步的顺序绝不能乱。
坑2:递归出口必须最先判。很多人喜欢把if (n==1)写到最后面,或者不写出口直接写if (n==0) return;,也能跑,但容易在传0的时候出问题。我建议统一n==1作为出口,逻辑最直观。
4. 青蛙跳台阶问题精解
如果说汉诺塔是递归的"形",那青蛙跳台阶就是递归的"神"——它背后藏着动态规划和斐波那契数列,是面试场上出现频率极高的题目。
4.1 问题描述
一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。请问它跳上n级台阶,总共有多少种跳法?
注意这里问的是多少种跳法,不是具体每一步怎么跳。这是个计数问题,最怕的就是一上来就在脑子里枚举所有路径,很快就会乱。正确姿势是把问题递推化。
4.2 递推思路推导
假设f(n)表示跳上n级台阶的跳法数。
先看最简单的:
f(1):只有1级台阶,只能跳1级,1种跳法。f(2):可以1+1跳两次,也可以直接跳2级,2种跳法。
现在跳到关键的n了。青蛙在第一跳只有两种选择:跳1级,或者跳2级。
- 如果第一跳跳1级,那剩余
n-1级台阶的跳法就是f(n-1)种。 - 如果第一跳跳2级,那剩余
n-2级台阶的跳法就是f(n-2)种。
这两种情况互斥且完备(不可能同时发生,也不会漏掉任何情况),所以:
f(n) = f(n-1) + f(n-2) f(1) = 1 f(2) = 2看到这个递推式,熟悉斐波那契数列的同学应该已经反应过来了:这不就是斐波那契吗?标准的斐波那契是F(1)=1, F(2)=1, F(n)=F(n-1)+F(n-2),青蛙跳台阶只是把第二项从1改成了2。
用表格列一下:
| n | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| f(n) | 1 | 2 | 3 | 5 | 8 | 13 |
验证一下n=3:跳法为 1+1+1、1+2、2+1,正好3种。n=4:1+1+1+1、1+1+2、1+2+1、2+1+1、2+2,正好5种。没问题。
4.3 朴素递归实现
#include <stdio.h> int jump(int n) { if (n == 1) { return 1; } if (n == 2) { return 2; } return jump(n - 1) + jump(n - 2); } int main() { for (int i = 1; i <= 10; i++) { printf("jump(%d) = %d\n", i, jump(i)); } return 0; }这段代码逻辑完全正确,但你拿它跑jump(45),会发现越来越慢,跑jump(50)可能就要等好久。问题出在哪?
4.4 递归的性能陷阱
把jump(5)的调用关系画出来:
jump(5) ├── jump(4) │ ├── jump(3) │ │ ├── jump(2) │ │ └── jump(1) │ └── jump(2) └── jump(3) ├── jump(2) └── jump(1)看到没有,jump(3)被算了2次,jump(2)被算了3次。n越大,重复计算的次数呈指数增长。jump(50)需要计算的次数大约是2^50级别,这谁扛得住?
这就是递归最典型的性能坑:当一个递归会把同一个子问题重复计算很多次的时候,它的时间复杂度是指数级的。斐波那契的朴素递归时间复杂度是O(2^n),听起来就吓人。
解决思路有两条:记忆化搜索(把算过的结果存起来)和改成迭代(从底部往上算)。这两个方案下面各写一段。
4.5 优化方案一:记忆化递归
思路很简单:第一次算出jump(3)之后,把结果存在一个数组里,后面再要jump(3),直接查表返回,不重复递归。
#include <stdio.h> #define MAX 100 long long memo[MAX] = {0}; long long jump_memo(int n) { 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() { for (int i = 1; i <= 50; i++) { printf("jump(%d) = %lld\n", i, jump_memo(i)); } return 0; }加了一个memo数组,每个子问题只算一次,时间复杂度直接从O(2^n)降到O(n)。我实测跑jump(50),瞬间出结果。注意我用了long long,因为jump(50)的结果超过int的范围了——这是另一个容易踩的坑,算到后面数字涨得飞快,int根本装不下。
4.6 优化方案二:迭代递推
既然有了递推公式f(n) = f(n-1) + f(n-2),那就完全没必要用递归。直接用两个变量滚着算:
#include <stdio.h> long long jump_iter(int n) { if (n == 1) { return 1; } if (n == 2) { return 2; } long long a = 1; // f(n-2) long long b = 2; // f(n-1) long long c = 0; for (int i = 3; i <= n; i++) { c = a + b; a = b; b = c; } return c; } int main() { for (int i = 1; i <= 50; i++) { printf("jump(%d) = %lld\n", i, jump_iter(i)); } return 0; }迭代版本连递归调用都没了,更不会爆栈,空间是O(1),时间还是O(n)。如果你被问到"青蛙跳台阶",面试官大概率会追一句"能不能不用递归实现?",你直接把这个版本甩出来,印象分拉满。
4.7 问题变体:一次能跳n级
同一道题的经典变体:如果青蛙一次可以跳1级、2级、……甚至n级,那跳到第n级有多少种跳法?
推理稍微绕一点。设f(n)为跳法数。第一跳可以跳k级(1 <= k <= n),跳完k级后,剩下n-k级的跳法数是f(n-k),所以:
f(n) = f(n-1) + f(n-2) + ... + f(1) + f(0)其中f(0)表示一次直接跳完,看作1种。展开这个式子:
f(n-1) = f(n-2) + f(n-3) + ... + f(1) + f(0)两个式子相减,得到f(n) = 2 * f(n-1),结合f(1) = 1,所以f(n) = 2^(n-1)。
这个拓展版本的核心思想是递推关系的归纳与消元,如果你把前面的f(n)=f(n-1)+f(n-2)理解透了,这个变形其实不难推导。面试遇到这种变体,能现场推出2^(n-1)这个结论,说明你的递推思维已经过关了。
5. 递归实战的进阶技巧
理论吃透了,代码也会写了,接下来聊聊真正写工程代码、刷题、做笔试时用得上的实战技巧。
5.1 什么时候用递归,什么时候别用
我的建议是:递归用在"问题天然有递归结构"的场景,比如树的遍历、目录遍历、分治排序(快速排序、归并排序)、动态规划的记忆化搜索。这些问题的数据结构(树、图)本身就是递归定义的,用递归顺手得不得了。
反过来,如果问题本质是线性的,能一眼看出循环能解决,就别硬递归。比如求和、求最大值、逐行处理文件,用循环简单明了,非要递归反而把简单问题搞复杂,还增加栈溢出风险。
至于递归和迭代怎么选,给个参考:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 树/图遍历 | 递归 | 结构天然递归,代码极简 |
| 分治算法 | 递归 | 分解合并逻辑清晰 |
| 大深度搜索(如数独) | 迭代+显式栈 | 避免栈溢出 |
| 线性计算(求和/阶乘) | 迭代 | 性能更好,更安全 |
| 递推关系(斐波那契) | 迭代/记忆化 | 避免重复计算 |
5.2 写递归的三个固定步骤
我自己带人的时候,都会教他们一个固定套路,按这个顺序想,递归就不会乱:
- 定义函数签名:明确这个函数输入什么、输出什么。比如
jump(int n),输入台阶数,输出跳法数。 - 找递推关系:想清楚当前问题和子问题之间的联系。这一步往往需要你手动推几个小规模case,找到规律。
- 确定递归出口:最小的规模,直接返回。注意出口必须覆盖所有可能走到最小规模的情况,不缺不漏。
三步走完,再翻译成代码。绝大部分写不出递归的人,都是卡在第二步——连递推关系都没想明白就急着写代码,全凭感觉瞎试,当然写不出来。
5.3 递归调试:打印大法
很多初学者调试递归有个坏习惯:一看到结果不对,就开始在脑子里模拟整个递归过程,恨不得把每一个栈帧推演一遍。这不是人类干的事。
正确的做法是在关键位置加打印语句,看每一层的参数进来是什么、返回值是什么:
int jump_dbg(int n, int depth) { for (int i = 0; i < depth; i++) { printf(" "); } printf("[%d] enter, n=%d\n", depth, n); if (n == 1) { printf("[%d] return 1\n", depth); return 1; } if (n == 2) { printf("[%d] return 2\n", depth); return 2; } int res = jump_dbg(n - 1, depth + 1) + jump_dbg(n - 2, depth + 1); printf("[%d] return %d\n", depth, res); return res; }用depth参数控制缩进,每一层的日志一眼就能对上。看到哪一层的返回值不对,问题就出在哪一层的递推关系或出口上。不要用眼睛追踪递归,要让计算机帮你把过程打印出来,这是区分新手和老手的一个重要习惯。
5.4 常见错误清单
日常写递归,集齐这六种错误就能召唤神龙了。我一个个说,你们一个个记。
错误1:递归出口缺失或永远到达不了。函数一直在递归调用,没有停下来的条件,最后栈溢出。典型代码:
int f(int n) { return f(n - 1); // 没有出口 }错误2:出口条件写错导致提前返回。比如n==0和n==1的出口返回值给搞混,结果整个递推全错。多检查边界值。
错误3:递推公式写错。比如汉诺塔写成了hanoi(n-1, from, to, tmp)却把参数顺序传错,或者青蛙跳台阶写成f(n-1) + f(n)——后者就永远递归不完,这种错误往往在参数多的时候特别隐蔽。
错误4:忽略了递归的返回值。有些人喜欢在递归调用外面包一层,却忘了把返回值返回给上层:
void hanoi(int n, char from, char tmp, char to) { if (n == 1) { printf("..."); return; } hanoi(n - 1, from, to, tmp); // 如果这个函数需要有返回值,你却没接收,信息就丢了 ... }这个在C语言里特别邪门,因为编译器往往只给warning不给error,程序能编译能运行,但结果就是不对。
错误5:重复计算导致超时。就是你写的朴素斐波那契,n一大就卡死。已经讲过了,上记忆化或者循环。
错误6:int溢出。递归算到后面数字很大,int不够用。之前那个青蛙跳台阶,算到46就超int了。习惯性用long long,必要时上unsigned long long或者大数库。
6. 从递归到工程思维的升华
递归学到最后,你会发现它不只是C语言的一个语法技巧,而是一种思维方式。它逼着你把大问题拆成小问题,小问题拆成更小的问题,直到每个问题都能直接求解。这个过程,就是工程里常说的分而治之。
6.1 递归思想在算法里的延伸
掌握了递归的基础,你去看后面这些算法会特别顺畅:
- 归并排序:把数组对半分,分别排序,再合并。分治思想的教科书级应用。
- 快速排序:选一个基准,把数组分成左右两半,递归排序。
- 树的遍历:二叉树的前序/中序/后序遍历,代码极其优雅,基本就是三行递归。
- 回溯算法:八皇后、数独、全排列,核心框架就是"递归+撤销选择"。
- 深度优先搜索:走迷宫、图的连通性判断,一个DFS函数递归调用自己,配上visited数组标记,就能走遍整张图。
很多人学算法觉得难,一个很重要的原因是递归思维没建立起来。因为算法世界里到处都是递归结构,你不会递归,看啥都像天书;你会了,很多东西就一通百通了。
6.2 C语言递归性能的几个优化细节
如果你在写性能敏感的程序,比如嵌入式、游戏服务器,递归有这几个锦上添花的点:
尾递归优化。如果递归调用是函数的最后一个操作,并且结果直接返回,这种叫"尾递归"。现代编译器一般能把它优化成循环,避免栈深度增长。C语言标准本身不强制要求尾调用优化,但GCC在优化级别-O2以上通常能做。想确认,可以反汇编看生成的代码里还有没有call指令。
// 尾递归版本的写法 int factorial_tail(int n, int acc) { if (n <= 1) { return acc; } return factorial_tail(n - 1, acc * n); }内联函数。在C99或C11里用inline关键字提示编译器把短小的函数体直接嵌入到调用处,省去函数调用开销。但递归函数通常不建议内联,因为无法完全展开,而且代码体积会膨胀。小递归函数可以试试,大递归别碰。
善用静态/动态规划。递归只是手段,不是目的。一个问题能递推就不要纯递归,能用迭代就用迭代。最经典的反例就是斐波那契,纯递归的时间复杂度是O(2^n),迭代是O(n),差了天和地。递归的价值在于"清晰",如果"清晰"和"高效"发生冲突,工程里优先保证清晰;但如果你发现复杂度已经不是常数级别的差距,那必须考虑优化方案来做折中。
6.3 从面试角度聊聊这两道题
面试官问汉诺塔、青蛙跳台阶,其实想考察的是三件事:
第一,你能不能建模。给你一个具体问题,你能不能抽象出递推关系。很多人卡在这一步,是因为脑子里没有"假设子问题已解决"这个概念。你得敢说:"假设我已经知道怎么移n-1个盘子了",然后在此基础上推导n个盘子。
第二,你知不知道边界条件。也就是递归出口。出口写不对,或者写不全,代码就跑不对。
第三,你了不了解性能边界。青蛙跳台阶问完之后,面试官多半会追问"你的递归有什么问题?怎么优化?"。能主动说出重复计算、记忆化、迭代三个优化方向,基本就是加分项。
我见过太多人去面试,青蛙跳台阶的递归代码写出来了,结果问一句"这个时间复杂度是多少"直接卡壳,再问"怎么优化"就抓瞎。所以这里再强调一遍:题目能AC只是及格,能分析复杂度、能优化、能变体扩展,才是面试官真正想看的。
6.4 我的一些个人体会
写递归写了这么多年,最大的感悟是:递归的核心不是代码,而是"信任"。你要相信,只要递推关系和出口都是对的,计算机一定能给你跑出正确结果。初学者最怕的是"不信任递归的自我修复能力",总想手动干预中间过程,结果越改越乱。
第二个感悟是:递归这东西,光看是真的看不懂的,必须动手推。我教过很多学生,最快的入门方式就是拿3个、4个硬币,照着汉诺塔的打印结果一行一行模拟,亲手把每步移动摆出来。摆过3遍,你就再也不会忘记汉诺塔为什么是那样写的了。
最后,如果你现在正在为C语言里某个递归题目抓狂,我给你一个可执行的建议:别盯着屏幕发呆,拿支笔,把调用树一层一层展开在纸上,把每一层的参数和返回值标出来。展开到第三层,你基本就能看清整个逻辑了。这个方法土,但百分之百管用。