☰
动态规划进阶:区间DP、树形DP与状态压缩DP实战拆解
2026/10/2 4:00:55 网站建设 项目流程

1. Day 34动态规划Part 07:从线性DP瓶颈到进阶模型的关键一跃

今天是动态规划专题的第7讲,也是我整个算法强化训练的第34天。连续一个多月的动态规划刷题下来,最明显的感觉是:普通线性DP、背包问题这类"入门模型"已经不再让人头疼,真正拉开差距的,是那些状态维度设计更复杂、转移逻辑更隐蔽的进阶模型。Part 07这一段内容,恰好就是一道分水岭——跨过去了,DP就不再是背模板,而是真正靠状态定义和转移方程的设计能力吃饭。

我自己走到这一步时最大的困惑是:明明线性DP和01背包都刷得滚瓜烂熟,但一碰到区间DP、树形DP、状压DP这种"上难度"的题目,脑子还是容易一片空白。后来把洛谷动态规划题单刷到中段,又对着几道经典题反复分析了若干遍,才慢慢琢磨出规律:进阶DP题的核心不在于"套模板",而在于三件事——搞清楚阶段是什么、状态要记录什么、转移时从哪个维度切入。

这篇文章不打算洋洋洒洒讲理论,而是把我从Day 34 Part 07这段时间实操里最有价值的东西拿出来:先是梳理我对DP模型的理解方式,再讲几个进阶模型的通用套路,最后用完整的代码和调试过程说清楚我到底是怎么一步步把一道看起来没思路的题拆出来的。无论你是刚开始刷动规的萌新,还是卡在进阶瓶颈期的同学,这篇文章都值得你花时间看一遍——因为踩过的坑和验证过的方法,比干巴巴的原理有用得多。

1.1 为什么Part 07往往是"劝退点"

动态规划专题学到第7讲,其实已经过了最开始的新鲜感期。线性DP里有最长上升子序列、最长公共子序列这些经典题铺路,背包问题有01背包、完全背包、多重背包一整套模板撑着,初学者靠记忆也能应付一部分题目。但到了Part 07这个阶段,刷的题目开始出现两个明显变化:第一,状态不再只有一个或两个维度,比如区间DP的状态往往是dp[i][j]表示从i到j这个区间的最优解,再比如树形DP要把子树合并上去,维度和阶段不再是简单的"从头推到尾";第二,转移不再只是"选或不选",有时候要枚举中间点、枚举子集、甚至要引入辅助数组来优化复杂度。

这两个变化叠加在一起,导致很多人的真实感受是:上课听例题都明白,但自己拿到新题完全不知道第一步该干什么。我也经历过这个阶段,印象特别深的一道题是石子合并的变种,第一眼看到"环形"两个字就懵了——线性区间DP都还没理顺,怎么又来一个环?后来才意识到,这不是"又来了新模型",而是我缺少一个通用的分析框架。只要把阶段、状态、转移、决策这四个要素逐个明确下来,难题和模板题的区别,只是状态定义更灵活而已。

1.2 我眼中动态规划的模型原理

先聊聊模型原理。很多人把动态规划理解为"暴力枚举加记忆化",这个说法没问题,但从实操角度我更愿意把DP拆成四步:定义状态、写出转移方程、确定初始化、决定遍历顺序。每一步都有它对应的坑,任何一步出问题,结果都不会对。

状态定义是灵魂。同一个题目,状态定义得巧妙,转移方程就自然清晰;状态定义得笨拙,哪怕代码写出来了也是绕远路。举个例子,一个最简单的爬楼梯问题,状态定义是dp[i]表示爬到第i阶的方法数,那么转移就是dp[i] = dp[i-1] + dp[i-2]。但如果题目改成"每次可以爬1到k阶,且不能连续两次爬相同步数",dp[i]就不够了,得加一维记录上一次爬了几阶——这就是状态维度设计的直觉来源。

转移方程则是在回答"当前状态能从哪些之前的状态推过来"。线性DP的转移通常只依赖一两个前驱状态,区间DP则依赖区间内部的某个分割点,树形DP依赖子树的合并结果。理解了这个区别,做题时就能准确判断该用哪种模型去分析。

