资讯详情

递归算法入门:从函数调用栈到经典实例的编程思维

📅 2026/10/11 4:38:48 | 华诺云谱 👁 阅读
递归算法入门:从函数调用栈到经典实例的编程思维
2. 一句理解递归函数自己调用自己先别急着背定义。递归的本质就一行话一个函数在执行过程中直接或间接地调用了它自己。听起来像死循环但加上“终止条件”之后它就成了一种极其优雅的解法。我最早学递归的时候总想着“深入底层去看它怎么一步步跑”结果把自己绕晕了。后来某天换了个角度——不跟踪每一步只信任两条规则一下就通了。那两条规则是递归必须有一个明确的终止条件否则就会无限循环直到程序栈溢出崩溃。每次递归调用都必须让问题规模变小朝着终止条件逼近。这两条缺一不可。没有终止条件叫死递归有终止条件但规模不变大或不变小那也是死递归。理解了这两条递归的理论地基就算打好了。为了不让这话太干我用一个生活化的例子说明。假设你要爬一栋100层的楼但电梯坏了。你给自己定了个规矩走到第1层就停如果还没到第1层就先走到“离第1层差1层”的位置然后重复同样的事情。这个“重复同样的事情”就是递归。每走一层问题规模变小一层直到抵达第1层——这就是终止条件。对应到代码里void climb(int n) { if (n 1) { printf(到达第1层停\n); return; } printf(当前在第%d层继续往第1层走\n, n); climb(n - 1); // 规模减1重复同样的事 }这段代码足够能说明问题函数climb在函数体内调用了自己每次传入的参数从n变成n-1一步步逼近n 1的底线。这就是递归最原始的形态。3. 为什么需要递归它到底解决了什么问题递归不是炫技。它解决的核心问题是让代码用“问题的定义”直接表达“问题的解法”。有些问题天生就是“自己包含自己”的比如树形结构、分形图形、目录遍历、排列组合。用循环硬写不是不行但思维负担会大很多。举个例子求阶乘。数学上阶乘的定义是这样的n! n * (n-1)!这个定义本身就是递归的要求5!我先求4!要求4!先求3!……直到1! 1。计算机里的递归函数就是在原样照搬这个数学定义。再看斐波那契数列F(1) 1 F(2) 1 F(n) F(n-1) F(n-2) (n 3)这种“后面的依赖前面的”的数列天然适合递归描述。如果硬用循环你也能写但要自己管理两个临时变量不断滚动更新代码可读性会差一截。还有更经典的二叉树的遍历。你要遍历一棵树的所有节点会发现每个节点都是一棵“小树”的根。遍历根节点、遍历左子树、遍历右子树而“遍历左子树”这件事和“遍历整棵树”是同一个操作。这种结构用递归写三五行搞定用循环写得自己维护栈代码量和出错概率都上来了。所以递归的价值在于它帮你把“复杂问题”降维成“比原问题小一号的同样问题”。你只需要关心两件事当前这一层做什么以及如何把规模缩小。剩下的交给函数自己。这个思维方式和数学归纳法完全一致。递归的“终止条件”对应数学归纳法的“基础情况”递归的“递推关系”对应“归纳步骤”。很多科班教材把递归放在数学归纳法之后讲就是这个道理。4. 递归函数怎么写一个标准的实现框架我总结了一个写递归函数的标准套路照着这个框架套能把写递归的试错成本压到最低。一个递归函数结构上必然包含三块函数签名确定入参和返回值。入参里必须有一个变量代表“规模”比如n、depth、cur、node这个变量在每次调用时都要变化。基准情况Base Case写在函数体最前面。一旦满足立即return不产生新的递归调用。递归情况Recursive Case处理当前逻辑并带着“规模变小后的参数”调用自己或者调用另一个函数间接调用自己。框架长这样返回类型 函数名(参数至少一个规模参数) { if (基准情况) { // 直接返回结果不再递归 return 基准值; } // 递归情况先处理一部分逻辑 // 然后带着缩小后的规模调用自身 // 最后组合结果并返回 }用这个框架套一个计算数组中最大值的递归函数。假设数组是int arr[]长度为n我们求从arr[0]到arr[n-1]的最大值。思路是这样的基准情况如果n 1最大值就是arr[0]。递归情况如果n 1最大值就是max(arr[0], 递归求arr[1]到arr[n-1]的最大值)。于是代码可以这么写int arrMax(int arr[], int n) { if (n 1) { return arr[0]; } int subMax arrMax(arr 1, n - 1); // 后一半的最大值 return arr[0] subMax ? arr[0] : subMax; }注意arr 1数组名作为参数时会退化为指针arr 1就相当于把数组的起始位置向后挪了一个元素这样“数组长度n-1”和“新的起始位置”就共同刻画了一个规模更小的子问题。这个写法在C语言里是常态别觉得奇怪。运行过程大概是这样的arrMax([3,7,2,9], 4) - arrMax([7,2,9], 3) - arrMax([2,9], 2) - arrMax([9], 1) 9 - max(2, 9) 9 - max(7, 9) 9 - max(3, 9) 9虽然我建议你不要每步都去追踪但偶尔追踪一遍有助于建立信任感。追踪完你会发现递归的每层都在做一件同样的小事拿到子问题的结果然后和自己当前的值比一下。5. 经典实例一用递归实现字符串逆序说完了框架来点有手感的例子。字符串逆序是很多刚学递归的同学遇到的第一个“非数学”递归问题。要求写一个函数把字符串hello逆序成olleh限制条件是你不能用循环只能用递归。先想清楚递归关系如果一个字符串长度为0或1它本身就是逆序的不需要处理。如果长度大于1逆序的结果 最后一个字符 剩余部分去掉最后一个字符的逆序。举例hello逆序 o 逆序(hell)而逆序(hell) l 逆序(hel)……就这样一路剥下去。按照这个思路可以写出如下函数#include stdio.h #include string.h // 把str[start]到str[end]这段字符逆序原地修改 void reverse(char str[], int start, int end) { if (start end) { return; // 基准情况只剩一个字符或空区间 } // 交换首尾字符 char tmp str[start]; str[start] str[end]; str[end] tmp; // 递归处理中间部分 reverse(str, start 1, end - 1); } int main() { char s[] hello; reverse(s, 0, (int)strlen(s) - 1); printf(%s\n, s); // 输出 olleh return 0; }这里我用了start和end两个指针下标来标记要处理的区间。每次交换一头一尾然后区间向内收缩一格。当start end时说明区间已经被处理完了递归停止。这个例子的好处在于它清晰展示了“递归处理区间”的模式。很多递归算法——比如二分查找、归并排序、快速排序——本质上都是这种“给定一个区间在区间内做点什么然后递归处理子区间”的结构。我第一次自己写这个函数的时候差点把参数写成reverse(str, start 1, end)结果发现这样每次换了尾部又换回原来的尾部永远处理不完。写完条件之后一定要检查新的参数是否比旧的参数更接近终止条件。start1和end-1让区间长度不断缩小这才能保证终止。6. 经典实例二递归实现汉诺塔汉诺塔我不多解释了三根柱子、若干个大小不同的圆盘把盘从A柱移到C柱要求每次只能移动一个圆盘。大圆盘不能压在小圆盘上面。可以借助中间柱B。这个故事百听不厌但真正能教会你递归思维的是它的解法逻辑。思路拆解要把n个圆盘从A移到C可以分三步先把上面n-1个圆盘从A移到B借助C。把最大的第n个圆盘从A移到C。再把B上的n-1个圆盘从B移到C借助A。你看第一步和第三步本质上都是“移动n-1个圆盘”这个同样的问题只是起始柱、目标柱、辅助柱不同。所以直接递归调用自己换一下参数即可。基准情况是什么n 1时直接移动这一个盘子不用递归。于是C语言代码#include stdio.h void hanoi(int n, char from, char to, char aux) { if (n 1) { printf(将盘子1从%c移到%c\n, from, to); return; } // 第一步将上面n-1个从from移到aux借助to hanoi(n - 1, from, aux, to); // 第二步将第n个从from移到to printf(将盘子%d从%c移到%c\n, n, from, to); // 第三步将n-1个从aux移到to借助from hanoi(n - 1, aux, to, from); } int main() { hanoi(3, A, C, B); return 0; }这段代码一共只有十来行却能把汉诺塔的每一步移动轨迹打印出来。我第一次在终端里跑出来的时候确实有被震撼到——递归把“听起来无比复杂的规则”压缩成了这么简洁的表达。值得注意的一点是hanoi函数本身没有任何显式的“状态记录”但通过参数from、to、aux的轮换它完美记录了每一层“当前任务”的完整信息。这就是递归的厉害之处——调用栈天然帮你保存了每一层的工作状态你不需要自己开数组或栈来管理。7. 递归的底层原理函数调用栈光会用还不行得知道递归为什么能“自己记住自己”。C程序的函数调用依赖一块内存区域叫调用栈Call Stack。每次调用一个函数系统会把这次调用的信息压栈包括函数的参数函数内部的局部变量返回地址等函数执行完回到调用处的下一条指令当函数A调用函数B时B的栈帧压在A的栈帧之上。B执行完弹栈恢复A的现场A继续执行。递归调用也是一样。只不过被调用的函数和当前函数是同一个函数。每次递归调用都会生成一个新的栈帧里面保存着这一层的局部变量和参数。所以递归最深处的任何一个局部变量都不会被上层覆盖——这就是递归能“记住每一层状态”的底层原因。举个例子。上面那段hanoi(3, A, C, B)它真正执行时的调用层次是这样的hanoi(3, A, C, B) - 打印“将盘子1从A移到C”第一层里第一步递归内部的打印 - hanoi(2, A, B, C) - hanoi(1, A, C, B) // 打印 1: A-C - 打印 2: A-B - hanoi(1, C, B, A) // 打印 1: C-B - 打印 3: A-C - hanoi(2, B, C, A) - hanoi(1, B, A, C) // 打印 1: B-A - 打印 2: B-C - hanoi(1, A, C, B) // 打印 1: A-C虽然只有8行核心代码但展开后的调用轨迹有15次移动。这就是递归的“代码少、执行多”特点。每一次调用都在栈上占一块空间所以递归深度过大时栈就会爆。8. 递归的代价与风险为什么有时候不要用递归递归虽好但不是银弹。它的代价大得实在具体体现在三方面第一栈空间的消耗。每一次递归调用都创建一个栈帧每个栈帧都有一定大小通常几十字节到几百字节。如果你递归100万层栈会直接溢出程序崩溃。默认的栈大小在常见平台上是8MB左右这决定了你的递归深度通常只能到几万到几十万级别视栈帧大小而定。第二重复计算。像斐波那契这种递归写出来很漂亮但执行效率低到吓人。因为fib(n)会调用fib(n-1)和fib(n-2)而fib(n-1)又会调用fib(n-2)和fib(n-3)……大量重叠子问题被反复计算。实测fib(50)如果用朴素递归运行时间长得无法忍受而用循环瞬间出结果。第三性能开销。每次函数调用都有压栈、传参、跳转、弹栈等操作比循环里的简单迭代要慢得多。在追求性能的底层代码里递归不是首选。所以业界有个不精确但足够实用的判断标准递归深度很小且问题天然具备递归结构 → 放心用递归。 递归深度可能很大或存在大量重复计算 → 用迭代或把递归改成循环。斐波那契用循环写是什么样的大概这样int fib(int n) { if (n 2) return 1; int a 1, b 1, c; for (int i 3; i n; i) { c a b; a b; b c; } return b; }这代码比递归版长一点但时间复杂度和空间复杂度都低得多。所以在我看来递归是一种“可读性优先、性能靠后”的武器。用之前先掂量掂量场景。9. 递归 vs 迭代一张表看懂该选谁有同学老问递归和循环到底什么关系是不是所有递归都能改循环先说结论所有递归都能用循环显式栈模拟出来所以理论上没有“必须递归才能做”的问题。但有些问题用循环栈写代码复杂度会飙升反而违背了你写代码的初衷。我整理了一张常用的选择对照表判断因素适合递归适合迭代循环问题结构天然自相似树、分治、排列线性迭代如求和、计数代码可读性极好和数学定义一致一般需要手动管理状态变量栈空间风险有深递归容易爆栈无循环不占调用栈重复计算问题容易发生如斐波那契可控可先用临时变量消除性能要求较低或深度可控较高循环快于函数调用实现难度低两三行核心逻辑有时很高如树的非递归遍历一句话总结我的使用经验如果是树、图、分治这类自带层级结构的优先递归。如果是纯数学递推阶乘、斐波那契、累加能用循环就循环看不惯的话递归也行但要注意性能。如果递归深度可能超过几千尽早改迭代别抱侥幸心理。10. 常见错误与调试技巧我踩过的坑写递归容易踩的坑就那几样我挨个给你点破。10.1 缺终止条件典型症状程序运行后卡死或者编译能过但一运行就Segmentation Fault。这是因为递归永远停不下来栈帧不断堆积最终把栈撑爆。解决办法就是在函数最开头写基准情况内容越短越好一眼能看出“当什么条件时直接返回”。10.2 基准条件写错 / 返回值写错有些递归函数返回值类型是void有些是int基准情况的return写错后面的逻辑全乱。比如求阶乘int fact(int n) { if (n 1) return 1; // 基准情况返回1 return n * fact(n - 1); // 递归情况返回n乘以下一层的返回 }如果基准情况写成return 0;那所有结果都会变成0。这种错很难查因为代码不报错跑出来结果不对。排查方法先用小规模输入手工算一两遍和手算结果对比。10.3 参数没有朝终止条件逼近比如写汉诺塔时把hanoi(n-1, ...)错写成hanoi(n, ...)那就永远终止不了。写完后看一眼递归调用的参数必须保证比上一层更接近基准条件。10.4 试图“优化”递归而把逻辑绕晕我见过不少同学在递归里加各种全局变量、静态变量试图省事结果状态相互干扰越改越乱。递归最重要的就是局部性每一层的状态都尽量通过参数传递不要依赖共享的全局状态。如果非要用全局变量也得想清楚每一层的生命周期。10.5 调试递归的方法递归调试最忌讳“人肉单步跟踪”。正确做法是在函数入口处打印参数在返回值处打印结果。比如int fact(int n) { printf(调用 fact(%d)\n, n); if (n 1) { printf(返回 1\n); return 1; } int res n * fact(n - 1); printf(fact(%d) 返回 %d\n, n, res); return res; }对着小输入跑一遍观察打印的溯源轨迹基本能把逻辑错误定位到具体某层。打印语句可以在调试完删掉但要小心删错——我就是经常删完又得重新加。另外一个实用技巧先用小规模输入跑通再试大规模。递归最容易在大输入下暴露栈溢出或性能问题但逻辑错误往往是小事先错。先验小规模可以隔离“逻辑错误”和“性能问题”两类bug。11. 递归的工程价值超越C语言本身有人觉得递归是窝在课本里的玩具实际工程用不上。这是误解。现代软件里到处都是递归的影子文件系统遍历目录套子目录天然递归。JSON解析JSON里可以嵌套JSON。语法树构建与遍历编译器、解释器的AST遍历全是递归。树形UI组件前端组件树渲染递归是常见实现。算法竞赛和面试二叉树、图论、动态规划的很多问题思考过程都建立在递归思维上。所以学好C语言的递归不只是学会一个语法特性而是训练一种“分而治之”的思维模式。等你以后接触数据结构里的树和图会发现自己比别人上手快得多因为递归思维早就内化了。12. 收尾一些写代码之外的话写到这我忍不住多说两句自己的体会。递归学起来最难的不是语法而是**“放弃追踪每一步转而信任函数的自我描述”**。像我前面反复强调的你不需要知道最深一层发生了什么只需要保证基准情况正确、递归关系正确、参数在一步步逼近基准。这很像和人打交道——你把事情委托给一个靠谱的人他再委托给下一个人只要链条中每个人做的事都正确最终结果就一定正确。我刚开始学递归时总是手痒去跟踪每一层调用结果三番两次把自己绕进去。后来我强制自己只看函数的“能力描述”不看具体执行细节腰不酸了腿不疼了写递归的速度也快了。这个转变我希望你们也能早点经历。如果哪天真在代码里被递归卡住了先把函数放到纸面上把基准情况框起来把递归情况框起来看看两者的参数是否冲突基本就解决了。递归这东西想通了就是一层纸想不通就是一堵墙。祝你好运也祝你的程序少爆栈。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