☰
递归、搜索与回溯算法精讲:六道核心题打通分治思维与面试实战
2026/10/21 23:28:47 网站建设 项目流程

1. 专题定位与核心价值

递归、搜索与回溯,这三个词在算法学习圈里几乎等同于“劝退三连”。我见过太多人在二叉树遍历、全排列、N皇后这些经典题上反复碰壁,最后得出“自己不适合写代码”的结论。但真相其实很简单:不是你不适合,而是你还没建立起一套完整的思维框架。

这套“递归、搜索与回溯算法必刷42题”专题,就是为了解决这个问题而设计的。整个专题分四个部分递进展开:基础递归、树形结构、搜索与回溯、进阶优化。今天这篇专题一,我们从最经典的汉诺塔问题出发,一路讲到快速幂,覆盖六道核心题目,把递归的“形”和“神”一次讲透。

这套题目适合谁?

  • 准备校招、社招算法面试的开发者,递归是面试手撕代码的高频考点
  • 正在刷 LeetCode 但卡在递归题上的学习者,需要一套系统的思维路径
  • 想搞懂分治思想和时间复杂度分析的进阶用户,快速幂是这个阶段的标杆题目
  • 带新人的技术导师,可以用这套题目作为辅导提纲

专题一的核心价值在于:用六道看似独立的题目,串起一条从“理解递归过程”到“设计递归算法”再到“优化递归性能”的完整链路。汉诺塔教会你如何抽离问题模型,链表翻转让你掌握递归的返回值设计,归并排序帮你理解分治的合拢阶段,而快速幂则把递归的时间复杂度优化到极致。每一步都有明确的训练目标,每一题都有对应的能力升级。

我见过很多人刷了几百道题仍然畏惧递归,根本原因是他们一直在“背代码”而不是“理解过程”。这个专题不搞玄学,不堆技巧,就是一步步把递归的每一个环节拆开、揉碎,让你真正看明白它在干什么。如果你能认真做完这六道题,并且能独立推导每道题的复杂度,那么后续的二叉树、回溯、DFS 等所有内容对你来说都是在现有框架上做延伸,而不是重新学一遍。

2. 递归的思维框架

2.1 递归的三个本质要素

在进入具体题目之前,我们必须先把递归的本质讲透。递归不是“函数调用自己”这么简单,它是一个完整的解决问题的策略:把一个大问题拆解成若干个小问题,每个小问题和原问题结构相同但规模更小,直到缩小到可以直接求解的规模。

这个定义里藏着三个核心要素,缺一不可。

第一,递归基(Base Case)。这是递归的终止条件,也就是说,当问题规模足够小的时候,我们不需要再继续拆解,可以直接给出答案。没有递归基的递归就是无限循环,最终导致栈溢出崩溃。

第二,递推关系(Recurrence Relation)。这是递归的核心逻辑,描述了如何从规模为 n 的问题推导出规模为 n-1(或更小)的问题。递推关系必须让问题朝着递归基的方向前进,否则永远无法终止。

第三,递归调用(Recursive Call)。这是实际去调用自己的过程,调用时传入的参数必须是“更小”的问题,同时所有子问题的解最终要能合并成原问题的解。

这三个要素听起来简单,但真正落实到代码里,无数人栽在了递推关系的设计上。我们把递归想象成“把一个任务分派给下属”:你是老板,接到一个任务,你判断这个任务是不是简单到自己就能完成,如果是,直接做掉(递归基);如果不是,你把它拆成几个小任务,分派给下属,等他们做完后把结果汇总(递推关系+递归调用)。关键在于,每个下属接到任务时,也会执行同样的判断流程。这个过程就是递归的执行本质。

2.2 从斐波那契数列理解递归状态树

作为热身,我们先看一个最经典的递归示例——斐波那契数列。虽然这不是专题一的核心题,但用它的状态树来理解递归过程再合适不过。

斐波那契数列的递推公式是 F(n) = F(n-1) + F(n-2),递归基是 F(0) = 0, F(1) = 1。代码只有三行:

def fib(n): if n <= 1: return n return fib(n-1) + fib(n-2)

当调用 fib(5) 时,程序内部发生了什么?

fib(5) 需要等 fib(4) 和 fib(3) 的结果,fib(4) 需要等 fib(3) 和 fib(2) 的结果,以此类推。画成树状结构,你会发现 fib(3) 被重复计算了两次,fib(2) 被重复计算了三次。这就是纯递归实现斐波那契的致命问题:重叠子问题。

这种递归方式的时间复杂度是 O(2^n),而递归深度只有 n。时间复杂度是指数级的,递归深度却是线性的,两者之间没有直接关系。很多初学者误以为递归树的高度就是复杂度,这是不对的,复杂度等于递归树中所有节点的总数,而不仅仅是每一层的节点数。

