欢迎光临
我们一直在努力

递归与回溯:用状态空间解决组合问题

摘要

递归通过把问题拆成规模更小的同类问题来求解,回溯则在递归基础上探索选择空间,并在不满足条件时撤销选择。排列、组合、子集、路径和棋盘问题都可以使用这一思想。

本文介绍递归函数的基本结构、终止条件、调用栈、回溯模板和剪枝方法,并通过全排列、子集、组合总和、迷宫路径和 N 皇后等示例,建立解决组合搜索问题的通用方法。

一、背景与问题

有一类问题很难通过简单的循环直接解决:

  • 从若干元素中选择不同组合。
  • 生成所有排列。
  • 在网格中寻找路径。
  • 放置棋子并满足约束。
  • 把一个问题拆成多个相同形式的子问题。

这些问题往往具有树形决策结构:

选择第一个元素
├─ 选择 A
│ ├─ 选择 B
│ └─ 选择 C
└─ 选择 B
├─ 选择 A
└─ 选择 C

递归负责沿着决策树向下探索,回溯负责在返回上一层时恢复状态。

二、核心概念

1. 递归的三个要素

一个正确的递归函数通常包含:

  • 基准条件:问题足够小,可以直接返回。
  • 递归调用:把问题缩小为同类子问题。
  • 状态推进:确保每次调用都接近终止条件。
  • 缺少基准条件会无限递归,状态不推进也会导致栈溢出。

    2. 调用栈

    递归调用会在调用栈中保存每一层的局部变量和返回位置:

    solve(3)
    → solve(2)
    → solve(1)
    → 返回
    → 返回
    → 返回

    递归深度较大时会消耗栈空间,可以考虑改写为显式栈或使用动态规划。

    3. 回溯状态

    回溯通常维护:

    • 当前路径。
    • 已使用元素。
    • 当前下标。
    • 剩余容量或目标值。
    • 当前棋盘状态。

    每次做出选择后递归,返回时撤销选择:

    做选择
    → 递归探索
    → 撤销选择

    4. 状态空间树

    每个节点表示一个部分解,每条边表示一次选择。叶子节点可能是完整解,也可能是违反约束的失败解。

    状态空间树可能指数增长,因此回溯通常需要剪枝。

    5. 剪枝

    剪枝是在确定当前路径不可能得到有效答案时提前停止:

    • 当前和已经超过目标。
    • 剩余元素不足。
    • 当前棋盘冲突。
    • 当前排序条件不满足。
    • 同一层已经处理过相同选择。

    剪枝的目标不是改变正确结果,而是减少无意义搜索。

    三、工作原理

    1. 通用回溯模板

    backtrack(state):
    if state 是完整解:
    记录答案
    return

    for choice in 当前可选项:
    if choice 不合法:
    continue

    做选择
    backtrack(新状态)
    撤销选择

    模板中的关键是状态定义和合法性判断。

    2. 排列与组合的区别

    问题是否关注顺序示例
    子集 不关注 [1, 2] 与 [2, 1] 相同
    组合 不关注 选 2 个人组成小组
    排列 关注 安排座位顺序

    组合通常通过递归下标避免重复选择,排列通常使用 used 数组记录元素是否已使用。

    3. 复杂度

    排列数量通常为 n!,子集数量为 2^n,组合数量为 C(n, k)。回溯问题的复杂度经常是指数级,因此输入规模通常不会很大,或者需要强剪枝。

    四、实战示例

    1. 计算阶乘

    def factorial(n: int) –> int:
    if n < 0:
    raise ValueError("n must be non-negative")
    if n <= 1:
    return 1
    return n * factorial(n – 1)

    基准条件是 n <= 1,递归规模每次减一。

    2. 生成子集

    def subsets(values: list[int]) –> list[list[int]]:
    result: list[list[int]] = []
    path: list[int] = []

    def backtrack(index: int) –> None:
    if index == len(values):
    result.append(path.copy())
    return

    backtrack(index + 1)

    path.append(values[index])
    backtrack(index + 1)
    path.pop()

    backtrack(0)
    return result

    每个元素都有“选择”和“不选择”两条分支,因此共有 2^n 个子集。

    3. 生成全排列

    def permutations(values: list[int]) –> list[list[int]]:
    result: list[list[int]] = []
    path: list[int] = []
    used = [False] * len(values)

    def backtrack() –> None:
    if len(path) == len(values):
    result.append(path.copy())
    return

    for index, value in enumerate(values):
    if used[index]:
    continue

    used[index] = True
    path.append(value)
    backtrack()
    path.pop()
    used[index] = False

    backtrack()
    return result

    used 保证同一个元素不会在一条路径中重复使用。

    4. 组合选择

    def combinations(
    values: list[int],
    size: int,
    ) –> list[list[int]]:
    result: list[list[int]] = []
    path: list[int] = []

    def backtrack(start: int) –> None:
    if len(path) == size:
    result.append(path.copy())
    return

    remaining = size – len(path)
    max_start = len(values) – remaining

    for index in range(start, max_start + 1):
    path.append(values[index])
    backtrack(index + 1)
    path.pop()

    backtrack(0)
    return result

    递归从 index + 1 开始,因此不会生成相同元素的反向组合。

    5. 组合总和

    def combination_sum(
    candidates: list[int],
    target: int,
    ) –> list[list[int]]:
    candidates = sorted(set(candidates))
    result: list[list[int]] = []
    path: list[int] = []

    def backtrack(start: int, remaining: int) –> None:
    if remaining == 0:
    result.append(path.copy())
    return
    if remaining < 0:
    return

    for index in range(start, len(candidates)):
    value = candidates[index]
    if value > remaining:
    break

    path.append(value)
    backtrack(index, remaining – value)
    path.pop()

    backtrack(0, target)
    return result

    排序后,当当前值大于剩余目标时,可以直接停止当前循环,这就是一种剪枝。

    6. 网格路径

    def count_paths(grid: list[list[int]]) –> int:
    rows = len(grid)
    cols = len(grid[0]) if rows else 0
    visited = [[False] * cols for _ in range(rows)]

    def dfs(row: int, col: int) –> int:
    if not (0 <= row < rows and 0 <= col < cols):
    return 0
    if grid[row][col] == 1 or visited[row][col]:
    return 0
    if row == rows – 1 and col == cols – 1:
    return 1

    visited[row][col] = True
    total = (
    dfs(row + 1, col)
    + dfs(row – 1, col)
    + dfs(row, col + 1)
    + dfs(row, col – 1)
    )
    visited[row][col] = False
    return total

    return dfs(0, 0)

    visited 防止在当前路径中重复进入同一个格子,递归返回后要撤销标记,让其他路径可以继续使用该格子。

    7. N 皇后

    def solve_n_queens(n: int) –> list[list[str]]:
    result: list[list[str]] = []
    board = [["."] * n for _ in range(n)]
    columns: set[int] = set()
    diagonals: set[int] = set()
    anti_diagonals: set[int] = set()

    def backtrack(row: int) –> None:
    if row == n:
    result.append(["".join(line) for line in board])
    return

    for col in range(n):
    diagonal = row – col
    anti_diagonal = row + col

    if (
    col in columns
    or diagonal in diagonals
    or anti_diagonal in anti_diagonals
    ):
    continue

    board[row][col] = "Q"
    columns.add(col)
    diagonals.add(diagonal)
    anti_diagonals.add(anti_diagonal)

    backtrack(row + 1)

    board[row][col] = "."
    columns.remove(col)
    diagonals.remove(diagonal)
    anti_diagonals.remove(anti_diagonal)

    backtrack(0)
    return result

    通过集合快速判断列和对角线是否冲突,避免每次扫描整张棋盘。

    五、常见问题与实践建议

    1. 为什么回溯结果要使用 path.copy()?

    因为递归过程中会不断修改同一个 path。如果直接把 path 放入结果,后续撤销操作会同时改变已经保存的结果。

    2. 为什么递归返回后必须撤销状态?

    不撤销会把上一条路径的选择带到下一条路径,导致结果错误。撤销是回溯区别于普通递归的关键。

    3. 如何避免重复组合?

    组合问题递归时传递下一个起始位置;排列问题使用 used 记录当前路径已经选择的元素;有重复输入时还要跳过同层重复值。

    4. 剪枝应该放在哪里?

    尽量在做选择前判断当前选择是否可能成功。越早剪枝,后续递归分支越少。

    5. 什么时候不适合使用回溯?

    当输入规模较大、状态大量重复或子问题具有重叠结构时,纯回溯可能指数爆炸。可以考虑动态规划、记忆化搜索、贪心或数学方法。

    六、进阶思考

    1. 回溯与动态规划

    回溯探索所有可能路径,动态规划保存重复子问题结果:

    回溯:强调搜索路径和约束
    动态规划:强调状态转移和重复利用

    当不同搜索路径会到达同一个状态时,可以加入缓存。

    2. 记忆化搜索

    from functools import lru_cache

    @lru_cache
    def climb_stairs(n: int) –> int:
    if n <= 2:
    return n
    return climb_stairs(n – 1) + climb_stairs(n – 2)

    缓存避免重复计算,但会占用额外空间。

    3. 状态压缩

    如果状态只包含若干布尔选择,可以使用整数位表示,减少复制和比较成本。状态压缩常用于子集、棋盘和图搜索。

    4. 搜索顺序

    先处理约束更严格的选择,通常更容易尽早发现冲突,从而产生更强剪枝。N 皇后、数独和排班问题都可以利用这一原则。

    结论

    递归负责拆解同类子问题,回溯负责在选择空间中探索并恢复状态。理解基准条件、状态定义、选择、递归和撤销五个环节后,可以解决子集、排列、组合、路径和棋盘等问题。

    面对指数级搜索,要通过排序、集合、边界判断、记忆化和更合理的搜索顺序进行剪枝。下一步可以继续学习动态规划,理解如何把重复子问题的结果保存下来。

    参考资料

  • Python 递归相关文档:https://docs.python.org/3/
  • Introduction to Algorithms:https://mitpress.mit.edu/9780262046305/introduction-to-algorithms/
  • Algorithm Design Manual:https://www.algorist.com/
  • 赞(0)
    未经允许不得转载:171主机测评 » 递归与回溯:用状态空间解决组合问题
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址