Personal Field Archive / Received 2026.09.10
递归|LeetCode 第一轮复盘
以函数契约、基本情形、规模收敛和返回值语义为框架,复盘递归从直觉到可验证结构的过程。
章节状态:第一轮已完成。LC 509 一遍完成;LC 344 使用双指针完成,并补充理解了递归表达;LC 24 起初没有思路,重点学习了链表递归中的“函数返回什么”;LC 50 的初始递归存在不收敛和效率问题。另以 LC 206 作为链表递归的补充对照。
一、第 3 组题目清单
| 顺序 | LeetCode | 难度 | 题目 | 核心训练 | 当前状态 |
|---|---|---|---|---|---|
| 1 | 509 | 简单 | Fibonacci Number | 基本情形、分支递归 | 一遍完成 |
| 2 | 344 | 简单 | Reverse String | 区间递归、递归前操作 | 双指针完成;递归已理解 |
| 3 | 24 | 中等 | Swap Nodes in Pairs | 链表递归、返回新头节点 | 重点复盘 |
| 4 | 50 | 中等 | Pow(x, n) | 快速幂、问题规模减半 | 重点复盘 |
| 补充 | 206 | 简单 | Reverse Linked List | 递归回溯、连接反转 | 对照复盘 |
二、递归的统一分析框架
写递归时,不应先想“代码怎样调用自己”,而应先回答下面六个问题。
1. 函数的契约是什么
递归函数接收什么,承诺返回什么?
例如:
swapPairs(head)
可以定义为:
接收一段链表的头节点,返回这段链表两两交换后的新头节点。
当前这一层只要相信较小问题会履行相同承诺,不必在脑中展开全部递归过程。
2. 基本情形是什么
基本情形是无需继续拆分、能够直接得出答案的最小输入。
Fibonacci:n 为 0 或 1
反转字符串:left >= right
两两交换链表:没有节点,或只剩一个节点
快速幂:指数为 0
反转链表:空链表,或只剩一个节点
缺少基本情形,递归通常不会停止;基本情形写错,则会漏掉边界输入或访问不存在的节点。
3. 问题规模是否严格缩小
每一次递归调用都必须更接近基本情形:
n → n - 1
[l, r] → [l + 1, r - 1]
head → head.next
head → head.next.next
n → n // 2
仅仅“再次调用自己”不是递归进展。如果参数不变,例如:
self.myPow(x, n) # n 没有变化
就会无限递归,直到 Python 抛出 RecursionError。
4. 较小问题是否仍是同一种问题
递归调用必须能够使用同一份函数契约。例如,交换前两个节点后,剩余的链表仍然是“对一段链表进行两两交换”;计算 x^n 时,x^(n // 2) 仍然是同一种幂运算问题。
5. 当前这一层需要做什么
递归不是把所有工作交给下一层。通常要把较小问题的结果与当前元素组合起来:
Fibonacci:将两个子问题的结果相加
反转字符串:交换当前两端
两两交换链表:交换当前两个节点并连接剩余结果
快速幂:将 half 与自身相乘
反转链表:把当前节点接到已反转后缀的末尾
6. 返回值代表什么
这是链表递归最容易遗漏的部分。递归返回的通常不是“操作是否完成”,而是处理后结构的入口:
swapPairs(head) 返回两两交换后的新头节点
reverseList(head) 返回完全反转后的新头节点
如果没有明确返回值含义,就很难知道当前层该如何重新连接节点,以及最终应该返回哪个节点。
三、LC 509. Fibonacci Number
递归写法
class Solution:
def fib(self, n: int) -> int:
if n == 0:
return 0
if n == 1:
return 1
return self.fib(n - 1) + self.fib(n - 2)
为什么它适合说明递归结构
函数契约是“返回第 n 个 Fibonacci 数”。当 n >= 2 时,当前问题可以拆成两个相同类型的较小问题:
fib(n) = fib(n - 1) + fib(n - 2)
两个参数都在缩小,并最终到达 0 或 1。
为什么朴素递归不是高效解法
递归树中会重复计算相同子问题。例如 fib(5) 的两条分支都会计算 fib(3)。朴素递归的时间复杂度近似为 O(2^n),递归栈空间为 O(n)。
可以使用记忆化将时间复杂度降为 O(n),也可以用迭代把额外空间降为 O(1)。本题在这一组中的主要价值是理解基本情形与递归关系,不是说明递归一定是最佳实现。
四、LC 344. Reverse String
我的实现方向:双指针
class Solution:
def reverseString(self, s: list[str]) -> None:
left, right = 0, len(s) - 1
while left < right:
s[left], s[right] = s[right], s[left]
left += 1
right -= 1
这是本题更实用的写法:时间复杂度为 O(n),额外空间为 O(1)。
为什么也能用递归解决
反转区间 [left, right] 可以分解为:
- 交换最外侧的
s[left]与s[right]; - 递归反转更小的内部区间
[left + 1, right - 1]。
class Solution:
def reverseString(self, s: list[str]) -> None:
def reverse(left: int, right: int) -> None:
if left >= right:
return
s[left], s[right] = s[right], s[left]
reverse(left + 1, right - 1)
reverse(0, len(s) - 1)
这里的递归关系是:
reverse(left, right)
= 交换两端 + reverse(left + 1, right - 1)
每次递归使待处理长度减少 2,最终到达空区间或单字符区间。
双指针与递归的关系
| 角度 | 双指针 | 递归 |
|---|---|---|
| 每轮动作 | 交换两端 | 交换两端 |
| 范围缩小 | left += 1、right -= 1 |
参数变为 left + 1、right - 1 |
| 停止条件 | left >= right |
left >= right |
| 时间复杂度 | O(n) |
O(n) |
| 额外空间 | O(1) |
O(n) 调用栈 |
递归和双指针在逻辑上做的是同一件事,只是递归用调用栈保存每一层的 left、right。由于题目要求原地修改,并且字符串可能很长,双指针更合适。
五、LC 24. Swap Nodes in Pairs
最初的难点
看到“两两交换节点”时,它与 LC 206 的 Reverse Linked List 很像:两者都要修改 next。真正的难点不只是反转当前一条边,而是:
- 当前两个节点交换后,谁是这一段的新头?
- 当前这一对如何连接前面已经处理的部分?
- 当前这一对如何连接后面尚未处理的部分?
- 递归调用应该从哪个节点开始?
先定义递归函数的契约
swapPairs(head)
表示:
将以
head开头的整段链表两两交换,并返回交换后的新头节点。
基本情形:
if head is None or head.next is None:
return head
没有节点时无需交换;只剩一个节点时,该节点保持原样。
递归写法
class Solution:
def swapPairs(
self,
head: Optional[ListNode],
) -> Optional[ListNode]:
if head is None or head.next is None:
return head
first = head
second = head.next
remaining = second.next
swapped_remaining = self.swapPairs(remaining)
second.next = first
first.next = swapped_remaining
return second
用 1 → 2 → 3 → 4 理解一层递归
当前这一层先识别:
first = 1
second = 2
remaining = 3 → 4
然后相信:
swapped_remaining = self.swapPairs(remaining)
会返回已经处理好的:
4 → 3
当前层只负责把 1、2 交换并接回去:
second.next = first 得到 2 → 1
first.next = swapped_remaining 得到 2 → 1 → 4 → 3
这一段交换后的新头是 second,因此返回 second,不是返回原来的 head。
为什么它与 Reverse Linked List 相似但不相同
它们都可以理解为“局部反转 + 重新连接”,但保持的结构不一样。
| 问题 | 每层处理几个节点 | 递归处理的剩余部分 | 当前层返回什么 |
|---|---|---|---|
| LC 206 反转整条链表 | 1 个 | head.next |
整条反转链表的新头 |
| LC 24 两两交换 | 2 个 | head.next.next |
当前交换后这一段的新头 second |
LC 206 会把整条链表的方向全部反转;LC 24 只反转每个长度为 2 的局部区间,并保留各组之间的前后顺序。
迭代写法中的对应思想
class Solution:
def swapPairs(
self,
head: Optional[ListNode],
) -> Optional[ListNode]:
dummy = ListNode(0, head)
prev = dummy
current = head
while current is not None and current.next is not None:
first = current
second = current.next
next_pair = second.next
prev.next = second
second.next = first
first.next = next_pair
prev = first
current = next_pair
return dummy.next
这里 prev 不是“前一个节点”这么模糊,而是“已经处理部分的尾节点”;current 是“下一组尚未处理节点的入口”。每轮同时维护当前组前方和后方的连接。
六、补充:LC 206. Reverse Linked List 的递归写法
class Solution:
def reverseList(
self,
head: Optional[ListNode],
) -> Optional[ListNode]:
if head is None or head.next is None:
return head
new_head = self.reverseList(head.next)
head.next.next = head
head.next = None
return new_head
用 1 → 2 → 3 理解当前层
对于 head = 1,先相信:
new_head = self.reverseList(head.next)
已经把后缀 2 → 3 反转成:
3 → 2
此时原来的 head.next 仍然是节点 2,它已经是反转后缀的尾节点。于是:
head.next.next = head
让 2 指回 1:
3 → 2 → 1
随后必须执行:
head.next = None
断开原来的 1 → 2,否则 1 与 2 会形成环。最后返回的仍然是整条反转链表的新头 3,所以返回 new_head。
LC 206 与 LC 24 的递归对照
| 关注点 | LC 206 | LC 24 |
|---|---|---|
| 函数契约 | 返回完全反转后的新头 | 返回两两交换后的新头 |
| 基本情形 | 少于 2 个节点 | 少于 2 个节点 |
| 递归入口 | head.next |
head.next.next |
| 当前层动作 | 把当前节点接到反转后缀末尾 | 交换当前两个节点并连接后缀 |
| 返回值 | new_head |
second |
共同点是:先明确递归调用返回的是一段已经处理好的链表,再由当前层完成边界处的连接。
七、LC 50. Pow(x, n)
我的原始代码
class Solution:
def myPow(self, x: float, n: int) -> float:
if n == 0:
return 1
if n == 1:
return x
if n < 0:
return 1 / self.myPow(x, n)
return x * self.myPow(x, n - 1)
问题一:负指数分支没有缩小问题
当 n < 0 时:
return 1 / self.myPow(x, n)
递归调用仍传入完全相同的 x 和 n。例如:
myPow(2, -3)
→ 1 / myPow(2, -3)
→ 1 / (1 / myPow(2, -3))
→ ...
它永远不会到达 n == 0 或 n == 1,最终触发 RecursionError。
负指数应先转化为正指数:
x^(-n) = (1/x)^n
因此可以执行:
if n < 0:
x = 1 / x
n = -n
问题二:正指数虽然数学正确,但规模缩小太慢
return x * self.myPow(x, n - 1)
能够正确表达 x^n = x × x^(n-1),但需要递归 n 层,时间复杂度和调用栈空间都是 O(n)。当 n 很大时会超时或超过 Python 的递归深度限制。
快速幂的核心
将指数每次减 1,只消去一个单位;将指数每次除以 2,才能真正形成对数级递归。
n 为偶数:x^n = (x^(n/2))²
n 为奇数:x^n = (x^(⌊n/2⌋))² × x
正确写法
class Solution:
def myPow(self, x: float, n: int) -> float:
if n < 0:
x = 1 / x
n = -n
def power(exp: int) -> float:
if exp == 0:
return 1.0
half = power(exp // 2)
if exp % 2 == 0:
return half * half
return half * half * x
return power(n)
为什么要先保存 half
正确:
half = power(exp // 2)
return half * half
如果写成:
return power(exp // 2) * power(exp // 2)
就会对同一个子问题递归计算两次,使递归树重新产生大量重复计算,失去快速幂的效率优势。
快速幂的时间复杂度为 O(log |n|),递归栈空间为 O(log |n|)。
八、四道题的递归形态对照
| 题目 | 每层递归次数 | 下一层规模 | 当前层的工作 | 主要风险 |
|---|---|---|---|---|
| LC 509 Fibonacci | 2 次 | n-1、n-2 |
两个结果相加 | 重复子问题 |
| LC 344 Reverse String | 1 次 | 区间长度减 2 | 交换两端 | 调用栈并非 O(1) |
| LC 24 Swap Pairs | 1 次 | 链表减少 2 个节点 | 交换一对并接回后缀 | 返回错头节点、断链 |
| LC 50 Pow | 1 次 | n // 2 |
平方,奇数时再乘 x |
参数不变、重复递归 |
| LC 206 Reverse List | 1 次 | 链表减少 1 个节点 | 当前节点接到后缀末尾 | 忘记断开旧连接而成环 |
九、本轮暴露出的关键遗漏
1. 递归调用必须保证进展
检查每个会继续递归的分支,确认参数都严格靠近基本情形。LC 50 的负指数分支正是因为 n 完全没有变化而无限递归。
2. 能使用递归,不等于递归是最佳实现
LC 344 可以自然地递归表达,但双指针空间更好;LC 509 可以递归定义,但朴素递归包含严重的重复计算。选择实现时还要评估时间复杂度、调用栈和语言限制。
3. 链表递归首先要确定“返回哪个头”
LC 24 交换 first 与 second 后,新头是 second;LC 206 完全反转后,新头来自最深层递归返回的 new_head。如果函数契约含糊,连接关系和返回值很容易同时出错。
4. 修改 next 前后都要检查链表可达性
修改指针前要保存仍需访问的后续节点;修改指针后要确认:
- 新头节点是谁;
- 所有需要保留的节点是否仍然可达;
- 是否形成了意外的环;
- 当前处理结果是否已经连接到递归返回的后缀。
5. 先相信函数契约,再分析当前一层
理解递归时无需同时追踪全部调用层。可以暂时假设较小输入已经被正确处理,然后只回答:当前层拿到这个结果后,要做什么才能构成当前输入的答案?完整展开只用于验证,不应成为设计递归的主要方法。
6. 注意工作发生在递归之前还是之后
LC 344:先交换两端,再递归内部区间
LC 24:可以先递归处理后缀,再交换并连接当前一对
LC 206:必须先得到反转后缀,再在回溯阶段反转当前连接
LC 50:先得到 half,再在回溯时组合结果
操作顺序由函数契约和数据依赖决定,不是固定模板。
十、写递归前的检查清单
- 我能用一句话说明这个函数保证返回什么吗?
- 哪些最小输入可以直接返回?
- 所有递归分支的参数都严格缩小了吗?
- 缩小后的问题仍然是同一种问题吗?
- 当前这一层是在递归前操作,还是在递归返回后组合?
- 如果有多个递归调用,是否重复计算了相同子问题?
- 返回值是数值、布尔值,还是修改后结构的新入口?
- 修改链表连接前,是否保存了后续入口?
- 时间复杂度由递归深度决定,还是由分支数量共同决定?
- 最大递归深度是否可能超过 Python 的限制?
十一、隔两到三天后的验收
不要看本文,从空白完成:
- 写出 LC 509 的递归解法,并说明它为什么会重复计算。
- 分别用双指针与递归完成 LC 344,并比较空间复杂度。
- 写出 LC 24,说明递归函数的返回值为什么是交换后链表的新头。
- 写出 LC 50 的快速幂,解释为什么指数必须减半,以及为什么只递归计算一次
half。 - 口头推演 LC 206 的递归写法,解释
head.next.next = head与head.next = None各自的作用。
如果能够从函数契约出发独立设计基本情形、缩小方式、当前层操作和返回值,并能判断递归复杂度,而不是只记住代码形状,才算完成这一轮递归训练。