明白了这一点,你就能理解为什么需要“备忘录递归”(也就是记忆化搜索)。用一个数组把已经算过的结果缓存下来,下次再遇到同样的问题直接返回,递归树瞬间被剪枝成一条链,时间复杂度降为 O(n)。这个优化思路在后续的搜索算法专题中会被反复使用。

不过,专题一的主线不是性能优化,而是先建立正确的递归直觉。斐波那契只是开胃菜,下面我们进入真正的第一道核心题目。

2.3 递归的时间和空间复杂度分析

很多人在面试时能写出递归代码,但被问到复杂度就慌了。递归的复杂度分析有固定的套路,掌握它以后所有递归题都能应付。

时间复杂度:如果递归过程中每个子问题只求解一次,那么复杂度等于“递归树的节点总数 × 每个节点的操作时间”。比如归并排序,递归树一共有 n 个叶子节点,加上内部节点总共大约 2n 个节点,每个节点做合并操作的时间是 O(n),但注意每个节点合并的规模不同,更准确的分析是每层总耗时 O(n),共 log n 层,整体 O(n log n)。

空间复杂度:递归的空间复杂度主要由递归深度决定,因为每一层调用都要在系统栈上压入一份上下文(参数、局部变量、返回地址)。归并排序的递归深度是 log n,所以空间复杂度是 O(log n),再加上合并时需要的辅助数组 O(n),总体是 O(n)。

我见过不止一个候选人,写对了代码但把归并排序的空间复杂度说成 O(log n),这就是没有搞清楚辅助空间和栈空间的区别。递归的空间复杂度也要考虑“单层占用的额外空间”,不能只看递归深度。

3. 汉诺塔问题

3.1 题目描述与递归建模过程

汉诺塔问题是递归启蒙的黄金题目。游戏规则很简单:有三根柱子,标记为 A、B、C,A 柱上有 n 个大小不同的圆盘,按从大到小自下而上叠放。目标是把所有圆盘从 A 移到 C,每次只能移动一个圆盘,且大圆盘不能压在小圆盘上面。

很多初学者看到汉诺塔就懵,因为它的操作步骤实在太“绕”了。但当你看透递归的视角,这个问题的求解几乎就是一句描述的事:想移动 n 个圆盘,先把上面 n-1 个圆盘挪到辅助柱 B,再把第 n 个圆盘直接挪到目标柱 C,最后把那 n-1 个圆盘从 B 挪到 C,中间借道 A。

注意这几个动词:“借道”。在这个描述里,A、B、C 的角色会随着任务不同而轮换。这就是理解汉诺塔的关键——我们不要用“哪根柱子是起点、终点”这样的固定视角去看问题,而要用“这一层递归任务里,源柱、辅助柱、目标柱分别是谁”的视角。

递归建模的过程分三步:

  1. 定义函数签名:hanoi(n, source, auxiliary, target),表示把 n 个圆盘从 source 借助 auxiliary 移到 target。
  2. 找递归基:当 n = 1 时,直接移动这一个圆盘,无需借助其他柱。
  3. 拆解递推:把 n 个圆盘的问题拆成两次 n-1 个圆盘的子问题,中间夹一次直接移动。

每一步都对应一个明确的函数调用或移动操作,整个问题的规模从 n 降到了 n-1,方向正确,递归基可达。

3.2 代码实现与每一步的栈状态推演

def hanoi(n, source, auxiliary, target): if n == 1: print(f"Move disk 1 from {source} to {target}") return # step 1: 把 n-1 个圆盘从 source 借助 target 移到 auxiliary hanoi(n-1, source, target, auxiliary) # step 2: 把第 n 个圆盘从 source 直接移到 target print(f"Move disk {n} from {source} to {target}") # step 3: 把 n-1 个圆盘从 auxiliary 借助 source 移到 target hanoi(n-1, auxiliary, source, target)

这段代码的精妙之处在于,第二步打印的那一行就是真正的“移动”,而第一步和第三步是递归调用,分别处理“把上面挡住的部分挪开”和“把挪开的部分归位”。

我们以 n = 3 为例推演一遍,看看每一步到底发生了什么。

调用 hanoi(3, 'A', 'B', 'C'):

  • 进入第一层递归 hanoi(2, 'A', 'C', 'B'),注意这里 source='A', auxiliary='C', target='B',角色的目标是把两个盘从 A 挪到 B,借用 C。
  • 在这个子任务里,又进入 hanoi(1, 'A', 'B', 'C'),此时只有一个盘,直接从 A 挪到 C,这是一次真实的移动。
  • 回到 hanoi(2, 'A', 'C', 'B'),打印 Move disk 2 from A to B。
  • 再进入 hanoi(1, 'C', 'A', 'B'),把一个盘从 C 挪到 B。
  • 完成 hanoi(2, 'A', 'C', 'B'),回到最外层,打印 Move disk 3 from A to C。
  • 最后进入 hanoi(2, 'B', 'A', 'C'),重复类似过程。

