跳转至

第 103 章 区间 DP、树形 DP、状压 DP

配套例题:BISHI145 石子合并、BISHI143 没有上司的舞会、BISHI144 食物链计数 来源:S3 day6/DP资料/进阶资料/树型动态规划七道题.mdday6/DP资料/进阶资料/DP例题选讲 by 罗翔宇.md、S4 模板.docx(区间 DP、树型 DP「选课」、状压 DP)

前两章的 DP 都沿「一维序列」推进。这一章的三种形态换了推进的载体: 区间 DP 沿区间长度推进,树形 DP 沿子树推进,状压 DP 沿集合推进。 共同点是——枚举「最后一步」的技巧依然有效,只是「最后一步」的含义变了。


103.1 区间 DP

状态是一个区间 \([l, r]\),转移枚举最后一次合并的断点

\[f[l][r] = \min_{l \le k < r}\big(f[l][k] + f[k+1][r]\big) + \text{cost}(l, r)\]

枚举顺序必须按区间长度

\(f[l][r]\) 依赖的 \(f[l][k]\)\(f[k+1][r]\) 都是更短的区间。所以外层必须枚举长度:

# [片段]
for length in range(2, n + 1):        # 区间长度从小到大:短区间先算好,长区间才敢用
    for l in range(1, n - length + 2):    # 左端点上界由「右端点不越过 n」倒推而来
        r = l + length - 1            # 长度为 length 的区间是 [l, r],闭区间
        for k in range(l, r):         # 断点 k 取到 r-1:切成 [l,k] 与 [k+1,r] 两段非空
            ...

写成 for l: for r: 的双层循环也可以,但必须 \(l\) 倒序、\(r\) 正序, 否则依赖没算好。按长度枚举更不容易错,推荐固定用这个写法。

环形区间:破环成链

若区间首尾相连(如「石子摆成一圈」),标准做法是把数组复制一倍变成长度 \(2n\) 的链, 再在所有长度为 \(n\) 的区间里取最优。

BISHI145 是不是环,不需要复制。读题时要确认这一点。

BISHI145 石子合并(较难)

\(N\) 堆石子排成一排,每次合并相邻两堆、代价为两堆之和,求最小总代价。\(N \le 300\)

\[f[l][r] = \min_{l \le k < r}\big(f[l][k] + f[k+1][r]\big) + (m_l + \cdots + m_r)\]

最后那一项与断点 \(k\) 无关——无论怎么合,最后一次合并的代价一定是整段之和。 用前缀和 \(O(1)\) 取出。

复杂度:状态 \(O(N^2) = 9\times10^4\),每个状态枚举 \(O(N)\) 个断点 ⇒ \(O(N^3) \approx 4.5\times10^6\)

Python 关键:维护转置表把内层下沉 C 层

最内层的「枚举断点取 min」是 \(4.5\times10^6\) 次 Python 迭代,勉强能过但很险。 可以整段下沉,但需要同时维护一张转置表

要取的数据 f 是否连续
\(f[l][k]\)\(k = l..r-1\) f[l][l:r] —— 第 \(l\) 的一段 ✅ 连续
\(f[k+1][r]\)\(k = l..r-1\) \(r\) 的一段 ❌ 不连续

所以额外维护 fT[r][k+1] = f[k+1][r],于是:

# [片段]
from operator import add

# f[l][l:r] 依次是 f[l][k],k = l..r-1;fT[r][l+1:r+1] 依次是 f[k+1][r],同一批 k
# map(add, ...) 逐位相加得到全部断点的候选值,min 一次取最小,两步都在 C 层
best = min(map(add, f[l][l:r], fT[r][l + 1:r + 1]))

map(add, ...) 把两个切片逐元素相加、min 取最小,全在 C 层。 每次更新 f[l][r] 时同步写一份 fT[r][l]

