☰
动态规划进阶实战:状态压缩与记忆化搜索的最新变化
2026/10/6 9:27:13 网站建设 项目流程

聊起"动态"这个词,程序员圈子里第一反应大概率是动态规划(DP)。上个月团队内部做技术分享,有同事问了我一个问题:动态规划是不是早就被研究透了?把背包九讲、区间DP、树形DP这些模板背熟,之后遇到题直接套不就行了?我当时一下没接上话——这个问题看着简单,但它踩中了很多人的误区。动态规划的核心思想确实几十年没变过,变的是它的"用法":状态怎么设计、转移怎么优化、代码怎么写、实际工程里怎么落地,这些东西这三五年变化非常大。

如果你准备面试算法岗、在打算法竞赛,或者工作中需要做资源分配、路径规划、任务调度这类事情,那这篇文章很值得看完。我会把动态规划"最新变化"背后的几个关键方向拆开讲,每一块都会给出可以从头跟到尾的推导过程和代码框架,尽量让你看完就能直接上手用。

1. 从"套模板"到"读题建模":动态规划考察风向的三个转向

先说结论:现在评价一个人DP水平高不高,看的不是背了多少模型,而是面对一道没见过的题,能不能在半小时内定义出正确的状态、识别出可以用什么优化手段。这个标准的转变,是"动态"这几个字背后最明显的变化。

1.1 面试场景:线性DP变成热身题,状态设计成了分水岭

前几年面试考动态规划,普遍是爬楼梯、打家劫舍、最大子数组和、最长递增子序列这类线性DP题。这类题目有一个很明显的特征:状态定义几乎是被题目写死的,你只需要搞清楚dp[i]表示前i个元素怎么怎么样,然后写一个递推公式。

而现在很多公司的算法面试,尤其是中高薪岗位,已经不太愿意出这种"背模板题"了。大家更愿意出需要你自己"拆状态"的题目:给你一堆工作任务,每个任务有截止时间和收益,同一时间只能做一个任务,怎么选收益最大;给一个字符串集合,问能不能拆分成若干合法片段;给一组区间,问选择若干个互不重叠区间后能覆盖的最大范围是多少。

这类题的共同点是什么?状态不是一维的,你需要自己想清楚"什么东西在变"。比如任务调度问题中,已经处理到第几个任务是一个维度,当前已经占用到的截止时间窗口是另一个维度——这种"维度识别"能力,恰恰是模板给不了你的。

1.2 竞赛场景:朴素DP只能拿基础分,优化手段成了中档题标配

说实话竞赛圈的变化更早、也更剧烈。大概五年前,一道动态规划题只要状态定义对、转移方程对,O(n²) 的复杂度就能过。但现在各种算法竞赛的中档题,出题人默认你会做决策单调性优化、斜率优化、状态压缩这些手段。

我见过不少参赛者卡在同一个地方:状态和转移方程都写对了,但复杂度差了一个数量级,只能拿一部分分。这种断层特别可惜——它不是你不会DP,而是不会对DP做"复杂度升级"。所以后面我会专门用一整节来讲转移优化的场景判断,而不是只给你公式。

1.3 工程场景:DP从面试玩具变成了系统设计的基本功

还有一个容易被忽略的变化:动态规划在真实工程里用得比想象中多。比如推荐系统里的序列决策、网络路由里的最短路径变种、运维系统里的资源调度,甚至是强化学习里的价值迭代和策略迭代,底层都有DP的影子。

工程场景中最关键的不再是"能不能写出转移方程",而是"状态空间会不会爆炸""缓存怎么设计""能否接受近似解"。这直接影响了我们对DP实现形态的选择,也是为什么现在记忆化搜索重新被重视起来。这个点放到第四章详细讲,先把结论放在这里:面试里默认填表法,工程里经常反过来。

这三个转向放在一起看,核心其实是一句话:动态规划的重点正从"记住模型"转移到"设计状态 + 优化转移 + 控制状态空间"。明白了这一点,后面所有内容你都能找到锚点。