如果你照着这个逻辑写一遍,会发现其实并没有想象中那么复杂。每一层递归只需要记住三件事:当前任务是什么、辅助柱是谁、目标柱是谁。角色会变,但规则一致。

3.3 复杂度计算与最小移动次数公式

汉诺塔问题的最少移动次数是 2^n - 1,这个结论可以从递推关系中推导出来。设 T(n) 表示移动 n 个圆盘所需的最少步数,那么 T(1) = 1,且 T(n) = 2 * T(n-1) + 1。

这个递推式的含义很直白:为了移动 n 个盘,你需要完整地把 n-1 个盘搬走一次,再完整地搬回来一次,中间另外移动一次最底下的盘。解这个递推式,得到 T(n) = 2^n - 1。

当 n = 3 时,总共需要 7 步,和我们上面的推演一致。当 n = 64,那就是 2^64 - 1 步,约 1.8 * 10^19 步,这就是传说中“世界末日”那个古老传说的数学来源。即便计算机每秒能模拟 10 亿步,也需要大概 584 年才能跑完。

递归深度是 n,每一层递归除了函数调用本身的栈开销外,不需要额外分配空间,所以空间复杂度是 O(n)。这份 O(n) 的空间是不得不付的,因为递归必须记住每一层任务的执行进度。

3.4 汉诺塔最常见的理解误区

我每次讲到汉诺塔,都会问一个问题:为什么第一步递归的辅助柱和目标柱要互换?

很多人会把第一步写成 hanoi(n-1, source, auxiliary, target),这就是典型的角色混淆。第一步的任务是“把 n-1 个盘从源柱挪到辅助柱”,所以目标参数应该填 auxiliary,而当前不动的那根柱子 target 恰好可以当辅助柱。

代码里参数位置的意义很容易被忽略,但在这里,它就是算法的灵魂。我的建议是:在草稿纸上用三根柱子的实际图来模拟执行,每次递归调用前先问自己三个问题:这一层的源柱是谁?辅助柱是谁?目标柱是谁?三个答案和函数签名对上了,再放心递归。

还有一个常见误区是试图“模拟计算机逐行执行递归”。初学者喜欢手推每一层栈,结果往往越推越乱。汉诺塔的递归深度是 n,n 稍微大一点手推就会爆炸。正确的学习方法是用“责任分派”的视角:你不关心每个下属怎么完成他们的任务,你只需要确认他们的任务定义正确即可。

4. 链表翻转:递归返回值设计的经典案例

4.1 从汉诺塔到链表翻转的思维迁移

汉诺塔解决的是“多步操作的顺序安排”,链表翻转则换了一个角度考察递归:递归函数的返回值该怎么设计。这个话题在很多递归教程里被一笔带过,但实际上,掌握返回值的设计,递归水平能上一个台阶。

我们先看链表翻转的题目描述:给定一个单链表的头节点 head,反转整个链表,返回新链表的头节点。

迭代写法很简单,三个指针 prev、curr、next 一轮遍历就完事。但递归写法,很多初学者就是绕不明白。原因在于,链表翻转的递归并不是“把大问题拆成两个相同结构的小问题”,而是一个“递推推进 + 回溯修改指针”的过程。

4.2 递归反转链表的两种解法对比

解法一:从后往前翻转(最常见的递归写法)

def reverseList(head): if not head or not head.next: return head new_head = reverseList(head.next) head.next.next = head head.next = None return new_head

理解这段代码的关键在 reverseList(head.next) 这行:这一调用会先把 head 之后的整条链表翻转好,并返回翻转后的新头节点。假设原来链表是 1 -> 2 -> 3 -> 4 -> 5,调用 reverseList(2) 会把 2 到 5 变成 5 -> 4 -> 3 -> 2,新头节点是 5。

然后回到 head = 1 这一层,执行第 4 行 head.next.next = head。注意此时 head.next 是 2,也就是说这行代码在做“2 的 next 指向 1”。这样就把断裂的两段接上了:5 -> 4 -> 3 -> 2 -> 1。

最后 head.next = None 是把 1 原来的 next 断掉,防止形成环。返回的 new_head 一直是 5,也就是整个递归过程中始终不变的“新链表的头”。

解法二:从前往后翻转(借助辅助参数)

def reverseList(head, prev=None): if not head: return prev next_node = head.next head.next = prev return reverseList(next_node, head)