代价是双倍内存和一次额外写入,换来内层循环从 Python 层挪到 C 层。 \(N = 300\) 时这个改写把耗时从约 3 秒降到 0.2 秒量级。

题解:solutions/BISHI145.py(已通过官方样例验证)

区间 DP 的其它经典模型

问题 状态与转移
括号匹配的最长合法子串 \(f[l][r]\),匹配则 \(f[l+1][r-1]+2\),否则枚举断点
回文划分(最少切几刀) \(f[i] = \min\{f[j] + 1\}\)\(s[j+1..i]\) 是回文
矩阵链乘 \(f[l][r] = \min f[l][k] + f[k+1][r] + p_{l-1}p_k p_r\)
能量项链(环形) 破环成链

103.2 树形 DP

状态是「某个子树」,转移由孩子的状态合并而来。

\[f[u][\text{状态}] = \text{merge}\big(f[c_1][\cdot],\ f[c_2][\cdot],\ \ldots\big)\]

⚠️ Python 必须用迭代,不能递归

这是本章最重要的一条工程约束。

\(n \le 2\times10^5\)链状树,递归深度就是 \(2\times10^5\)

做法 结果
直接递归 RecursionError(默认上限 1000)
sys.setrecursionlimit(300000) + 递归 爆 C 栈,段错误(判题机显示 RE,且没有 traceback)
threading.stack_size(1<<26) + 线程内递归 ✅ 可行
显式栈的迭代式后序遍历 ✅ 推荐,通用姿势

setrecursionlimit 只解除了解释器的软限制,真正的物理限制是线程栈大小。 这是 Python 竞赛里最隐蔽的 RE 来源之一——本地小数据全过,判题机上大数据直接段错误。

迭代式后序遍历模板

# [片段]
# 通用写法:显式栈,用一个标记位区分「进入」与「回溯」
stack = [(root, -1, False)]           # 三元组:结点、父结点、是否处于回溯阶段
while stack:
    u, fa, back = stack.pop()
    if back:
        # 此时 u 的所有孩子都已处理完 —— 在这里做合并
        for c in g[u]:
            if c != fa:               # 无向图里要靠父结点编号挡住「往回走」
                merge(u, c)
    else:
        stack.append((u, fa, True))   # 先压回自己的回溯帧,它会在所有孩子之后被弹出
        for c in g[u]:
            if c != fa:
                stack.append((c, u, False))   # 再压孩子,于是孩子先被处理

更快的写法(当输入直接给出有向父子边时):只要按 BFS 序遍历一遍, 再倒着累加即可——子节点一定排在父节点后面,倒序就是合法的后序

# [片段]
order = bfs_order(root)               # 一次 BFS 得到层序:父结点一定排在孩子之前
for u in reversed(order):             # 倒序 = 后序,连栈都不用
    merge_into_parent(u)              # 轮到 u 时它的子树已全部合并完毕

这个技巧避免了显式栈的元组打包开销,是树形 DP 在 Python 里的最优形态。 BISHI143 的输入天然给出「\(k\)\(\ell\) 的上司」的有向父子边,正好适用。

BISHI143 没有上司的舞会(较难)

树上最大权独立集:选了某点就不能选它的父亲。\(n \le 2\times10^5\)\(w_i \in [-128, 127]\)

两个状态:

\[ \begin{aligned} g[u] &= \text{邀请 } u \text{ 时子树的最大气氛值} = w_u + \sum_{c} f[c] \\ f[u] &= \text{不邀请 } u \text{ 时的最大值} = \sum_{c} \max(g[c],\ f[c]) \end{aligned} \]

答案 \(= \max(g[\text{root}],\ f[\text{root}])\)

\(w_i\) 可以是负数,所以 \(g[u]\) 不一定优于 \(f[u]\), 不能写成「叶子一定选」的贪心。\(f[u]\) 里对每个孩子取 \(\max(g[c], f[c])\) 而不是 \(g[c]\), 就是因为孩子的权值可能为负、不选反而更好。

