回溯算法总结

回溯算法总结:核心是画出递归树,一文搞定所有题型!

回溯算法是算法面试中的常客,也是很多初学者觉得“玄学”的部分。其实,回溯本质上就是决策树的遍历,只要掌握了画递归树的技巧,理解“选择-递归-撤销”的套路,就能轻松应对各种题型。本文将从核心思想、模板、题型分类到剪枝技巧,带你彻底掌握回溯算法!


一、回溯算法是什么?

回溯算法(Backtracking)是一种通过试错来寻找所有解的算法。当它尝试某种选择时,如果发现该选择不符合要求(或已经找到解),就撤销该选择,回退到上一步,尝试其他选项。这个过程类似于在迷宫中的探索:走不通就回头,直到找到所有出口。

回溯的本质是深度优先搜索(DFS),通常用于解决:

  • 组合问题(如组合总和)
  • 子集问题(如子集 II)
  • 排列问题(如全排列)
  • 分割问题(如分割回文串)
  • 棋盘问题(如 N 皇后)

二、核心思想:画出递归树

回溯算法的核心是画出递归树。每一个节点代表当前的状态(已经做出的选择),分支代表下一步可做的选择。通过递归遍历整棵树,就能找到所有满足条件的路径。

举例: 全排列 [1,2,3] 的递归树(简化):

                []
       /        |        \
     [1]       [2]       [3]
    /  \       /  \      /  \
 [1,2] [1,3] [2,1] [2,3] [3,1] [3,2]
   |     |     |     |     |     |
[1,2,3] [1,3,2] ...(所有叶子节点)

叶子节点就是完整的排列。回溯算法通过深度优先遍历这棵树,并在到达叶子节点时记录结果。


三、回溯算法通用模板

def backtrack(路径, 选择列表):
    if 满足结束条件:
        结果.append(路径[:])  # 保存当前路径的副本
        return
    if 需要剪枝:
        return
    for 选择 in 选择列表:
        # 做选择
        路径.append(选择)
        # 递归,进入下一层决策树
        backtrack(新路径, 新选择列表)
        # 撤销选择
        路径.pop()

参数设计要点:

  • 路径:记录已经做出的选择。
  • 选择列表:当前可以做的选择,通常由起始索引、used 数组等控制。
  • 结束条件:到达决策树的底层(如路径长度等于 n,或和等于 target)。
  • 剪枝条件:提前终止不可能的分支,提高效率。

四、经典题型分类与解题套路

根据问题的不同要求,回溯算法有几种常见的变体。下表总结了常见题型及其核心技巧:

题型 特点 关键参数 是否排序 去重方式
组合总和 I 元素可重复使用,求组合 start = i 建议排序 自然避免重复(start 控制)
组合总和 II 元素不可重复,可能有重复值 start = i+1 必须排序 同一层跳过相同值
子集 I 求所有子集,元素互异 start = i+1 可选 自然避免重复
子集 II 求所有子集,可能有重复值 start = i+1 必须排序 同一层跳过相同值
全排列 I 元素互异,求所有排列 used 数组 不需要 用 used 避免重复使用
全排列 II 元素可能有重复,求所有排列 used 数组 必须排序 同一层跳过相同值(且 !used[i-1])
分割问题 按条件分割字符串 start 表示起始位置 通常不需 根据分割条件判断
棋盘问题 放置棋子,满足约束 通常逐行放置 不需要 用数组记录冲突

1. 组合类问题(无序)

关键:使用 start 参数控制下一层遍历的起点,避免产生重复组合(如 [1,2][2,1] 只取其一)。

  • 元素可重复使用:递归时传入 i

    def backtrack(start, ...):
        for i in range(start, len(nums)):
            path.append(nums[i])
            backtrack(i, ...)      # 可重复使用,仍从 i 开始
            path.pop()
    
  • 元素不可重复使用:递归时传入 i+1

    def backtrack(start, ...):
        for i in range(start, len(nums)):
            path.append(nums[i])
            backtrack(i+1, ...)    # 不可重复,从 i+1 开始
            path.pop()
    
  • 有重复元素:先排序,在 for 循环中跳过同一层相同的值。

    for i in range(start, len(nums)):
        if i > start and nums[i] == nums[i-1]:
            continue   # 跳过重复
        ...
    

典型例题

2. 排列类问题(有序)

关键:不能使用 start,因为每个位置都可以放任意一个未使用的元素。需要使用 used 数组标记哪些元素已经被选过。

  • 无重复元素:只需 used 标记。

    used = [False] * len(nums)
    def backtrack():
        if len(path) == len(nums):
            res.append(path[:])
            return
        for i in range(len(nums)):
            if used[i]:
                continue
            used[i] = True
            path.append(nums[i])
            backtrack()
            path.pop()
            used[i] = False
    
  • 有重复元素:先排序,在 for 循环中加上条件 if i > 0 and nums[i] == nums[i-1] and not used[i-1]: continue,保证同一层不会重复选取相同值。