2. 状态表示的变化:从二维表格到集合压缩,用手写一遍最短Hamilton路径

这一节是全文最硬核的部分,也是近两年算法面试和竞赛题里出现频率飙升的状态设计类型:状态压缩DP。我建议跟着推导一步步来,代码我放在后面,确保你能完整复现。

2.1 为什么二维DP会"不够用"

先思考一个问题:有 n 个城市(n ≤ 20),已知任意两个城市之间的距离,一个旅行商从城市0出发,每个城市恰好访问一次,最后回到城市0,要求总路程最短。这就是经典的最短Hamilton回路问题。

如果你直接用一维状态dp[i]表示"当前在城市 i 的最短距离",你会发现完全无法转移——因为它丢掉了一个关键信息:你已经访问过哪些城市。访问过 {1,2} 和访问过 {3,4} 到达城市5,后续能走的路完全不一样。

那用二维dp[i][j]行不行?比如用"上次访问 i,当前在 j"?也不行。你还是没法知道哪些城市已经被访问过了,所以下一步会不会走回头路,完全没法判断。

这里就是"状态表示"层面的变化:当"哪些元素被选过"成为决策的核心信息时,你必须把这个集合本身放进状态里。而计算机里表示一个集合最自然的方式,就是用一个整数的二进制位——bit i 为1表示城市i已访问,为0表示未访问。这就是状态压缩。

2.2 状态压缩DP的完整推导

定义状态:

dp[mask][i] = 已经访问过的城市集合为 mask,并且当前停在城市 i 时,已走过的最短路径长度。

其中 mask 是一个整数,它的二进制第 k 位表示城市 k 是否已经访问过。比如 mask = 0b10101 表示城市0、2、4已被访问。

初始化:dp[1 << 0][0] = 0,表示从城市0出发,且城市0已经访问过,此时路径长度为0。其余所有状态初始化为无穷大。

转移的核心思路:往前凑。假设当前你正处于状态(mask, i),已经走了一段最短路径。现在你准备去下一个未访问的城市 j,那么新状态就是(mask | (1 << j), j),新路径长度就是在旧路径长度基础上加上w[i][j]。转移公式写出来就是:

dp[mask | (1 << j)][j] = min(dp[mask | (1 << j)][j], dp[mask][i] + w[i][j])

条件有三个:(mask >> j) & 1 == 0(j 未访问过)、dp[mask][i]不是无穷大、w[i][j]不是无穷大。

最终答案:访问完所有城市后的 mask 是(1 << n) - 1,然后枚举最后停在哪个城市 i,加上从 i 回到城市0的距离,取最小值。

ans = min(dp[(1 << n) - 1][i] + w[i][0]),其中 i 遍历 1 到 n-1,且 w[i][0] 可达。

这里补充一点为什么会这样设计:mask本身就是"无后效性"的保证。一旦我们知道了访问过的集合和当前所在城市,过去走过的具体顺序就不再重要——这就是动态规划"只保留影响未来决策的最少信息"这一原则的直接体现。

2.3 完整代码与两个关键细节

我给出一个可以直接跑通的 Python 版本,逻辑按上面"向前扩展"的思路写:

INF = 10 ** 18 n = int(input()) w = [list(map(int, input().split())) for _ in range(n)] size = 1 << n dp = [[INF] * n for _ in range(size)] dp[1][0] = 0 # 从城市0出发,只访问过城市0 for mask in range(1, size): for i in range(n): if not ((mask >> i) & 1): continue if dp[mask][i] == INF: continue # 尝试前往未访问的城市 j for j in range(n): if (mask >> j) & 1: continue if w[i][j] == INF: continue nxt = mask | (1 << j) dp[nxt][j] = min(dp[nxt][j], dp[mask][i] + w[i][j]) ans = INF for i in range(1, n): if dp[size - 1][i] + w[i][0] < ans: ans = dp[size - 1][i] + w[i][0] print(ans)

