第 29 章

动态规划(三):树形与状压

第二十九章:动态规划(三)— 树形与状压

前两章我们征服了一维 DP、背包、序列 DP 和区间 DP。这一章进入动态规划的"高级武器库":当状态空间不再是线性序列或矩形网格,而是集合的子集时,我们需要全新的状态表示方式。

树形 DP 的核心思想是自底向上:先算出所有子树的最优解,再合并到父节点。状态压缩 DP 的核心思想是用二进制位表示集合:一个 n 位整数可以编码 2^n 种子集状态,把"哪些元素已被选择"压缩进一个整数。数位 DP 和博弈 DP 则是状态定义的进一步推广。

这些技巧在面试中属于"压轴题"级别——能写出来的候选人凤毛麟角,但原理其实并不神秘。


Level 1 · 你需要知道的

29.1 树形 DP 概念

什么是树形 DP

树形 DP 是指在树结构上进行动态规划。与线性 DP 不同,树的拓扑结构决定了转移的方向:一个节点的状态依赖于其所有子节点的状态。

为什么需要树形 DP? 因为很多现实问题天然是树状的:组织架构(能不能同时邀请直属上下级参加派对?)、文件系统、编译器的语法树、网络拓扑。在这些结构上求最优解,线性 DP 无法直接套用。

核心范式

def dfs(node):
    # 1. 初始化当前节点的状态
    # 2. 递归处理每个子节点
    for child in node.children:
        dfs(child)
        # 3. 用子节点的结果更新当前节点的状态
    # 4. 返回当前节点的最终状态

这是后序遍历(post-order traversal)的结构——先处理子树,再处理当前节点。