这种写法的思路是:每层递归只处理当前节点 head,把它和 prev 连起来,然后把任务抛给下一个节点,同时更新 prev 为当前节点。整个过程更像迭代的递归化表达,好处是不需要依赖“回溯”修改指针,坏处是多了个参数,函数签名没那么干净。

两种解法的时间复杂度都是 O(n),空间复杂度都是 O(n)(递归栈深度 n)。解法二的空间其实可以优化成 O(1),因为它是尾递归,部分编程语言的编译器能做尾调用优化,把递归改成循环,从而让空间复杂度降到 O(1)。但 Python 默认不支持尾递归优化,所以面试时主要考察的还是第一种写法。

4.3 递归返回值的设计原则与易错点

链表翻转这个题最值得提炼的就是“返回值设计”的原则。

原则一:递归函数返回什么,取决于上一层还需要什么信息。在解法一里,reverseList(head) 返回的是“当前子链表翻转后的新头节点”。这个新头节点在整条链表完成翻转后就是整个链表的头节点,所以每一层都要原封不动地继续向上返回。

原则二:别把返回值当成“结果”,而要当成“下一层给你的礼物”。如果递归的返回值只是简单地献给上层函数使用,而不是在每一层都要被修改,那代码写起来会轻松很多。

原则三:修改指针的顺序要谨慎。先改 head.next.next 还是先改 head.next?如果先执行 head.next = None,那你就在修改指针前把原来的链表断掉了,后面的 head.next.next 就无法访问。所以必须先把“后继的后继”接上,再断开自己,这个顺序不能乱。

我见过一个学生,写链表反转递归时反复调试,最后发现只是第 4 行和第 5 行写反了。这个错误的本质是:把“先接后断”的指针修改顺序记反了。链表递归的核心不是移动数据,而是调整指针,指针的时序是绝对优先级。

4.4 与迭代写法的对比:何时选递归,何时选迭代

链表翻转这个例子特别适合说明“递归不是银弹”。迭代写法的空间复杂度是 O(1),代码也不复杂,实际工程中几乎没有理由用递归来实现链表翻转。但在面试中,递归写法是考察候选人是否真正理解递归返回值设计的好题。

我的建议是:面试时先写出你能快速证明正确性的写法,然后和面试官讨论两种写法的复杂度差异。如果你两种写法都能立刻写出来,那给面试官的印象会非常深。

递归和迭代的取舍原则其实很简单:递归让代码清晰,但以空间换时间(栈空间),迭代让代码稍微长一点,却往往有更好的空间效率。当递归深度可能达到几千甚至上万时(比如链表很长),递归会爆栈,这时必须改成迭代。

5. 归并排序:分治思想的完整落地

5.1 归并排序的分治三步走

归并排序是分治算法的代表,而分治恰恰是递归最重要的应用场景。分治三步走是:分解、解决、合并。

  • 分解:把原数组从中间切成两半。
  • 解决:递归地对左右两半分别排序。
  • 合并:把两个已经有序的子数组合并成一个有序数组。

看起来简单的三步,每一步都有讲究。分解这一步最简单,只需要计算中间位置 mid,然后对左半和右半分别递归调用归并排序。解决这一步就是递归基的体现:当子数组长度为 0 或 1 时,它天然有序,不需要继续分解。合并这一步是归并排序的精华,也是代码里最容易出错的地方。

5.2 代码实现与指针移动的边界条件

def merge_sort(arr, left, right): if left >= right: return mid = (left + right) // 2 merge_sort(arr, left, mid) merge_sort(arr, mid + 1, right) merge(arr, left, mid, right) def merge(arr, left, mid, right): temp = [] i, j = left, mid + 1 while i <= mid and j <= right: if arr[i] <= arr[j]: temp.append(arr[i]) i += 1 else: temp.append(arr[j]) j += 1 while i <= mid: temp.append(arr[i]) i += 1 while j <= right: temp.append(arr[j]) j += 1 arr[left:right+1] = temp

归并排序的边界条件是很多人的梦魇。用闭区间 [left, right] 表示当前要排序的子数组,那么左半是 [left, mid],右半是 (mid, right]。中间位置 mid 怎么取?如果 left = 0, right = 7,那么 mid = 3,左半是 [0,3] 共 4 个元素,右半是 [4,7] 共 4 个元素,正好对半分。

如果数组长度是奇数呢?比如 left = 0, right = 6,mid = 3,左半 [0,3] 有 4 个元素,右半 [4,6] 有 3 个元素。这里采用了“左长右短”的切分方式,而另一种写法 mid = (left + right + 1) // 2 则会产生“左短右长”。两种方式都能得到正确结果,但整个代码中必须保持一致,否则合并时的指针逻辑会乱。