这个代码第一次跑就会踩到两个坑,我直接帮你标出来:

第一个坑:位运算优先级。(mask >> j) & 1的括号绝对不能省。在 Python 里写成mask >> j & 1结果一样,因为&的优先级低于>>,但换成 C++ 就不一定了;而在判断mask是否包含 i 时,很多人会写成mask & (1 << i),这个没问题,但千万别省括号写成mask & 1 << i,C++ 里它会被解释成(mask & 1) << i,直接错到离谱。所以我的建议是:只要涉及位运算,一律加括号,不给自己留犯错空间。

第二个坑:循环顺序。这个写法是"从已知状态向外扩展",所以外层循环 mask,内层枚举 i 和 j。如果你改成"从当前位置往回看上一个城市",那状态转移公式要换成:

dp[mask][i] = min(dp[mask ^ (1 << i)][j] + w[j][i])

两种写法等价,但千万别混合用。我自己经常看到有人把两种思路混着写,结果状态定义和转移方向对不上,排查起来极其痛苦。

2.4 什么时候该想到状态压缩

给你一个可操作的判断标准,比经验直觉更稳定:

  • 数据范围 n ≤ 20(或者 20 左近),因为2^20 = 1,048,576,这个量级的状态数组在内存和时间上都是可以接受的;n 到 25 就会比较吃力了。
  • 题目里出现"子集""匹配""全覆盖""恰好访问一次""选一部分"这类语义,而这些选择会显著影响后续决策。
  • 常规的一维/二维数组状态无法表达"哪些元素已经用过"这个信息。

这三条同时满足的时候,不要犹豫,直接往状态压缩DP方向想。它的代价(指数级状态)是明摆着的,但相对于问题本身的组合爆炸,已经是非常好的收敛了。

3. 转移优化的变化:O(n²) 不再默认能过,三种优化手段的判定标准

状态定义对了,复杂度超了,这是另一个高频翻车点。以前大家觉得"能写出转移方程就是会DP",现在出题人默认你会对转移过程做优化。这一节不讲花哨技巧,重点讲怎么判断一道题该用哪种优化。

3.1 前缀和优化:最朴素但最容易被忽略

先说一个最简单、也最容易被忽略的优化。很多DP的转移式长这样:

dp[i] = max(dp[i-1], max_{1 <= j <= i}(dp[j-1] + cost(j, i)))

其中cost(j, i)是区间 [j, i] 上的某种可加性收益。比如你在处理一个"选择若干不相邻的正向收益区间"的问题时,cost(j, i)往往可以写成前缀和之差:pre[i] - pre[j-1]。

这时候内层那个"枚举 j 取最大值"其实是个掩耳盗铃的重复劳动。因为我们可以把转移式改写:

dp[i] = max(dp[i-1], pre[i] + max_{1 <= j <= i}(dp[j-1] - pre[j-1]))

看到没有?max(dp[j-1] - pre[j-1])这个值和 i 完全无关,它只随 j 增长而更新。所以每算完一个dp[i],顺手用dp[i] - pre[i]更新一个全局变量best,下一次转移直接用best + pre[i+1],内层循环直接消失,O(n²) 变 O(n)。

这个优化之所以值得专门说,是因为它不需要任何高深数学,纯粹是"把不变量从循环里提出来"的思维。很多 DP 优化题的第一步,就是看内层枚举的那个 j,跟当前 i 到底是什么关系——如果 j 只出现在一个可以通过预计算维护的量里,那就一定可以提出来。

3.2 单调队列优化:窗口滑动下的队头淘汰

再上一个台阶:如果转移式变成dp[i] = max(dp[j] + f(i)),但是 j 的取值范围被限制在一个滑动窗口内,比如i - k <= j < i,这时候用单调队列,复杂度从 O(nk) 降到 O(n)。

