递归与回溯:从递归树到剪枝优化的系统方法

系统讲解递归的本质、递归树分析方法、尾递归优化,以及回溯法的通用模板与常用剪枝策略,涵盖排列组合、子集、N皇后等经典问题的递归与回溯解法。

递归与回溯:从递归树到剪枝优化

递归是算法面试中最核心的思维方式之一。从树的遍历到动态规划,从回溯搜索到分治算法,递归无处不在。

一、递归的本质

递归 = 重复调用自身 + 终止条件

def recursion(参数):
    if 终止条件:
        return 基准结果
    # 分解问题
    子问题结果 = recursion(更小的参数)
    # 合并结果
    return 合并(子问题结果)

经典示例:阶乘

def factorial(n):
    if n <= 1:
        return 1
    return n * factorial(n - 1)

调用栈展开:

factorial(5)
= 5 × factorial(4)
= 5 × (4 × factorial(3))
= 5 × (4 × (3 × factorial(2)))
= 5 × (4 × (3 × (2 × factorial(1))))
= 5 × (4 × (3 × (2 × 1)))
= 120

二、递归树分析

递归树是分析递归算法复杂度的有力工具。

斐波那契的递归树

def fib(n):
    if n <= 1:
        return n
    return fib(n-1) + fib(n-2)
                    fib(5)
                   /      \
               fib(4)    fib(3)
              /    \      /    \
           fib(3) fib(2) fib(2) fib(1)
          ...

问题:大量重复计算,时间复杂度 O(2ⁿ)。

优化:记忆化

from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n):
    if n <= 1:
        return n
    return fib(n-1) + fib(n-2)

时间复杂度降为 O(n),空间 O(n)。

三、尾递归优化

尾递归是递归的特殊形式:递归调用是函数的最后一个操作。

# 非尾递归:递归调用后还要做乘法
def factorial(n):
    if n <= 1:
        return 1
    return n * factorial(n - 1)  # 还有 ×n 操作

# 尾递归:最后操作就是递归调用
def factorial_tail(n, acc=1):
    if n <= 1:
        return acc
    return factorial_tail(n - 1, n * acc)

优势:部分语言(如 Scheme、Erlang)可将尾递归优化为循环,避免栈溢出。Python 不支持尾递归优化。

四、回溯法(Backtracking)

回溯法是递归的重要应用,用于搜索所有(或部分)解空间。

通用模板

def backtrack(路径, 选择列表):
    if 满足结束条件:
        result.add(路径)
        return
    for 选择 in 选择列表:
        if 剪枝条件:
            continue
        做选择
        backtrack(路径, 新选择列表)
        撤销选择  # 关键!恢复状态

回溯 = DFS + 状态恢复,「撤销选择」是核心。

经典问题 1:全排列(LeetCode 46)

def permute(nums):
    result = []
    def backtrack(path, used):
        if len(path) == len(nums):
            result.append(path[:])
            return
        for i in range(len(nums)):
            if used[i]:
                continue
            used[i] = True
            path.append(nums[i])
            backtrack(path, used)
            path.pop()
            used[i] = False
    backtrack([], [False] * len(nums))
    return result

经典问题 2:组合总和(LeetCode 39)

def combination_sum(candidates, target):
    result = []
    def backtrack(start, path, remaining):
        if remaining == 0:
            result.append(path[:])
            return
        if remaining < 0:
            return
        for i in range(start, len(candidates)):
            path.append(candidates[i])
            backtrack(i, path, remaining - candidates[i])  # 可重复选
            path.pop()
    backtrack(0, [], target)
    return result

经典问题 3:N 皇后(LeetCode 51)

def solve_n_queens(n):
    result = []
    board = [['.' for _ in range(n)] for _ in range(n)]

    def is_valid(row, col):
        # 检查同列
        for i in range(row):
            if board[i][col] == 'Q':
                return False
        # 检查左上
        i, j = row - 1, col - 1
        while i >= 0 and j >= 0:
            if board[i][j] == 'Q':
                return False
            i -= 1
            j -= 1
        # 检查右上
        i, j = row - 1, col + 1
        while i >= 0 and j < n:
            if board[i][j] == 'Q':
                return False
            i -= 1
            j += 1
        return True

    def backtrack(row):
        if row == n:
            result.append([''.join(r) for r in board])
            return
        for col in range(n):
            if is_valid(row, col):
                board[row][col] = 'Q'
                backtrack(row + 1)
                board[row][col] = '.'

    backtrack(0)
    return result

五、剪枝策略

1. 可行性剪枝

如果当前路径不可能到达解,提前返回。

# 组合总和中的剪枝
if remaining < 0:
    return  # 已经超过目标,无需继续

2. 重复剪枝

处理有重复元素时的重复解问题。

# 组合总和 II(LeetCode 40):候选有重复,解不能重复
for i in range(start, len(candidates)):
    if i > start and candidates[i] == candidates[i - 1]:
        continue  # 跳过同层相同元素,避免重复
    # ...

3. 记忆化剪枝

缓存已计算状态,避免重复搜索。

def can_win(state):
    if state in memo:
        return memo[state]
    # ... 计算 ...
    memo[state] = result
    return result

六、排列 vs 组合 vs 子集

问题顺序重要?可重复?代码特征
全排列是否used[] 标记,for i in range(n)
组合否否start 参数,for i in range(start, n)
子集否否每个节点都收集结果
组合总和 I否是backtrack(i) 而非 backtrack(i+1)
组合总和 II否否排序 + 去重剪枝

七、面试高频问题

题号题目类型
LeetCode 46全排列排列
LeetCode 47全排列 II(含重复)排列 + 去重
LeetCode 77组合组合
LeetCode 78子集子集
LeetCode 39组合总和组合 + 可重复
LeetCode 40组合总和 II组合 + 去重
LeetCode 51N 皇后棋盘类
LeetCode 37解数独搜索 + 约束
LeetCode 212单词搜索 IITrie + 回溯

八、常见问题

Q: 递归和迭代的区别?

  • 递归:代码简洁,有栈溢出风险,天然适合树形结构
  • 迭代:效率更高,需手动维护状态(栈/队列)
  • 面试建议:递归写法为主,能说出如何改迭代加分

Q: 回溯的时间复杂度怎么算?
通常是指数级 O(kⁿ) 或 O(n!),但因为剪枝,实际远小于理论上限。

Q: 什么时候用回溯不用 DP?

  • 需要「所有方案」→ 回溯
  • 只需要「最优解/数量」→ DP 更高效

相关文章:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页