合并阶段的两个 while 循环是关键。第一个 while 同时比较两个子数组的当前元素,谁小谁进 temp。循环退出时,可能有一个数组已经走完,另一个数组还剩元素。此时由于两个子数组各自已经有序,直接顺序添加到 temp 后面即可。我见过有人在读了while i <= mid and j <= right之后,看到循环结束了就把 temp 认定为最终结果,直接赋值回原数组,结果漏掉了剩余元素,排序结果不完整。这个坑非常经典,一定要记住:第一个 while 结束后,至少再执行一个后续 while,把剩余元素全部收进来。

把 temp 写回原数组使用切片操作 arr[left:right+1] = temp,可以看到 Python 的切片赋值帮我们省掉了逐元素拷贝的循环。其他语言需要写一个 for 循环,从 left 遍历到 right,把 temp 中的有序结果放回 arr 对应位置。

5.3 归并排序的稳定性与复杂度分析

归并排序是稳定的排序算法,这一点在很多场景下至关重要。稳定性指的是值相等的元素在排序前后保持原有的相对顺序。归并排序的合并阶段,当左右两边的元素相等时,我们先取左边的元素,就是通过这个小细节保证了稳定性。

时间复杂度方面,递归树一共有 log n 层,每一层的合并操作总耗时是 O(n),所以总时间复杂度是 O(n log n)。无论数组原本是什么顺序,归并排序的复杂度都稳定在 O(n log n),这一点比快速排序更可预测。

空间复杂度是 O(n),主要来自合并时创建的临时数组。有些优化版本会尽量减少临时数组的创建,例如在参数中传入额外数组,左右交换使用,避免每个递归层级都新建栈或数组,但核心的 O(n) 空间省不掉。如果面试官追问“能否用 O(1) 空间做归并排序”,答案是理论上可以(原地归并),但实现复杂且常数因子大,工程上很少这么做。

5.4 归并思想在更多场景的扩展

归并排序的价值不仅在于排序本身。归并阶段“把两个有序数组合并”的思想,可以迁移到很多其他问题上。

典型场景之一是求逆序对数量。在一个数组中,如果 i < j 且 arr[i] > arr[j],就称它们构成一个逆序对。暴力的双重循环是 O(n^2),而利用归并排序的合并过程,可以在合并时统计逆序对数:当右半的 j 元素小于左半的 i 元素时,左半从 i 到 mid 的所有元素都大于这个 j 元素,逆序对数量直接加上 mid - i + 1。这样在 O(n log n) 的时间内就能完成统计。

这个思路在 LeetCode 上有原题(剑指 Offer 51),是归并思想的经典应用。如果你学完归并排序只是会写排序,那就浪费了这个算法的价值;把它的合并阶段抽象出来,能解决一大类“统计跨区间数量”的问题。

另一个扩展是外部排序。当数据量大到无法一次性加载进内存时,可以先切分成多个小文件,每个小文件内部排序,再通过多路归并合并。这是数据库系统和大数据处理中广泛使用的策略,归并排序正是它的理论基石。

6. LeetCode 第 78 题:子集问题

6.1 子集问题的递归树展开

子集问题是回溯算法的入门题,也是理解“决策树”概念的最佳载体。题目要求:给定一个不含重复元素的整数数组 nums,返回所有可能的子集。

比如 nums = [1,2,3],需要返回 [], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3]。

解决子集问题最直觉的递归思路是:从数组的第一个元素开始,对每个元素,你都有两个选择——选或者不选。这个二维的决策过程展开后就是一棵二叉树,树的每一层代表一个位置的决策。

路径画出来之后,你会发现,每一个从根节点到叶子节点的路径都对应一个子集。有些路径长一些,有些路径短一些,这就是“子集数量为什么是 2^n”的直观来源。

6.2 回溯三步法:选择、递归、撤销

回溯算法有一个模板化极强的三步法,在所有回溯题里都能套用:

  1. 选择:在当前状态,把所有可选的选项列举出来,选一个尝试。
  2. 递归:进入下一个状态,继续尝试后续的选项。
  3. 撤销:把这一步的选择回退,恢复到尝试前的状态,以便尝试下一个选项。

应用到子集问题,写出来的代码长这样:

def subsets(nums): res = [] path = [] def dfs(start): res.append(path[:]) # 每个状态都记录一次 for i in range(start, len(nums)): path.append(nums[i]) # 做选择 dfs(i + 1) # 递归,注意是 i+1 不是 start+1 path.pop() # 撤销选择 dfs(0) return res

代码很精简,但至少有四个细节需要理解透彻。

细节一:为什么每次都res.append(path[:])而不是res.append(path)?因为 path 是一个列表,Python 里列表是引用类型,直接 append path 只会把引用加入 res,后续 path 的修改会同步影响到 res 里已经记录的“子集”。用path[:]创建当前 path 的浅拷贝,才能把当前状态“快照”保存下来。这个坑几乎是所有 Python 回溯题的共同陷阱。

