模板索引:动态规划
背包问题
关于状态的边界定义问题
加入给定我们的是背包的容量,或者说一个最大的代价,我们可以浪费掉一些额度,从而达到最优解。
于是比如简单的背包问题,直接把整个 \(dp\) 数组设置为 \(0\) 即可,因为我们把装填定义为不超过容量 \(V\) 的最大收益。
但如果要求恰好使用完额度,这就需要我们把不可能状态设置为一个特殊值。
比如有代价为 \(2,6\) 的两个物品,给你容量为 \(5\) 的背包,选第一个物品即可,但如果要求刚好用掉 \(5\),那是不可能的,为避免错误转移,应当把 \(dp\) 初始化为 \(-inf\)。
01背包问题
有 \(N\) 件物品和一个容量为 \(V\) 的背包。放入第 \(i\) 件物品耗费的空间是 \(C_i\),得到的价值是 \(W_i\)。最大化价值和。
特点:每种物品仅有一件,可以选择放或者不放
状态转移方程:
伪代码:
特别注意枚举方向。
for (int i = 1; i <= n; i++)
for (int l = W; l >= w[i]; l--) f[l] = max(f[l], f[l - w[i]] + v[i]);
完全背包问题
有 \(N\) 种物品和一个容量为 \(V\) 的背包,每种物品都有无限件可用。放入第 \(i\) 种物品的耗费的空间是 \(C_i\),得到的价值是 \(W_i\)。
特点:每种物品都有无限件可用。
状态转移方程:
代码:
for (int i = 1; i <= n; i++)
for (int l = w[i]; l <= W; l++)
if (f[l - w[i]] + v[i] > f[l]) f[l] = f[l - w[i]] + v[i];
多重背包问题
有 \(N\) 种物品和一个容量为 \(V\) 的背包。第 \(i\) 种物品最多有 \(M_i\) 件可用,每件耗费的空间是 \(C_i\),价值是 \(W_i\)。
方案一:考虑类似完全背包问题
因为对于第 \(i\) 种物品有 \(M_i + 1\) 种策略:取 \(0\) 件,取 \(1\) 件……取 \(M_i\) 件。令 \(F[i, v]\) 表示前 \(i\) 种物品恰放入一个容量为 \(v\) 的背包的最大价值,则有状态转移方程:
复杂度是 \(O\!\left(V \sum M_i\right)\)。
方案二:转化为 01 背包问题
(朴素思想略)
利用二进制分组优化:
点击查看代码
for (int i = 1; i <= n; i++) {
int v, w, s;
cin >> v >> w >> s;
for (int k = 1; k <= s; k <<= 1) {
int vol = k * v;
int val = k * w;
for (int j = V; j >= vol; --j) {
f[j] = max(f[j], f[j - vol] + val);
}
s -= k;
}
if (s > 0) {
int vol = s * v;
int val = s * w;
for (int j = V; j >= vol; --j) {
f[j] = max(f[j], f[j - vol] + val);
}
}
}
点击查看代码
index = 0;
for (int i = 1; i <= m; i++) {
int c = 1, p, h, k;
cin >> p >> h >> k;
while (k > c) {
k -= c;
list[++index].w = c * p;
list[index].v = c * h;
c *= 2;
}
list[++index].w = p * k;
list[index].v = h * k;
}
混合背包

分组背包
特别要注意循环的顺序,对于每一组,必须先循环 \(dp\) 的下标 \(j\),再去循环组内的物品。
因为我们跑一个 \(0/1\) 背包,倒着遍历不仅可以避免同一个物品被选多次,也可以避免同一组物品被选多个:
这是由于倒着枚举,故对于每个下标 \(j\) 的循环,其所使用的比 \(j\) 小的下标一定还没被这一组更新过。