一个典型的应用场景是"跳石头"类问题:你在一条数轴上,每次最多往前跳 k 步,每到一个位置拿到对应的分数,求到终点的最大分数。转移式是:

dp[i] = max(dp[i-k] ... dp[i-1]) + score[i]

内层枚举的是一个长度固定为 k 的滑动窗口。如果我每算一个 dp[i] 都重新遍历窗口内 k 个数,复杂度就是 O(nk)。但窗口里的"最大值候选"是可以动态维护的。

操作流程分四步:

  1. 队头判断:如果队头位置的索引小于i - k,说明它已经滑出窗口,弹出。
  2. 队头就是当前窗口最大值,用它更新dp[i]。
  3. 用dp[i]准备入队。在此之前,从队尾依次弹出所有值小于等于dp[i]的元素,因为它们"又老又弱",以后永远不可能更优。
  4. 将 i 入队。

判断是否该用单调队列,核心信号就一句话:转移来源是一个连续且窗口长度固定的区间,且贡献函数只和来源位置有关。以后看到"最多连续/最近几个"这类描述,第一反应就应该是单调队列。

3.3 斜率优化:从几何视角理解"决策单调性"

斜率优化是很多人的心理阴影,其实它的核心直觉很简单。假设转移式长这样:

dp[i] = min(dp[j] + (x[i] - x[j])^2 + C)

把平方项展开:

dp[i] = min(dp[j] + x[j]^2 - 2 * x[i] * x[j]) + x[i]^2 + C

把和 i 无关的量提出来之后,问题变成:在若干个候选 j 中,最小化

(dp[j] + x[j]^2) - (2 * x[i]) * x[j]

如果你把每个候选 j 看成一个平面上的点,横坐标是x[j],纵坐标是dp[j] + x[j]^2,那上面这个式子就是在问:过点(x[j], y[j])画一条斜率为2 * x[i]的直线,哪条直线的截距最小。截距最小的点一定位于所有这些点的下凸壳(convex hull)上。

实际操作上,你可以不深究凸包理论,直接用单调队列维护一个"下凸壳候选队列":

  • 队头出队条件:如果slope(q[0], q[1]) <= 2 * x[i],说明在斜率2*x[i]下q[0]不如q[1]优,因为随着 i 增大这个判定斜率也在增大,q[0] 永远不会再成为最优,弹出。
  • 队尾出队条件:如果新点 i 会让最后两个点q[-2], q[-1]和新点 i 形成"上凸"形态,也就是slope(q[-2], q[-1]) >= slope(q[-1], i),说明 q[-1] 不再可能成为最优决策点,弹出。
  • 计算dp[i]时直接用队头作为最优决策点。

这里我要强调一个经常被忽略的前提:斜率优化可用的前提是x[i]随着 i 递增而单调递增。如果 x 不单调,队列维护就会失效,这时得改用平衡树或李超线段树,复杂度也会上一个台阶。所以看到"代价函数里带平方项"或"两个决策点之间有交叉比较"时,先确认单调性,再决定是否套斜率优化。

3.4 怎么快速识别"决策单调性"

三类优化看下来,你会发现它们的共同底层逻辑是决策单调性:随着 i 增大,最优的 j 也在单调地朝一个方向移动。当一个转移式满足四边形不等式条件时,最优决策点一定单调右移。四边形不等式不用背,直接看三个信号:

  • 代价函数cost(l, r)满足"区间交叉不比区间包含优",也就是cost(a,c) + cost(b,d) >= cost(a,d) + cost(b,c)之类的关系,这个条件在区间DP里非常常见。
  • 你在暴力验证小数据时发现,随着 i 从1到 n,最优的 j 没有回退过。
  • 转移式中存在类似(x[i] - x[j])^2这样"距离平方"结构的项,天然具备凸性。

