很多人第一次接触递归算法,看到函数在函数体里调用自己,第一反应都是“这代码真的不会死循环吗”。这个反应太正常了,我刚入行那会儿也一样。递归初看很绕,可一旦想明白它背后的调用栈机制,你会发现它其实就是一套非常优雅的“拆解问题”的思路:把一个大问题拆成结构相同的子问题,一直拆到不能再拆,然后逐层返回结果。这个思路在处理树形结构、嵌套目录、层级菜单、对象深拷贝时,比任何一层层嵌套的循环都好写、好读、好维护得多。
这篇内容我会从递归的核心原理讲起,用实际代码演示它的执行过程,再配合几个项目里真正用得上的实战例子,最后把容易踩的坑和排查方法一起整理出来。不管你是初学者还是写过几年代码但一直没搞懂递归的开发者,看完都应该能自己上手写递归、改递归,并且知道什么时候该用递归、什么时候该果断换迭代。
1. 递归算法为什么值得专门拿来说
1.1 递归的本质,就是函数调用自身
递归算法的定义精简到极致:函数直接或间接调用自身。但这句话背后藏着一个完整的运行机制。所有编程语言在运行函数时,都会在内存里开辟一段叫“调用栈”的区域。每次调用一个函数,系统就把当前函数的参数、局部变量、返回地址打包成一个“栈帧”,压入调用栈;函数执行完,栈帧弹出,流程回到调用点。
递归之所以特别,是因为函数还没执行完,就遇到了一次新的函数调用,于是当前栈帧不弹出,继续压入新的栈帧。这就是一个不断“叠加”的过程。比如一个经典的递归:
javascript复制function countdown(n) {
if (n <= 0) {
console.log('done');
return;
}
console.log(n);
countdown(n - 1);
}
countdown(3);
执行 countdown(3) 时,栈里压入 countdown(3);执行到 countdown(2) 时,countdown(3) 的栈帧还在栈底,上面又压入 countdown(2);接着压入 countdown(1)、countdown(0)。当 n=0 命中了 if 分支,函数开始层层返回,栈帧依次弹出。这个“不断压栈-触底-弹栈”的过程,可以理解成一次“向下钻取”的过程。你钻到最深的那一层,拿到答案,再一层层带回来。
光看文字可能还是有点抽象,我习惯用一个生活中的例子来解释。想象你去传话:你问第3层的人“你楼上的工资是多少”,第3层的人不知道,得问第2层,第2层得问第1层,第1层把自己的工资告诉第2层,第2层再把数字加上自己的工资告诉第3层,最后第3层把完整数字告诉你。在这里,每一层的人都是一个栈帧,它们都在等待下层的结果,等到底层的人给出答案,整个链条才逐层返回并汇总。
1.2 真正让递归产生价值的,是分治思想
递归定义里真正的核心不是“自己调自己”,而是“用相同逻辑解决更小规模的同类问题”。用术语叫分治,说人话就是:一件事我搞不定完整版,但我能搞定最微小的一版;而这件事本身又是“由很多微小版组成”的,那我就能由小到大拼出完整答案。
拿计算 n! 来说。5! = 5 × 4 × 3 × 2 × 1。如果你直接想把 5! 一次性算出来,逻辑上要循环累乘;但换个角度:5! = 5 × 4!,4! = 4 × 3!,一路往下,直到 1! = 1。每走一步,问题规模都在缩小,但处理方式完全一致——这就是递归最适合解决的问题形态。
递归分两段看:一段是“往下拆”,另一段是“往回归”。往下拆时,做的事情就是调用自己、把小问题交给下一个栈帧;往回归时,每个栈帧拿到子结果后,按自己的逻辑加工再返回给上一层。上面 countdown 的例子只是演示了往下拆,没有涉及回归时的加工,所以看起来意义不大。等你看到阶乘和斐波那契的完整实现,就能理解“拆”和“归”是递归的两条腿,缺一不可。
1.3 判断一个问题该不该用递归,看结构不看难度
很多初学者喜欢问“递归和循环哪个更厉害”,这个问题本身就不对。递归和循环不是竞争关系,而是各有所长。我判断用不用递归,主要看问题本身有没有“自相似”的结构:一个大的整体里套着若干个小的同构整体。典型代表是:
- 文件夹系统:文件夹里套文件、套子文件夹
- 家谱或组织架构:节点下面挂子节点
- HTML DOM:元素里嵌套元素
- JSON 对象:属性值可能是对象、数组、基本类型,无限嵌套
- 各类树形控件的数据结构
这类问题天然长着一副递归的脸,你用循环硬解也行,但往往要手工维护一个栈或队列,代码写出来又长又容易漏。递归最大的优势是代码量小、逻辑直观,因为它让运行时帮你维护了那套“当前处理到哪一层”的状态。
我先给出一个我常用的判断表格,后面几节会就这几个维度具体展开说明:
| 问题特征 | 更适合递归 | 更适合迭代循环 |
|---|---|---|
| 数据结构天然嵌套(树、图、目录、JSON) | 明显适合 | 能写,但代码明显更复杂 |
| 问题可拆成同构子问题 | 适合 | 也可以,得自己管理状态 |
| 问题深度固定且很浅 | 无所谓 | 无所谓 |
| 问题深度可能非常深(几万层) | 风险大,可能栈溢出 | 稳妥 |
| 对性能极其敏感的场景 | 需做记忆化或改写迭代 | 通常更可控 |
| 代码可读性和维护性优先 | 首选递归 | 可用,但要小心嵌套地狱 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 理解调用栈的运作,递归就不迷路
2.1 函数调用时底层到底发生了什么
要让递归不出错,最重要的一步是理解“栈帧”。每个函数调用都会在内存中存下这几次信息:当前执行到哪一行代码、函数参数的值、函数内局部变量的值、返回后要回去的地址。这些信息打包存放,就叫栈帧。
当递归发生时,因为函数没有执行完又调用了下一层,所以前面的栈帧不能释放,只能一直占着内存,继续往上叠新的栈帧。栈空间是有上限的,叠到一定层数,就会把内存挤爆,程序直接报错。所以递归绝对不是“无限套娃”,每一个递归函数都必须有一个明确的出口条件,让调用能停止,栈帧能逐个弹出。
这里顺便回答一个很多新人的疑惑:“函数自己调用自己,参数不就不一样了,为什么每次不是从头开始?”因为每次调用都会生成新的栈帧,新的参数、新的局部变量都存放在新栈帧里,跟上一层互不干扰。可以理解为同一个函数被复制了一份新的执行环境,只是这份副本的名字和逻辑跟原版一模一样。
有时候面试题里会考“递归函数如何改为非递归”,其实核心思路就是把系统替你维护的调用栈,换成你自己维护的一个显式栈。逻辑是一模一样的,只是内存从系统栈挪到了