初始化看似简单却很致命。dp[0]到底是0还是1,在很多题目里能决定整个答案对不对。还有遍历顺序,背包问题里先遍历物品还是先遍历容量,完全背包和01背包的结果完全不同。这些细节在Part 07的难题里都会被放大,因为状态维度一多,一不小心就会初始化错、遍历反。

2. 从线性DP到区间DP:状态维度设计的进阶之路

Part 07给我最大的收获,是终于理顺了线性DP和区间DP之间的关系。以前总觉得它们是两种完全独立的题型,后来发现区间DP本质上还是线性DP的推广——只不过阶段不再是一个"点",而是一个"区间",转移时不是从前一个点推过来,而是从更小的区间合并而来。这一节先讲清楚区间DP的分析套路,再谈树形DP和状态压缩DP的建模差异,这样后面看代码时会更容易理解为什么要那样设计状态。

2.1 区间DP的核心套路:枚举区间长度与分割点

区间DP最经典的框架是三件事:第一层循环枚举区间长度,第二层循环枚举区间起点,第三层循环枚举区间分割点。以最典型的石子合并为例,dp[i][j]表示合并第i堆到第j堆石子的最小代价,那么转移方程是:dp[i][j] = min(dp[i][k] + dp[k+1][j] + sum(i,j)),其中k从i到j-1。

我第一次写这个循环时犯了一个经典错误:直接先枚举i再枚举j,结果发现dp[i][j]引用的dp[k+1][j]还没被计算出来。原因很简单,我们需要保证在计算大区间之前,所有更小的子区间都已经有值了。所以必须先枚举区间长度,再枚举起点。这个"先长度后起点"的遍历顺序,是区间DP与普通线性DP最大的不同,也是很多人写完代码跑不出正确结果的根源。

提示:如果你发现区间DP代码中出现了"需要使用的子区间状态还是初始值"的情况,首先检查遍历顺序是不是"先长度、再起点、最后分割点"。

石子合并还有一个值得注意的变种——环形石子合并。做法是把数组复制一份接在后面,把环拆成2n长度的链来做,最后枚举长度为n的所有区间取最小值。这个技巧叫"破环成链",我第一次看到时觉得非常巧妙,但后来发现它就是"用空间换处理方式"的典型,理解了原理之后应对同类题目就顺了。

2.2 树形DP与状态压缩DP的建模差异

如果说区间DP是把线性推广到二维区间,那么树形DP就是把DP搬到了树上。树形DP的思考方式又有所不同:通常需要先递归处理子树,得到子树的DP结果,再做合并决策。常见的题目套路是dfs(u)返回以u为根的子树相关状态,父节点选择是否包含某个子节点。

树形DP里最典型的例子是"没有上司的舞会":每个人有快乐值,如果上司参加了,直接下属就不能参加。这道题每个节点需要两个状态,dp[u][0]表示u不参加时的子树最大快乐值,dp[u][1]表示u参加时的值。转移时dp[u][0]要累加每个子节点v的max(dp[v][0], dp[v][1]),而dp[u][1]只能累加dp[v][0]。这种"父子互相影响"的模型,在图上稍加变形就是树上独立集、树上背包,本质上都是围绕节点状态做合并。

状态压缩DP又是另一套逻辑。当状态数量不大时,用一个整数的二进制位表示某个集合的选取情况,用dp[mask]表示当前选取集合为mask时的最优值。复杂的地方在于枚举子集,比如dp[mask] = min(dp[sub] + cost),其中sub是mask的子集。这种转移往往需要枚举所有子集,复杂度是O(3^n)级别,所以状态压缩DP能解决的问题规模通常很小,但它的思路在路径规划、任务分配类问题中非常常用。

3. 洛谷动态规划题单实操:三道经典题完整拆解

光讲框架没有说服力,这一节我从洛谷动态规划题单里挑了3道很有代表性的题,完整讲清楚我从读题到AC的全过程。这三道题难度有所递进,分别覆盖了区间DP、树形DP、以及需要结合模型优化的情况,也是Part 07阶段最有代表性的一类题目。

3.1 经典区间DP:石子合并(洛谷P1880)

石子合并的题意很直白:N堆石子围成一圈,每次选相邻两堆合并成一堆,代价是新合并那一堆的石子数,求合并成一堆的最小/最大代价。我用的写法是标准区间DP加破环成链,N最大100,所以复杂度O((2N)^3)完全可以接受。