部分感想与序列DP
用了接近两周的时间把线性动态规划和区间环形动态规划过完了,对DP有了更加深刻的认识。
这里随性写一些杂想,主要是以后想找的时候不至于定位不到东西。
DP的精髓:减少重叠子结构
DP的精髓在于用尽可能准确地状态和精简的转移,最大程度减少重叠子结构,并用一个中间状态表示尽可能多的最优子结构。
-
我们可以这样理解,绝大多数DP的思维都能从搜索转化而来,以洛谷P1854 花店橱窗布置这一题为例,为什么枚举花束的摆放进行搜索必爆而动态规划却可以轻松解决,明明他们表示的状态数应该是一样的啊:所有的花的摆法应该都会被遍历一遍以确保不会漏掉最优解法,那么动态规划到底比搜索快在哪里,我冥思苦想数日突然灵光一现:
我们不妨先看看这题 P2363 马农 ,这题暴力枚举完的 \(O(n^6)\) 是无法通过的,但是我看到了一种很妙的解法:
通过map映射区块值为 x 的方案数有多少种,成功把复杂度 \(O(n^6) \to O(n^4)\),这种将两序列通过映射降低枚举握手 y 引入的 \(O(N^2)\) 复杂度思想很值得记忆。
简单来说,你现在有两个长度为 \(n, m\) 的序列,全部互相枚举遍历复杂度是 \(O(nm)\)。 但是现在我采用桶排或者映射,能大幅降低枚举引入的复杂度。这两部分有什么共通性吗?这在于,在我们定义下的状态数量是没有改变的,但是我们状态之间的枚举、转移,是可以大大降低的。
包括记忆化搜索、动态规划,降低复杂度的核心之一都在这里,我们的巧妙转移,我们的记忆化,都减少了状态之间的沟通握手成本。 -
另一个核心是,我们用一个中间状态表示尽可能多的最优子结构。
以刚刚说的那题《花店橱窗布置》为例,暴力搜索的子问题(子结构)是有重叠的,只使用暴力搜索看似在搜索过程中无法剪枝与优化,但是在dp算法下,我们通过划分子问题使得一些重叠状态/子问题暴露出来从而简化:考虑现在有三盆花,在考虑加入第四盆花的时候,暴力搜索要把所有四种花的排列组合全部遍历一遍,最坏复杂度 \(n^4\),而换用dp,可以认为第一第二种花的最优摆法在子任务已经实现过了,我们拆出这个问题,只考虑第四盆花接续在第三盆后面的情况即可,这种引入大大降低了复杂度,是一种更为厉害的分治子问题结构,而不是单纯的剪枝。例如在想这题 P1541 [NOIP 2010 提高组] 乌龟棋的时候我没想出来,在于设计状态思维固化了,使得状态与子问题重叠,自然会重复求解带来天量复杂度。
我们可以这么认为,记忆化不只是做了搜索的子问题的记忆剪枝,避免重复遍历重叠子问题。更是把很多个子问题压缩最优解得到一个状态,这个状态表示很多子问题的最优解。
怎么有一股量子计算的味道。。
比如这题P4310 绝世好题。 -
还有一个问题在于我们经常思考出子问题重叠求解的设计,会使得局面扑朔迷离:
比如我写P3146 [USACO16OPEN] 248 G这题时就设计出这样的状态:
int dp[maxn][maxn][4];
// dp[l][r][0] : 用完了整个区间
// dp[l][r][1] : 取了子序列的左边,不能使用右边
// dp[l][r][2] : 取了子序列的右边,不能使用左边
// dp[l][r][3] : 取[l,r]序列的中间,由左子序列的右边和右子序列的左边结合而来
实际上左右端点子序列和中间序列其实就包含在某个取完整序列的最优子结构中,完全不需要什么左子序列右子序列,只是此时应该使得在dp过程中不断取ans的max,只在结果取会因为两个不完整序列无法合并丢失子结构最优解。
同样的教训还有这题:P1220 关路灯(懒得打字了插个图)