题解:solutions/BISHI143.py(已通过官方样例验证)

树形 DP 的常见状态设计

问题 状态
最大权独立集 \(f[u][0/1]\)\(u\) 选 / 不选
树的直径 \(f[u]\) = 从 \(u\) 往下的最长链;答案在每个点合并两条最长链
树上背包(选课) \(f[u][j]\) = 子树里选 \(j\) 个点。见 101-背包问题 §101.9
树的重心 \(f[u]\) = 子树大小,重心是 \(\max(\text{最大子树},\ n - size_u)\) 最小的点
换根 DP 两次遍历:先自下而上求子树内答案,再自上而下把「父亲方向」的贡献传下来

103.3 DAG 上的 DP

DAG 上 DP 与树形 DP 同源,但拓扑序取代了后序。

\[f[u] = \text{merge}\big(f[v] \mid u \to v\big)\]

必须按拓扑序处理(或反拓扑序,取决于转移方向)。

BISHI144 食物链计数(较难)

求 DAG 上从「入度为 0 的点」到「出度为 0 的点」的路径条数。\(n, m \le 10^5\)

\[ f[x] = \begin{cases} 1 & x \text{ 出度为 } 0 \\ \sum_{x \to y} f[y] & \text{否则} \end{cases} \]

答案 \(= \sum_{\text{入度为 0 的 } x} f[x]\)

实现上按「出度」做 Kahn:先把出度为 0 的点入队;出队 \(v\) 时把 \(f[v]\) 累加给所有前驱 \(u\), 并把 \(u\) 的出度减 1,减到 0 就入队。这样天然是逆拓扑序,不需要显式排序。

坑 1(最容易错)方向别搞反。输入 u v 表示「\(v\) 捕食 \(u\)」,即有向边 \(u \to v\); 而 \(f\) 是沿边正向走到出度 0 的点的方案数,所以递推要用反向邻接表

坑 2:题面文字与样例说明略有出入(说明里把 8 也列成了生产者)。 以形式化定义为准:出度为 0 = 链的一端,入度为 0 = 链的另一端。 按样例验算 \(f(1) = 2+3+4 = 9\),与期望输出一致,确认定义无误。

坑 3:孤立点(入度出度都是 0)自成一条链,计入答案 1——公式天然覆盖,不用特判。

题解:solutions/BISHI144.py(已通过官方样例验证)

DAG 上 DP 是本章唯一在 Python 里完全没有劣势的形态——纯线性、无嵌套循环。 拓扑排序模板见 93-拓扑排序与二分图


103.4 状压 DP

用一个整数的二进制位表示「集合」,状态是这个整数。

\[f[\text{mask}] = \ldots\]

适用条件:集合元素个数 \(n \le 20\) 左右\(2^{20} \approx 10^6\) 个状态)。

常用位操作

46-位运算,这里列出状压 DP 专用的:

# [片段]
(mask >> i) & 1                # 第 i 个元素是否在集合里:把第 i 位挪到最低位再取出
mask | (1 << i)                # 加入元素 i:或运算只会把该位置 1,其余位不动
mask & ~(1 << i)               # 移除元素 i:取反后该位为 0,与运算把它清掉
mask & (mask - 1)              # 移除最低位的 1:减一会翻转最低位 1 及其右边的全部 0
bin(mask).count("1")           # 集合大小(3.9 写法;3.10+ 用 mask.bit_count())

# 枚举 mask 的所有子集(含空集),O(2^popcount)
sub = mask                     # 从全集出发,逐个变小地枚举
while True:
    ...                        # 处理 sub
    if sub == 0:               # 空集也要处理,所以判空放在处理之后
        break
    sub = (sub - 1) & mask     # 减一借位后再与 mask,跳到下一个更小的子集