核心实现思路分三步:

  1. 数组a读入后复制一份,得到长度为2N的链。
  2. 预处理前缀和数组s,方便快速计算sum(i,j)。
  3. 三重循环计算dp_min[i][j]和dp_max[i][j]。

代码关键段如下(C++版本):

const int INF = 1e9; int n; cin >> n; vector<int> a(2 * n + 1); for (int i = 1; i <= n; i++) { cin >> a[i]; a[i + n] = a[i]; } vector<int> sum(2 * n + 1, 0); for (int i = 1; i <= 2 * n; i++) sum[i] = sum[i - 1] + a[i]; vector<vector<int>> dp_min(2 * n + 1, vector<int>(2 * n + 1, INF)); vector<vector<int>> dp_max(2 * n + 1, vector<int>(2 * n + 1, 0)); for (int i = 1; i <= 2 * n; i++) dp_min[i][i] = 0; for (int len = 2; len <= n; len++) { for (int i = 1; i + len - 1 <= 2 * n; i++) { int j = i + len - 1; for (int k = i; k < j; k++) { int cost = sum[j] - sum[i - 1]; dp_min[i][j] = min(dp_min[i][j], dp_min[i][k] + dp_min[k + 1][j] + cost); dp_max[i][j] = max(dp_max[i][j], dp_max[i][k] + dp_max[k + 1][j] + cost); } } } int ans_min = INF, ans_max = 0; for (int i = 1; i <= n; i++) { ans_min = min(ans_min, dp_min[i][i + n - 1]); ans_max = max(ans_max, dp_max[i][i + n - 1]); } cout << ans_min << "\n" << ans_max << "\n";

这里len从2到n而不是2*n,是因为我们最后只需要统计长度为n的区间,没必要把整条2n链都合并完。中间加cost时用sum[j] - sum[i-1]一步算出区间和,避免每次循环里再写循环求和。

调试时我踩过一个坑:初始化dp_min时如果全设成INF,那么dp_min[i][i]必须设成0,否则长度为1的区间合并代价会错误地变成INF。这个细节看起来没什么,但一旦遗漏,后面的所有转移都会把INF传染下去,最终答案全是INF。

3.2 树形DP入门:没有上司的舞会(洛谷P1352)

这道题是树形DP的入门必修题。题意是公司有N名员工,每个员工都有快乐值,如果某个员工的直接上司参加舞会,那么这名员工就不能参加,求最大快乐总值。树结构本身就是天然适合递归处理的,所以我直接用邻接表存图,然后用DFS从根节点开始跑。

状态设计就是上一节说的两个状态:dp[u][0]和dp[u][1]。DFS过程中先处理每个子节点,然后回头更新dp[u]。要注意必须先递归子节点再更新当前节点,因为父节点的状态依赖子节点的状态。

vector<vector<int>> g; vector<int> happy; vector<vector<int>> dp; void dfs(int u, int father) { dp[u][1] = happy[u]; for (int v : g[u]) { if (v == father) continue; dfs(v, u); dp[u][0] += max(dp[v][0], dp[v][1]); dp[u][1] += dp[v][0]; } } int main() { int n; cin >> n; happy.assign(n + 1, 0); g.assign(n + 1, {}); dp.assign(n + 1, vector<int>(2, 0)); vector<int> indeg(n + 1, 0); for (int i = 1; i <= n; i++) cin >> happy[i]; for (int i = 1; i < n; i++) { int l, k; cin >> l >> k; g[k].push_back(l); indeg[l]++; } int root = 0; for (int i = 1; i <= n; i++) { if (indeg[i] == 0) { root = i; break; } } dfs(root, 0); cout << max(dp[root][0], dp[root][1]) << "\n"; }

实现里有一个容易忽略的点:找到根节点。题目给的是边的方向,但没有明确说谁是根。通过统计入度,入度为0的节点就是根。这个处理在真实代码里很常见,因为树形DP的DFS入口必须从根开始,否则状态合并会出错。另外,递归时用father参数避免走回父节点,这样即使是无向图也不怕重复遍历。

树形DP的代码通常很短,难的是想明白"子节点有哪些状态需要传递给父节点"。我做这道题时的感受是:只要画出树,把每个节点需要决策的信息写在旁边,状态设计就水到渠成了。等你刷上几道树形DP,会发现这类题的套路比线性DP还要机械——无非就是DFS后处理子节点信息,然后在父节点做一次合并操作。