逆序思考
DP的一个关键在于无后效性。
即历史路径不影响未来抉择。
所以当出现难以避免的后效性时,我们要考虑倒过来 \(DP\)。
例题一、2026sdu省赛排位赛1 F.巴巴博弈
给定 \(N\) 块派,它们会按照固定顺序依次分配,每块派只能由 Alice 或 Bob 中的一人吃掉,各自有权值。
有一个特殊的 token,初始时由 Bob 持有。
在还有派未分配时,当前持有 token 的人需要执行以下操作:
- 决定下一块派给 Alice 或 Bob 中的哪一方;
- 将 token 交给没被分到派的一方。
双方都会采取最优策略,使自己吃到的派的总大小尽可能大。
请你求出在最优策略下,Alice 和 Bob 最终分别吃到的派的总大小。
首先,这是一个零和博弈,当我们把 dp 的状态定义为 alice 或者 bob 在某一局面最大能吃多少,我们会发现特别难维护:
- 首先,两人都在当前局面最大,很难构造转移,你怎么知道对手会如何决策
- 其次,如果存在一种可能性,某方暂时隐忍,把派全给对面吃了,那两人最大值的派是不是很有可能有重复,你还不知道
- 再次,即便不考虑上述两个问题,分别维护这两个人的局面,显然是有重复维护的嫌疑,往下看就知道了
我们首先跳出要维护两个人的泥潭,这是一个零和博弈,并且双方的主动权可以换手,我们只维护角色位而不管当前是谁,明显是可行的,或者说我们只关心决策权持有者的最优收益,不关心谁是持有者。
当你是持有者的时候,前面的派已经尘埃落定了,你能决策和影响的显然是后缀的派,在所有后缀局面中,你显然会给自己选一个最优的,收益最大的。
于是我们定义:
dp[i][0]为这一次这一轮结束没有主动权,那肯定是吃到了。因为如果主动权原本在自己手里,吃了决策权要易手;而如果原本不在自己手里,对面保留 token 就得给我吃。dp[i][1]为这一次这一轮结束有主动权,因为保留 token 就得给对面吃。
综上,因为决策只能影响后缀,所以要倒着 dp。
然后,有决策权的人可以在下一轮两种局面选 \(max\),而没有决策权的只能在下一轮两种局面中拿到 \(min\) 的一种,因为一共只有两种局面。
转移方程:
for(int i = n; i; i--){
dp[i][1] = max(dp[i + 1][0], dp[i + 1][1]);
dp[i][0] = min(dp[i + 1][0], dp[i + 1][1]) + a[i];
}
总结
前面的分法不重要,重要的是从当前剩余局面开始双方会怎么博弈
博弈题通常就是这样:
状态定义在“剩余局面”上,比定义在“已经做完的前缀”上自然得多。
这题倒着 dp 的本质
因为这是一个“从当前局面出发,接下来双方都会最优行动”的问题。
而“当前局面的值”天然由“后继局面的值”决定,所以从后往前推最自然。
例题二、CF2208 C. Stamina and Tasks
有 \(n\) 任务供您使用。任务 \(i\) 的整数值为 \(c_i\) ,难度为 \(p_i\) 。同时,你的初始耐力为 \(1\) ,表示为 \(S\) 。您应该处理从任务 \(1\) 到任务 \(n\) 的任务。对于每个任务,您有两个选择。
- 放弃任务。这样,什么也不会发生。
- 完成任务。这样,你将获得 \(S\cdot c_i\) 点。但是,在**任务完成后, \(S\) 将下降到 \(S\cdot (1-\frac{p_i}{100})\) **。
你需要在完成这个过程后最大化你的分数。
double dp = 0.0;
for(int i = n; i; i--){
double tmp = 1.0 - 1.0 * wk[i].p / 100.0;
dp = max(dp, wk[i].c + dp * tmp);
}
总结
这题的关键不是“做当前任务能赚多少”,而是:做了当前任务以后,耐力 \(S\) 会下降,从而影响后面所有任务的收益。
因为当前动作会改变未来所有收益的倍率,所以你必须先知道“未来部分本身值多少”,才能判断当前该不该做。
例题三、P4316 绿豆蛙的归宿
给出张 \(n\) 个点 \(m\) 条边的有向无环图,起点为 \(1\),终点为 \(n\),每条边都有一个长度,并且从起点出发能够到达所有的点,所有的点也都能够到达终点。
绿豆蛙从起点出发,走向终点。 到达每一个顶点时,如果该节点有 \(k\) 条出边,绿豆蛙可以选择任意一条边离开该点,并且走向每条边的概率为 \(\frac{1}{k}\)。现在绿豆蛙想知道,从起点走到终点的所经过的路径总长度期望是多少?
总结
终点 \(n\) 的答案最容易知道,也就是 \(0\)。
然后别的点都依赖后继点,所以应该按拓扑序的反序算。
这就是倒着 \(dp\)。
为什么正着不行:
\(E[u]\) 不是由前驱决定的,它是由“从 \(u\) 往后怎么走”决定的
特别是,倒着决策,枚举从终点方向的后继转移到 \(i\),就是这些后继乘以同一个概率,也就是 \(i\) 的后继数的倒数。
但是如果是正的,如果照反向做法,求 \(i\) 的期望是对前驱的合并,就是错的,因为你不知道前驱到你这条边的概率。
并且即便想到从 \(i\) 对每个后继分发期望,也是错的,因为对于后继的每个来源,其路径概率权重是未知的。
关键不在于我们是合并到 \(i\) 还是从 \(i\) 分发,而是路径概率权重或者说流量不确定,我们要多开一个数组 \(g[i]\) 表示 \(1\) 到 \(i\) 的概率。
推导:

谈谈区间dp
区间dp其实套路比较一般的貌似就两种转移:
一种是只能在一个区间的左边或者右边加入一个新的节点;(遍历端点,通常为 \(O(n ^ 2)\))
一种是一个区间可以由区间内的子区间结合而来,这时要多枚举一个 \(k\) 来取出所有子序列的可能结合。(即还要讨论断点才能转移一个大区间,通常为 \(O(n ^ 3)\))
第一种情况如下,以P1435 [IOI 2000] 回文字串为例:

点击查看代码
cin >> s;
len = s.length();
for(int i = 0; i < len; i++) f[i][i] = 0; // 预处理边界条件子区间长为 1
for(int i = 1; i < len; i++) // ;预处理边界条件子区间长为 2
if(s[i] == s[i - 1]) f[i - 1][i] = 0;
else f[i - 1][i] = 1;
for(int l = 2; l < len; l++)
for(int i = 0; i+l < len; i++){
int j = i + l;
f[i][j] = min(f[i + 1][j] + 1, f[i][j - 1] + 1);
if(s[i] == s[j]) f[i][j] = min(f[i + 1][j - 1], f[i][j]);
}
cout << f[0][len - 1];
第二种情况没找到图懒得画了。
经典例题:P1775石子合并弱化版、CF607B祖玛游戏
谈谈环形DP
常见两种方案解决:
- 把原序列复制一遍再辅以 \(len \le n\)限制。
- 断环为链,枚举断环,会引入一维时间复杂度,多数情况下更劣。(\(O(n^4) <- O((2n)^3)\))
- 先按照序列问题做,然后单独分类讨论头尾边界(比如最大化环上子集和但要求至少相隔 1 个点,分类讨论 1,n 是否都选。先强制 1 不选然后强制 1 选)
树上动态规划
对于相当多的树上 DP 的转移和背包问题思想重叠很大.
而树上背包几乎都可以统一成“孩子分组模型”。
可以把绝大多数树上背包都统一成下面这个框架:
树上背包统一框架
定义:
这里“资源”可能是:
- 选的点数
- 选的边数
- 课程数
- 体积
- 花费
- 代价
然后处理 (u) 的儿子时:
第一步:每个儿子 (v) 自己先算完
你已经知道了:\(dp[v][0], dp[v][1], \dots, dp[v][siz[v]]\)
第二步:把儿子 (v) 看成一个组
这个组里每个候选方案是:
- 取 (k) 个资源
- 得到价值 (dp[v][k])(可能还要加边权/点权修正)
第三步:组与组之间做合并,把多个儿子依次合并进来。
所以:
树上背包的“背包味”主要来自“多个儿子争夺总资源”;
树上背包的“分组味”主要来自“每个儿子只能从其所有内部方案中选一种”。
此外
此外,我们其实是在枚举 \(u\) 的每一个儿子 \(v\) 过程中顺带背包,对于一个组,就是拿当前 \(u\) 已经记录好的 \(dp\) 值,再和 \(v\) 的 \(dp\) 转移凑更优解。
不过分组背包的处理思想和 0/1 背包是很像的,就是把组内每一个物品对最终答案数组跑一次
分组背包有时会退化为 \(0/1\) 背包,比如每个子树只有不选和整棵树两个选项,下面的【2026 sdu省赛排位赛 G】就是这个例子。
2026 sdu省赛排位赛 G
给定一棵有 \(n\) 个结点的树,你需要给结点着红蓝两色之一,满足每种颜色至少有一个节点。并且不存在一条边连接两种不同颜色的节点
请找出所有满足条件的数对 \((a,b)\),其中 \(a\) 表示红色节点数,\(b\) 表示蓝色节点数,并且 \(a+b\) 达到最大可能值,按 \(a\) 升序给出所有方案。
显然 \(a + b = n - 1\),因为我们只需要让一个点不被着色,直接挖掉,形成的森林分别着异色即可。
有一个关键的问题在于,对于一个点,不对其着色,但是它会有多个子树,无论怎么分配着色都可以。
采用分组背包思想,每棵子树都是一个组,我们只能选其中一个,不过由于一整个子树联通块要么都选要么都不选,这完全就退化为 \(0/1\) 背包了。
P2515 软件安装
- 注意背包不能省第一维,因为不同子树回溯过程中会互相污染。
- 注意要先预处理选这个节点的收益
- 注意合并枚举要用
v的dp[v][]数组来更新u的dp[u][],不要把下标v写成u**。
点击查看代码
int dp[500][maxn];
void dfs2(int u){
for(int i = m; i >= wei[u]; i--)
dp[u][i] = max(dp[u][i], dp[u][i - wei[u]] + val[u]); // !
for(auto v : G[u]){
dfs2(v);
for (int j = m; j >= wei[u]; --j)
for (int k = 0; k <= j - wei[u]; ++k) // 此题特殊性:必须把父亲选了才能选儿子
dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k]); // !
}
}
P2015 二叉苹果树
给定一个\(n\)个结点的二叉树,满足每个结点要么是叶子,要么有两个子结点。
每条边上有若干苹果,要求砍掉一些子树(即取出一个包含根结点的连通块),求刚好剩下\(Q\)条边时,这些边苹果数之和最大是多少。
数据范围:\(1 \le Q \le n \le 100\),每条边的苹果数\(\le 3 \times 10^4\)。
分组背包写法:
点击查看代码
void dfs(int u){
siz[u] = 0;
for(int i = 0; i <= Q; i++) dp[u][i] = INF;
dp[u][0] = 0; // 不选任何边,价值为 0
for(int i = 0; i < 2; i++){
int a = son[u][i];
if(!a) continue; // 没有这个儿子,跳过
dfs(a);
// 合并儿子 a
for(int j = min(Q, siz[u] + siz[a] + 1); j >= 1; j--){
for(int k = 0; k <= min(siz[a], j - 1); k++){
// 当前目标是 dp[u][j]
// 儿子 a 子树里取 k 条边,再加上 u-a 这条边,共占 k+1 条边
if(dp[u][j - k - 1] == INF || dp[a][k] == INF) continue;
dp[u][j] = max(dp[u][j], dp[u][j - k - 1] + dp[a][k] + val[u][i]);
}
}
siz[u] += siz[a] + 1;
}
}
P1273 有线电视网
点击查看代码
#define pr pair<int, int>
vector<pr> g[maxn];
int dp[maxn][maxn]; // dp[u][j] 表示节点u为根的子树,接入j个用户的最大收益,注意可能是负数
void dfs(int u){
if(g[u].empty()){
siz[u]++;
dp[u][0] = 0;
dp[u][1] = val[u];
return;
}
for(int i = 1; i <= n; i++) dp[u][i] = -inf;
for(auto [v, c] : g[u]){
dfs(v);
siz[u] += siz[v];
// 对于每个子树,跑一个分组背包
for(int j = n; j >= 0; j--) // 枚举总用户数,或者说“代价”
for(int k = 1; k <= min(siz[v], j); k++){ // 枚举组内的所有物品
dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k] - c);
}
}
}
P2014 [CTSC1997] 选课
给定 \(n\) 门课程来选,每门课程 \(i\) 有依赖的课程 \(k_i\),即选了 \(k_i\) 才能选 \(i\),如果 \(k_i = 0\) 则表示没有依赖。每门课程有一个权值 \(s_i\),选出 \(m\) 门课,要求最大化被选的课程权值和。数据范围:$n, m ≤ 300 $
\(f_{u,w}\) 表示根为 \(u\) 的子树中,选择 \(w\) 门课程,权值和最大是多少。
在转移到父结点的时候,对于每一个子树枚举其选择了多少课程,直接枚举的话复杂度会爆炸,因为上一题固定子树个数是 2,而这一题显然是没有保证的。
简单分析发现这是一个 分组背包模型(即物品有若干类,同一类里面恰好只能选一个物品的背包模型)。
点击查看代码
void dfs(int u){
for(int i = 1; i <= n; i++)
dp[u][i] = -inf;
for(int v = head[u]; v; v = nxt[v]){
dfs(v);
static int g[maxn * 2]; // 使用滚动数组
for(auto &gg : g) gg = -inf;
for(int j = 0; j <= m; j++)
for(int k = 0; k <= m; k++){
// 对子树进行分组背包
/* 每个子树视为一个物品组
选择子树内的若干条边(对应背包的选物品,i 条边就是物品 i)
然后将不同子树的选择结果合并,得到当前节点的状态*/
g[j + k] = max(g[j + k], dp[u][j] + dp[v][k]);
}
memcpy(dp[u], g, (m + 1) << 2);
}
if(u == 0) return;
// 考虑当前根选不选
for(int i = m; i; i--)
dp[u][i] = dp[u][i - 1] + w[u];
}
P1040 [NOIP 2003 提高组] 加分二叉树
设一个 \(n\) 个节点的二叉树 \(\text{tree}\) ,每个节点都有一个分数(均为正整数),记第 \(i\) 个节点的分数为 \(d_i\),\(\text{tree}\) 及它的每个子树都有一个加分,任一棵子树 \(\text{subtree}\)(也包含 \(\text{tree}\) 本身)的加分计算方法如下:
- \(\text{subtree}\) 的左子树的加分 \(\times\) \(\text{subtree}\) 的右子树的加分 \(+\) \(\text{subtree}\) 的根的分数。
若某个子树为空,规定其加分为 \(1\),叶子的加分就是叶节点本身的分数。不考虑它的空子树。
试求一棵符合中序遍历为 \((1,2,3,\ldots,n)\) 且加分最高的二叉树 \(\text{tree}\)。要求输出
- \(\text{tree}\) 的最高加分。
- \(\text{tree}\) 的前序遍历。
这种给定中序遍历建树的有一种很好的解决思路:
1.这题为什么会想到 dp[l][r]
这题的核心思路是:
- 中序遍历固定 ⇒ 任意子树对应一个连续区间;
- 枚举根后 ⇒ 左右子树正好是两个更小的连续区间。
所以自然设计出区间状态:
dp[l][r]:用编号l...r这些点构成一棵子树时,能得到的最大加分
为什么能想到区间dp呢:
- 树建立在中序序列上
- 枚举根
k - 左右子树自然变成两个子区间
- 合并公式为
左子树得分 × 右子树得分 + 根权值
2.为什么这样定义后可以转移
如果在区间 [l,r] 中选 k 作为根,那么由中序遍历性质可知:
- 左子树只能由
[l,k-1]构成 - 右子树只能由
[k+1,r]构成
于是有转移:
注意可以用 root[i][j] 来记录这个区间的根是谁,跑一个 \(dfs\) 每次把根先输出了,自然得到前序遍历。
代码:
点击查看代码
for(int i = 1; i <= n; i++) {
cin >> f[i][i];
f[i][i - 1] = 1; // 空子树为1
root[i][i] = i;
}
f[n + 1][n] = 1;
for(int len = 2; len <= n; len++)
for(int l = 1; l + len - 1 <= n; l++){
int r = l + len - 1;
for(int k = l; k <= r; k++){ // 枚举断点,注意左右子树为空时自然无权值,我们已经设置空子树为1
ll tmp = f[l][k - 1] * f[k + 1][r] + f[k][k];
if(tmp > f[l][r]){
f[l][r] = tmp;
root[l][r] = k;
}
}
}

浙公网安备 33010602011771号