函数递归这四个字,几乎是每个学C语言的人都要卡一下的地方。我带过不少刚入行的同学,指针那关咬着牙过了,链表也能手写出来了,一到递归又回到“看得懂但写不出”的状态。函数递归说白了就是函数自己调用自己,它在C语言里不是什么高级语法糖,而是一种把大问题拆成同构小问题的思维方式,配合函数调用栈这套底层机制跑起来。它能解决什么问题?树的遍历、链表的逆序、分治排序、回溯搜索、表达式解析,这些场景你用循环硬写也能写,但代码会变得又长又难维护。这篇内容适合三类人:刚学完函数和指针、准备啃递归的新手;写过递归但总在边界条件上翻车的半熟练工;以及想把递归的底层代价算清楚、在工程里做取舍的开发者。我会把递归的执行过程、栈帧开销、常见坑和排查手法一次性讲透,代码都能直接编译跑。
1. 先把“递归到底在干什么”这件事想明白
1.1 递归不是循环的替代品,是问题结构的映射
很多人第一次接触递归,是被阶乘那个例子带进来的,然后形成了一个错误印象:递归就是循环的另一种写法。这个印象一旦形成,后面写树、写回溯就会一直别扭。我个人的理解是,递归描述的是“问题自身的结构”,而循环描述的是“重复的动作”。
拿二叉树的中序遍历举例。这棵树的结构本身就是递归的:一棵树由根、左子树、右子树组成,而左子树又是一棵树。你用循环写中序遍历,必须自己维护一个显式的栈,手动模拟“先一路向左,弹栈,转向右子树”的过程。这个显式栈本质上就是把编译器帮你做的事情又手写了一遍,代码量翻倍,可读性下降,出错概率上升。而递归版本只有三行,因为代码结构直接对应了数据结构。
所以判断一个场景该不该用递归,我的标准很简单:这个问题本身是不是由同类型的子问题构成的。阶乘是,n的阶乘等于n乘以n-1的阶乘;汉诺塔是,移动n个盘子依赖移动n-1个盘子;全排列是,固定第一个元素之后剩下的还是一个全排列问题。反过来,遍历一个一维数组求和,虽然也能写成递归,但它的自然结构是线性的,用循环更直白,没必要为了递归而递归。
这里有个容易忽略的点:递归的“自相似”必须是规模严格缩小的自相似。如果子问题的规模和原问题一样大,递归就永远到不了出口,直接栈溢出。这一点我在第2章会专门展开,因为这是新手翻车率最高的地方。
1.2 一次递归调用,内存里到底发生了什么
要真正理解递归,绕不开函数调用栈。C语言的函数调用不是“跳过去执行完再跳回来”这么抽象,它在内存里是有实体支撑的。
程序运行时,每个线程有一块栈区(stack),函数每被调用一次,就在栈顶压入一个栈帧(stack frame,也叫活动记录)。一个典型的栈帧里装这些东西:函数的局部变量、传进来的参数(部分通过寄存器传递,溢出部分放栈上)、返回地址(函数执行完该回到哪一行继续)、以及调用者保存的寄存器现场。函数返回时,这个栈帧被弹出,栈指针回退,局部变量随之失效。
这就解释了一个经典现象:递归函数里的局部变量,每一层都是独立的。比如你写int f(int n) { int x = n; ... },递归十层就有十个互不干扰的x,它们各自躺在自己的栈帧里。这一点和循环完全不同,循环里只有一个x,每轮覆盖。很多新手误以为递归函数里的变量是共享的,然后在里面累加,结果算出乱七八糟的值,根子就在这里。
栈的大小是有限的。在常见的桌面系统上,主线程栈默认在几MB量级,具体数值和操作系统、编译器、链接选项都有关系。这意味着递归深度存在一个物理上限,超过就栈溢出。这个上限不是“大概几万层”这么模糊,后面第4章我会给出估算方法和实测手段。现在你只需要建立一个直觉:递归的每一层都要占地方,层数越多,占用越大,占满了程序就崩。
1.3 用生活化的类比把栈帧讲透
光讲术语不好记,我一般用两个类比。
第一个类比是叠盘子。你手上有一摞盘子,每次递归调用就是往上再放一个。你只能在最上面那个盘子上操作,想拿下面的必须先拿走上面的。函数返回就是从顶上取走一个盘子。栈溢出就是你往上放的速度太快,盘子摞太高,倒了。
第二个类比是俄罗斯套娃。打开最外层娃娃,里面是一个一模一样的、小一号的娃娃。你要找最里面那颗糖,就得一层层打开,打开到底(边界条件)拿到糖,再一层层合回去(返回值逐层返回)。这个类比特别适合解释返回值的传递路径:糖不是从最里面直接飞到最外面的,它是一层一层往外递的。
我建议你在纸上手画一次三层的递归调用过程,画出栈帧的压入和弹出顺序,标上每一层的参数值。画过一次之后,脑子里就有画面了,之后再看到递归代码,你会自动在脑海里跑一遍栈的变化。这个动作比看十篇文章都管用。
2. 递归的三根支柱:出口、递推、规模收敛
2.1 边界条件:写递归第一个该想的是“什么时候停”
我见过太多这样的代码:逻辑全对,就是忘了写出口,或者出口写得不够全,跑起来直接段错误。写递归的正确顺序不是先写“怎么拆”,而是先写“什么时候停”。
边界条件要满足两个要求:覆盖所有终止情形,且在终止时不产生新的递归调用。用阶乘举例,最朴素的写法是:
long long factorial(int n) { if (n <= 1) return 1; return (long long)n * factorial(n - 1); }这里的n <= 1比n == 1更稳。为什么?因为如果调用方传了个0或者负数进来,n == 1的出口永远命中不了,递归会一路往负数跑下去,直到栈溢出。写成n <= 1就把0和负数一并兜住了。当然更负责任的做法是在函数入口做参数校验,直接拒绝非法输入:
long long factorial(int n) { if (n < 0) return -1; /* 用约定值表示非法输入 */ if (n == 0 || n == 1) return 1; return (long long)n * factorial(n - 1); }再看不那么直观的例子:二叉树遍历的出口。空节点就是出口,if (root == NULL) return;,一行解决。但如果是“求二叉树的最大深度”,出口要返回具体数值而不是单纯返回:
int max_depth(struct TreeNode *root) { if (root == NULL) return 0; int l = max_depth(root->left); int r = max_depth(root->right); return (l > r ? l : r) + 1; }这里有个细节值得说:我把左右子树的结果先存到局部变量 l 和 r 里,再比较,而不是写成return max(max_depth(root->left), max_depth(root->right)) + 1。两种写法结果一样,但前者的调用顺序是确定的(先左后右),后者依赖max宏或函数的求值顺序,如果max是个宏,a > b ? a : b里 a 和 b 会被展开两次,导致子树被遍历两遍。这是个非常隐蔽的性能坑,我在实际项目里真的见过有人这么写,一棵几千节点的树跑出了指数级的耗时。
2.2 递推关系:从 n 到 n-1 的那一步要想清楚
出口解决了“停”的问题,递推关系解决“怎么走”的问题。所谓递推关系,就是用子问题的答案拼出原问题的答案,这一步是递归的灵魂。
很多人写递归卡住,不是不会写代码,是没想清楚“假设子问题已经解决了,我该怎么用这个结果”。这个思路有个名字叫“递归的信任跳跃”:你不需要在写第n层的时候去想第n-1层内部怎么实现的,你只需要相信它会正确返回,然后专注于“拿到返回值之后我干什么”。
链表反转是个典型例子。假设reverse(head->next)已经正确地把后面那段链表反转好了,返回了新头节点。现在你手上是什么局面?原来的第二个节点变成了尾节点,而head还指着它。你要做的就三件事:
struct Node { int val; struct Node *next; }; struct Node *reverse(struct Node *head) { if (head == NULL || head->next == NULL) return head; struct Node *new_head = reverse(head->next); head->next->next = head; /* 让后一个节点指回自己 */ head->next = NULL; /* 自己变成尾节点 */ return new_head; }head->next->next = head这一行是很多人第一次看不明白的地方。拆开看:head->next是原来的下一个节点,反转之后它应该指向head,所以执行head->next->next = head。注意顺序不能反,如果先写head->next = NULL,那head->next->next就变成对 NULL 解引用了,直接崩。这类“赋值顺序”的坑在递归里特别多,因为它涉及指针之间的相互引用。
再补充一个必须注意的点:这个递归版本在链表很长的时候会栈溢出。一个十万节点的链表,递归深度就是十万层,每层一个栈帧,几MB的栈根本扛不住。所以工程上反转链表我基本都用迭代,三根指针循环搞定。递归版本适合教学和理解,以及节点数可控的场景。
2.3 规模收敛:为什么你的递归会无限往下跑
三根支柱里最容易被忽略的是“规模收敛”。前两根你都写对了,出口有,递推也有,但程序还是崩,八成是规模没收敛。
什么叫规模收敛?就是每一次递归调用,问题的规模必须严格小于上一层。听起来像废话,但实际写代码时非常容易违反。举几个真实的反例。
第一个反例,二分查找里把mid算错:
int bsearch_bad(int *a, int lo, int hi, int target) { if (lo > hi) return -1; int mid = (lo + hi) / 2; if (a[mid] == target) return mid; if (a[mid] < target) return bsearch_bad(a, mid, hi, target); /* 错:mid 没加 1 */ return bsearch_bad(a, lo, mid, target); /* 错:mid 没减 1 */ }当lo == mid时,bsearch_bad(a, mid, hi, target)的区间和上一层完全一样,规模没缩小,死循环。正确写法是mid + 1和mid - 1。这个错误我敢说每个写过二分的人都犯过至少一次。
第二个反例,全排列里忘了恢复现场或者下标传错,导致同一层反复处理相同前缀。这类问题在回溯里极其常见,第3.5节会详细讲。
第三个反例更隐蔽:规模虽然在缩小,但缩得太慢。比如把线性递归写成f(n) = f(n-1) + f(n-1),规模是缩小了,但调用次数是 2 的 n 次方,n 稍微大一点就跑不动了。斐波那契的朴素递归就是这个毛病,第3.2节会拿它开刀。
我的经验是,写完一个递归函数,先在纸上列三步验证:终止条件能不能被所有输入走到?每一层的参数是不是严格远离终止条件?最坏情况下深度是多少?这三步花不了一分钟,能省掉后面半小时的调试。
3. 从零手写:代码、栈帧、执行轨迹全对上
3.1 阶乘:最小可运行的样板,也是溢出的第一课
阶乘是所有教材的第一个例子,但它其实藏着两个坑:整数溢出和参数范围。
先看溢出。long long在这个时代一般是64位,最大值是 9223372036854775807。20 的阶乘是 2432902008176640000,还在范围内;21 的阶乘是 51090942171709440000,已经溢出了。溢出的后果是结果变成一个莫名其妙的数,而且不会报错。所以如果你真的要在程序里算阶乘,要么用高精度(数组模拟大数乘法),要么在入口限制 n 的范围:
long long factorial(int n) { if (n < 0 || n > 20) { return -1; /* 明确拒绝,避免静默溢出 */ } if (n <= 1) return 1; return (long long)n * factorial(n - 1); }再看执行轨迹。调用factorial(4),栈的变化是这样的:先压f(4),它要算4 * f(3),于是压f(3);f(3)要算3 * f(2),压f(2);f(2)要算2 * f(1),压f(1);f(1)命中出口返回 1,栈帧弹出。
然后返回值开始往外走:f(2)拿到 1,算2 * 1 = 2,返回 2;f(3)拿到 2,算3 * 2 = 6,返回 6;f(4)拿到 6,算4 * 6 = 24,返回 24。
这个“先一路压到底,再一路弹回来”的过程,是理解所有递归的基础。你可以用一个小技巧验证:在函数里加一句printf("enter n=%d\n", n);放在递归调用之前,另一句printf("leave n=%d\n", n);放在递归调用之后,跑一遍就能直观看到“进入顺序”和“离开顺序”是相反的。这个技巧我在教新人的时候必用,比画图还直观。
3.2 斐波那契:一个必须亲手跑一遍的反面教材
斐波那契的朴素递归长这样:
long long fib(int n) { if (n <= 1) return n; return fib(n - 1) + fib(n - 2); }代码只有三行,漂亮得不像话。但它是递归教学里最好的反面教材,因为它把“重复计算”这四个字演绎到了极致。
算一下调用次数。设 T(n) 是算 fib(n) 需要的调用次数,那么 T(n) = T(n-1) + T(n-2) + 1,这个递推和斐波那契数列本身同阶,所以 T(n) 约等于 O(1.618 的 n 次方)。具体点说,fib(30) 的调用次数在百万量级,fib(40) 大约 1.6 亿次,fib(50) 直接是百亿级,你在普通机器上等一杯咖啡都跑不完。
为什么会这样?因为fib(n-1)内部已经算过fib(n-2)了,外层又算了一遍。而且这种重复是指数级放大的,越往下重复越多。你在纸上画出 fib(5) 的调用树就能看到,fib(3) 被算了两次,fib(2) 被算了三次,fib(1) 被算了五次。
改造方案有两个,我一般先讲记忆化,因为它保留了递归的结构,只加了一层缓存:
#define MAXN 100 static long long memo[MAXN]; long long fib_memo(int n) { if (n <= 1) return n; if (memo[n] != 0) return memo[n]; memo[n] = fib_memo(n - 1) + fib_memo(n - 2); return memo[n]; } void fib_init(void) { for (int i = 0; i < MAXN; i++) memo[i] = 0; }这样每个 n 只会被真正计算一次,时间复杂度降到 O(n),代价是 O(n) 的空间。注意memo是静态数组,所以每次重新开始一组测试之前必须清零,否则上一组的结果会污染这一组。这个坑在刷题的时候坑过太多人。
另一个方案是自底向上的迭代:
long long fib_iter(int n) { if (n <= 1) return n; long long a = 0, b = 1, c; for (int i = 2; i <= n; i++) { c = a + b; a = b; b = c; } return b; }O(n) 时间、O(1) 空间,还不用担心递归深度。所以我一直的观点是:斐波那契的递归版本,写一遍、跑一遍、感受一下指数爆炸,然后就把它放进柜子里,生产代码用迭代。学它的价值在于理解重叠子问题和记忆化,不在于拿它当工具。
3.3 汉诺塔:递归思维最好的试金石
汉诺塔的规则不用多讲:三根柱子,n 个盘子从 A 移到 C,每次只能移一个,大盘不能压小盘。这个问题的递归解法短得惊人:
void hanoi(int n, char from, char aux, char to) { if (n == 0) return; hanoi(n - 1, from, to, aux); /* 把上面 n-1 个挪到辅助柱 */ printf("%c -> %c\n", from, to); /* 把最大的那个挪到目标柱 */ hanoi(n - 1, aux, from, to); /* 把 n-1 个从辅助柱挪到目标柱 */ }为什么这么写是对的?我习惯这样讲:假设你现在要把 n 个盘子从 from 挪到 to。最大的那个盘子一定得从 from 直接挪到 to,而在此之前,压在它上面的 n-1 个盘子必须先全部挪到 aux 上。这 n-1 个盘子怎么挪?那是一个规模小一号的汉诺塔问题,交给递归。它们挪走之后,你把最大的那个挪过去,最后再把 n-1 个盘子从 aux 挪到 to,又是一个规模小一号的汉诺塔问题。
关键在于参数顺序的对应。第一次递归调用hanoi(n-1, from, to, aux),意思是“把 n-1 个从 from 挪到 aux,用 to 当辅助”。第二次hanoi(n-1, aux, from, to),意思是“把 n-1 个从 aux 挪到 to,用 from 当辅助”。这两行的参数顺序是很多人写错的地方,写完之后拿 n=3 手推一遍,立刻就能发现错在哪。
复杂度方面,移动次数是 2 的 n 次方减 1。n=10 是 1023 次,n=20 是 1048575 次,n=30 就是十亿次级别了。传说中 64 个盘子的版本,移动次数是 18446744073709551615,按每秒一次算要几百亿年。这个数字很适合用来建立“指数增长”的体感。同时也要注意,n 大了之后打印本身就会成为瓶颈,所以做性能测试的时候最好把printf换成计数器。
3.4 链表与二叉树:工程里真正用得到的递归
前面几个例子偏教学,这一节讲两个真在项目里会用到的。
二叉树遍历是递归最自然的应用场景,没有之一。前中后序的差别仅仅在于“处理根节点”这一行放在哪:
struct TreeNode { int val; struct TreeNode *left; struct TreeNode *right; }; void preorder(struct TreeNode *root) { if (!root) return; printf("%d ", root->val); preorder(root->left); preorder(root->right); } void inorder(struct TreeNode *root) { if (!root) return; inorder(root->left); printf("%d ", root->val); inorder(root->right); } void postorder(struct TreeNode *root) { if (!root) return; postorder(root->left); postorder(root->right); printf("%d ", root->val); }后序遍历有个特殊用途:释放整棵树。因为父节点必须在子节点之后释放,所以只能用后序:
void free_tree(struct TreeNode *root) { if (!root) return; free_tree(root->left); free_tree(root->right); free(root); }这里要提醒一个实际项目里的坑:如果树是退化成链表的形态(比如有序数据依次插入二叉搜索树),递归深度就等于节点数。一棵十万节点的退化树,递归遍历百分之百栈溢出。这种情况下要么改成显式栈的迭代版本,要么换成平衡树结构。这个坑我在处理用户上传的有序数据时踩过,数据量一大就崩,查了半天才意识到是树退化了。
另一个工程实例是目录遍历。虽然目录操作不是纯C标准库的内容,但思路值得说:一个目录里可能有子目录,子目录里又可能有子目录,这是天然的递归结构。写的时候除了边界条件(目录为空、没有权限),还要额外加一个深度限制,防止符号链接成环导致无限递归:
/* 伪代码,展示结构,实际实现依赖具体的目录操作接口 */ void walk_dir(const char *path, int depth) { if (depth > MAX_DEPTH) { printf("达到深度上限,跳过: %s\n", path); return; } /* 列出当前目录下的条目 */ for (每个条目 e) { if (e 是普通文件) { 处理(e); } else if (e 是目录 && 不是 "." && 不是 "..") { walk_dir(拼接后的路径, depth + 1); } } }depth这个参数是防御性的,很多教程里不写。但只要你的程序要处理真实文件系统,这个限制就必须有,因为文件系统的结构不由你控制。
3.5 全排列与回溯:递归里的“恢复现场”
回溯是递归的一个变种,它的特征是:做出一个选择,递归,然后撤销这个选择,再试下一个。撤销这一步叫“恢复现场”,是回溯最容易漏的地方。
看全排列的实现:
static void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; } static void permute(int *a, int l, int r) { if (l == r) { for (int i = 0; i <= r; i++) printf("%d ", a[i]); printf("\n"); return; } for (int i = l; i <= r; i++) { swap(&a[l], &a[i]); /* 选择:把第 i 个换到当前位置 */ permute(a, l + 1, r); /* 递归:处理剩下的位置 */ swap(&a[l], &a[i]); /* 撤销:换回来,保证下一轮循环的初始状态正确 */ } }第二行swap就是恢复现场。如果漏了它,数组的状态会被前面的分支改乱,后面的分支基于错误的状态继续,结果就是重复、遗漏、甚至死循环。我建议你在写任何回溯代码时,把“做选择”和“撤销选择”这两行写成对称的结构,中间夹着递归调用,视觉上形成夹心饼干。这样一眼就能看出有没有配对。
复杂度方面,n 个元素的全排列有 n! 种,n=10 是 362 万,n=11 是 3991 万。这个增长速度意味着全排列只能用于很小的规模,实际工程里如果想做排列相关的优化,得考虑剪枝或者换算法。
再补充一个回溯的经典应用:N 皇后。核心结构一模一样,选一个位置放皇后,递归下一行,发现冲突就撤回。区别在于要在递归前判断合法性,这个判断就是所谓的“剪枝”,能把搜索树砍掉绝大部分。
4. 递归的底层代价:栈帧、深度与优化边界
4.1 一个栈帧大概占多少字节
这个问题经常有人问,但答案不是固定值,它取决于编译器、优化级别、目标平台和函数本身的局部变量数量。
粗略的估算方法:一个只传几个整型参数、没有大局部数组的简单递归函数,在常见的64位平台上,栈帧大小通常在几十字节到一百多字节之间。如果函数里有局部数组,那就要把数组大小算进去。
实测方法也不难,用取地址差估算:
#include <stdio.h> void probe(int n) { char pad[1]; static char *base = NULL; if (base == NULL) base = pad; long used = base - pad; /* 指针相减,得到大致字节数 */ if (n % 1000 == 0) { printf("深度 %d, 累计约 %ld 字节\n", n, used); } probe(n + 1); }这个手法只是估算,因为编译器优化、对齐、寄存器分配都会影响结果,而且严格来说跨对象指针相减属于未定义行为。但用来建立数量级直觉足够了。
有了栈帧大小,就能算深度上限。假设栈上限是 8MB,每个栈帧平均 64 字节,那么理论上能撑 8 × 1024 × 1024 ÷ 64 ≈ 13 万层。如果栈帧里有 1KB 的局部数组,那就只剩 8000 层左右。这个计算很粗糙,但足以让你在写递归前先心里有数:如果输入规模可能到十万级以上,递归方案要先打个问号。
4.2 栈溢出长什么样,怎么定位
栈溢出的典型表现是程序直接崩溃,报段错误(在类 Unix 系统上就是 SIGSEGV),而且崩溃位置往往看起来很莫名其妙,可能在一个跟递归毫无关系的函数里。
为什么会这样?因为栈溢出本质是栈指针越过了合法边界,写到了不该写的地方。有时候它先破坏了别的数据,然后在后面某处才炸,所以崩溃点不等于问题点。
定位手法我常用三个。
第一,看崩溃时的调用栈。用调试器跑,崩溃发生后打印回溯信息,如果看到同一个函数名重复出现几百上千次,基本就实锤了。
第二,在递归函数里加深度计数。传一个depth参数,超过某个阈值就打印并返回。这个手法简单粗暴但极其有效,能快速区分“是递归太深”还是“是别的 bug”。
第三,缩小输入规模。把数据量从十万降到一千,如果程序正常了,那就是深度问题;如果还崩,那就是逻辑问题。这一步能帮你快速二分定位。
预防手段也很直接:要么给递归加显式的深度上限,要么改成迭代。加深度上限的写法:
int process(struct Node *p, int depth) { if (p == NULL) return 0; if (depth > MAX_SAFE_DEPTH) { /* 记录日志,走备用路径,或者直接放弃这个分支 */ return -1; } int a = process(p->left, depth + 1); int b = process(p->right, depth + 1); return a + b + 1; }有人会说“我的数据不可能那么深”,但只要程序处理的是外部输入,就不能假设数据形态。这是我吃过亏之后形成的习惯:凡是深度由输入决定的递归,一律加防护。
4.3 尾递归与编译器的优化边界
尾递归是指递归调用是函数的最后一个动作,返回值直接被返回,不再参与任何运算。比如:
long long fact_tail(int n, long long acc) { if (n <= 1) return acc; return fact_tail(n - 1, n * acc); }因为调用返回之后没别的事要干,理论上编译器可以把这次调用优化成一次跳转,复用当前栈帧,从而把空间复杂度降到 O(1)。这叫尾调用优化。
但这里有个必须说清楚的事实:C 标准并不要求编译器做尾调用优化。某些编译器在较高优化级别下确实会做,某些情况不做,换个编译器或者换个优化选项行为就可能变。所以你不能把“我写的是尾递归,所以不会栈溢出”当成设计前提。
我的实际做法是:如果一个问题天然适合尾递归,我会写成尾递归形式,因为它逻辑清晰;但同时在文档或者注释里说明“不要依赖编译器优化”,并且在深度可能很大的场景里直接改用循环。反过来,如果一个问题不天然适合尾递归(比如二叉树遍历,你怎么都做不到尾递归),那就不要硬凑,该用迭代就用迭代。
顺便说一个容易混的点:尾递归和“递归转迭代”不是一回事。尾递归是一种代码形态,能不能转成循环要看问题的结构。像阶乘这种线性递归,转循环是一行的事;像树的遍历,转循环就要自己维护栈,代码复杂度完全不同。
5. 踩坑实录:递归问题的排查清单
5.1 常见问题速查表
下面这张表是我这些年攒下来的,基本上新手遇到的递归问题都在里面。
| 现象 | 最可能的原因 | 排查动作 | 修复方式 |
|---|---|---|---|
| 程序直接崩溃,报段错误 | 递归太深,栈溢出 | 加深度计数打印;看调试器回溯里是否同一函数重复 | 加深度上限;改迭代;减小栈上局部变量 |
| 程序卡死不动,CPU 占满 | 规模没收敛,无限递归 | 打印每层参数值,看是否出现循环 | 检查递推参数是否严格靠近出口 |
| 结果不对,某个值算错了 | 边界条件写得不够全 | 用最小输入(n=0、n=1、空链表)测试 | 补全边界条件,尤其是空输入和单元素输入 |
| 结果里出现重复项 | 回溯没恢复现场 | 检查“做选择”和“撤销选择”是否成对 | 补上撤销操作 |
| 小数据正常,大数据崩 | 深度随规模线性增长 | 测 n=1000、n=10000、n=100000 的临界点 | 改迭代或加防护 |
| 同一个子问题被算了很多次 | 重叠子问题没有缓存 | 在函数入口打印参数,看重复率 | 加记忆化数组或哈希表 |
| 结果每次运行都不一样 | 用了未初始化或跨层共享的静态变量 | 检查递归函数里的 static 变量 | 把状态改成参数传递,或用局部变量 |
| 性能比预期慢一个数量级 | 宏展开导致重复求值 | 检查递归调用是否被写在宏参数里 | 先存局部变量再参与运算 |
表格里最后一条值得单独说。我见过这样的代码:return MAX(get_depth(left), get_depth(right)) + 1;,其中 MAX 是个宏#define MAX(a,b) ((a)>(b)?(a):(b))。宏展开之后,get_depth(left)在比较里出现一次、在取值里又出现一次,每次调用都变成两次,而且是指数级的倍增。这种 bug 极难通过读代码发现,只能靠性能分析和打印调用来定位。所以我的习惯是:任何有副作用的表达式,绝不直接塞进宏参数。
5.2 调试递归的四个实用手法
递归的调试比循环难受,因为你看到的是一层套一层,用单步执行会疯掉。我总结的几个手法,按使用频率排序。
手法一:带缩进的日志。给递归函数加一个depth参数,打印的时候按深度缩进,输出的就是一棵可视化的调用树。
void trace(int n, int depth) { for (int i = 0; i < depth; i++) printf(" "); printf("enter n=%d\n", n); if (n <= 1) { for (int i = 0; i < depth; i++) printf(" "); printf("leave n=%d (base)\n", n); return; } trace(n - 1, depth + 1); for (int i = 0; i < depth; i++) printf(" "); printf("leave n=%d\n", n); }跑一次trace(3, 0),你就能看到缩进逐层加深又逐层回退,进入和离开的对称关系一目了然。这个方法不依赖任何工具,纯printf就能搞定,在嵌入式或者远程环境里特别管用。
手法二:条件断点。用调试器的时候,别在递归函数第一行直接下断点,那样会停几百次。把断点条件设成n == 3或者depth == 5,只在关心的那一层停下来,然后用bt看完整的调用链。这个技巧能把调试效率提高十倍以上。
手法三:手工缩小规模。把 n 从 100 改成 3,在纸上把每一层的参数和返回值列出来。这一步看起来笨,但对理解“递推关系到底对不对”极其有效。我个人的经验是,凡是递归写不出来的情况,手推三次小规模样例之后一定能写出来。
手法四:先写迭代版再翻译。有些问题你从递归角度想不通,那就先写迭代版,把状态转移搞清楚,再翻译成递归。反过来也成立:递归写出来了但结果不对,把它翻译成迭代,用循环把每步状态打印出来,往往一眼就能看出问题。
5.3 什么情况下我劝你果断放弃递归
递归是好东西,但不是所有场景都该用。以下几种情况,我会毫不犹豫地换成迭代。
深度由输入决定且可能很大。链表反转、目录遍历、深层嵌套的 JSON 解析,都属于这一类。这些场景用递归写出来的代码确实漂亮,但上线之后遇到极端数据就崩,维护成本远高于省下来的那几行代码。这种情况下用显式栈或者迭代,虽然代码长一点,但行为可控。
对性能和内存有严格要求的场景。递归的函数调用有固定开销:压栈、跳转、返回、弹栈。虽然单次开销不大,但在高频路径上会累积成可观的数字。一个每帧都要调用的渲染函数里如果嵌了递归,性能损耗可能肉眼可见。这种情况循环是更好的选择。
团队里有不熟悉递归的成员。这不是技术问题,是协作问题。一段只有作者看得懂的递归代码,在别人接手维护时就是负债。我现在的做法是:如果递归版本和迭代版本的可读性差距不大,优先选迭代;如果递归版本能显著简化代码(比如树的遍历),那就用递归,但一定要写清楚注释,说明每层的语义和边界条件。
语言环境本身不支持深度递归。C 语言的栈是有限的,也没有内建的尾调用保证。这一点和某些函数式语言的运行时不一样,在 C 里写递归,你必须自己为深度负责。
最后再分享一个小技巧,也是我在实际项目中体会最深的一点:写递归之前,先用一句话把“这个函数做什么”写下来,比如“返回以 root 为根的树的最大深度”、“把 head 开始的链表反转后返回新头节点”。这句话必须包含输入是什么、输出是什么、和子问题的关系怎么描述。如果这句话写不出来,说明你对问题还没想清楚,这时候敲代码只会得到一堆调试时间。我见过太多人一上来就写递归体,写了五十行发现不对,全删重来。花三分钟把那句话想清楚,比写两小时代码有用得多。
还有一个习惯值得养成:写完递归之后,拿三个输入验证——最小规模(空或者1个元素)、中等规模(3到5个)、大规模(千级)。前两个验证逻辑,第三个验证深度。这三个测试跑通,代码基本就能交付了。