如果你在推导过程中发现三个信号里的任何一个,就可以考虑用决策单调性优化;如果同时发现两个以上,基本可以确定——这时候再去套单调队列或斜率优化的模板,就不太会翻车。经验是:不要一上来就背模板,先用小数据暴力推导一遍,确认最优决策点的移动方向,再选择优化方式,反而更快。

4. 实现形态的变化:记忆化搜索重新被重视,填表法不再默认优先

这里想聊一个很实际的工程变化。以前教科书和大部分题解默认动态规划就是"自底向上填表":开一个数组,按顺序把每个格子算出来。但最近几年,记忆化搜索在面试题解和工业代码里的出场率明显变高了。原因很直接:它更贴近人的思维习惯,而且在某些场景下性能反而更好。

4.1 两种实现方式的硬碰硬对比

直接给一张对比表,你可以收藏起来做决策参考。

对比维度自底向上填表记忆化搜索(自顶向下)
编码思维先确定边界,按递推顺序填格子直接写"我要求的状态依赖于哪些子状态",递归调用
调试难度填表顺序错了很难发现,数据错位排查痛苦每个递归函数就管一件事,出错容易定位
状态访问密度不管状态是否真的用到,全部计算一遍只计算真正会被访问到的状态
依赖顺序要求必须自己保证子状态先于当前状态算好递归天然保证,不需要手动拓扑排序
额外空间开销数组本身,基本无额外开销有递归栈开销,Python里可能爆栈
典型适用场景状态空间稠密、层与层完全对齐、迭代顺序清晰状态空间稀疏、依赖关系复杂、树形/博弈类问题

一句话总结:状态空间大但访问稀疏时,记忆化搜索胜出;状态空间本身就不大、层与层完全对齐时,填表更快。很多新手有一个错误的执念,觉得填表法"才是正经DP",记忆化搜索是"用递归偷懒"。实际上两种只是不同的执行顺序,背后完全是同一个状态转移方程。面试时如果你能明确说出"这里用记忆化搜索是因为状态访问稀疏",反而是加分项。

4.2 什么时候应该果断选择记忆化搜索

根据我自己的使用经验,下面三种情况强烈建议直接用记忆化搜索:

第一,转移依赖不确定。典型就是博弈类DP,当前状态可达的下一个状态很依赖对手的走法,而不是一个固定的递增维度。用填表法你得先搞清楚状态的拓扑序,写起来十分痛苦;递归则完全不用关心这个。

第二,状态空间稀疏。典型是棋盘上的马走日或者带障碍物的路径搜索。整个网格是 n×n,但每个格子的可达状态只有少数几个。如果填表法会算出大量根本不会用到的中间状态,而记忆化搜索只沿着实际路径递归,计算量会小一个量级。

第三,树形依赖结构。树形DP用记忆化搜索几乎是天然匹配的——因为树的遍历本身就是递归的,边递归边把子树的DP结果缓存下来,完全不需要手动模拟栈。

4.3 缓存细节:从 lru_cache 到自定义 dict

Python 里最方便的做法是直接用functools.lru_cache:

from functools import lru_cache @lru_cache(None) def dp(i, j): if i >= j: return 0 res = INF for k in range(i, j): res = min(res, dp(i, k) + dp(k + 1, j) + cost(i, j)) return res

这个写法有很多优点:缓存键自动处理、函数调用直接复用结果、代码几乎和状态转移式一一对应。但有两点你一定要注意:

  • 递归深度。Python 默认递归深度是 1000 左右,如果状态维度(比如 i 和 j 的范围)超过这个数,必须提前sys.setrecursionlimit(10000),否则运行到一半直接RecursionError。
  • 缓存键必须可哈希。lru_cache 要求所有参数都是可哈希的。如果状态里有数组或者可变对象,就得自己写一个专门的缓存字典,把状态编码成元组或者字符串作为键。