细节二:递归参数为什么是dfs(i + 1)而不是dfs(start + 1)?因为每一次循环里的 i 表示当前选择的下标。选择完 nums[i] 之后,下一阶段的起点必须是 i + 1,也就是从当前元素的下一个开始继续选,这样才不会重复选到同一个元素。如果用 start + 1,那在 for 循环的第二次迭代里,start + 1 可能还是指向已经访问过的位置,造成重复组合。

细节三:撤销操作为什么在循环体内部、递归调用之后?因为每一轮 for 循环都要尝试一个新的选择,而进入这个新选择之前,必须把上一轮选择留下的 path 恢复原状。path.pop()把刚刚加入的元素移出,让 path 回到进入递归前的状态。

细节四:res.append(path[:]) 放在循环开始之前,意味着每次进入 dfs 时都会记录当前路径。初始状态 path 为空,记录的就是空集。每一次循环里,我们在追加完 nums[i] 后立刻进入 dfs,因此所有中间状态都会被记录,最终得到所有子集。

这四点是回溯算法的核心细节,而不是语法糖。只要理解背后的指针、状态和时机问题,以后写任何回溯题都不容易出错。

6.3 子集的组合数规律与二进制枚举法

子集问题除了递归回溯,还有一种位运算的做法:对 n 个元素的数组,用一个 n 位的二进制数来表示每个元素选或者不选,0 到 2^n - 1 的每个整数都对应一个子集。

def subsets(nums): res = [] n = len(nums) for mask in range(1 << n): path = [] for i in range(n): if mask >> i & 1: path.append(nums[i]) res.append(path) return res

1 << n 等于 2^n,range(1 << n) 枚举了 0 到 2^n - 1 的所有整数。每个整数 mask 的第 i 位是 1 表示选择 nums[i],是 0 表示不选。这种解法的时间复杂度同样是 O(n * 2^n),空间复杂度 O(n)(不算输出结果)。

位运算解法的优点是代码简洁、不依赖递归,缺点是不好扩展到需要剪枝或带约束条件的场景。面试时优先写回溯写法,因为面试官往往想考察你递归和回溯的掌握程度;位运算写法可以作为补充思路提一下,展示你对问题的多角度理解。

2^n 这个数字增长得有多快?n = 20 时大约是 100 万,n = 30 时已经超过 10 亿。所以子集问题在 n 稍大时一定要考虑剪枝和迭代加深等手段,否则连遍历所有子集本身都是灾难。

6.4 从子集到组合总和:回溯思路的第一次升级

子集问题是回溯的入门,组合总和问题则是回溯思路的第一次升级。典型题目是给定一个无重复元素的数组 candidates 和一个目标数 target,找出所有可以使数字和为 target 的组合,且每个数字可以重复使用。

def combinationSum(candidates, target): res = [] path = [] def dfs(start, remaining): if remaining == 0: res.append(path[:]) return if remaining < 0: return for i in range(start, len(candidates)): path.append(candidates[i]) dfs(i, remaining - candidates[i]) path.pop() dfs(0, target) return res

注意这里的递归调用是dfs(i, remaining - candidates[i])而不是dfs(i + 1, ...),区别就在“数字可以重复使用”。如果允许重复,下一轮起点仍然是 i;如果不允许重复,下一轮起点就必须是 i + 1。一个字符之差,含义完全不同,写的时候要养成先想清楚“这个数字能不能重复选”的习惯。

组合总和还有剪枝优化空间:如果把 candidates 先排序,当 candidates[i] > remaining 时,后续元素只会更大,可以提前 break 退出循环。这种“排序 + 剪枝”的组合在回溯题里非常常见,之后刷组合总和 II、全排列等题目时都会用到。

7. 快速幂:从递归到分治的终极优化

7.1 快速幂的数学原理与递归基设计

快速幂,也叫幂运算的二分法,是计算 a^n 的高效算法。如果按最朴素的做法,把 a 连乘 n 次,时间复杂度是 O(n)。当 n 很大时(比如 10^9),这种算法在竞赛和工程中都不可接受。快速幂利用指数的二进制性质,把时间复杂度降到 O(log n)。

数学原理很简单:

  • 当 n 是偶数时,a^n = (a^(n/2))^2。
  • 当 n 是奇数时,a^n = a * (a^((n-1)/2))^2。

