第1章:递归的陷阱——无限递归与段错误
递归这东西,用好了是神器,用不好就是定时炸弹。
我记得刚入行那会儿,写了一个斐波那契数列的递归函数,自我感觉良好。结果一运行,程序直接卡死,然后弹了个"段错误"。当时我一脸懵——代码逻辑明明没错啊?后来才明白,我掉进了递归最经典的陷阱:无限递归。
今天咱们就来聊聊这个坑,以及怎么避开它。
1.1 无限递归是怎么发生的?
说白了,递归就是函数调用自己。但如果你不给它一个"停下来"的信号,它就会一直调下去,直到把栈空间撑爆。
看个最简单的例子:
void bad_recursion() {
printf("我又调自己了\n");
bad_recursion(); // 没有终止条件!
}
这个函数会一直打印,直到栈溢出。为什么会这样?
每次函数调用,系统都会在栈上分配一块内存,用来存局部变量、返回地址什么的。递归调用一次,就多占一块。无限递归就等于无限占内存——栈空间就那么大,撑爆了就是段错误。
1.2 我踩过的一个坑
我曾经写过一个二叉树遍历的函数,大概长这样:
void traverse(Node* root) {
if (root != NULL) {
printf("%d ", root->data);
traverse(root->left);
traverse(root->right);
}
}
看着没问题对吧?但问题出在调用方——我传进去的root指针是野指针!结果函数判断root != NULL为真,然后访问root->data,直接段错误。
嗯,这里要注意:递归的终止条件不仅要检查"是否为空",还要确保传入的参数是合法的。
1.3 如何避免无限递归?
其实就三条铁律:
- 必须有明确的终止条件——递归到什么时候停?
- 每次递归都要向终止条件靠近——参数要变化,不能原地踏步
- 检查输入合法性——别让非法数据进入递归
拿计算阶乘来说:
int factorial(int n) {
// 终止条件
if (n <= 1) {
return 1;
}
// 向终止条件靠近
return n * factorial(n - 1);
}
这个函数,n每次减1,最终会到1。完美。
但如果你写成这样:
int factorial(int n) {
if (n <= 1) {
return 1;
}
return n * factorial(n); // 没减1!死循环
}
那就等着段错误吧。
1.4 调试递归的实用技巧
递归出问题了怎么查?我分享几个实战经验:
| 技巧 | 说明 | 适用场景 |
|---|---|---|
| 加打印日志 | 在函数入口打印参数值 | 快速定位递归深度 |
| 限制递归深度 | 加一个全局计数器,超过阈值就报错 | 防止无限递归拖垮系统 |
| 用GDB调试 | 设置断点,观察栈帧变化 | 复杂递归逻辑 |
| 小规模测试 | 先用小数据跑,验证逻辑 | 所有递归函数 |
我个人最常用的是加打印日志。比如:
int factorial(int n) {
printf("factorial(%d) 被调用\n", n); // 调试用
if (n <= 1) {
return 1;
}
return n * factorial(n - 1);
}
跑一下就能看到递归的调用顺序。如果发现某个参数值一直不变,那肯定有问题。
1.5 递归深度与栈溢出
就算你写对了终止条件,递归深度太大也会出问题。
Linux默认的栈空间一般是8MB。每次函数调用大概占几十到几百字节。算一下:8MB / 100字节 ≈ 80000次调用。看起来挺多?但如果你递归处理一个10万长度的链表,直接就爆了。
我遇到过这种情况:一个同事用递归遍历超长链表,测试数据小的时候没问题,一上生产数据就崩。排查了半天,最后发现是递归深度太大导致栈溢出。
- 对于深度不确定的递归,考虑改用循环(迭代)实现
- 或者用尾递归优化(但C编译器不一定支持)
- 实在要用递归,可以手动增大栈空间(不推荐,治标不治本)
1.6 知识体系总览
下面这张图帮你理清本章的核心逻辑:
1.7 写在最后
递归本身并不可怕。可怕的是你以为写对了,实际上没有。
我现在的习惯是:每写一个递归函数,先问自己三个问题——
- 终止条件写了吗?
- 参数在变化吗?
- 输入数据合法吗?
这三个问题过一遍,90%的递归陷阱都能避开。
至于剩下的10%...嗯,那就靠调试技巧了。加日志、限制深度、小规模测试,三板斧下去,基本都能搞定。
记住一句话:递归是工具,不是目的。该用的时候用,不该用的时候别硬上。有时候一个简单的循环,比递归靠谱得多。
公众号:蓝海资料掘金营,微信deep3321