LeetCode · Backtracking
完整教程 · 全部内置 · 本机记录学习进度
INTERVIEW PREPARATION / BACKTRACKING

LeetCode 回溯专题
8 道题,建立系统的搜索思维

从“选什么、何时收集、如何撤销”出发,用 Python 3 把组合、排列、约束构造、字符串切分与棋盘搜索连成一条清晰的学习路线。

08精选题目 · 全量覆盖
04递进阶段
15完整 Python 解法

回溯的核心:在隐式决策树上进行深度优先搜索。沿一条路径做选择,必要时剪枝,保存合法答案,再恢复状态去尝试下一条路径。

讲解、代码、图示与交互均已内置。原题链接是可选在线入口,不影响离线阅读。学习记录保存在当前浏览器;移动文件、更换浏览器或清理浏览器数据可能使记录无法沿用。

学习路线:先定义状态,再写递归

阶段题目顺序本阶段重点
1. 基础知识与核心模板78 子集 → 77 组合从子集到定长组合
2. 典型应用:排列与多叉选择46 全排列 → 17 电话号码的字母组合区分候选来源与位置状态
3. 典型应用:约束与剪枝22 括号生成 → 39 组合总和让无效分支尽早停止
4. 进阶算法:切分与约束搜索131 分割回文串 → 51 N 皇后预处理协作与位运算优化

理解回溯:递归、DFS 与恢复状态

递归是函数调用方式;DFS 是优先走深的遍历策略;回溯强调尝试选择并恢复现场。搜索树无需显式建出来:每个节点是一份已作出的决定,每条边是一次新选择,递归参数描述下一步的限制。

① 检查状态→② 做出选择→③ 递归搜索→④ 撤销选择

纵向看深度:已经决定多少元素、位置、字符段或棋盘行。横向看分支:当前一步可以尝试哪些候选。for 循环枚举同一层,递归进入下一层;不要把循环次数与递归层数混为一谈。

子集 [1,2,3] 的递增下标搜索树,每个节点都是答案[][1][2][3][1,2][1,3][2,3][1,2,3]选 1选 2选 3只往后选,不会产生 [2,1]
每个节点保存一次。向下 append,退回 pop;start 规定后续可选下标。

核心模板:用四个问题确定搜索

设计问题需要明确的内容本专题例子
状态是什么?path + 描述下一步的参数start、位置 i、remain、左右计数、row
哪些候选合法?范围、已用状态、约束used[i]、value≤remain、right<left、斜线未占用
何时收集?每个节点还是完成状态子集每节点;排列长度 n;切分 start=n
什么要撤销?共享可变状态append/pop,add/remove,交换/交换回来
进入 dfs(状态)维护不变量:路径合法,共享状态与路径一致。
达到收集条件?复制答案;完成型问题随后 return,子集型继续扩展。
枚举候选 → 检查约束证明该分支不可能有答案,才可 continue、break 或 return。
选择 → 递归 → 撤销函数返回后,共享状态应与选择前完全一致。

参数与共享变量:整数、字符串参数产生新值时无需改回;外层列表和集合被原地修改时必须恢复。列表答案用 copy,字符串答案用 join。所有解法都使用局部 ans,重复调用同一个 Solution 实例不会残留上次答案。

证明三个落点:合法性:只扩展合法状态;完整性:任一答案对应未被剪掉的路径;唯一性:递增下标、位置选择或唯一切分序列避免重复。

交互推演:观察选择、保存和撤销

用 78 题主解走一遍 nums=[1,2]。“保存”复制当前 path;回到父层不会删除已保存的答案。

1 / 13

第 1 阶段 · 基础知识与核心模板

从子集到定长组合。

01LEETCODE 78子集

原题:78. 子集 ↗

难度:中等 | 核心知识点:子集树、start、路径快照、位掩码

题意与边界

给定元素互不相同的整数数组 nums,返回所有子集,包括空集与全集。子集不考虑排列顺序,答案中不能重复。原题 1 ≤ n ≤ 10,元素可为负数;可用性由下标决定,不能用数值大小判断。

解题思路一:枚举下一个元素(主解)

把 dfs(start) 定义为:路径已固定,接下来只能选择下标 ≥ start 的元素。进入函数时,当前路径本身已经是一个合法子集,立即保存快照。再枚举下一个元素,递归传入 i+1。下标严格递增,使同一批元素只按一种顺序生成。

Python 3 · 枚举下一个元素
from typing import List

class Solution:
    def subsets(self, nums: List[int]) -> List[List[int]]:
        ans, path = [], []

        def dfs(start: int) -> None:
            ans.append(path.copy())
            for i in range(start, len(nums)):
                path.append(nums[i])
                dfs(i + 1)
                path.pop()

        dfs(0)
        return ans

关键代码解释

  • ans.append(path.copy()) 在每个节点执行,根节点保存 [];若仅在叶子保存,会漏掉短子集。
  • dfs(i + 1) 同时保证一个元素最多使用一次、不会出现 [2,1] 这样的重排重复。
  • path.pop() 恢复进入当前选择前的路径,下一分支才能从相同父状态出发。