这两条公式可以合并成一个递归关系:a^n = (a^(n//2))^2 * a^(n%2)。其中 n % 2 只能是 0 或 1。

递归基:当 n = 0 时,a^0 = 1。

这个递归基是唯一需要注意的地方。很多人把递归基写成 n = 1 时返回 a,也能工作,但写成 n = 0 时返回 1 更通用,因为这样可以统一处理 n = 0 的情况,而且递归过程中 n 不断减半,最终一定到达 0。

递归实现如下:

def pow(a, n): if n == 0: return 1 half = pow(a, n // 2) if n % 2 == 0: return half * half else: return half * half * a

注意这里有一个刻意设计的细节:我们先计算 half = pow(a, n // 2),然后在返回语句里使用 half 两次,而不是分别写 pow(a, n // 2) * pow(a, n // 2)。前者只递归一次,后者会递归两次,时间复杂度立刻从 O(log n) 退化成 O(n)。这个陷阱值得单独强调,因为它在递归问题的复杂度分析中反复出现。

7.2 二进制视角:非递归实现的逻辑

递归版的快速幂好懂,但在实际代码中,更常用的是非递归的迭代版本。迭代版的思路是:把 n 写成二进制形式,从最低位到最高位逐位处理。

比如 n = 13,二进制是 1101,也就是说 a^13 = a^8 * a^4 * a^1。这个分解规律是:二进制中位权为 2^k 的位置为 1 时,就把 a^(2^k) 乘进结果。

迭代实现的要点是:用一个 base 变量记录 a^(2^k),每当 n 的当前位为 1 时,把 base 乘入 result,然后 base 自乘(变成 a^(2^(k+1))),n 右移一位。

def pow(a, n): result = 1 base = a while n > 0: if n & 1: result *= base base *= base n >>= 1 return result

这个版本的巧妙之处在于:base 每次自乘,相当于指数翻倍;result 只在 n 的二进制位为 1 时累乘 base。整个过程不需要递归,空间复杂度降为 O(1),在工程实现里更常用。

顺带一提,如果指数 n 很大,普通的整数乘法在 Python 里不会溢出(大整数自动扩展),但在 C++/Java 这类固定位数语言里要考虑取模运算。比如计算 a^n mod m,只需在每次乘法后加一步取模即可。

7.3 复杂度分析与三处关键细节

快速幂的时间复杂度为什么是 O(log n)?因为递归版每一层把 n 除以 2,递归深度是 log n,每层只做常数次乘法。迭代版循环的终止条件是 n 右移直到 0,右移的次数恰好是 n 的二进制位数,也就是 O(log n)。

但在递归版里有一个必须警惕的陷阱:pow(a, n // 2) * pow(a, n // 2)这种写法会让时间复杂度变回 O(n) 甚至更糟。我们用 T(n) 表示时间复杂度,写出递推式 T(n) = 2 * T(n/2) + O(1),解出来是 T(n) = O(n)。也就是说,即使是“看起来挺聪明”的递归调用,如果重复调用两次同一参数的递归,复杂度也会指数级上升。正确写法必须先存变量再复用,即half = pow(a, n//2),然后 half * half。

第二个细节是负数指数的处理。快速幂默认处理非负整数 n。如果 n 是负数,可以转化为 pow(1/a, -n) 来处理,前提是 a 不等于 0。在 Python 里,整数除法向下取整的特性会让负数处理出现意外,所以更推荐先统一转为正指数。

第三个细节是 n 为 0 时返回 1,这是数学上 a^0 = 1 的规定。如果题目要求在模运算下计算,模数 m 可能等于 1,此时任何结果都是 0,需要提前特判,否则会陷入 1 mod 1 = 0 的困惑。

7.4 快速幂的实际应用场景

快速幂不是一道孤立的算法题,它在真实世界里有一大堆应用场景。

最直接的应用是模幂运算。在 RSA 等加密算法中,计算 a^e mod n 是整个流程的核心步骤,这里的 e 通常是几百位的大整数。如果用朴素连乘,地球毁灭都算不完;用快速幂,只需要 O(log e) 次乘法,瞬间完成。

第二个应用是矩阵快速幂。把“数的乘法”推广到“矩阵的乘法”,用快速幂的思路可以在 O(log n) 时间内计算矩阵的 n 次方。而矩阵快速幂是求解线性递推关系(如斐波那契数列)的利器:把递推关系写成矩阵形式,求矩阵的 n 次方,直接在 O(log n) 时间得到斐波那契数列第 n 项。这比之前的递归做法又上了一个大台阶。

第三个应用是幂运算的取模在哈希、随机数生成中的应用。例如计算某个大数的模幂来生成伪随机序列,快速幂几乎总是被调用到。

我见过不少人在学了快速幂以后觉得“没什么用”,直到某天遇到模意义下的组合数计算(需要阶乘和逆元)才明白,快速幂是整个数论工具箱的地基之一。

8. 常见问题与排查技巧实录

8.1 递归超时与爆栈的原因分析

递归超时和爆栈是刷题时最常见的两个运行时错误,但它们的本质完全不同。

超时通常意味着算法复杂度过高。以递归版的斐波那契为例,O(2^n) 的复杂度在 n = 30 左右还能勉强运行,n = 50 时已经慢到无法接受。排查方法是画递归树,看是否存在大量重复子问题。如果有,引入备忘录或改成动态规划;如果没有,看能不能通过剪枝减少搜索空间。

爆栈意味着递归深度超过了系统栈的限制。Python 默认递归深度限制是 1000 左右,在 LeetCode 上有时会更小。当链表长度超过几千,或者树的高度达到几万,递归就会直接 RecursionError。排查方法是先输出递归深度看看,然后选择改成迭代实现,或者扩大递归限制(不推荐在刷题时这样做,治标不治本)。

还有一个隐蔽的问题:递归函数的参数传递了大量数据。比如每次递归都传一个拷贝后的数组,会让空间和时间复杂度同时暴涨。排查方法是检查参数是否为引用传递,是否有不必要的深拷贝。

8.2 指针修改顺序错误与状态丢失

链表翻转和归并排序这类需要修改数据结构的题目,最常见的错误就是指针修改顺序错乱。

链表翻转的经典错误场景在 4.3 节已经提到。归并排序的经典错误则是忘记把剩余元素合并进 temp。我建议在你的调试工具里专门准备一个长的测试用例,比如长度是 2^k 的数组和一个 len = 2^k + 1 的数组,分别测试偶数长度和奇数长度的边界情况。

在回溯题里,状态丢失的典型场景是res.append(path)而没有用path[:]。如果你发现输出的所有子集都一样且是最后一个状态,那几乎可以确定是这个原因。排查方法是打印每一步 path 和 res 的内容,观察 res 里的数据是否随着 path 的变化而变化。

8.3 自测用例设计与递归可视化技巧

学会设计自测用例,是排查递归问题最快的方法。

汉诺塔的自测用例:n = 1 时输出一步,n = 2 时输出三步,n = 3 时输出七步。如果输出步数不符,说明递归基或递归调用写错了。

链表翻转的自测用例:输入 1->2->3,期望输出 3->2->1;输入空链表,期望返回 None;输入只有一个节点的链表,期望返回原节点。这三种情况正好覆盖递归基的三种分支。

归并排序的自测用例:逆序数组 [5,4,3,2,1] 是必测项,因为它会给划分和合并过程施加最大压力;完全有序的数组 [1,2,3,4,5] 用来验证稳定性;包含重复元素的数组用来检查是否会破坏稳定性。

递归可视化技巧:在递归函数里加一个缩进参数,每次调用打印带缩进的日志。比如:

def hanoi(n, source, auxiliary, target, depth=0): print(" " * depth + f"hanoi({n}, {source}, {auxiliary}, {target})") # 原有逻辑

这样你能在终端里直观地看到递归树的展开和回溯过程。这个技巧看起来简单,但在调试递归逻辑时极为有效,远胜于盲猜加 print。

8.4 从专题一到后续专题的进阶路线

做完专题一,你已经掌握了递归的底层思维、返回值设计、分治方法和回溯框架。接下来的学习路线应该这样走:

专题二聚焦树形结构。二叉树的前序、中序、后序遍历,最大深度、直径、最近公共祖先等题目,本质上是把递归应用于一棵多分支的结构上。你会发现,树的递归代码往往是“递归基 + 当前节点逻辑 + 左右子树递归调用”的三段式,而每一段都有迹可循。

专题三聚焦搜索与回溯。DFS、BFS、全排列、组合、岛屿数量、单词搜索等题目,会在回溯三步法的基础上引入方向数组、visited 数组、剪枝条件等内容。如果说专题一让你学会“递归能解决问题”,那么专题三就是教你在“解空间”里高效地搜索答案。

专题四聚焦进阶优化。记忆化搜索、动态规划的状态转移、剪枝技巧、位运算加速等内容,会把前面所有知识整合起来,真正提升解题速度的上限。

学习递归最忌讳的两个心态是“只刷题不总结”和“只看答案不推导”。每做完一道题,宁可少做一道新题,也要花十分钟把这道题的递归树画一遍、复杂度推一遍、易错点记一遍。日积月累,你的直觉会越来越准。

我在带人的过程中,经常看到一种奇怪的循环:看视频课的时候觉得“我懂了”,一合上视频写代码就卡住。解决办法只有一个——动手。递归不是看会的,也不是背会的,是亲手画图、亲手推演、亲手踩坑踩出来的。这篇专题一的六道题,每一道都值得你写三遍:第一遍对照理解写,第二遍默写并加注释,第三遍独立推导复杂度并尝试一题多解。三遍下来,再遇到新的递归题,你心里会有一个自动运转的框架,而不会每次都是从头开始硬想。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询