回溯必刷10题:排列组合与搜索模板

精选 LeetCode 回溯法 10 道必刷题:全排列、子集、组合、组合总和、括号生成、N 皇后、单词搜索、复原 IP 地址、数独、分割回文串,一套通用模板吃透「选与不选」与剪枝去重。

引言

回溯(Backtracking)本质是带剪枝的 DFS,是面试中最「套模板」的一类题:排列、组合、子集、棋盘类搜索,共享同一套「做选择 → 递归 → 撤销选择」的骨架。刷透回溯题的关键不是背题,而是掌握选择列表的三种构造方式(元素、索引、剩余集合)和去重模板。

本文精选 10 道必刷回溯题,从最简单的子集开始,逐步引入去重、剪枝、棋盘约束,最后汇总一张「题目 → 模板变体」速查表。

前置:/leetcode-greedy-essential-problems/。递归与回溯原理参考 https://plumephp.com/recursion-backtracking/。


目录


1. 回溯通用模板

def backtrack(path, choices, ...):
    # 1. 结束条件:收集答案 / 剪枝
    if len(path) == k:
        result.append(path[:])
        return

    # 2. 遍历选择列表
    for i, choice in enumerate(choices):
        # 2a. 剪枝 / 去重
        if used[i]:
            continue
        # 2b. 做选择
        path.append(choice)
        used[i] = True
        # 2c. 递归下一层
        backtrack(path, choices, ...)
        # 2d. 撤销选择(回溯的关键)
        path.pop()
        used[i] = False

三步口诀:选 → 进 → 撤。撤销必须与选择严格对称,否则状态会串层。


2. 子集(LeetCode 78)— 入门骨架

问题:给定不含重复元素的数组,返回所有子集。

思路:对每个元素「选 or 不选」,或按 startIndex 收集每一层的路径。

def subsets(nums):
    result = []

    def backtrack(start, path):
        result.append(path[:])          # 每个路径都是答案
        for i in range(start, len(nums)):
            path.append(nums[i])
            backtrack(i + 1, path)      # 下一层从 i+1 开始(不回头)
            path.pop()

    backtrack(0, [])
    return result

要点:子集与组合的区别只在「何时收集答案」——子集收集所有路径,组合只收集长度达标的路径。


3. 全排列(LeetCode 46)— 排列与 used 数组

问题:给定不含重复元素的数组,返回所有排列。

思路:排列中每个位置都可以用任何元素,需要 used 数组记录已选;而子集/组合用 startIndex 避免回头。

def permute(nums):
    result = []
    n = len(nums)
    used = [False] * n

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

    backtrack([])
    return result

排列 vs 子集 vs 组合:

类型约束工具收集时机
子集startIndex(不回头)每层都收集
组合startIndex(不回头)长度 = k 时收集
全排列used 数组(可回头)长度 = n 时收集

4. 组合(LeetCode 77)— 组合与 startIndex

问题:从 1..n 中选 k 个数的所有组合。

思路:startIndex 保证「下一个元素永远比上一个位置靠后」,天然避免重复组合。

def combine(n, k):
    result = []

    def backtrack(start, path):
        if len(path) == k:
            result.append(path[:])
            return
        # 剪枝:剩余可选数必须足够填满 path
        # 最多还能取到 n - (k - len(path)) + 1
        for i in range(start, n - (k - len(path)) + 2):
            path.append(i)
            backtrack(i + 1, path)
            path.pop()

    backtrack(1, [])
    return result

剪枝技巧:for i in range(start, n - (k - len(path)) + 2) —— 当前已选 len(path) 个,还差 k - len(path) 个,i 最多取到 n - 剩余 + 1,否则后面不够数。


5. 子集 II(LeetCode 90)— 去重模板

问题:数组含重复元素,返回所有子集(不重复)。

去重模板:先排序,if i > start and nums[i] == nums[i-1]: continue 跳过同层重复。

def subsets_with_dup(nums):
    nums.sort()
    result = []

    def backtrack(start, path):
        result.append(path[:])
        for i in range(start, len(nums)):
            if i > start and nums[i] == nums[i - 1]:
                continue              # 同层去重
            path.append(nums[i])
            backtrack(i + 1, path)
            path.pop()

    backtrack(0, [])
    return result

为什么是 i > start 而不是 i > 0:i > start 只去掉同一层里重复选择;i > 0 会误伤「不同层但数值相同」的合法选择(如 [1,1,2] 选第一个 1 再选第二个 1 是合法的)。


6. 组合总和(LeetCode 39)— 无限重复

问题:数组无重复元素,元素可以无限次使用,求所有和为 target 的组合。

思路:因为元素可重复使用,下一层 startIndex 不 +1(可以再选当前元素),但配合 sum 剪枝。