3.3 状态压缩DP实战:最短Hamilton路径(洛谷P1171)

状态压缩DP最有代表性的题目之一就是最短Hamilton路径。题意是给定n个点,求从点0出发经过所有点恰好一次再回到点0的最短路径,n不超过20。如果枚举全排列复杂度是n!,根本不可能,但用状态压缩DP,复杂度是O(n^2 * 2^n),可以轻松跑完。

状态定义是dp[mask][i]表示已经经过的点集为mask,当前所在点为i时的最短路径长度。转移时枚举下一个未访问的点j,dp[mask | (1<<j)][j] = min(dp[mask | (1<<j)][j], dp[mask][i] + w[i][j])。

int n; cin >> n; vector<vector<int>> w(n, vector<int>(n)); for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) cin >> w[i][j]; const int INF = 1e9; vector<vector<int>> dp(1 << n, vector<int>(n, INF)); dp[1][0] = 0; for (int mask = 1; mask < (1 << n); mask++) { for (int i = 0; i < n; i++) { if (!(mask & (1 << i))) continue; if (dp[mask][i] == INF) continue; for (int j = 0; j < n; j++) { if (mask & (1 << j)) continue; int nmask = mask | (1 << j); dp[nmask][j] = min(dp[nmask][j], dp[mask][i] + w[i][j]); } } } cout << dp[(1 << n) - 1][0] << "\n";

这段代码里有几个细节需要特别注意。第一,初始状态dp[1][0]表示只经过点0、当前在点0,初始长度为0;第二,外层循环枚举mask时要从1开始,因为mask为0(没经过任何点)是没有意义的;第三,当dp[mask][i]是INF时跳过,否则后续更新会污染其他状态。

状态压缩DP最容易错的地方是位运算优先级。我第一次写的时候把mask & (1 << i)的括号漏了,结果编译报错,后来才意识到位运算优先级低于关系运算符,必须加括号。还有1 << n在n=20时是1048576,dp数组开成(1<<n)行n列,内存大约20MB,在ACM竞赛环境下没问题,但在一些限制严格的环境里需要留意。

4. 车辆动态规划问题视角:当DP走进工程场景

前面讲的题目还都是算法竞赛视角,但动态规划不止停留在刷题里。搜索热词里有一条"车辆动态规划问题",这个方向很有意思,因为它代表DP在实际工程场景中的应用,也能帮助我们反过来理解DP模型的实用性。

4.1 车辆路径规划中的DP思想

车辆路径问题(Vehicle Routing Problem,VRP)是运筹学和交通规划中的经典难题,核心是在满足一系列约束条件(如车辆容量、时间窗口、行驶距离限制)的前提下,为一组车辆规划最优配送路径。这个问题最原始的模型是旅行商问题(TSP)的推广,而TSP恰恰就是上一节里最短Hamilton路径的广义形式——所有点都要访问且路径最短。

在VRP问题里,动态规划的应用思路通常是把整个配送区域划分为多个阶段,比如先分区再排路线,或者直接用状态压缩DP处理小规模车队问题。状态里除了要记录已经访问的点,还要记录每辆车的剩余容量,所以状态维度可能比标准TSP还要多一维。这也是为什么很多工程实现会对VRP问题采用启发式算法,因为纯DP的状态爆炸问题在车辆数量一多时就完全不可控。

学习DP对我的帮助不只是会解竞赛题,更在于能用同样的建模语言去理解这类工程问题。比如看到"多辆车容量约束下的配送路径问题",我能立刻反应过来它和状态压缩DP的关系,也能分析出哪些状态可以合并、哪些约束会让状态空间迅速膨胀。

4.2 动态规划模型的扩展思路

顺着工程应用继续想,DP的变种其实非常多。除了经典的最短路、背包、区间问题,还有概率DP(比如期望值计算)、数位DP(按位计数)、DAG上的DP(拓扑序转移)等等。Part 07学到的"状态设计"能力,放在这些变种里依然适用,差别只在于怎么把状态描述清楚。