示例推演

示例 / 输出(答案顺序不限)
输入:nums = [1,2,3]
输出:[[],[1],[1,2],[1,2,3],[1,3],[2],[2,3],[3]]
事件path发生了什么
进入 dfs(0)[]保存空集
选 1 → dfs(1)[1]保存 [1]
选 2 → dfs(2)[1,2]保存 [1,2]
选 3 → dfs(3)[1,2,3]保存全集,循环为空
弹出 3,再弹出 2[1]回到 [1] 所在层,继续选 3
选 3 → dfs(3)[1,3]保存后撤销,再回根节点选 2、3

时间复杂度与空间复杂度

共有 2ⁿ 个子集,所有结果的元素总数为 n·2ⁿ⁻¹(n≥1)。时间 Θ(n·2ⁿ),由复制输出主导;辅助空间 O(n)(路径与递归栈);输出空间 Θ(n·2ⁿ)。

解题思路二:每个元素选或不选

固定处理下标 i,只有“不选 nums[i]”与“选 nums[i]”两个分支。所有决策结束后才收集答案;这与主解在每个节点收集的方式不同。

Python 3 · 选或不选
from typing import List

class Solution:
    def subsets(self, nums: List[int]) -> List[List[int]]:
        ans, path = [], []

        def dfs(i: int) -> None:
            if i == len(nums):
                ans.append(path.copy())
                return
            dfs(i + 1)  # 不选
            path.append(nums[i])
            dfs(i + 1)  # 选
            path.pop()

        dfs(0)
        return ans

关键是 i==len(nums) 才保存;每层都前进到 i+1。对 [1,2],四片叶子依次为 []、[2]、[1]、[1,2]。时间 Θ(n·2ⁿ),辅助空间 O(n),输出 Θ(n·2ⁿ)。

解题思路三:位掩码枚举

用 n 位二进制数表示是否选择每个下标,枚举 0 到 2ⁿ−1。第 i 位为 1 就收录 nums[i];例如 [1,2,3] 的掩码 101 表示 [1,3]。

Python 3 · 位掩码枚举
from typing import List

class Solution:
    def subsets(self, nums: List[int]) -> List[List[int]]:
        n = len(nums)
        ans = []
        for mask in range(1 << n):
            subset = []
            for i in range(n):
                if mask & (1 << i):
                    subset.append(nums[i])
            ans.append(subset)
        return ans

时间 Θ(n·2ⁿ),结果之外辅助空间 O(n) 用于当前构造的子集(本题范围内,掩码按固定字长计);无递归栈。

解法优点取舍
枚举下一个直接迁移到组合、组合总和需要理解每个节点都可收集
选或不选决策树直观固定走到第 n 层才保存
位掩码无需递归,状态紧凑不便按复杂约束提前剪枝

易错点

  • 不能直接保存可变路径 ans.append(path),否则答案共享同一列表。
  • 无需排序;原题元素互异,start 控制的是下标顺序。
  • 原题不含空数组;若扩展输入 [],三种实现都返回 [[]],与幂集定义一致。

02LEETCODE 77组合

原题:77. 组合 ↗

难度:中等 | 核心知识点:定长组合、剩余名额、可行性剪枝

题意与边界

从整数 1 到 n 中选 k 个不同数字,返回全部组合。原题 1 ≤ k ≤ n ≤ 20,顺序不重要:[1,2] 和 [2,1] 是同一组合。

解题思路一:递增选择 + 上界剪枝(主解)

沿用 dfs(start),只在路径长度等于 k 时收集。设还缺 need=k−len(path) 个数;本轮选择 x 后,区间 [x+1,n] 必须至少剩 need−1 个数,因此 x≤n−need+1。最后可选值随路径长度变化。

n − x ≥ need − 1 ⇒ x ≤ n − need + 1
Python 3 · 组合上界剪枝
from typing import List

class Solution:
    def combine(self, n: int, k: int) -> List[List[int]]:
        ans, path = [], []

        def dfs(start: int) -> None:
            if len(path) == k:
                ans.append(path.copy())
                return
            need = k - len(path)
            for x in range(start, n - need + 2):
                path.append(x)
                dfs(x + 1)
                path.pop()

        dfs(1)
        return ans

关键代码解释

  • range 不包含右端点,所以写 n - need + 2。
  • 传入 x+1,避免重复使用 x,也避免重排组合。
  • 达到 k 后必须 return,不再扩展更长路径。

示例推演