def combination_sum(candidates, target):
    candidates.sort()
    result = []

    def backtrack(start, path, remaining):
        if remaining == 0:
            result.append(path[:])
            return
        for i in range(start, len(candidates)):
            if candidates[i] > remaining:    # 剪枝:剩余不够
                break
            path.append(candidates[i])
            backtrack(i, path, remaining - candidates[i])   # i 不 +1,可复用
            path.pop()

    backtrack(0, [], target)
    return result

变体:组合总和 II(LeetCode 40) — 元素只能使用一次 + 有重复 → 排序 + i > start 去重 + i+1 递归。


7. 括号生成(LeetCode 22)— 剪枝条件

问题:生成 n 对括号的所有合法组合。

思路:每次可加 ( 或 ),但右括号数绝不能超过左括号数(否则非法)。用两个计数器剪枝。

def generate_parenthesis(n):
    result = []

    def backtrack(path, left, right):
        if len(path) == 2 * n:
            result.append(path)
            return
        if left < n:
            backtrack(path + '(', left + 1, right)
        if right < left:
            backtrack(path + ')', left, right + 1)

    backtrack('', 0, 0)
    return result

剪枝本质:right < left 保证任意前缀中 ( ≥ )——这正是合法括号串的充要条件。


8. N 皇后(LeetCode 51)— 棋盘约束

问题:n×n 棋盘放 n 个皇后,任意两个不能同行同列同对角线。

思路:每行放一个,用三个集合记录「被占用的列、主对角线、副对角线」。行号由递归深度天然保证不冲突。

def solve_n_queens(n):
    result = []
    cols, diag1, diag2 = set(), set(), set()

    def backtrack(row, board):
        if row == n:
            result.append([''.join(r) for r in board])
            return
        for col in range(n):
            d1, d2 = row - col, row + col
            if col in cols or d1 in diag1 or d2 in diag2:
                continue
            cols.add(col); diag1.add(d1); diag2.add(d2)
            board[row][col] = 'Q'
            backtrack(row + 1, board)
            board[row][col] = '.'
            cols.remove(col); diag1.remove(d1); diag2.remove(d2)

    backtrack(0, [['.'] * n for _ in range(n)])
    return result

对角线技巧:主对角线 row - col 恒定,副对角线 row + col 恒定——这是棋盘类问题的通用技巧。


9. 单词搜索(LeetCode 79)— 网格 DFS

问题:在 m×n 网格中找单词,可上下左右走,每个格子只能用一次。

思路:从每个起点开始 DFS,边走边匹配,用「标记 → 撤销」代替 visited 数组(就地修改)。

def exist(board, word):
    m, n = len(board), len(board[0])
    dirs = [(0, 1), (0, -1), (1, 0), (-1, 0)]

    def dfs(i, j, k):
        if board[i][j] != word[k]:
            return False
        if k == len(word) - 1:
            return True
        board[i][j] = '#'            # 标记已访问
        for di, dj in dirs:
            ni, nj = i + di, j + dj
            if 0 <= ni < m and 0 <= nj < n and dfs(ni, nj, k + 1):
                return True
        board[i][j] = word[k]        # 撤销标记(回溯)
        return False

    for i in range(m):
        for j in range(n):
            if dfs(i, j, 0):
                return True
    return False

要点:网格 DFS 与普通回溯的区别是选择列表 = 四个方向 + 边界检查;# 标记在失败路径上会被自动撤销。


10. 回溯题解速查表

题目模板变体关键技巧
子集 78startIndex,全收集每层收集
子集 II 90startIndex + 去重i > start 同层去重
全排列 46used 数组可回头
全排列 II 47used + 去重排序 + i>0 and used[i-1]
组合 77startIndex + 剪枝剩余数不足剪枝
组合总和 39可复用递归 i 不 +1
组合总和 II 40不可复用 + 去重排序 + i > start
括号生成 22双计数剪枝right < left
N 皇后 51集合判冲突行列对角线
单词搜索 79网格 DFS就地标记

一句话总结:先确定「是排列还是组合」(used vs startIndex),再确定「能否重复」和「有无重复元素」(去重模板),最后套骨架。


延伸阅读

  • /leetcode-greedy-essential-problems/ — 贪心:另一类「局部决策」题
  • https://plumephp.com/recursion-backtracking/ — 回溯原理:递归树、剪枝策略、排列组合子集区分
  • https://plumephp.com/graph-theory/ — DFS/BFS 在图论中的系统应用
  • LeetCode 回溯标签 — 完整题库

继续阅读

探索更多技术文章

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

全部文章 返回首页

「algorithm-interview」更多文章

  1. 贪心必刷9题:从区间问题到序列贪心
  2. 数学必刷8题:素数、进制与数学建模
  3. 位运算必刷8题:异或、计数与二进制技巧