什么是递归?
递归 是指在函数的定义中使用函数自身的方法。
用一个经典的段子来形容:"要想理解递归,你必须先理解递归。"
它的核心思想是分治法:把一个大问题,分解成与原问题形式相同、但规模更小的子问题。通过解决这些子问题,最终合并出大问题的答案。
例题剖析
题目一:汉诺塔问题
cpp
class Solution {
public:
void hanota(vector<int>& a, vector<int>& b, vector<int>& c) {
// 调用递归函数,传入三个柱子以及 A 柱子上盘子的初始数量
dfs(a, b, c, a.size());
}
// a: 起始柱子
// b: 辅助柱子
// c: 目标柱子
// n: 当前需要移动的盘子数量
void dfs(vector<int>& a, vector<int>& b, vector<int>& c, int n) {
// 递归终止条件:如果只有一个盘子,直接将其从 a 移动到 c
if (n == 1) {
c.push_back(a.back()); // 将 a 的顶部盘子放入 c 的顶部
a.pop_back(); // 从 a 中移除该盘子
return;
}
// 步骤 1:将 A 上的 n-1 个盘子移动到 B 上(此时 C 作为辅助)
dfs(a, c, b, n - 1);
// 步骤 2:将 A 上剩下的最后 1 个(最大的)盘子移动到 C 上
c.push_back(a.back());
a.pop_back();
// 步骤 3:将 B 上的 n-1 个盘子移动到 C 上(此时 A 作为辅助)
dfs(b, a, c, n - 1);
}
};
算法核心思想(分治与递归)
汉诺塔问题是一个典型的递归 问题,代码中使用的 vector 巧妙地模拟了"栈"的操作(push_back 相当于入栈,pop_back 相当于出栈),并且 back() 总是能取到当前柱子最顶端的盘子。
解决 n 个盘子从 A 移动到 C 的问题,可以拆解为以下三个步骤:
-
先把上面
n-1个盘子从A移到B(借助C)。 -
把最底下的第
n个盘子从A移到C。 -
把
B上的n-1个盘子移到C(借助A)。
学习递归时最痛苦的地方:我们是在用人类的线性思维去写代码,但电脑是在跑非线性的树状结构。
我们试图在脑子里模拟整个执行过程 。但在实际写这种递归代码时,高手从来不去脑补整个递归栈,而是用"宏观信任法"和"数学归纳法"来写代码。
以下是写出这段汉诺塔代码的真实思维推导逻辑:
第一步:丢掉代码,只关注"如何把大象装进冰箱"
假设你面前只有一个最简单的任务:把 n 个盘子从起始柱 A 移动到目标柱 C,可以借用辅助柱 B。
不管 n 是多少,你作为人类,思考的只能是宏观的三个步骤(也就是分治法):
-
我得先把上面那
n-1个盘子挪开,不能让他们挡着最大的盘子。挪去哪?只能挪到辅助柱B上。 -
现在
A上最大的盘子露出来了,把它直接放到目标柱C上。 -
最后,把刚才藏在辅助柱
B上的那n-1个盘子,挪到目标柱C上。
你看,在这个阶段,根本没有递归,也没有栈,这就是最朴素的物理动作。
第二步:把"大动作"翻译成"函数调用"
我们定义一个函数,叫 move(n, 起, 辅, 终)(在题目代码里叫 dfs)。它的作用就是:把 n 个盘子从"起"移动到"终"。
现在把我们刚才想的三步翻译成函数:
-
把
n-1个从A移到B(以 C 为辅助) ->move(n-1, A, C, B) -
把最大的从
A移到C-> 物理动作:C.push(A.pop()) -
把
n-1个从B移到C(以 A 为辅助) ->move(n-1, B, A, C)
此时,你的代码雏形是这样的:
cpp
void move(int n, 起点, 辅助, 终点) {
move(n-1, 起点, 终点, 辅助); // 步骤1
把起点最大的盘子移到终点; // 步骤2
move(n-1, 辅助, 起点, 终点); // 步骤3
}
到这里为止,你不需要知道递归栈是什么,你只需要相信:move 这个函数真的能把盘子挪过去。
第三步:"宏观信任法"
这是写递归最核心的心法。
当你写下 move(n-1, 起点, 终点, 辅助) 这句话时,你的大脑会抗议:"等等!这 n-1 个盘子具体怎么移啊?盘子那么多,会不会违反规则?"
你需要强行按住大脑,告诉自己:
"我信任这个函数! 只要我告诉它起点、辅助和终点是谁,它就一定能合规地把
n-1个盘子搬过去,我才不管它内部是怎么绕来绕去的。"
这就好比你写了一个 sort(array) 排序函数,你调用它的时候,不需要在脑子里把冒泡排序或快速排序的每一次数组元素交换都推演一遍,你只知道调用完它,数组就有序了。
第四步:寻找"出口"
如果一直"信任"下去,n-1 会变成 n-2、n-3......最后变成 0 或 1。计算机不能无限循环,必须给一个明确的终止条件。
于是你补上:
cpp
if (n == 1) {
// 只有一个盘子了,直接搬过去,不需要任何辅助
终点.push(起点.pop());
return;
}
至此,完整的代码就被推导出来了。你在写代码的时候,脑子里根本没有"调用栈"的概念,只有"三个物理步骤"和"强制信任函数"。
那么,递归栈是什么时候出现的?
递归栈是代码写完之后,计算机在执行时内部产生的产物。
-
当我们写完代码点击"运行"时,计算机开始执行。
-
因为它碰到了函数里调用自己的情况(比如 n=3 时调用 n=2),它只能把这个任务"挂起",先记录在栈里(压栈),去处理 n=2。
-
处理 n=2 时又碰到调用自己,只能再挂起,去处理 n=1。
总结一下:
-
写代码时:用"分治法"(三步走)和"宏观信任法"(假设子函数一定能完成),不要试图在大脑里跑栈。
-
读代码或排错时:如果代码逻辑写错了,或者你想知道程序到底干了什么,这时候才需要把"递归栈"画出来,去看看它是怎么一层层压入又弹出的。
千万不要在写代码之前去脑补整个栈的走向,那是反人类的。 只要你的"三步走"逻辑是物理上成立的,并且"出口"条件是对的,那么递归栈无论多么复杂,最终跑出来的结果一定是正确的。
结合我们刚刚说的"宏观信任法",这里为你揭示为什么只要你按那个逻辑写,结果绝对不可能出错。
1. 逻辑基石:数学归纳法(多米诺骨牌效应)
汉诺塔的递归之所以绝对正确,是因为它在数学上完美契合了数学归纳法。
数学归纳法分两步:
-
证明第一块骨牌会倒(Base Case)。
-
假设第 kk 块骨牌会倒,证明第 k+1k+1 块骨牌也一定会倒。
我们来套用一下:
-
第一块骨牌 :当 n=1时,只有一个盘子,直接把它从 A 挪到 C。这绝对正确,物理上完全合法(代码里的
if (n == 1))。 -
假设与递推 :假设
dfs函数在处理 n−1 个盘子时,是绝对正确、绝对不会违反任何规则的(这就是你写代码时的"宏观信任法")。 -
那么当 n 个盘子时:
-
第一步:你调用函数去移动 n−1 个盘子。因为你的"假设",这一步必定合法且成功。
-
第二步:你把底下最大的盘子挪过去。此时原来的起点柱上只有这一个最大的盘子,目标柱是空的,辅助柱上放着所有的 n−1 个小盘子。把最大的盘子放到空柱子上,必定合法。
-
第三步:你又调用函数去移动 n−1 个盘子。同样因为你的"假设",这一步也必定合法且成功。
-
结论: 只要 n=1 是对的,n=2 就是对的;n=2 是对的,n=3 就是对的;以此类推,无论 n 有多大,逻辑的链条决定了它必然是正确的。你不需要去验证 n=64 时的每一步,数学逻辑已经替你证明了。
2. 计算机底层的保障:栈的"后进先出"与"上下文保存"
在数学逻辑正确的基础上,计算机是如何保证在执行时不"乱套"的呢?这就归功于函数调用栈。
当计算机执行 dfs(n-1) 时,它并不是把当前的任务给"忘了",而是把它冻结在了内存的"栈"里:
-
它记住了:当前处于步骤 1,参数是 A、C、B。
-
然后它去执行更小规模的
dfs。
如果最底层的 n=1 成功了,它就会 return(出栈),唤醒上一层被冻结的任务,让它继续执行步骤 2 和步骤 3。计算机的"栈"就像是一个严谨的秘书,一丝不苟地记录着每一个子任务的进度,确保即使递归挖了几十层,回溯的时候也能精准地执行每一个物理动作。
3. 为什么在写代码时我们不需要操心栈?
因为这就是抽象的力量。
当你定义了一个函数 move(n, from, to, aux),并从数学上证明了它的逻辑是完备的,你就可以把它当成一个"黑盒"。
-
你在写 n=3 的代码时,你直接把 n=2 的任务扔给这个黑盒。
-
黑盒内部怎么调用栈、怎么暂停恢复,那是底层计算机的事情。
-
就像你按计算器算 1+1=2,你不需要在脑子里模拟 CPU 是怎么翻转晶体管才得出 2 的,你只需要相信电子电路的物理规律。
下次写递归,只要你确认了:
有一个正确的终止条件(n==1)。
大问题确实能拆成几个小问题,且小问题的参数顺序传递正确。
拆解动作在物理上是合法的。
题目二:合并两个有序链表
cpp
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) {
if (list1 == nullptr)
return list2;
if (list2 == nullptr)
return list1;
if (list1->val <= list2->val) {
list1->next = mergeTwoLists(list1->next, list2);
return list1;
} else {
list2->next = mergeTwoLists(list1, list2->next);
return list2;
}
}
};
代码逻辑拆解
这段递归代码的核心思想极其简单:比较两个链表的头节点,把较小的那个摘下来,然后让它的 next 指针指向"剩下部分合并后的结果"。
我们分三步来看:
1. 找出口
cpp
if(l1 == nullptr) return l2;
if(l2 == nullptr) return l1;
这是递归的终点。如果其中一个链表已经空了,那就不需要再比较了,直接把另一个链表剩下的部分拼在最后面即可。
2. 比较与递归(宏观信任)
cpp
if(l1->val <= l2->val)
{
// 如果 l1 的头更小
l1->next = mergeTwoLists(l1->next, l2);
return l1;
}
else
{
// 如果 l2 的头更小
l2->next = mergeTwoLists(l1, l2->next);
return l2;
}
这里就是**"宏观信任"** 发生的地方。
以 l1->val <= l2->val 为例:
-
既然
l1比较小,那它一定是合并后链表的新头节点 ,所以我们最终return l1;。 -
那
l1后面的节点该接什么呢?当然是把l1剩下的部分(l1->next)和整个l2合并起来的结果。 -
此时你不需要管
mergeTwoLists内部怎么比较、怎么连指针,你只要"信任"它:只要你把两个链表的头扔给它,它必定能给你返回一个排好序的链表头。
对比汉诺塔,为什么这里的结果一定正确?
我们再用数学归纳法来验证一遍:
-
初始状态(n=1) :如果两个链表各自只有 1 个节点,比较一下大小,小的在前,大的在后,
next连起来,结果正确。(这就是Base Case保证了基础)。 -
假设条件 :假设
mergeTwoLists函数在处理总长度为 k 的链表时,能完美合并并返回正确结果。 -
递推过程(k+1):
-
当前有两个链表,总长度为 k+1。
-
我们取出较小的一方的头节点(假设是
l1),拿在手里。 -
把剩下的一大坨(
l1->next和l2)扔给函数处理。 -
根据我们的假设,函数一定会返回一个完美排序好的链表。
-
我们把手里的
l1指向这个返回的链表。 -
由于
l1是当前所有节点中最小的,它放在最前面,后面跟着完美排序的链表,整体必定是完美排序的。
-
如果非要看递归栈(内存视角)
虽然写代码时不用管,但为了满足好奇心,我们以 l1 = [1, 2, 4], l2 = [1, 3, 4] 为例,看看栈是怎么走的:
-
压栈:
merge(l1(1), l2(1))- 比较 1 <= 1,成立。执行
l1->next = merge(l1(2), l2(1))。当前函数暂停,压栈。
- 比较 1 <= 1,成立。执行
-
压栈:
merge(l1(2), l2(1))- 比较 2 <= 1,不成立。执行
l2->next = merge(l1(2), l2(3))。当前函数暂停,压栈。
- 比较 2 <= 1,不成立。执行
-
压栈:
merge(l1(2), l2(3))- 比较 2 <= 3,成立。执行
l1->next = merge(l1(4), l2(3))。当前函数暂停,压栈。
- 比较 2 <= 3,成立。执行
-
压栈:
merge(l1(4), l2(3))- 比较 4 <= 3,不成立。执行
l2->next = merge(l1(4), l2(4))。压栈。
- 比较 4 <= 3,不成立。执行
-
压栈:
merge(l1(4), l2(4))- 比较 4 <= 4,成立。执行
l1->next = merge(l1(null), l2(4))。压栈。
- 比较 4 <= 4,成立。执行
-
压栈:
merge(l1(null), l2(4))- 触发 Base Case:
l1 == nullptr,返回l2(4)。开始出栈。
- 触发 Base Case:
出栈与拼接过程(神奇的地方):
-
回到第 5 步:
l1(4)->next = 返回的 l2(4)。此时链表变成了4 -> 4。返回l1(4)。 -
回到第 4 步:
l2(3)->next = 返回的 l1(4)。此时链表变成了3 -> 4 -> 4。返回l2(3)。 -
回到第 3 步:
l1(2)->next = 返回的 l2(3)。链表变成了2 -> 3 -> 4 -> 4。返回l1(2)。 -
回到第 2 步:
l2(1)->next = 返回的 l1(2)。链表变成了1 -> 2 -> 3 -> 4 -> 4。返回l2(1)。 -
回到第 1 步:
l1(1)->next = 返回的 l2(1)。链表变成了1 -> 1 -> 2 -> 3 -> 4 -> 4。返回l1(1)。
最终结果正确。
题目三:反转链表
cpp
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* reverseList(ListNode* head) {
if (head == nullptr || head->next == nullptr)
return head;
ListNode* newHead = reverseList(head->next);
head->next->next = head;
head->next = nullptr;
return newHead;
}
};
还是用最熟悉的思维框架来拆解它。
1. 找出口
cpp
if(head == nullptr || head->next == nullptr) return head;
这是递归的终点。
-
如果链表是空的(
head == nullptr),反转个寂寞,直接返回空。 -
如果链表只有一个节点(
head->next == nullptr),它的反转就是它自己,直接返回该节点。
2. 宏观信任
cpp
ListNode* newHead = reverseList(head->next);
假设我们的 head 指向的是节点 1 ,那么 head->next 就是节点 2 。
当你写下这一行代码时,你要强行信任 :reverseList 这个函数非常靠谱。你只要把节点 2 传给它,它一定会把 2 -> 3 -> 4 -> 5 这一串完全反转成 5 -> 4 -> 3 -> 2,并且返回反转后的新头节点(也就是节点 5)。
此时,在这个函数内部,链表的状态是断裂的:
-
原来的
1 -> 2这条线还在。 -
2 -> 3 -> 4 -> 5已经变成了5 -> 4 -> 3 -> 2并且指向了nullptr。 -
newHead接收到了节点 5。
3. 拼图(指针操作)
现在,我们的目标是把节点 1 也反转过去。它应该接在哪里?应该接在节点 2 的后面。
但此时,我们手里只有一个 head(节点 1),怎么找到节点 2 呢?
这就是代码这行出现的理由:
cpp
head->next->next = head;
-
head是节点 1。 -
head->next依然是节点 2。 -
head->next->next就是节点 2 的next指针。
这句话的意思是:让节点 2 的 next 指针,重新指回节点 1 。
至此,1 <- 2 <- 3 <- 4 <- 5 的链条就连接成功了(前向指针接好了)。
4. 斩断旧情(防止成环)
cpp
head->next = nullptr;
这句非常关键!经过上一步,节点 1 和节点 2 之间变成了双向指向 (1 指向 2,2 又指向 1)。在单链表中,这会导致死循环(环形链表) 。
因为我们反转后,节点 1 应该变成最后一个节点,它的 next 必须指向空。所以必须把 1 原来指向 2 的线切断。
最后:
cpp
return newHead;
把反转后的新头节点(节点 5)一路往上传递。
题目四:两两交换链表中的节点
cpp
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* swapPairs(ListNode* head) {
if (head == nullptr || head->next == nullptr)
return head;
ListNode* newHead = swapPairs(head->next->next);
ListNode* ret = head->next;
head->next->next = head;
head->next = newHead;
return ret;
}
};
这道题虽然也是递归,但它结合了**"局部节点交换"**的物理操作,比单纯的反转链表要绕脑子一点。
我们依然用**"找出口"** 和**"宏观信任法"**来拆解它。
1. 找出口
cpp
if(head == nullptr || head->next == nullptr) return head;
- 如果链表为空,或者只剩一个节点(无法两两配对),直接返回当前头节点,不需要交换。
2. 宏观信任(处理后面的链表)
cpp
auto tmp = swapPairs(head->next->next);
假设当前链表是 1 -> 2 -> 3 -> 4。head 指向 1。
-
head->next是2。 -
head->next->next是3。所以,这句话的意思是:"把节点 3 及它后面的所有节点,交给函数去处理。"
根据我们的"宏观信任",函数处理完后,会返回一个已经两两交换好的新链表的头节点。假设后面的节点交换完变成了
4 -> 3,那么tmp就会指向节点4。
3. 局部交换
现在的状态是:
-
head指向1。 -
head->next指向2。 -
tmp指向4(它后面跟着3)。此时链表内部还是断开的,我们要把
1和2交换位置,变成2 -> 1,并且把1接上后面的4 -> 3。
cpp
auto ret = head->next; // 1. 记住新的头节点(节点 2)
head->next->next = head; // 2. 让节点 2 指向节点 1
head->next = tmp; // 3. 让节点 1 指向后面处理好的链表(节点 4)
图解这三步(假设当前是 1 -> 2):
-
ret记录了 2。 -
head->next->next相当于2->next,把它改成指向 1。(此时 2 指向了 1) -
head->next相当于1->next,把它改成指向tmp(也就是4)。(此时 1 指向了 4)
- 原先的
1 -> 2指针被彻底改写,变成了2 -> 1 -> 4 -> 3。
最后:
cpp
return ret;
返回新的头节点(节点 2)。
总结
做链表递归题,永远不要在大脑里画整条长链表的指针变动 ,那样一定会晕。
你只需要:
-
盯住当前的两个节点 (
head和head->next)。 -
搞清楚后面的烂摊子 已经交给了
tmp(信任它已经处理好了)。 -
用笔在纸上画出这三个部分(当前节点 1、当前节点 2、后续部分 tmp)的箭头,看看怎么连才能变成
2 -> 1 -> tmp。 -
照着画出的图,写出那三句指针赋值的代码。
题目五:Pow(x,n)
cpp
class Solution {
public:
double myPow(double x, int n) {
return n < 0 ? 1.0 / pow(x, -(long long)n) : pow(x, n);
if (n == 0)
return 1.0;
double tmp = pow(x, n / 2);
return n % 2 == 0 ? tmp * tmp : tmp * tmp * x;
}
};
1. 为什么要单独写一个 pow 函数?(处理负数与溢出)
看 myPow 函数:
cpp
return n < 0 ? 1.0 / pow(x, -(long long)n) : pow(x, n);
-
处理负数 :如果
n是负数,转换成求正数次幂,最后取倒数。 -
防溢出(极其关键) :
n的取值范围最小是 −2的31次方。如果直接写-n,当n = -2^{31}时,会超出int的最大正数范围(2的31次方−1),导致整数溢出 变成负数。所以这里强制转换成了(long long)n再取负,保证安全。
2. 核心逻辑:快速幂(折半计算)
进入真正的计算函数 pow(x, n)(此时 n 已经是非负数):
cpp
if(n == 0) return 1.0; // 出口
double tmp = pow(x, n / 2); // 宏观信任
return n % 2 == 0 ? tmp * tmp : tmp * tmp * x; // 合并
出口
任何数的 0 次方都是 1,这是递归的终点。
宏观信任与数学规律
如果你要求 x10,传统方法是乘 10 次。
但我们可以利用指数法则:x10=(x5)2 。
同理:x5=(x2)2⋅x。
代码里的 pow(x, n / 2) 就是在做这件事:
"我信任这个函数,它一定能帮我算出 xn/2 的值,并存入
tmp。"
拿到 tmp(即 xn/2)之后,怎么拼装成 xn 呢?
-
如果
n是偶数(n % 2 == 0):xn=xn/2×xn/2,也就是tmp * tmp。 -
如果
n是奇数(n % 2 != 0):xn=x⌊n/2⌋×x⌊n/2⌋×x,也就是tmp * tmp * x。
时间复杂度蜕变: 原本需要乘 n 次,现在每一次递归 n 减半,时间复杂度从 O(n) 降到O(logn)。
3. 出栈过程模拟(以 x=2,n=10 为例)
我们看看计算机是如何通过出栈一步步算出来的:
-
进栈 :
pow(2, 10)调起pow(2, 5),调起pow(2, 2),调起pow(2, 1),调起pow(2, 0)。 -
出栈 1(触底) :
pow(2, 0)触发n==0,返回 1.0。 -
出栈 2(计算 n=1):
-
tmp = 1.0 -
n=1是奇数,返回tmp * tmp * x= 1×1×2 = 2.0。
-
-
出栈 3(计算 n=2):
-
拿到上一次的返回值,
tmp = 2.0(即 21) -
n=2是偶数,返回tmp * tmp= 2×2 = 4.0(即 22)。
-
-
出栈 4(计算 n=5):
-
tmp = 4.0(即 22) -
n=5是奇数,返回tmp * tmp * x= 4×4×2 = 32.0(即 25)。
-
-
出栈 5(计算 n=10):
-
tmp = 32.0 -
n=10是偶数,返回tmp * tmp= 32×32 = 1024.0。
-
完美算出结果。栈的深度只有 4 层(因为每次 n 都除以 2),而不是 10 层。
递归的优缺点
-
优点:
-
代码极其简洁、优雅。用循环写可能要几十行的链表反转,递归几行就搞定。
-
逻辑清晰,非常适合处理树形结构和分治问题。
-
-
缺点:
-
空间开销大:每次调用都要保存栈帧,空间复杂度通常是 O(n)(递归深度)。容易发生栈溢出。
-
时间开销:函数调用本身有时间成本(压栈出栈的上下文切换)。
-
可能存在重复计算:比如最基础的斐波那契数列递归 f(n)=f(n−1)+f(n−2),会导致大量重复计算。
-
识别信号
结合我们刚刚拆解的几道题,你可能会发现一个现象:这些题的代码一旦写出来,短得让人难以置信;但在想出来之前,往往抓耳挠腮。
这说明,用递归不是靠"灵光一闪",而是有明确的触发条件的。当你看到一道题,脑海里能匹配上以下几种特征时,你就应该立刻想到:"这题大概率要用递归"。
以下是判断是否使用递归的四大核心信号:
信号一:题目或数据结构天然具备"子结构"(自相似性)
这是最强烈的信号。如果一个大问题的解决过程,和它缩小规模后的子问题解决过程完全一模一样,这就是"自相似"。
-
链表(天然递归) :链表本身就是由一个头节点和一个"更短的链表"组成的。所以处理链表时,经常是把
head摘下来,剩下的head->next扔给递归。- 例如:反转链表、合并两个有序链表、两两交换节点。
-
树(天然递归):树的一个节点包含左子树和右子树,而左右子树又各是一棵树。树的遍历(前中后序)几乎清一色是递归。
-
数学公式/分治:如果一个数学计算可以通过折半、拆分来简化,直接上递归。
信号二:整体做法依赖于"局部操作"的累积(宏观信任)
当你发现,只要你把当前这一步处理好,剩下的全丢给一个"黑盒"去干,就能成事,这就该用递归了。
-
思考过程 :"我要把这两个链表合并,我只需要比较出哪个头小,把小的那个拿下来,剩下的两个链表怎么合并?不知道,扔给函数自己合并吧!"
-
思考过程 :"我要交换相邻节点,我只需要把当前两个节点交换好,后面的怎么换?扔给函数自己处理!"
-
这就是我们之前反复强调的宏观信任法。只要你敢于"甩锅",递归就成立。
信号三:可以"倒推"或"从后往前"处理问题
有些问题顺着推很麻烦,但如果知道最后的终点是什么,倒着往回推却异常简单。递归的"出栈"过程,天然就是从后往前执行的。
-
例如:反转链表。 顺着想怎么把箭头反向很难(因为会把链表断开);但如果是"先走到最后,再从尾巴开始往回把箭头反向",逻辑就无比清晰。
-
例如:汉诺塔。 想不明白第一步该移哪个盘子,但可以想"最后一步绝对是把最大的盘子从 A 移到 C"。以此为起点往前倒推,就是递归的逻辑。
信号四:寻找"递推关系式"和"边界条件"
如果你能在纸上写出这两个东西,代码基本就出来了:
-
边界 :什么情况下不需要再算了?(比如
n == 1,或者链表为空)。 -
递推(状态转移):f(n)怎么由 f(n−1) 表达出来?
反面教材:什么时候不要用递归?
虽然递归很优雅,但以下情况请谨慎(甚至绝对不要)使用:
-
递归深度太深 :如果数据量极大(比如链表有 10 万个节点),递归会导致调用栈太深,直接栈溢出 。此时老老实实用
while循环。 -
存在大量重复计算:像最原始的斐波那契 f(n)=f(n−1)+f(n−2),递归树会爆炸。虽然可以用"记忆化"解决,但有时候直接用动态规划(DP)或循环更省内存。
-
能用简单迭代解决且可读性不差 :比如求 1+2+...+n,用
for循环一秒钟写完。 -
回溯/全局状态难以维护:有些图算法(如复杂的最短路径、大图搜索),递归容易把状态搞乱,此时广度优先搜索(BFS)加队列是更好的选择。