29.2 打家劫舍 III(LeetCode #337)

问题描述

一棵二叉树,每个节点上有一个非负整数代表可偷金额。规则:不能同时偷直接相连的两个节点(即父子不能同时偷)。求能偷到的最大金额。

状态定义

对每个节点,定义两个状态:

转移方程

rob(node) = node.val + not_rob(left) + not_rob(right)
    # 偷当前节点,则左右孩子都不能偷

not_rob(node) = max(rob(left), not_rob(left)) + max(rob(right), not_rob(right))
    # 不偷当前节点,左右孩子各自独立选择偷或不偷

为什么这样设计状态? 关键约束是"父子不能同时偷"。如果我们只定义一个状态 dp[node] = 以 node 为根能偷的最大值,在转移时无法区分 node 本身是否被偷——但这个信息对 node 的父节点至关重要。所以必须拆成两个状态。

完整代码

from typing import Optional, Tuple

class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

def rob(root: Optional[TreeNode]) -> int:
    """打家劫舍 III:树形 DP"""
    def dfs(node: Optional[TreeNode]) -> Tuple[int, int]:
        """返回 (偷当前节点的最大值, 不偷当前节点的最大值)"""
        if not node:
            return (0, 0)
        
        left_rob, left_not = dfs(node.left)
        right_rob, right_not = dfs(node.right)
        
        # 偷当前节点:左右孩子都不能偷
        rob_current = node.val + left_not + right_not
        # 不偷当前节点:左右孩子各自取最大
        not_rob_current = max(left_rob, left_not) + max(right_rob, right_not)
        
        return (rob_current, not_rob_current)
    
    r, nr = dfs(root)
    return max(r, nr)

时间复杂度:O(n),每个节点访问一次。 空间复杂度:O(h),h 为树高,递归栈深度。

常见错误

  1. 忘记返回两个状态:只返回一个最大值,导致父节点无法判断子节点是否被偷
  2. 重复计算:用记忆化但 key 选择不当(节点对象不可哈希)——直接用后序遍历一次性计算更优雅
  3. 混淆"不偷"和"一定不偷子树中任何节点":不偷当前节点不意味着必须偷子节点,子节点也可以不偷

29.3 状态压缩 DP 概念

什么是状压 DP

状态压缩 DP(Bitmask DP)用一个整数的二进制表示来编码集合的选取状态。如果有 n 个元素,每个元素"选/不选"两种状态,总共 2^n 种组合——用一个 n 位二进制数就能完整表示。

为什么需要状压 DP? 考虑这样的问题:给你 n 个城市,求经过所有城市恰好一次的最短路径(旅行商问题 TSP)。如果用暴力枚举所有排列,复杂度是 O(n!)。但如果我们把"已经访问过哪些城市"压缩为一个位掩码,就能把问题转化为 DP,复杂度降到 O(n^2 * 2^n)——对 n <= 20 的情况完全可行。

位运算基础

# 检查第 i 位是否为 1(第 i 个元素是否在集合中)
(mask >> i) & 1

# 将第 i 位设为 1(把第 i 个元素加入集合)
mask | (1 << i)

# 将第 i 位设为 0(把第 i 个元素从集合中移除)
mask & ~(1 << i)

# 枚举 mask 的所有子集
sub = mask
while sub > 0:
    # 处理子集 sub
    sub = (sub - 1) & mask

29.4 旅行商问题(TSP)

问题描述

给定 n 个城市和任意两个城市之间的距离矩阵 dist[i][j],求从城市 0 出发,经过所有城市恰好一次,再回到城市 0 的最短路径长度。

状态定义

dp[mask][i] 表示:已经访问了 mask 所表示的城市集合,当前停在城市 i 时的最短路径长度。

注意:mask 是一个 n 位二进制数,第 k 位为 1 表示城市 k 已被访问。

转移方程

dp[mask][i] = min(dp[mask ^ (1 << i)][j] + dist[j][i])
    其中 j 满足:j != i,且 j 在 mask 中(即 mask 的第 j 位为 1)
    mask ^ (1 << i) 表示从 mask 中去掉城市 i 后的状态

直觉理解:要到达状态 (mask, i),必须从某个城市 j 转移过来——j 是 mask 中除了 i 之外的某个城市,从 j 走到 i 花费 dist[j][i]

初始条件

dp[1][0] = 0  # 只访问了城市 0,当前在城市 0,路径长度为 0
    (这里 mask = 1 表示只有第 0 位为 1)

最终答案

answer = min(dp[(1 << n) - 1][i] + dist[i][0])  对所有 i != 0
    # 所有城市都访问了,从最后一个城市回到起点

完整代码

def tsp(dist: list[list[int]]) -> int:
    """
    旅行商问题:状压 DP
    dist[i][j] 表示城市 i 到城市 j 的距离
    返回从城市 0 出发经过所有城市恰好一次再回到城市 0 的最短距离
    """
    n = len(dist)
    INF = float('inf')
    
    # dp[mask][i]: 已访问 mask 表示的城市集合,当前在城市 i 的最短路径
    dp = [[INF] * n for _ in range(1 << n)]
    dp[1][0] = 0  # 起点:只访问了城市 0
    
    for mask in range(1, 1 << n):
        for i in range(n):
            if dp[mask][i] == INF:
                continue
            if not (mask >> i & 1):
                continue  # 城市 i 不在 mask 中,状态不合法
            
            # 从城市 i 出发,尝试访问尚未访问的城市 j
            for j in range(n):
                if mask >> j & 1:
                    continue  # 城市 j 已经访问过
                new_mask = mask | (1 << j)
                dp[new_mask][j] = min(dp[new_mask][j], dp[mask][i] + dist[i][j])
    
    # 所有城市都访问后,回到起点
    full_mask = (1 << n) - 1
    ans = INF
    for i in range(1, n):
        if dp[full_mask][i] != INF:
            ans = min(ans, dp[full_mask][i] + dist[i][0])
    
    return ans

时间复杂度:O(n^2 * 2^n)。外层枚举所有 2^n 个 mask,内层枚举当前城市 i 和下一个城市 j。

空间复杂度:O(n * 2^n)。

实际限制:n = 20 时,2^20 = 1048576,乘以 n^2 = 400,约 4 亿次操作——用 Python 会超时,C++ 可以在 1-2 秒内完成。面试中 n 通常不超过 15。


Level 2 · 它是怎么运行的

29.5 数位 DP

问题引入:数字 1 的个数(LeetCode #233)

给定整数 n,计算 1 到 n 中所有整数里数字 1 出现的总次数。例如 n = 13,答案是 6(1, 10, 11, 12, 13 中 1 出现了 6 次)。

数位 DP 的核心思想

数位 DP 把数字看成一串数位(digit),从高位到低位逐位决策。核心技巧是维护一个 tight 标记:

这个技巧保证我们只统计 [1, n] 范围内的数。

为什么需要 tight 约束? 假设 n = 345。当我们在百位选了 3,十位就不能超过 4;但如果百位选了 2,十位可以取 0-9 的任何值。tight 标记正是为了区分这两种情况。

通用框架

from functools import lru_cache

def count_digit_one(n: int) -> int:
    """LeetCode 233: 数字 1 的个数"""
    if n <= 0:
        return 0
    
    digits = list(str(n))
    length = len(digits)
    
    @lru_cache(maxsize=None)
    def dp(pos: int, count: int, tight: bool, started: bool) -> int:
        """
        pos: 当前处理第几位(从高位到低位)
        count: 已经出现的 1 的个数
        tight: 当前是否受上界约束
        started: 是否已经开始填非零数字(处理前导零)
        """
        if pos == length:
            return count  # 所有位都决策完毕,返回 1 的个数
        
        limit = int(digits[pos]) if tight else 9
        total = 0
        
        for d in range(0, limit + 1):
            new_tight = tight and (d == limit)
            new_started = started or (d != 0)
            new_count = count + (1 if d == 1 else 0)
            total += dp(pos + 1, new_count, new_tight, new_started)
        
        return total
    
    return dp(0, 0, True, False)

逐步执行示例(n = 13)

digits = ['1', '3']

十位(pos=0):
  d=0: tight=False, 进入个位可选 0-9
       其中 d=1 贡献 1 次 → 子树贡献 1
  d=1: tight=True, 进入个位可选 0-3
       十位本身贡献 1
       个位 d=0: count=1, 贡献 1
       个位 d=1: count=2, 贡献 2(十位的1 + 个位的1)
       个位 d=2: count=1, 贡献 1
       个位 d=3: count=1, 贡献 1
       子树贡献 = 1+2+1+1 = 5

总计 = 1 + 5 = 6 ✓

数位 DP 的通用性

这个框架可以解决大量"统计 [L, R] 范围内满足某种数位条件的数"的问题:

所有这些问题都套用同一个框架,只需修改状态参数和转移条件。

29.6 博弈类 DP

Nim 游戏

两个玩家轮流从石子堆中取石子,每次至少取 1 个、最多取 3 个,取到最后一个石子的人赢。问先手是否有必胜策略。

关键结论:如果石子数 n % 4 == 0,后手必胜;否则先手必胜。

为什么是 4? 这是 Sprague-Grundy 理论的一个特例。分析小情况:

规律清晰:n 是 4 的倍数时先手必败,否则先手必胜。

def can_win_nim(n: int) -> bool:
    """Nim 游戏:每次取 1-3 个"""
    return n % 4 != 0

石子游戏(LeetCode #877)

两个人轮流从一排石子中取,每次只能取最左边或最右边的一堆。石子总数为奇数,不存在平局。问先手是否一定赢。

状态定义

dp[i][j] 表示面对 piles[i..j] 这段石子时,当前玩家比对手多得的最大分数差。

转移方程

dp[i][j] = max(
    piles[i] - dp[i+1][j],   # 取左边
    piles[j] - dp[i][j-1]    # 取右边
)

直觉理解:如果我取了 piles[i],对手面对 piles[i+1..j],对手的最优分差是 dp[i+1][j]。从我的视角看,对手多得的等于我少得的,所以减去它。

完整代码

def stone_game(piles: list[int]) -> bool:
    """石子游戏:区间博弈 DP"""
    n = len(piles)
    # dp[i][j]: 面对 piles[i..j] 时,当前玩家比对手多得的最大分差
    dp = [[0] * n for _ in range(n)]
    
    # 基础情况:只有一堆石子时,当前玩家全取
    for i in range(n):
        dp[i][i] = piles[i]
    
    # 按区间长度从短到长填表
    for length in range(2, n + 1):
        for i in range(n - length + 1):
            j = i + length - 1
            dp[i][j] = max(
                piles[i] - dp[i + 1][j],
                piles[j] - dp[i][j - 1]
            )
    
    return dp[0][n - 1] > 0

时间复杂度:O(n^2)。空间复杂度:O(n^2)。

LeetCode #877 的数学捷径

实际上,对于 LeetCode #877 的特定条件(偶数堆、总数为奇数),先手永远可以赢。因为先手可以选择"只取奇数位"或"只取偶数位"(按照堆的下标奇偶分组),而这两组的总和一定不相等(总和为奇数),先手选择总和大的那组即可。

但上面的 DP 解法是通用的,适用于任何变体。

29.7 树形 DP 的更多例子

所有可能的满二叉树(LeetCode #894)

满二叉树:每个节点要么是叶子,要么有两个子节点。给定节点数 n,返回所有可能的满二叉树。

这个问题的关键观察:满二叉树的节点数一定是奇数(根 + 左子树 + 右子树,左右子树节点数都是奇数)。如果 n 为偶数,答案为空。

from functools import lru_cache
from typing import Optional, List

class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

def all_possible_fbt(n: int) -> List[Optional[TreeNode]]:
    """所有可能的满二叉树"""
    @lru_cache(maxsize=None)
    def build(num_nodes: int) -> List[Optional[TreeNode]]:
        if num_nodes == 1:
            return [TreeNode(0)]
        if num_nodes % 2 == 0:
            return []
        
        result = []
        # 枚举左子树的节点数(奇数)
        for left_count in range(1, num_nodes - 1, 2):
            right_count = num_nodes - 1 - left_count
            left_trees = build(left_count)
            right_trees = build(right_count)
            for lt in left_trees:
                for rt in right_trees:
                    root = TreeNode(0)
                    root.left = lt
                    root.right = rt
                    result.append(root)
        return result
    
    return build(n)

Level 3 · 规范怎么定义的

29.8 状压 DP 的复杂度分析

为什么是 O(n * 2^n)?

状压 DP 的状态空间由两部分组成:

  1. 子集枚举:n 个元素的所有子集共 2^n 个
  2. 每个子集中需要一个额外维度:通常是"当前在哪个元素",共 n 种选择

因此状态总数为 n * 2^n。转移时,通常枚举从哪个元素转移来,再乘一个 n,得到 O(n^2 * 2^n)。

与全排列的对比

方法 时间复杂度 n=15 n=20
暴力全排列 O(n!) 1.3 * 10^12 2.4 * 10^18
状压 DP O(n^2 * 2^n) 7.4 * 10^6 4.2 * 10^8

对于 n=20,状压 DP 比暴力快了 10^10 倍!这就是 Held-Karp 算法(1962 年)的威力。

Held-Karp 算法的历史

Michael Held 和 Richard Karp 在 1962 年发表论文 "A Dynamic Programming Approach to Sequencing Problems"(Journal of the Society for Industrial and Applied Mathematics),首次将状态压缩与动态规划结合,把 TSP 的精确求解复杂度从 O(n!) 降到 O(n^2 * 2^n)。虽然仍是指数级,但这是 TSP 精确算法的最优已知复杂度(在合理假设下无法改进)。

枚举子集的子集

有些状压 DP 需要枚举一个集合的所有子集。一种常见模式:

# 枚举 mask 的所有非空子集
sub = mask
while sub > 0:
    # 处理子集 sub
    sub = (sub - 1) & mask

这段代码的复杂度是多少? 对于一个固定的 mask(假设有 k 个 1),它枚举 2^k - 1 个非空子集。如果对所有 mask 都执行这个枚举,总复杂度为:

$$\sum_{k=0}^{n} \binom{n}{k} \cdot 2^k = 3^n$$

这来自二项式定理 $(1+2)^n = 3^n$。所以"对所有子集枚举其子集"的总复杂度是 O(3^n),不是 O(4^n)。

29.9 Sprague-Grundy 定理

博弈论基础

组合博弈论(Combinatorial Game Theory)研究的是两个玩家轮流操作、完全信息、无随机因素的博弈。在这类博弈中,每个局面要么是 N-position(下一个行动的人必胜),要么是 P-position(上一个行动的人必胜,即当前行动者必败)。

Sprague-Grundy 值

每个博弈局面可以赋一个非负整数 g(Grundy 值或 nimber):

$$g(x) = \text{mex}{g(y) : y \text{ 是 } x \text{ 的后继}}$$

其中 mex(minimal excludant)是不在给定集合中的最小非负整数。

Sprague-Grundy 定理的内容

由 Roland Sprague(1935 年)和 Patrick Grundy(1939 年)独立发现:

定理:一个组合博弈等价于一个 Nim 堆,其大小等于该博弈的 Grundy 值。多个独立博弈的组合,其 Grundy 值等于各子博弈 Grundy 值的 XOR(异或)。

为什么重要? 这个定理把任何有限无循环的公平博弈(impartial game)归约为 Nim 游戏。只要能算出每个子博弈的 Grundy 值,整个组合博弈的胜负就能通过异或运算判断。

经典 Nim 游戏的 SG 分析

多堆石子的 Nim 游戏:有 k 堆石子,数量分别为 n_1, n_2, ..., n_k,两人轮流从任意一堆中取任意数量。

每堆石子是一个独立的子博弈,大小为 n_i 的 Nim 堆的 Grundy 值就是 n_i 本身。

整个博弈的 Grundy 值 = n_1 XOR n_2 XOR ... XOR n_k。

代码验证

def compute_grundy(n: int, max_take: int) -> int:
    """
    计算取石子博弈的 Grundy 值
    一堆 n 个石子,每次最多取 max_take 个
    """
    grundy = [0] * (n + 1)
    for i in range(1, n + 1):
        reachable = set()
        for take in range(1, min(i, max_take) + 1):
            reachable.add(grundy[i - take])
        # mex: 最小的不在 reachable 中的非负整数
        mex = 0
        while mex in reachable:
            mex += 1
        grundy[i] = mex
    return grundy[n]

# 验证:每次最多取 3 个时,Grundy 值 = n % 4
for n in range(20):
    assert compute_grundy(n, 3) == n % 4

SG 定理在面试中的应用

面试中遇到博弈题,解题思路:

  1. 先找小情况的规律(列表找 P/N position)
  2. 如果是多个独立子博弈的组合,算各子博弈 Grundy 值然后异或
  3. 很多题目实际上不需要完整 SG 分析,直接找规律或用数学结论即可

29.10 数位 DP 的理论基础

数位 DP 的正确性证明

数位 DP 的核心是把"统计 [0, n] 内满足条件的数"转化为"逐位决策"。其正确性基于以下观察:

对于一个 d 位数 n = a_{d-1} a_{d-2} ... a_1 a_0,[0, n] 内的任何数 x 可以唯一分解为:

这正是 tight 标记的含义:tight = True 表示"前面所有位都贴着上界",一旦某位选了比上界小的数字,tight 变为 False,之后的位完全自由。

记忆化的有效性

数位 DP 使用记忆化搜索时,状态 (pos, count, tight, started) 中:

总状态数 = O(d^2 * 4) = O(d^2),这是极小的——数位 DP 的效率非常高。


Level 4 · 边界与陷阱

29.11 面试题详解:打家劫舍 III(#337)

面试中的陷阱

  1. 递归爆栈:如果树极度不平衡(退化为链表),递归深度可能达到 O(n)。Python 默认递归深度 1000,需要 sys.setrecursionlimit

  2. 返回值设计:很多人初次尝试会写 dfs(node, robbed) 表示"当前节点是否被偷",这会导致状态爆炸。正确做法是每个节点返回元组 (rob, not_rob)

  3. 面试官追问:如果不是二叉树呢? 同样的思路,但 not_rob 的计算变为所有子节点的 max(rob_child, not_rob_child) 之和:

def rob_general_tree(root):
    """通用树(多叉树)的打家劫舍"""
    def dfs(node):
        if not node:
            return (0, 0)
        rob_sum = node.val
        not_rob_sum = 0
        for child in node.children:
            child_rob, child_not = dfs(child)
            rob_sum += child_not
            not_rob_sum += max(child_rob, child_not)
        return (rob_sum, not_rob_sum)
    
    r, nr = dfs(root)
    return max(r, nr)

29.12 面试题详解:最短超级串(#943)

问题描述

给定一个字符串数组 words,找到包含每个字符串作为子串的最短字符串。

分析

这本质上是 TSP 的变体!把每个字符串看作一个"城市",从字符串 A 到字符串 B 的"距离"定义为把 B 接在 A 后面时需要新增的字符数(即 B 去掉与 A 后缀重叠的部分后剩余的长度)。

步骤

  1. 预处理:计算任意两个字符串之间的重叠长度
  2. 状压 DP:类似 TSP,找到最优的字符串排列顺序
  3. 路径重建:根据 DP 结果构造最终字符串

完整代码

def shortest_superstring(words: list[str]) -> str:
    """最短超级串:状压 DP"""
    n = len(words)
    
    # 预处理:overlap[i][j] = words[i] 的后缀与 words[j] 的前缀的最大重叠长度
    overlap = [[0] * n for _ in range(n)]
    for i in range(n):
        for j in range(n):
            if i == j:
                continue
            # 找 words[i] 的后缀与 words[j] 的前缀的最大重叠
            max_k = min(len(words[i]), len(words[j]))
            for k in range(max_k, 0, -1):
                if words[i].endswith(words[j][:k]):
                    overlap[i][j] = k
                    break
    
    # 状压 DP
    # dp[mask][i] = 已选 mask 中的字符串,最后一个是 words[i] 时的最大总重叠
    dp = [[0] * n for _ in range(1 << n)]
    parent = [[-1] * n for _ in range(1 << n)]  # 路径追踪
    
    for mask in range(1, 1 << n):
        for i in range(n):
            if not (mask >> i & 1):
                continue
            prev_mask = mask ^ (1 << i)
            if prev_mask == 0:
                continue  # i 是第一个选的
            for j in range(n):
                if not (prev_mask >> j & 1):
                    continue
                val = dp[prev_mask][j] + overlap[j][i]
                if val > dp[mask][i]:
                    dp[mask][i] = val
                    parent[mask][i] = j
    
    # 找最优终点
    full_mask = (1 << n) - 1
    last = max(range(n), key=lambda i: dp[full_mask][i])
    
    # 路径重建
    path = []
    mask = full_mask
    cur = last
    while cur != -1:
        path.append(cur)
        prev = parent[mask][cur]
        mask ^= (1 << cur)
        cur = prev
    path.reverse()
    
    # 构造结果字符串
    result = words[path[0]]
    for k in range(1, len(path)):
        i, j = path[k - 1], path[k]
        result += words[j][overlap[i][j]:]
    
    return result

复杂度:O(n^2 * 2^n),n <= 12 时可行。

29.13 面试题详解:所有可能的满二叉树(#894)

面试要点

  1. 递归 + 记忆化:关键观察是 n 为偶数时无解,n=1 时只有一个节点。对于 n>=3,枚举左子树大小 1, 3, 5, ..., n-2。

  2. 面试官常见追问

    • "时间复杂度是多少?"——这是 Catalan 数的一个变体,满二叉树的数量是 Catalan(n/2),约为 4^(n/2) / (n/2)^(3/2)
    • "能否迭代实现?"——可以用自底向上的方式,从 n=1 开始逐步构建
  3. 注意引用共享:在记忆化版本中,返回的子树可能被多个父节点引用。如果题目要求返回所有不同的树(而非修改树),这没问题。但如果后续要修改返回的树,需要深拷贝。

# 迭代版本
def all_possible_fbt_iterative(n: int) -> list:
    """自底向上构建所有满二叉树"""
    if n % 2 == 0:
        return []
    
    # dp[i] = 所有有 i 个节点的满二叉树
    dp = [[] for _ in range(n + 1)]
    dp[1] = [TreeNode(0)]
    
    for total in range(3, n + 1, 2):
        for left in range(1, total - 1, 2):
            right = total - 1 - left
            for lt in dp[left]:
                for rt in dp[right]:
                    root = TreeNode(0, lt, rt)
                    dp[total].append(root)
    
    return dp[n]

29.14 状压 DP 的工程实践建议

Python 中的状压 DP 优化

  1. 位运算替代集合操作:Python 的 set 虽然方便,但在状压 DP 中用整数位运算更快一个数量级。

  2. 预计算 popcount

popcount = [bin(i).count('1') for i in range(1 << n)]
  1. 降维优化:如果 dp[mask] 只依赖 popcount(mask) 较小的状态,可以按 popcount 分层处理。

  2. 注意整数溢出:Python 的整数无上限,但在其他语言中 n > 30 时 2^n 会溢出 32 位整数。面试中要说明这点。

面试中的典型陷阱

陷阱 说明 解决方法
mask 枚举顺序 从小到大枚举 mask 即可保证依赖关系 确保 dp[mask] 计算时其子集已就绪
起始状态遗漏 TSP 中忘记设 dp[1][0]=0 仔细定义"初始只包含起点"的状态
回到起点 TSP 最后要加回起点的距离 别忘了 + dist[last][0]
对称性 有些问题中(A,B)和(B,A)等价 可以固定起点节省一半时间

29.15 综合对比:何时用哪种 DP

问题特征 DP 类型 典型题目
树结构上的最优化 树形 DP 打家劫舍III、树的最长路径
集合选取顺序相关 状压 DP TSP、最短超级串
统计数值范围内的计数 数位 DP 数字1的个数、不含连续1的非负整数
两人轮流博弈 博弈 DP 石子游戏、Nim
两个序列的关系 序列 DP LCS、编辑距离
区间合并/分割 区间 DP 矩阵链乘法、戳气球

面试中遇到 DP 题,先判断属于哪一类,再套用对应的状态定义模板——这比从零开始设计状态要快得多。


本章小结

  1. 树形 DP 的核心是后序遍历:先算子树,再合并到父节点。状态通常需要拆分(如"选/不选当前节点")。

  2. 状压 DP 用位掩码编码集合状态,把 O(n!) 问题降到 O(n^2 * 2^n)。适用于 n <= 20 的全排列/全选择问题。

  3. 数位 DP 逐位决策,用 tight 约束保证不超上界。适用于统计区间内满足特定条件的数。

  4. 博弈 DP 中,Sprague-Grundy 定理把任意公平博弈归约为 Nim 游戏,多个独立子博弈取异或。

  5. 面试策略:先分类(看问题特征),再套模板(状态定义 + 转移方程),最后验证(小例子手算)。

本章评分
4.9  / 5  (3 评分)

💬 留言讨论