拿数位DP举例,通常状态是dp[pos][sum][limit],pos表示处理到数字的第几位,sum表示当前位的累加值,limit表示是否受到上界限制。这个模型的转移逻辑和区间DP完全不是一回事,但"状态定义决定转移复杂度"这条大原则是一模一样的。所以我在学DP时特别强调"建模能力"而不是"背模板",原因就在这里——模板会过时、会变花,但建模能力是通用的。

5. 常见问题与排查技巧实录:DP调试避坑速查

Part 07这段时间我调试了不少DP代码,踩过的坑几乎可以整理成一份速查表。这一节把最典型的问题和排查思路分享出来,希望能帮你节省大量调试时间。

现象可能原因排查思路
输出结果明显偏大状态初始化成了很大的值,导致转移时min失效检查dp数组初始值是否够大,是否忘了设置dp[i][i]=0之类的基础状态
输出结果偏小状态初始化成0,导致max转移失效检查是否需要对部分状态设置负无穷初值
某些样例对、某些样例错边界条件或遍历顺序有误用最小区间/最小状态手动推演一遍转移过程
区间DP结果全是INF没有先枚举区间长度,子区间还没算出来检查循环顺序是否正确
树形DP递归栈溢出树很深,数据规模大改用迭代方式处理,或把DFS改成显式栈
状态压缩DP位运算错误括号缺失或位运算优先级问题统一写成(mask & (1 << i)) != 0这样带括号的形式
递归DFS重复访问父节点没有传father参数在递归参数里带上父节点,遍历时跳过

5.1 调试DP的几个独家技巧

第一个技巧是打印小规模状态表。比如n=5的Hamilton路径,我直接输出每一轮的dp[mask][i]值,眼睛扫一遍就能看出转移有没有错。这个习惯在区间DP里尤其管用——把len=1和len=2的状态表打出来,手工验证几个值,基本就能定位问题。

第二个技巧是拆分中间变量。当转移方程里带了区间和、前缀和这类辅助计算时,先把辅助数组的值单独打印或验证,不要一上来就怀疑整个转移方程。很多时候问题恰恰出在前缀和算错了,而不是DP本身写错了。

第三个技巧是反向验证。用暴力法(比如全排列枚举)写一个正确答案生成器,用小规模数据随机对拍。这个方法虽然笨,但甚至比官方题解注释更能说明问题。我自己对拍过几次后,反馈回来的问题都出在初始化上,这让我形成了每道题先检查初始化的习惯。

5.2 从"不会做"到"会做":我的解题心法

最后再分享一个心态层面的技巧。遇到难题卡住时,我现在的做法是先把暴力状态定义写出来,不去管复杂度。比如一道树形DP,我先定义dfs(u)返回所有可能的信息,哪怕是一个很笨的重型结构体,然后再看哪些信息可以精简、哪些状态可以合并。这个方法帮我解决了不少一开始完全没有头绪的题目。

还有一点是多练变式题。同一个模型,换一个外壳就是一道新题。比如区间DP的框架,可以套在合并果子、括号匹配、最大回文子串上;树形DP的框架,可以套在上司舞会、树上背包、树的重心上。练得多了你会发现,DP的题目虽然无穷无尽,但模型就那么几大类,分析方式也是固定的。

6. 后续扩展方向与我的个人建议

到这里,Part 07的内容基本梳理完了。从线性DP到区间DP,再到树形DP和状态压缩DP,每一类模型都有它独特的思维方式和代码套路,但它们又共享同一个核心——状态定义和转移方程的设计。

后续想继续深挖的话,我比较推荐几个方向:首先是概率DP和期望DP,这类题目在竞赛里很常见,状态定义往往是"当前状态到目标状态的期望步数",转移方程会包含概率加权;其次是数位DP,它看起来套路化但细节很多,很适合打磨状态定义能力;最后是DP优化,包括斜率优化、四边形不等式优化、单调队列优化,这些内容能显著提升对复杂度的把控能力。

根据我个人经验,学习DP最忌讳的就是"看题解看得懂,自己写就完蛋"。要打破这个魔咒,只有一个笨办法——亲手画状态表、亲手写转移、亲手调错。别怕慢,每天能彻底吃透一道题,就比草草刷十道题强得多。我在Day 34之后仍然保持着这个习惯,每天只精做两三道题,但每道题都做到能给别人讲清楚为止。如果你正在经历DP的瓶颈期,希望这篇总结能帮你少走几步弯路。

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

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

立即咨询