示例 / 输出(答案顺序不限)
输入:n = 4, k = 2
输出:[[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]
当前路径还缺 need本层候选效果
[]21、2、3根节点不选 4:其后已无第二个数
[1]12、3、4保存 [1,2]、[1,3]、[1,4]
[2]13、4生成 [2,3]、[2,4]
[3]14生成 [3,4] 后结束

时间复杂度与空间复杂度

设 B=C(n,k)。剪枝后每个访问到的前缀都能完成为答案,节点数可用 B 条、每条至多 k 的根叶路径上界计数。复制每个答案也需 O(k),总时间 O(k·B);辅助空间 O(k);输出空间 Θ(k·B)。

解题思路二:选或不选 + 剩余数量剪枝

处理整数 x,分别考虑选与不选。展开前检查 n−x+1 是否足够补齐路径;不足就无需访问整棵子树。

Python 3 · 选或不选与容量剪枝
from typing import List

class Solution:
    def combine(self, n: int, k: int) -> List[List[int]]:
        ans, path = [], []

        def dfs(x: int) -> None:
            if len(path) == k:
                ans.append(path.copy())
                return
            if n - x + 1 < k - len(path):
                return
            path.append(x)
            dfs(x + 1)
            path.pop()
            dfs(x + 1)

        dfs(1)
        return ans

n=4,k=2,path=[3]、x=4 时选择 4 得到 [3,4];不选 4 后 x=5,剩余 0 个数,立即停止。先检查完成条件,再判断余量。保守时间上界 O(n·B),递归深度可到 n,辅助空间 O(n),输出 Θ(k·B)。循环法把连续“不选”压缩为枚举,通常更简洁,且栈只需 O(k)。

易错点

  • 固定枚举到 n 也正确,但会搜索凑不齐 k 个数的分支;剪枝上界别少 1。
  • 无需 used;递增 start 已足够,used 不能代替组合的顺序规范。
  • k=n 时仅有 [1,…,n];k=1 应有每个单元素。扩展 k=0,两实现均返回 [[]]。

第 2 阶段 · 典型应用:排列与多叉选择

区分候选来源与位置状态。

03LEETCODE 46全排列

原题:46. 全排列 ↗

难度:中等 | 核心知识点:排列树、used、原地交换、状态恢复

题意与边界

给定 n 个互不相同的整数,返回全部长度为 n 的排列。原题 1 ≤ n ≤ 6。排列区分顺序,[1,2] 与 [2,1] 必须都出现。

解题思路一:used 标记(主解)

每层决定一个位置放谁。候选始终是所有元素,used[i] 标记下标 i 是否已经出现在当前路径中。长度到 n 才收集。这里不能让 start 单调递增,否则后续无法选择更早元素,会退化为组合。

Python 3 · used 标记全排列
from typing import List

class Solution:
    def permute(self, nums: List[int]) -> List[List[int]]:
        ans, path = [], []
        used = [False] * len(nums)

        def dfs() -> None:
            if len(path) == len(nums):
                ans.append(path.copy())
                return
            for i, value in enumerate(nums):
                if used[i]:
                    continue
                used[i] = True
                path.append(value)
                dfs()
                path.pop()
                used[i] = False

        dfs()
        return ans

关键代码解释

  • used[i] 属于当前路径,不是永久访问标记;兄弟分支仍可用相同元素。
  • 选择时同时修改 path 与 used,返回后两个状态都要恢复。
  • 用下标标记可用性;原题元素互异,无需额外去重。

示例推演

示例 / 输出(答案顺序不限)
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
操作pathused(按下标)
选 1[1][True,False,False]
选 2,再选 3[1,2,3][True,True,True]
保存,撤销 3 和 2[1][True,False,False]
选 3,再选 2[1,3,2][True,True,True]
最终回根节点[][False,False,False];可开始选 2

时间复杂度与空间复杂度

n! 个答案,每份复制 n 个元素。各层扫描候选的总代价也被 O(n·n!) 覆盖,时间 Θ(n·n!);辅助空间 O(n)(used、path、栈);输出空间 Θ(n·n!)。

解题思路二:原地交换

把 nums[0:first] 看成已固定前缀。从 first 到 n−1 选择一个元素,与 first 交换,然后固定下一位置。返回后再次交换恢复。

Python 3 · 交换固定前缀
from typing import List

class Solution:
    def permute(self, nums: List[int]) -> List[List[int]]:
        ans = []

        def dfs(first: int) -> None:
            if first == len(nums):
                ans.append(nums.copy())
                return
            for i in range(first, len(nums)):
                nums[first], nums[i] = nums[i], nums[first]
                dfs(first + 1)
                nums[first], nums[i] = nums[i], nums[first]

        dfs(0)
        return ans

例如 [1,2,3] 固定首位 1 后,交换后两位得到 [1,3,2];交换恢复后,再让 2 到首位。first 表示要填的位置,不是禁止使用较早下标的 start。时间 Θ(n·n!),辅助 O(n) 仅来自栈,输出 Θ(n·n!)。执行中修改 nums,正常返回时完全恢复。used 法更易增加候选约束,交换法省去 used 与 path 数组。

易错点

  • 列表 value in path 查询是 O(n),不能替换索引标记后仍声称查询 O(1)。
  • 漏恢复 used 或漏交换回来,会污染后续分支。
  • 原题不含重复元素;若允许重复,以上实现需另加同层去重。
  • 单元素 [0] 返回 [[0]];扩展空数组返回 [[]]。

04LEETCODE 17电话号码的字母组合

难度:中等 | 核心知识点:多叉树、固定位置、笛卡尔积

题意与边界

将只包含 2–9 的数字字符串转换为所有可能字母串。每个数字恰好选一个对应字母,字符顺序与数字位置一致。当前原题长度为 1–4;实现额外兼容空串并返回 []。0 和 1 不属于输入范围。

数字23456789
字母abcdefghijklmnopqrstuvwxyz

解题思路一:按数字位置递归(主解)

dfs(i) 表示前 i 个数字已映射,接下来枚举第 i 个数字的字母。每层候选集合不同,同一字母可以出现在不同位置,无需 used 或 start。到 i=m 时 join 路径。

Python 3 · 按数字位置 DFS
from typing import List

class Solution:
    def letterCombinations(self, digits: str) -> List[str]:
        if not digits:
            return []
        mapping = {"2": "abc", "3": "def", "4": "ghi",
                   "5": "jkl", "6": "mno", "7": "pqrs",
                   "8": "tuv", "9": "wxyz"}
        ans, path = [], []

        def dfs(i: int) -> None:
            if i == len(digits):
                ans.append("".join(path))
                return
            for ch in mapping[digits[i]]:
                path.append(ch)
                dfs(i + 1)
                path.pop()

        dfs(0)
        return ans

关键代码解释

  • mapping[digits[i]] 决定当前层的全部分支,而非枚举所有字母。
  • join 生成独立字符串,代价 O(m),不能省略在复杂度分析中。
  • 空串特判防止根节点直接收集成 [""]。

示例推演

示例 / 输出(答案顺序不限)
输入:digits = "23"
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]
层路径变化收集或撤销
i=0,数字 2[] → [a]进入下一层
i=1,数字 3[a,d] → [a,e] → [a,f]每条长度 2 的路径保存后撤销末位
回到 i=0撤销 a,再选 b得到 bd、be、bf
最后选 ccd、ce、cf共 3×3=9 种