典型例题

3. 分割类问题

关键:将问题转化为在字符串中插入分割线,每次从 start 开始截取子串,判断是否满足条件(如回文串)。通常也需要 start 参数。

def backtrack(start):
    if start == len(s):
        res.append(path[:])
        return
    for i in range(start, len(s)):
        if isPalindrome(s, start, i):
            path.append(s[start:i+1])
            backtrack(i+1)
            path.pop()

典型例题

4. 棋盘类问题(如 N 皇后)

关键:通常逐行放置,每层递归处理一行。需要记录已放置棋子的位置,判断是否冲突。

def backtrack(row):
    if row == n:
        res.append(board)
        return
    for col in range(n):
        if is_valid(row, col):
            place_queen(row, col)
            backtrack(row+1)
            remove_queen(row, col)

典型例题


五、剪枝优化:让回溯不再暴力

回溯本身是穷举,但通过剪枝可以大幅减少搜索空间。常见的剪枝方法:

  1. 和约束剪枝:在组合总和中,如果当前和加上当前元素已经超过 target,由于数组已排序,后续更大元素更不可能,直接 break
  2. 括号合法性剪枝:生成括号时,右括号不能多于左括号,左括号不能超过 n。
  3. 排列去重剪枝:全排列 II 中,利用 used[i-1] 的状态避免同层重复。
  4. 可行性剪枝:N 皇后中,提前判断当前位置是否与已放置皇后冲突,不合法则跳过。

六、实例分析:通过递归树理解回溯

例1:全排列 [1,2,3](used 数组)

递归树(简化,每个节点显示 path):

level0: []  
         ├─选择1→ [1]  
         │       ├─选择2→ [1,2]  
         │       │      └─选择3→ [1,2,3] ✅  
         │       └─选择3→ [1,3]  
         │              └─选择2→ [1,3,2] ✅  
         ├─选择2→ [2]  
         │       ├─选择1→ [2,1]  
         │       │      └─选择3→ [2,1,3] ✅  
         │       └─选择3→ [2,3]  
         │              └─选择1→ [2,3,1] ✅  
         └─选择3→ [3]  
                 ├─选择1→ [3,1]  
                 │      └─选择2→ [3,1,2] ✅  
                 └─选择2→ [3,2]  
                        └─选择1→ [3,2,1] ✅  

代码中,used 数组确保每个元素只被选一次。

例2:组合总和 [2,3,6,7], target=7(可重复)

递归树(部分):

sum=0 []
   ├─选2→ sum=2 [2]
   │    ├─选2→ sum=4 [2,2]
   │    │    ├─选2→ sum=6 [2,2,2]
   │    │    │    ├─选2→ sum=8>7 剪枝
   │    │    │    ├─选3→ sum=9>7 剪枝
   │    │    │    └─...
   │    │    ├─选3→ sum=7 [2,2,3] ✅
   │    │    └─...
   │    ├─选3→ sum=5 [2,3]
   │    │    ├─选2→ sum=7 [2,3,2] ❌ 但这里因为start=i,会导致 [2,3,2] 出现吗?不会,因为下一层从i=1开始(选3后i=1),所以不会重复选2吗?其实选3后,下一层可以从3开始(因为可重复),也可以从之后的6、7开始,但不会回头选2,因为start保证了不会回头,所以不会出现 [2,3,2](那是排列)。所以组合总和用start保证了组合的唯一性。
   │    └─...
   ├─选3→ sum=3 [3]
   │    ├─选3→ sum=6 [3,3]
   │    │    └─选3→ sum=9>7 剪枝
   │    └─...
   ├─选6→ sum=6 [6]
   │    └─选6→ sum=12>7 剪枝
   └─选7→ sum=7 [7] ✅

通过画递归树,可以直观看到剪枝的效果以及为什么用 start 可以避免重复组合。


七、总结:回溯算法三步走

  1. 画递归树:明确状态(路径、选择列表)、结束条件、剪枝条件。
  2. 套用模板:根据题型确定参数(是否用 start、used、是否排序等)。
  3. 剪枝优化:利用排序、约束条件减少无效搜索。

回溯算法虽然听起来复杂,但只要勤画图、多练习,就能掌握其中的套路。建议初学者从经典题目入手,一步步理解“选择-递归-撤销”的过程。当你看到一道新题,能立刻画出递归树时,就已经成功了一大半!

希望这篇总结能帮助你彻底攻克回溯算法!如果觉得有用,欢迎点赞、收藏、分享给更多的小伙伴。

posted @ 2026-03-11 17:47  Leon_LL  阅读(2)  评论(0)    收藏  举报