另外还有一个常见问题:lru_cache(None)表示缓存不设上限。如果状态空间本身就接近指数级,它会把所有算过的状态都堆在内存里,可能导致内存爆掉。工程代码里我通常会给一个maxsize,比如@lru_cache(maxsize=100000),超过上限后旧状态会被淘汰,虽然理论上可能重复计算,但能控制内存在一个安全范围。刷题时不需要,工程上很有用。

4.4 为什么工程场景越来越偏爱记忆化搜索

两年前我在一个资源分配系统里做过一次重构,核心模块是一个二维 DP,状态是一个「时间窗口 × 资源数」的矩阵。原来的实现是填表法,无论当前输入需要的状态组合稀疏还是稠密,它都会把整个矩阵算满。后来改成记忆化搜索,只算实际被请求的路径,平均耗时下降约40%,代码可读性也高了不少。

当然这不是说填表法不好。在状态层与层之间完全对齐、每个状态都需要计算的时候,填表法没有递归跳转和函数调用开销,性能肯定更好。但在真实业务里,状态空间往往是稀疏的——用户只关心几条路径,而不是所有可能组合。这也是"动态规划实现形态变化"里最实用的一个观察:别让模板决定你的实现,让状态空间的密度决定。

5. 实操案例:从线性DP到树形依赖DP,体会"结构变化"带来的思维跳跃

前面讲了状态、优化、实现三个维度的变化,这一节用一个完整的案例把它们串起来。我选的是经典的"选课问题",因为它是从线性思维升级到树形结构的典型代表,也是近两年面试里频繁出现的依赖决策类问题。

5.1 题目描述与贪心为什么不行

题目很简单:有 n 门课程,每门课程有学分score[i]和最多一门先修课pre[i](为 0 表示没有先修课)。你想要选 m 门课程,使得总学分最大。但是,如果要选某门课,必须先选它的先修课。比如"数据结构"的先修课是"程序设计",那你不选程序设计就不能选数据结构。

很多人第一反应是贪心:按学分从大到小排序,能选就选。但反例非常容易构造。假设只能选 2 门课,课程 A 没有先修课、学分是 5;课程 B 没有先修课、学分是 4;课程 C 的先修课是 B,学分是 100。按学分从大到小贪心,你会先选 C,然后被迫选 B,总学分是 104,看起来没问题。但再换一组数据:课程 A 没有先修课、学分 6,课程 B 没有先修课、学分 5,课程 C 先修课是 A,学分 4,课程 D 先修课是 B,学分 100,限制 m=3。贪心会先选 D(100)和 B(5),然后你还能选一个,可能是 A 或 C,但就会丢掉别的组合。这种"依赖链"一出现,贪心的局部最优策略就没法保证全局最优。

一旦你发现"选某个东西必须同时选另一个东西",这种链条关系天然就是树。所以解法必须转向树形DP。

5.2 状态定义:把"树上的选择"压缩成背包容量

思路是给没有先修课的课程增加一个虚拟先修课 0,所有无先修的课程都挂在节点 0 下面。这样整张依赖图就变成一棵以 0 为根的树。然后做树上背包。

定义状态:

dp[u][j] = 在 u 的子树中,并且强制选择课程 u 的情况下,总共选择了 j 门课时能拿到的最大总学分。

为什么强制选 u?因为如果我们要选 u 的子树中任何一门课,u 本身必须先被选。这个"先修"约束直接在状态定义里解决掉。dp[u][1] = score[u]表示在 u 子树中只选 u 这一门课。

对每个子节点 v,我们要做一次"背包合并":把 v 子树能提供的各种选课数量,和 u 当前已累积的选择数合并起来。这个过程等价于:你已经有了一个容量为 m 的背包,里面放了一些"已选课程",现在你要决定把子树的多少课程放进同一个背包。

5.3 代码实现与合并顺序的致命细节

直接看代码,我用递归 + 记忆化的形式实现:

import sys from functools import lru_cache sys.setrecursionlimit(10000) n, m = map(int, input().split()) kids = [[] for _ in range(n + 1)] # 孩子列表 score = [0] * (n + 1) for i in range(1, n + 1): pre, s = map(int, input().split()) kids[pre].append(i) score[i] = s # 把无先修课的课挂在虚拟节点0下,所以节点0也有孩子 @lru_cache(None) def dfs(u): # 强制选u,所以dp[1] = score[u],dp[0]不可达(因为u必选) dp = [-10**18] * (m + 1) dp[1] = score[u] for v in kids[u]: vdp = dfs(v) # 拷贝一份当前dp,避免同一轮内相互覆盖 ndp = dp[:] for j in range(1, m + 1): if dp[j] <= -10**18 // 2: continue # k表示从v子树中额外选k门课,k=0表示不选v子树任何课 for k in range(0, m - j + 1): if vdp[k] <= -10**18 // 2: continue if j + k <= m: ndp[j + k] = max(ndp[j + k], dp[j] + vdp[k]) dp = ndp return dp ans = dfs(0)[m] print(ans)

这个代码里有一个非常容易踩的坑,就是那个ndp = dp[:]。为什么必须拷贝?因为如果不拷贝,直接在 dp 上更新,会出现"自己更新自己"的问题:当 j 从 1 往大走时,某个dp[j+k]刚被当前子树更新过,后面再次循环到它时,可能又被拿去作为基准继续加,等于一个子树的课程被重复用了两次。这是树上背包最常见的 bug,也是最难排查的问题之一。

另一个细节是:vdp[k]里 k=0 的情况。dfs(v)返回的数组里vdp[0]是多少?初始化为 -INF,表示"如果强制选 v,必须至少选1门"。但我的代码里把dp[1]=score[u]设为初始基线,然后子树的vdp[0]是 -INF,所以 k 从 0 开始时vdp[0]不会通过判断,因此不会出现不选 v 却选了 v 子树课程的情况。如果希望允许完全不选 v 子树(v 不选),可以在 dfs(v) 里把vdp[0]设为 0,因为 v 子树一门课都不选是完全合法的。这时候合并循环里 k=0 就表示跳过整个子树。建议把dp[0] = 0加入初始化,这样逻辑更清晰。我把这个微调也写进注释里,方便你直接复用:

def dfs(u): dp = [-10**18] * (m + 1) dp[0] = 0 # u子树一个都不选,合法 dp[1] = score[u] # 只选u ...

如果你只想要"要么选u的子树至少一门,要么完全跳过该子树",两种初始化各有各的语义,自己按题目含义取舍即可。

5.4 复杂度分析与复盘

树上背包的复杂度是 O(n × m²),因为每个节点的合并阶段的j和k都要各自枚举到 m。n 和 m 都是 100 量级时完全没有压力;m 到 1000 时就要考虑用"子树大小限制"优化,也就是把 j 的枚举上限从 m 改成子树当前已合并的课程数,这个剪枝能把复杂度降到 O(n × m)。凡是做树上背包,第一版就建议把这个剪枝加上,否则数据一大很容易超时:

def dfs(u): dp = [-10**18] * (m + 1) dp[0] = 0 dp[1] = score[u] size = 1 # 当前子树已合并的节点数 for v in kids[u]: vdp = dfs(v) ndp = dp[:] # j 只枚举到 size,而不是 m for j in range(0, size + 1): if dp[j] <= -10**18 // 2: continue for k in range(0, min(m - j, len(vdp) - 1) + 1): if vdp[k] <= -10**18 // 2: continue ndp[j + k] = max(ndp[j + k], dp[j] + vdp[k]) dp = ndp size += sum_of_v_subtree_size return dp

这个题目让我很有感触的点在于:如果你脑子里只有"线性DP模板",看到这道题会非常茫然——状态是什么?转移顺序是什么?但一旦接受"树形结构 + 背包合并"这个思维,代码写起来反而很快。这就是我开头说的"状态设计能力"的价值。

6. 这些"变化"最容易踩的坑,我替你踩过了