时间复杂度与空间复杂度

设 m 为数字个数,a 个数字对应 3 个字母,b 个对应 4 个,P=3ᵃ·4ᵇ,m=a+b。时间 Θ(m·P),最坏 O(m·4ᵐ);辅助空间 O(m);输出 Θ(m·P)。

解题思路二:迭代扩展笛卡尔积

从一个空前缀开始,每读入一个数字,将已有前缀与它的字母两两拼接。无需共享可变路径,也无需撤销。

Python 3 · 迭代笛卡尔积
from typing import List

class Solution:
    def letterCombinations(self, digits: str) -> List[str]:
        if not digits:
            return []
        mapping = {"2": "abc", "3": "def", "4": "ghi",
                   "5": "jkl", "6": "mno", "7": "pqrs",
                   "8": "tuv", "9": "wxyz"}
        result = [""]
        for digit in digits:
            result = [prefix + ch
                      for prefix in result
                      for ch in mapping[digit]]
        return result

"23" 的扩展为 [""] → ["a","b","c"] → 九个双字符结果。时间 Θ(m·P),总峰值空间 O(m·P);若不计最终结果,构造新一层时仍保留旧一层前缀,辅助空间最坏上界也为 O(m·P),不能宣称是 DFS 的 O(m)。DFS 易加前缀剪枝,迭代法更短并避开递归。

易错点

  • 7、9 各对应四个字母,并非每层都只有三分支。
  • digits="22" 应有 "aa";全局 used 会误删答案。
  • 不能排序数字;"23" 与 "32" 的输出位置不同。
  • "2" 返回 ["a","b","c"];空串扩展返回 []。

第 3 阶段 · 典型应用:约束与剪枝

让无效分支尽早停止。

05LEETCODE 22括号生成

难度:中等 | 核心知识点:合法前缀、左右计数、卡特兰数

题意与边界

给定 n 对括号,生成全部有效括号串。原题 1 ≤ n ≤ 8。有效不仅要求总数匹配,还要求任意前缀中右括号数不超过左括号数。

解题思路:只扩展合法前缀

left、right 分别记录已用的左、右括号数量。还剩左括号时可以添加左括号;仅当 right<left 时可以添加右括号。生成时就排除无效前缀,比先生成 2²ⁿ 个括号串再检验更有效。

0 ≤ right ≤ left ≤ n
Python 3 · 合法前缀剪枝
from typing import List

class Solution:
    def generateParenthesis(self, n: int) -> List[str]:
        ans, path = [], []

        def dfs(left: int, right: int) -> None:
            if left == n and right == n:
                ans.append("".join(path))
                return
            if left < n:
                path.append("(")
                dfs(left + 1, right)
                path.pop()
            if right < left:
                path.append(")")
                dfs(left, right + 1)
                path.pop()

        dfs(0, 0)
        return ans

关键代码解释

  • 两个条件必须是独立的 if:一个前缀可能既能加左括号,也能加右括号。
  • right<left 表示当前至少有一个未匹配左括号;仅检查 right<n 不够。
  • 递归参数是整数,子调用不会修改父层 left/right;共享列表 path 才需 pop。

示例推演