枚举所有 mask 的所有子集,总复杂度是 \(O(3^n)\) 而不是 \(O(4^n)\)—— 每个元素有「在 mask 且在 sub」「在 mask 不在 sub」「不在 mask」三种状态。 \(n = 16\)\(3^{16} \approx 4.3\times10^7\),Python 下已经不行了。

两类经典状压

类型一:集合是「已选过哪些元素」

旅行商问题(TSP):\(f[\text{mask}][i]\) = 走过集合 mask、当前在 \(i\) 的最短路。 \(O(2^n n^2)\)\(n \le 15\) 时 Python 可行(\(7.4\times10^6\))。

类型二:集合是「一行的状态」(棋盘类)

逐行推进,\(f[i][\text{mask}]\) = 前 \(i\) 行、第 \(i\) 行状态为 mask 的最优值。 行内合法性与行间兼容性都用位运算一次判完:

# [片段]
mask & (mask << 1) == 0                        # 行内不相邻:左移一位后无公共 1
(m1 | (m1 << 1) | (m1 >> 1)) & m2 == 0         # 行间不相邻(含斜向)
# 上一行的每个 1 向左右各扩一位,覆盖正下方与两个斜下方,再与本行取交

solutions/BISHI79.py(取数游戏)用的正是这个套路。

Python 下的状压优化

手段 说明
预处理合法 mask 列表 先筛出行内合法的 mask,循环只跑这些(通常远少于 \(2^n\)
预处理兼容表 ok[m1] = 与 \(m_1\) 兼容的 mask 列表,避免内层重复位运算
list 而不是 dict\(f\) mask 是连续整数下标,list 快 2–3 倍
大整数当位集合 当「集合」超过 64 位时,Python 大整数天然是无限位 bitset,位运算走 C 层

最后一条是 Python 的独有优势:solutions/BISHI4.py 的两级值域位图就是这个思路的极致运用, 见 116-平衡树与有序集合


103.5 计数 DP 与取模

三种形态都可能出计数版本(把 \(\min\)/\(\max\) 换成 \(+\))。要点:

要点 说明
每步取模 否则大整数膨胀,见 100-DP入门 §100.7
「互不重叠 + 覆盖全部」 计数 DP 的正确性依据,必须显式检查
答案可能不需要取模 BISHI144 保证答案 \(\le 10^9\),Python 不溢出所以不用取模
最优值的方案数 同时维护 \(f\)(最优值)与 \(g\)(方案数):更优则覆盖 \(g\),相等则累加 \(g\)

103.6 本章速查

形态 状态载体 枚举顺序 「最后一步」是什么
区间 DP 区间 \([l,r]\) 按长度从小到大 最后一次合并的断点 \(k\)
树形 DP 子树 后序(孩子先于父亲) 每个孩子选哪个状态
DAG DP 拓扑序 / 逆拓扑序 走哪条出边
状压 DP 集合 mask mask 从小到大(子集先于超集) 加入哪个元素 / 上一行是什么
Python 关键 做法
树形 DP 绝不能递归 迭代后序,或「BFS 序倒序」
setrecursionlimit 不够 只解除软限制,物理栈仍会段错误
区间 DP 内层下沉 维护转置表 fT,用 min(map(add, f[l][l:r], fT[r][l+1:r+1]))
状压枚举子集 sub = (sub - 1) & mask,总量 \(O(3^n)\)\(n \le 15\) 为限
状压存储 list 不用 dict
超 64 位集合 Python 大整数天然是 bitset,位运算走 C 层
DAG DP 本章唯一 Python 无劣势的形态
规模判据 Python
区间 DP \(O(n^3)\) \(n \le 300\) ✅(内层下沉后)、\(n \le 500\) ⚠️
树形 DP \(O(n)\) \(2\times10^5\) ✅(必须迭代)
树上背包 \(O(n^2)\) \(n \le 500\)
状压 \(O(2^n n)\) \(n \le 20\)
状压 \(O(3^n)\) \(n \le 15\) ✅、\(n = 16\)