最后分享几个我在实际写动态规划(尤其是新题)时,反复踩到的坑。它们都不是什么高深理论,但每一个都能让你调试到怀疑人生。

6.1 位运算优先级和 int 溢出

状态压缩DP里最隐蔽的坑就是位运算符的优先级。在 C++ 里,&的优先级低于==,所以如果你写:

if (mask & (1 << i) == 0) // 实际解析成 mask & ((1 << i) == 0)

结果完全不是你想要的。还有1 << i默认是 int,当 n 达到 31 以上时,位移结果会溢出 int 范围。解决办法是写1LL << i,把左移提升到 64 位。Python 没有 int 溢出问题,但同样存在括号缺失导致逻辑错乱的场景。我的习惯是:位运算表达式一律加括号,不靠记忆赌优先级。

6.2 枚举子集的写法误区

状态压缩DP里经常要枚举一个 mask 的所有子集,标准写法是:

sub = mask while sub: # 处理子集 sub sub = (sub - 1) & mask

这个写法很经典,但有两个坑。第一,sub从 mask 开始递减,但"递减"不是数值意义上的减1,而是按二进制子集大小顺序。很多人第一次看到(sub - 1) & mask会懵,其实它做的事情是:把 sub 的二进制低位清零,然后跳到下一个小于 sub 的子集。第二,循环结束条件是sub为 0,也就是空集不会被枚举到。如果你需要处理空集,就得在循环外加一次单独判断。还有,n > 31 时子集数量超过 int 范围,要记得用 64 位类型。

6.3 滚动数组的脏数据:比想象中更隐蔽

滚动数组是经典的空间优化手段,把二维dp[i][j]优化成dp[2][j]。但它的一个代价是:当前层数组里可能残留着上一层(甚至上上层)的旧数据。如果你在更新时没有覆盖所有状态,某些状态就会用上一轮的旧值参与转移,得出完全错误的结果。

最常见的触发场景是:for j in range(m + 1):而不是for j in range(m, -1, -1):。背包问题里如果你正序枚举容量,同一轮内会反复使用刚刚更新过的状态,导致"一个物品被放进去多次"。这个问题和滚动数组叠加后,排查难度会直接翻倍——因为数据看起来只是"略微偏大",而不是完全乱掉。我的排查经验是:一旦发现滚动数组的结果比预计值偏大,优先检查容量维度的遍历顺序,90% 的情况都是这里出了问题。

6.4 记忆化搜索的缓存无限膨胀

前面提到过lru_cache(None)可能导致内存爆炸。这里补充一个更隐蔽的场景:如果状态参数里混入了不可哈希的对象(比如列表),lru_cache 会直接报错。此时需要自己设计缓存键,比如把列表转成元组,或者把多维状态编码成一个字符串。但要注意,缓存键的设计本身会引入额外开销,如果状态太复杂,可能不如直接用填表法。

6.5 斜率优化里"凸性"不是玄学

最后一个坑是关于斜率优化的。很多人套模板没验证凸性,结果队列维护的"凸包"是凹的,导致最优决策点根本不该从队头取,答案偏小但看起来合理。验证方法很简单:先用 O(n²) 暴力跑一遍小数据,打印每个 i 的最优决策点;如果最优决策点不是单调递增的,那说明这个 DP 不具备决策单调性,别强行套斜率优化。

我在实际刷题时验证过不少题目,这个检查只需要几行代码,却能省下后面数小时的调参时间。强烈建议任何 DP 优化套模板前,都先跑一遍暴力验证。

这篇文章从状态表示、转移优化、实现形态、结构变化和踩坑经验五个角度,聊了动态规划实操中"最新变化"的几个核心方面。说到底,动态规划从来没有变成一门可以直接套公式的学科,恰恰相反,它一直在随着题目类型和应用场景的变化而变化。希望这些从实际操作里沉淀下来的判断标准和代码细节,能帮你少走一点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询