示例 / 输出(答案顺序不限)
输入:n = 3
输出:["((()))","(()())","(())()","()(())","()()()"]
前缀left / right下一步原因
空串0 / 0只能加 (右括号会使余额为负
(1 / 0可加 ( 或 )两个独立分支
(((3 / 0只能加 )左括号配额用完
(()2 / 1可加 ( 或 )分别通向 (()())、(())()
()1 / 1只能加 (没有未匹配左括号

时间复杂度与空间复杂度

答案数为第 n 个卡特兰数 Cₙ=C(2n,n)/(n+1),渐近 Θ(4ⁿ/n³ᐟ²)。每个答案长度 2n,所有合法前缀都可延伸到答案,节点可用答案路径覆盖。生成与复制总时间 Θ(n·Cₙ),即 Θ(4ⁿ/√n);辅助空间 O(n)(路径与栈深度至多 2n);输出 Θ(n·Cₙ)。

正确性:每个有效串的前缀均满足约束,故其路径不会被剪掉,保证完整;每条选择序列唯一确定一个字符串,保证不重;达到 n,n 时,计数与前缀都合法。

方法比较

暴力生成所有长 2n 的括号串再检验,需要 O(n·4ⁿ) 时间和 O(n) 工作空间。主解把合法性变为两个局部条件,直接生成合法前缀;这正是本题应掌握的优化,不必先写低效暴力。

易错点

  • 把两个 if 写成 if/elif 会漏掉同一前缀下的另一合法选择。
  • right≤left 会在相等时放入非法右括号,应为严格小于。
  • 总数相等并不意味着有效,例如 ")("。
  • n=1 返回 ["()"];扩展 n=0 返回 [""],表示唯一空构造。

06LEETCODE 39组合总和

难度:中等 | 核心知识点:可重复选择、排序剪枝、剩余目标

题意与边界

给定互不相同的正整数 candidates 和正目标 target,返回全部和为 target 的不同组合。同一数字可无限次使用,组合内部顺序不重要。原题候选数 1–30,数值 2–40,target 为 1–40;正数保证递归终止。

解题思路:递增下标 + 允许复用当前位置

dfs(start,remain) 表示接下来只选 start 及之后的候选,还需凑 remain。先排序,遇到候选大于 remain 时 break,后续更大值同样不可选。选择下标 i 后仍传 i,让它可继续使用;不回到更早下标,消除 [2,2,3] 与 [3,2,2] 的重排重复。

Python 3 · 排序与重复选择
from typing import List

class Solution:
    def combinationSum(self, candidates: List[int],
                       target: int) -> List[List[int]]:
        nums = sorted(candidates)
        ans, path = [], []

        def dfs(start: int, remain: int) -> None:
            if remain == 0:
                ans.append(path.copy())
                return
            for i in range(start, len(nums)):
                value = nums[i]
                if value > remain:
                    break
                path.append(value)
                dfs(i, remain - value)
                path.pop()

        dfs(0, target)
        return ans

关键代码解释

  • sorted 保留原输入,并让 break 有效;未经排序只能 continue 跳过当前过大值。
  • dfs(i, remain - value) 不能改成 i+1,否则禁止复用,改变题意。
  • 无需 used;重复使用被允许,重复排列由非递减下标避免。
  • remain 是不可变整数,无需手动加回;共享列表 path 仍需恢复。

示例推演

示例 / 输出(答案顺序不限)
输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]
边界:candidates = [2], target = 1 → []
路径remain下一步与剪枝
[]7先选 2
[2]5仍可从下标 0 选 2
[2,2]3尝试 2 后剩 1,无候选可选;退回再选 3
[2,2,3]0保存;逐层撤销
[3]4再选 3 后剩 1;遇 6>4 时 break
[7]0保存第二个答案

时间复杂度与空间复杂度

设 m 为候选数,D=⌊target/min(candidates)⌋ 为最大路径长度,R 为答案数,S 为所有输出组合长度之和,V 为实际访问节点数。排序 O(m log m)。循环每一次成功尝试生成一条边,每个节点至多另遇一次 break,所以搜索 O(V),结果复制 O(S)。总时间 O(m log m+V+S)。

只依赖输入参数的安全上界:深度 d 的非递减下标序列至多 C(m+d−1,d) 种;0 到 D 层合计至多 C(m+D,D) 个节点。于是时间上界为 O(m log m+C(m+D,D)+R·D)。这不是精确等式,正数和目标限制会剪掉很多节点;不能统一写 O(2ᵐ),因为元素允许重复。辅助空间 O(m+D)(排序副本及工作区、路径、栈),输出空间 O(R+S)。

终止与剪枝都依赖正数条件。允许 0 会使重复选择时 remain 不变;允许负数会使 value>remain 的剪枝失效,这些均不属于原题。

易错点

  • 不能把 start 重置为 0,否则会按多种顺序生成同一组合。
  • 不能为了“去重”而禁止同一个数再次出现。
  • [7]、target=7 应返回 [[7]];无解返回 [],不是 [[]]。
  • 本题候选互异,无需邻值去重;不要混用含重复候选题目的规则。

第 4 阶段 · 进阶算法:切分与约束搜索

预处理协作与位运算优化。

07LEETCODE 131分割回文串

难度:中等 | 核心知识点:区间切分、回文判断、动态规划预处理

题意与边界

把字符串 s 切为若干连续非空子串,要求每一段都是回文,返回全部方案。每个字符按原序恰好覆盖一次。原题 1 ≤ n ≤ 16,只含小写字母;不能跳字符或重新排列字符。

解题思路一:枚举段尾 + 即时回文检查

dfs(start) 表示 s[:start] 已合法切分,接下来选择下一段的右端 end。若 s[start:end+1] 为回文就加入路径,继续从 end+1 切下一段。只有 start==n 才覆盖了完整字符串。

Python 3 · 即时回文检查
from typing import List

class Solution:
    def partition(self, s: str) -> List[List[str]]:
        ans, path = [], []

        def dfs(start: int) -> None:
            if start == len(s):
                ans.append(path.copy())
                return
            for end in range(start, len(s)):
                piece = s[start:end + 1]
                if piece != piece[::-1]:
                    continue
                path.append(piece)
                dfs(end + 1)
                path.pop()

        dfs(0)
        return ans

关键代码解释

  • 切片右端不包含,所以写 end+1。
  • 长度为 ℓ 时,切片、反转与比较均需 O(ℓ),并非 O(1)。
  • 下一起点必须为 end+1,不能 start+1:每段长度不固定。
  • path.copy() 复制字符串引用,字符串不可变,浅拷贝已安全。

示例推演

aab 的两种回文分割:a、a、b 或 aa、baabaab切两次切一次
切分要连续覆盖:选择一段后,下一段从 end+1 开始。
示例 / 输出(答案顺序不限)
输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]
输入:s = "a" → [["a"]]
起点尝试段结果
0a回文,从下标 1 继续
1a,再接 b到 start=3,保存 [a,a,b]
1ab非回文,跳过当前段
0aa,再接 b保存 [aa,b]
0aab非回文,结束

时间复杂度与空间复杂度

n−1 个缝隙可切可不切,最多 2ⁿ⁻¹ 份答案。直接按“节点×候选段尾×回文长度”放宽可得 O(n²·2ⁿ) 的安全上界;更精细地按 start 分组:到达 start=t 的调用最多 O(2ᵗ),每次检查所有段的字符工作量 O((n−t)²),总和 Σ 2ᵗ(n−t)²=O(2ⁿ)。再加保存答案的成本,总时间可收紧为 O(n·2ⁿ)。

辅助空间 O(n):递归深度 O(n);活跃路径中的切片覆盖互不重叠的已选前缀,字符总量至多 n;当前回文检查的临时反转串也至多 n。输出空间最坏 O(n·2ⁿ)。共享字符串可减少实际内存,但这里不依赖共享来降低输出上界。

解题思路二:DP 预处理回文表(进阶)

定义 pal[i][j] 表示 s[i:j+1] 是否为回文。两端相等,且内部回文或长度至多 2,就成立。i 从右往左,保证 pal[i+1][j−1] 已算出;回溯时先 O(1) 查表,合法才创建切片。

pal[i][j] = (s[i] == s[j]) and (j−i < 2 or pal[i+1][j−1])
Python 3 · 回文 DP 与回溯
from typing import List

class Solution:
    def partition(self, s: str) -> List[List[str]]:
        n = len(s)
        pal = [[False] * n for _ in range(n)]
        for i in range(n - 1, -1, -1):
            for j in range(i, n):
                pal[i][j] = (s[i] == s[j] and
                             (j - i < 2 or pal[i + 1][j - 1]))
        ans, path = [], []

        def dfs(start: int) -> None:
            if start == n:
                ans.append(path.copy())
                return
            for end in range(start, n):
                if not pal[start][end]:
                    continue
                path.append(s[start:end + 1])
                dfs(end + 1)
                path.pop()

        dfs(0)
        return ans

对 "aab",单字符表项与 pal[0][1] 为 True;pal[1][2]、pal[0][2] 为 False。搜索分支与直接法一致,合法性改成查表。短路表达式先处理长度 1、2,避免访问不存在的内部区间。

预处理时间、空间 O(n²);搜索连同切片、输出复制为 O(n·2ⁿ),总时间 O(n²+n·2ⁿ);辅助空间 O(n²),输出 O(n·2ⁿ)。DP 减少重复回文判断,但整体仍受枚举输出限制,不能承诺变为多项式时间。

解法优点取舍
直接检查首次学习,代码简洁重复切片与反转
回文 DP合法性 O(1) 查询,适合扩展额外 O(n²) 表,不保证所有输入都更快

易错点

  • 整串回文时仍需枚举其他分割;"aaa" 有 [a,a,a]、[a,aa]、[aa,a]、[aaa]。
  • 不能在中间节点收集,那只是尚未完整覆盖的前缀分割。
  • 二维表不能写 [[False] * n] * n,那会让各行共享同一列表。
  • 原题字符串非空;扩展空串时两实现返回 [[]],表示唯一空切分。

08LEETCODE 51N 皇后

原题:51. N 皇后 ↗

难度:困难 | 核心知识点:按行放置、列与对角线、集合剪枝、位运算

题意与边界

在 n×n 棋盘上放 n 个皇后,使任意两者不在同一行、同一列或同一斜线。返回全部棋盘,每行是长度 n 的字符串,Q 为皇后,. 为空格。原题 1 ≤ n ≤ 9;n=2、3 无解。

解题思路一:逐行放置 + 三个集合(主解)

每层固定一行,只放一个皇后,自然排除行冲突。列冲突检查 col;同一条左上到右下斜线的 r−c 相同,同一条右上到左下斜线的 r+c 相同。三个集合只记录已放皇后的占用,放置时加入,退回时删除。

同列:c 相同 | 主对角线:r−c 相同 | 副对角线:r+c 相同
Python 3 · 列与对角线集合
from typing import List

class Solution:
    def solveNQueens(self, n: int) -> List[List[str]]:
        ans, placement = [], []
        cols, diag1, diag2 = set(), set(), set()

        def dfs(row: int) -> None:
            if row == n:
                ans.append(["." * c + "Q" + "." * (n - c - 1)
                            for c in placement])
                return
            for col in range(n):
                if (col in cols or row - col in diag1
                        or row + col in diag2):
                    continue
                placement.append(col)
                cols.add(col)
                diag1.add(row - col)
                diag2.add(row + col)
                dfs(row + 1)
                diag2.remove(row + col)
                diag1.remove(row - col)
                cols.remove(col)
                placement.pop()

        dfs(0)
        return ans
0 基下标 placement=[1,3,0,2]:行、列与两类斜线均无冲突。

关键代码解释

  • placement[row]=col 记录每行列号,仅在找到答案后构造 n 行字符串,无需每个节点复制棋盘。
  • r−c 可为负,集合可直接保存;改成数组下标时才需偏移 n−1。
  • 返回后恢复三类占用和 placement,才能试下一列。
  • 每行每列最多一个皇后仍不够,两类斜线也必须检查。

示例推演

示例 / 输出(答案顺序不限)
输入:n = 4
输出:[[".Q..","...Q","Q...","..Q."],
       ["..Q.","Q...","...Q",".Q.."]]
n = 1 → [["Q"]];n = 2 或 3 → []
rowcolr−c / r+c验证
01−1 / 1记录三个集合
13−2 / 4与上一皇后不同列、不同斜线
202 / 2均未占用
321 / 5得到 [1,3,0,2]
回溯后继续[2,0,3,1]另一条路径得到第二个棋盘

时间复杂度与空间复杂度

令 Q 为答案数。仅考虑列互异,第 r 层至多 P(n,r) 个节点,总计 O(n!);本实现每个内部节点仍扫描 n 列,安全搜索上界 O(n·n!)。输出每个棋盘还需 n² 个字符,总时间 O(n·n!+Q·n²),集合查询按平均 O(1) 计。辅助空间 O(n);输出 Θ(Q·n²)。常见 O(n!) 简写忽略了本实现扫描列与输出构造的成本。

解题思路二:位掩码表示当前行的禁用位置

用 n 位整数表示受攻击列。cols 记录列占用,left、right 表示两种对角线在当前行的攻击位置。可用位置为 full & ~(cols|left|right)。每次取最低有效 1 位放皇后;进入下一行时,两类斜线攻击分别左移、右移一位。

Python 3 · 位运算枚举可用列
from typing import List

class Solution:
    def solveNQueens(self, n: int) -> List[List[str]]:
        full = (1 << n) - 1
        ans, placement = [], []

        def dfs(cols: int, left: int, right: int) -> None:
            if cols == full:
                ans.append(["." * c + "Q" + "." * (n - c - 1)
                            for c in placement])
                return
            available = full & ~(cols | left | right)
            while available:
                bit = available & -available
                available &= available - 1
                placement.append(bit.bit_length() - 1)
                dfs(cols | bit, ((left | bit) << 1) & full,
                    (right | bit) >> 1)
                placement.pop()

        dfs(0, 0, 0)
        return ans

约定第 c 位对应列 c。n=4、首行选 col=1(0010),下一行 cols=0010、left=0100、right=0001,可用位为 1000,只能放 col=3。available & -available 取最低位,available &= available - 1 删除该位。Python 的 ~ 带无限前导 1,必须与 full 做与运算限制范围。

在本题范围的固定字长模型中,按位操作按 O(1) 计;每个状态只枚举合法位置,搜索上界 O(n!),加输出总时间 O(n!+Q·n²);辅助 O(n),输出 Θ(Q·n²)。推广至任意大的 n 时,Python 大整数的按位操作与 bit_length 不能始终视为常数。整数状态传参无需撤销,只有 placement 需 pop。

解法优点取舍
三个集合易读,便于现场推导每节点扫描 n 列
位运算仅枚举可用位置,常数较小需说明范围、位方向、递归状态

易错点

  • 无需浮点斜率;r−c、r+c 能精确判断斜线。
  • 返回棋盘字符串列表,而非列号列表或答案数量。
  • 要求全部方案,不能找到第一解就停止,不能擅自去掉镜像答案。
  • 位运算对角线掩码表示当前行受攻击位置,集合版存的是固定斜线编号,两种含义不同。

复盘:把八题放在同一张地图上

题目状态 / 候选收集条件下一步 / 剪枝
78 子集start / 后续元素每个节点i+1;下标递增
77 组合start / 后续整数len(path)=kx+1;剩余名额上界
46 全排列used / 所有未用元素len(path)=n标记与恢复下标
17 电话号码数字位置 i / 对应字母i=mi+1;位置独立
22 括号生成left,right / 两种括号left=right=nleft<n,right<left
39 组合总和start,remain / 后续候选remain=0仍传 i;排序后 break
131 分割回文串start / 下一段尾 endstart=nend+1;只选回文段
51 N 皇后row / 未受攻击列row=nrow+1;列与两类斜线

start、used 与位置索引,不要混用

无序组合:规范下标顺序消除排列重复;能复用传 i,不能复用传 i+1。排列:每层从全体未用下标中选,需 used 或交换。逐位构造:候选由位置决定,例如电话号码,位置前进即可。连续切分:下一状态为当前段尾之后,不能跳字符。

剪枝必须有理由

类型代表不会漏答案的理由
容量不足77 组合剩余元素少于所缺数量,无法完成
总和超过39 组合总和正数条件下,排序后更大候选同样不能用
非法前缀22 括号生成负余额前缀不能被后续字符修复
局部非法131、51非回文段或皇后冲突,后续追加不能改变已有事实

复杂度:先计输出,再计搜索

枚举全部答案时,输出规模是时间下界。不能把“每份答案路径深 n”误写成整个算法 O(n),也不能给所有回溯统一写 O(2ⁿ)。空间要区分辅助状态与最终答案,Python 切片、反转、join 和棋盘构造都要计入。

题目主解时间辅助空间输出空间
78 子集Θ(n·2ⁿ)O(n)Θ(n·2ⁿ)
77 组合O(k·C(n,k))O(k)Θ(k·C(n,k))
46 全排列Θ(n·n!)O(n)Θ(n·n!)
17 电话号码Θ(m·P),P=3ᵃ4ᵇO(m)Θ(m·P)
22 括号生成Θ(n·Cₙ)O(n)Θ(n·Cₙ)
39 组合总和O(m log m+V+S)O(m+D)O(R+S)
131 回文 DPO(n²+n·2ⁿ)O(n²)O(n·2ⁿ)
51 集合版O(n·n!+Q·n²)O(n)Θ(Q·n²)

m 按具体题目定义为候选或数字个数;D 为 39 题最大深度,V 为搜索节点数,S 为输出元素总数,R 为答案数;P 为 17 题输出串数;Cₙ 为卡特兰数;Q 为 N 皇后解数。表中输出按非空渐近规模描述,空列表本身占常量空间。

掌握程度自测

看懂后再闭卷写一次,并口述不变量。下列勾选独立于题目掌握进度保存。

五天学习计划:练习、复述、隔天再写

安排学习内容验收目标
Day 1 · 基础核心模板;78 子集、77 组合画搜索树,解释收集位置与剪枝上界
Day 2 · 选择46 全排列、17 电话号码;复写 Day 1不看答案区分 used、start、位置 i
Day 3 · 约束22 括号生成、39 组合总和解释两个括号条件与正数剪枝前提
Day 4 · 进阶131 直接法与 DP;51 集合版推导回文转移与对角线编号
Day 5 · 面试51 位运算;闭卷复写 77、39、131、51先状态与不变量,再代码、样例、复杂度
复习日第 7、14、30 天各抽两题重做无法解释恢复或剪枝就撤销掌握标记,重画树

面试表达:先把问题建模清楚

  1. 识别结果结构。要求所有方案,答案天然对应一棵决策树。
  2. 定义递归状态。参数表示什么,path 已保证什么?
  3. 说明候选与剪枝。哪些选择合法,删掉的分支为何无解?
  4. 写出恢复动作。共享列表、集合如何回到父状态?
  5. 用小例子验证。包含最小输入、无解与快照独立性。
  6. 分析复杂度。先答案数量和长度,再搜索与预处理。

最终养成的习惯:用不变量控制正确性,用选择顺序控制重复,用可行性条件控制剪枝,用对称恢复隔离分支。

原题与编写说明

题号、难度、范围与题意对照力扣原题。教学文字、推导、代码与图示为本专题编写;示例按具体代码顺序展示,力扣允许任意答案顺序。全部 Python 代码块均是可独立提交的完整 Solution,不需拼接其他代码块。设计参考本地二叉树学习手册。

原题链接核对日期:2026-10-31。扩展边界已与原题范围区分。浏览器存储受限时,仍可阅读并在当前页面标记。