DP 优化方法大杂烩 I.
前方标 * 的是推荐阅读的部分或推荐做的题目。
由于文章内容过多,为分摊压力,更多内容详见 DP 优化方法大杂烩 II.。
CHANGE LOG
- 2021.12.21:计划重构整篇文章。当前重构至矩阵快速幂。
- 2021.12.22:重构至 wqs 二分。
- 2021.12.23:施工完毕。
- 2022.1.25:第二遍重构文章,修改表述。
- 2022.2.11:施工完毕。
0. 前言
动态规划是 OI 界的一个博大分支,因而衍生出了很多优化方法。最基本的是对 状态设计 的优化。如果状态从三维降到了两维,甚至一维,将对时间复杂度和常数产生巨大影响。
可惜的是,对状态设计的优化并没有一般性方法,全凭做题经验与观察性质的能力。文章涉及到的方法大都是对 如何转移 的优化,如根据决策单调性尝试分治。
一般用
D / D:最长上升子序列。- 2D / 0D:最长公共子序列,普通背包问题。
- 2D / 1D:多源最短路 Floyd。
1. 动态 DP
动态 DP 简称 DDP(Dynamic Dynamic Programming),其本质是用 矩阵 维护带修改的动态规划问题。
1.1 矩阵描述转移
部分动态规划转移方程涉及到的状态较少,且一个状态由其前驱的 线性组合 得到。其实并不一定需是线性组合,只需满足 结合律,见下方说明。此时可以用 矩阵乘法 描述转移方程。
斐波那契数列
注意到
这就是矩阵描述转移。
当转移系数如点权或边权带修时,重新 DP 的复杂度不可接受,考虑用数据结构如线段树维护 区间转移矩阵乘积 可支持单点修改。此方法还可快速计算一段区间或一棵子树的 DP 值,非常优美。DDP 也有局限性,它对转移方程要求较高。
说明:可以用矩阵描述的转移方程不一定必须是前驱的线性组合。广义 矩阵乘法
只需满足
常见广义矩阵乘法如
1.2 算法介绍:树链剖分写法
接下来,我们以 P4719 为例,深入剖析一下动态 DP 的一般树剖写法与诸多细节。
若没有修改操作,本题是经典的 树上最大独立集 问题,详见没有上司的舞会。设
加上修改操作,一般想法是用矩阵表示转移方程,再用数据结构维护。注意到一个节点可能有非常多的儿子,因此三方级别的矩阵乘法就凉了。
我们利用 矩乘与 ds 能够 快速转移方程 的特点,与 树链剖分 从根到任意节点的轻边个数级别为
首先,对树
定义 广义 矩阵乘法
我们注意到,一次修改仅改变
思考到这一步,我们遇到了所有 DDP 都需特别注意的关键问题:如何在修改点
如何求
上述过程要求我们在更新
综上,时间复杂度线性对数平方,乘以矩阵乘法的时间,常数非常大。
const int N = 1e5 + 5;
const int inf = 1e9;
struct Matrix {
int a, b, c, d;
Matrix operator * (Matrix x) { // 广义矩阵乘法
Matrix y;
y.a = max(a + x.a, b + x.c);
y.b = max(a + x.b, b + x.d);
y.c = max(c + x.a, d + x.c);
y.d = max(c + x.b, d + x.d);
return y;
}
} I, ans, G[N], val[N << 2];
int n, m, a[N], f[N][2], g[N][2];
int cnt, hd[N], nxt[N << 1], to[N << 1];
void add(int u, int v) {nxt[++cnt] = hd[u], hd[u] = cnt, to[cnt] = v;}
int dn, sz[N], fa[N], dep[N], son[N], dfn[N], rev[N], top[N], ed[N];
void GenMat(int x) {G[x].a = G[x].c = g[x][0], G[x].b = g[x][1], G[x].d = -inf;}
void dfs1(int id) {
f[id][1] = a[id], sz[id] = 1, dep[id] = dep[fa[id]] + 1;
for(int i = hd[id]; i; i = nxt[i]) {
int it = to[i];
if(it == fa[id]) continue;
fa[it] = id, dfs1(it), sz[id] += sz[it];
f[id][1] += f[it][0], f[id][0] += max(f[it][0], f[it][1]); // 先处理出 f
if(sz[it] > sz[son[id]]) son[id] = it;
}
}
void dfs2(int id, int tp) {
g[id][1] = a[id], top[id] = tp, rev[dfn[id] = ++dn] = id;
if(son[id]) dfs2(son[id], tp), ed[id] = ed[son[id]];
else ed[id] = id;
for(int i = hd[id]; i; i = nxt[i]) {
int it = to[i];
if(it == fa[id] || it == son[id]) continue;
dfs2(it, it), g[id][1] += f[it][0], g[id][0] += max(f[it][0], f[it][1]); // 再处理出 g
}
}
struct SegTree {
void build(int l, int r, int x) {
if(l == r) return val[x] = G[rev[l]], void();
int m = l + r >> 1;
build(l, m, x << 1), build(m + 1, r, x << 1 | 1);
val[x] = val[x << 1 | 1] * val[x << 1];
}
void modify(int l, int r, int p, int x) {
if(l == r) return val[x] = G[rev[p]], void();
int m = l + r >> 1;
if(p <= m) modify(l, m, p, x << 1);
else modify(m + 1, r, p, x << 1 | 1);
val[x] = val[x << 1 | 1] * val[x << 1]; // 右乘左儿子
}
void query(int l, int r, int ql, int qr, int x) {
if(ql <= l && r <= qr) return ans = ans * val[x], void();
int m = l + r >> 1;
if(m < qr) query(m + 1, r, ql, qr, x << 1 | 1); // 先递归右子树
if(ql <= m) query(l, m, ql, qr, x << 1);
}
} tr;
int modify(int x, int val) {
g[x][1] += val - a[x], a[x] = val;
while(top[x] != 1) {
int tp = top[x], ft = fa[tp];
ans = I, tr.query(1, n, dfn[tp], dfn[ed[tp]], 1);
g[ft][1] -= ans.a, g[ft][0] -= max(ans.a, ans.b); // 先减掉 x 重链顶端父节点 ft 的 x 所在的轻儿子的 f 的贡献
GenMat(x), tr.modify(1, n, dfn[x], 1); // update
ans = I, tr.query(1, n, dfn[tp], dfn[ed[tp]], 1);
g[ft][1] += ans.a, g[ft][0] += max(ans.a, ans.b), x = ft; // 再加回去
} GenMat(x), tr.modify(1, n, dfn[x], 1);
ans = I, tr.query(1, n, 1, dfn[ed[1]], 1);
return max(ans.a, ans.b);
}
int main(){
cin >> n >> m;
for(int i = 1; i <= n; i++) a[i] = read();
for(int i = 1, u, v; i < n; i++) add(u = read(), v = read()), add(v, u);
dfs1(1), dfs2(1, 1);
for(int i = 1; i <= n; i++) GenMat(i); tr.build(1, n, 1);
for(int i = 1, x, y; i <= m; i++) x = read(), print(modify(x, read())), pc('\n');
return flush(), 0;
}
- 注意点 1:由于 dfn 大的节点深度大,且转移顺序 自下而上,所以线段树维护时应用 右区间乘左区间,查询时也要先向右区间递归。这与写法习惯有关。用行向量右乘矩阵和用列向量左乘矩阵,线段树
push_up
时的区间顺序不同。总之,读者需要根据实际意义理解并实现。 - 注意点 2:就算初始全是单位矩阵
,也要 初始化,因为大部分情况下 。 - 技巧 1:当矩阵乘法仅有某些位置上的值为非常数时,仅维护这些位置的值可以有效减小常数,如切树游戏。
- 技巧 2:一般情况下信息满足可减性,即直接从
中扣掉 的贡献。不满足可减性时可以使用线段树维护带修半群元素积,但似乎如果没有可减性,我们甚至无法用矩阵描述转移,笔者也没有见过这样的题目。
1.3 全局平衡二叉树实现
等学会了 LCT 再来填坑。
upd:可能永远也不会填了,因为永远也不打算学 LCT。
1.4 例题
I. P4719 【模板】"动态 DP" & 动态树分治
动态 DP 的模板题。
II. CF750E New Year and Old Subsequence
题意简述:多次询问一个字符串的子串
,求至少删去多少字符后才能使得其不包含子序列 而包含 。 。
大力子序列自动机上 DP:设
对于直接接在
每次询问用初始向量
不难看出实际上
*III. P3781 [SDOI2017]切树游戏
题意简述:给出
个节点的树,点有小于 的权值 ,权值带修。多次询问有多少非空子连通块满足所有点权值异或和为 。 。
首先设计 DP,设
每个节点
考虑套上动态 DP,设
由于我们要查询全树的
树剖线段树维护。修改需要实时更新
洛谷上树剖被卡了,必须使用全局平衡二叉树。LOJ 可以通过。
IV. P6573 [BalticOI 2017] Toll
究极套路题。注意到同一层大小仅有
V. P7359 「JZOI-1」旅行
裸题。不带修所以不需要线段树,直接倍增即可。时间复杂度
VI. LOJ #3539. 「JOI Open 2018」猫或狗
给定一棵
个节点的树,节点有权值 。带修权值并查询使所有权值为 的点与权值为 的点不连通最少需要割掉的边数。 。
启示:连通性相关树上最优化问题考虑将连通性作为 DP 的维度。设
对于
2. 矩阵快速幂优化
2.1 算法简介
矩阵快速幂优化 DP 与动态 DP 的本质思想相同,都是用矩阵描述转移方程。不同的是,前者每一轮的转移方程相同,可以快速幂优化,后者则是套上各种数据结构支持修改。
仍然以斐波那契数列为例,因为每一轮转移的矩阵都是
2.2 常见技巧
- 拆点:对于图上转移,当边权不为
时,需要把每个点拆成 个点, 是边权。因为 会从 转移,所以每个点要维护 个时刻的信息。如例 VII. 和 VIII. - 向量乘矩阵:对于多组矩阵快速幂询问且转移矩阵
相同,我们预处理 的 的幂次方 。我们知道向量乘矩阵可以做到平方,因此单次查询 在快速幂过程中就不需要 自乘,只需要向量乘矩阵,时间复杂度 。如例 VIII. - 对于转移中存在的特殊时刻,可以配合技巧 2 做到特殊点数量
的时间复杂度,如例 VIII. 与 IX.。
2.3 例题
*I. P3176 [HAOI2015]数字串拆分
题意简述:定义
表示将 拆分成若干个不大于 个数的方案数。给出数字字符串 ,求 ,其中 为将 分割成若干个允许有前导零的数后它们的和。例如 时,答案为 。 。
注意到
不难写出转移方程
注意到
*II. CF576D Flights for Regular Customers
好题!将所有边按照
设答案向量为
根据或对与的分配律,使用矩阵快速幂解决。01 矩阵乘法用 bitset
优化,时间复杂度
III. P1707 刷题比赛
有点裸,不过可以用来熟悉矩阵快速幂。
*IV. P4569 [BJWC2011]禁忌
题解。
V. P5059 中国象棋
注意到每一行是独立的,且方案数为
VI. P1397 [NOI2013] 矩阵游戏
比较裸的矩阵加速 DP。我们没有办法快速十进制转二进制(复杂度
VII. P3597 [POI2015]WYC
非常显然的矩阵快速幂,由于边权只有
*VIII. P6772 [NOI2020] 美食家
结合各种矩阵快速幂的常见优化技巧,拆点 + 预处理矩阵的
*IX. AT2371 [AGC013E] Placing Squares
考虑平方的组合意义实在是太神仙了,考虑维护
3. 状态压缩优化
状态压缩优化应用于以集合为状态的动态规划中。此时用
3.1 常见技巧
- 枚举子集:如果一个集合状态
由其所有子集 转移得到,这样转移的时间复杂度为 。代码实现形如:
for(int T = (S - 1) & S; ; T = (T - 1) & S) {
......
if(!T) break;
}
- 进阶:高维前缀和(SOSDP)和子集卷积(Subset Convolution)本质上也是一种状压 DP。关于 SOSDP 详见 位运算卷积,子集卷积与高维前缀和。
状压 DP 并没有一个很固定的模板,因此本质上仅是用二进制表示集合(或划分)的技巧,而非 DP 的优化方法。此外,状压 DP 是 插头 DP 的基础。
3.2 例题
高难度例题:II, VI, VII, VIII.
I. P1357 花园
注意到 C
/ P
,判断合法性。
由于是环形 DP,所以枚举一开始
实际上最终答案可以由转移矩阵
*II. AT695 マス目
题解。
*III. ACM/ICPC Regional Aizu 2013 Hidden Tree
注意到一棵 balanced tree 上所有数都是最小数乘以
计算每个数中有多少个质因子
后面的条件是因为加入
IV. 2021 联考模拟北大附 瘟疫公司
给出一张
个点有边权的无向图。定义点集 的代价 为连通这些点的最小代价和最大代价异或和。试确定一个点集序列 满足 包含 个点且 包含所有点,最小化 。 , 。
首先 kruskal
时间复杂度
启示:DP 状态的设计时尽量 忽略无用信息,抓住 对转移有关键影响的量。思路:求出代价
V. P5911 [POI2004]PRZ
状压枚举子集,时间复杂度
*VI. CF1463F Max Correct Set
考虑问题的弱化版本。当
因此,对于更一般的情况,我们猜测最优解存在循环节
*VII. CF1152F2 Neko Rules the Catniverse (Large Version)
神仙题。按位置的顺序 DP 显然行不太通,注意到对于所有相邻位置均有值域上的限制,因此考虑 按值域从小到大 DP。
不妨设当前值为
转移就很简单了,方案数作为系数直接乘上去:
由于
*VIII. CF1342F Make It Ascending
题目相当于求将序列
朴素的想法是将所有限制全部装进 DP 里面,即设
这样复杂度太大了,因为我们把值域装了进去。考虑如何把值域去掉。由于若
转移条件有三个:
。- 存在
使得 。设 为最小的这样的 。 。
则
4. 单调队列优化: D / D
单调队列优化常见于 任何维度 的动态规划。可见它是多么基础。
4.1 算法简介
“当一个选手比你小还比你强,你就打不过他了”。这是对单调队列非常形象的概括。
具体地,单调队列通过 及时排除不可能成为最优转移的位置,将寻找决策点的复杂度均摊成
其中
经过上述操作后,取出队首
4.2 常见技巧
一般的转移方程并不友好,因为贡献
结合下方应用以更好理解。
4.3 应用:单调队列优化多重背包
记物品个数为
考虑 拆贡献:考察每个
注意到当
这很好理解,因为上式的
因为对于相同的
4.4 例题
I. *P2254 [NOI2005] 瑰丽华尔兹
注意到求最长滑行距离等价于求最少使用魔法的次数,那么设
注意到一段时间内移动方向相同,设
并满足
II. P3572 [POI2014]PTA-Little Bird
由于
III. P3423 [POI2005]BAN-Bank Notes
单调队列优化多重背包模板题。由于要记录转移点,时空复杂度均为
IV. P3487 [POI2009]ARC-Architects
POI 真的很喜欢出 单调队列!
字典序最大给予我们贪心的思路,即优先选择最大的数字肯定更优,若数字相同则尽量选更前面的,因为这样剩下来的选择就更多。如
但是有
V. P3512 [POI2010]PIL-Pilots
POI 真的很喜欢出 单调队列!直接 two-pointers + 两个单调队列就做完了。时间复杂度线性。
VI. P3422 [POI2005]LOT-A Journey to Mars
POI 真的很喜欢出 单调队列!一开始看错题,以为可以来回走,以为是个神题,想了两个小时 ……
首先破环成链,考虑从
对于逆时针方向,同理要有
*5. wqs 二分优化:2D / 1D
王钦石二分,简称 wqs 二分,又称带权二分,斜率凸优化。常与斜率优化(例题 IX
该算法常见于 限制选取物品个数 的 DP。它有很明显的标志,因此看起来比较套路。
设
常见证明凸性的方法:
- 考虑选取
和 个物品的最优方案,调整得到选取 个物品的方案,同时证明 不大于或不小于 。 - 将问题抽象成费用流模型,则每一滴流量带来的贡献单调不减或单调不增。
不妨设
其中用绿色标出的点为
考虑用斜率
通过比较
问题转化为求切点。用
进行一步非常巧妙的转化:我们发现
时间复杂度为
5.2 细节:数据类型
大部分题目
部分题目涉及小数运算,斜率不是整数。这种情况建议平衡精度与效率,设定二分次数
5.3 特殊情况:三点共线
三点共线是王钦石二分最重要也最容易出错的细节之一。
三点共线可能导致我们无论如何也二分不到想要的
如图,设
有一点需要格外注意:
- 如果在保证答案最优的前提下求得段数最大值,那么当
时应有 , 时应有 。 时截到点 B,因为 ,所以 一定不是 我们要找的斜率, 。 时截到点 D,因为 ,所以 可能是 我们要找的斜率, 。- 所以
应为 。
- 相反,如果求得段数最小值,那么当
时应有 , 时应有 。 时截到的点是 D,因为 ,所以 一定不是 我们要找的斜率, 。 时截到的点是 B,因为 ,所以 可能是 我们要找的斜率, 。- 所以
应为 。
对比上述两种情况,可知在保证答案最优的前提下,求物品个数 最大值 或 最小值 对二分过程有很大影响。
注意:上述结论的前提为
易错点:注意,若二分求得斜率
5.4 细节:更新答案
若边界值变为
如果答案所对应的斜率没有被 DP 过怎么办?实际上不会出现这种情况。类比普通二分能二分出边界值,wqs 二分也能二分出边界斜率。
5.5. 技巧
wqs 二分的常用技巧:用结构体将 DP 值与所选取物品个数结合在一起,不仅方便更新 DP 值,还能快速比较两个 DP 值的偏序关系:根据 5.3 所述细节,若 DP 值相同还需根据所选取的物品个数钦定大小关系。重载运算符可以做到。具体实现见例题 III.
5.6 参考博客
在此感谢这些博主。
- https://www.mina.moe/archives/6349/comment-page-1#comment-1923
- https://blog.csdn.net/a_forever_dream/article/details/105581221
- https://www.cnblogs.com/CreeperLKF/p/9045491.html
5.7 例题
很多时候我们都猜测答案是凸函数,而非严谨证明。
I. P2619 [国家集训队] Tree I
本题是 wqs 二分的经典题。但在这里的用途并不是优化 DP。
设
II. CF739E Gosha is hunting
更进一步地,两维上都具有凸性使得我们可以二分套二分做到线性对数平方。代码。
*III. P4383 [八省联考2018]林克卡特树
神仙题,顺便巩固一下树形 DP。题目本质是选择 恰好
尝试设计在 wqs 二分内部的树形 DP。单走一个
另外记录当前 DP 值下链的条数的 最大值 方便判断 wqs 二分截得的点的横坐标。记
不难发现
注意点:计算
*IV. CF802O April Fools' Problem (hard)
神仙题!双倍经验:CF802N April Fools' Problem (medium)。
这个题神仙之处在于如何对贪心进行反悔:
接下来考虑怎么贪心:对于每一个
考虑如何反悔:每个
V. P1484 种树
注意题目求的是 最多
时间复杂度线性对数,关于本题的反悔贪心解法见例题 VII. 给出的链接。
VI. P1792 [国家集训队]种树
双倍经验。显然如果
VII. P3620 [APIO/CTSC 2007]数据备份
三倍经验。相邻两个坐标相减,即求不相邻的
四倍经验:SP1553 BACKUP - Backup Files。
VIII. CF958E2 Guard Duty (medium)
五倍经验。
*IX. P5896 [IOI2016] aliens
题解。
*X. P5308 [COCI2019] Quiz
猜想答案关于
XI. P4072 [SDOI2016]征途
注意到题目限制恰好
求后一项的最小值可以斜率优化:
XII. P4983 忘情
发现题目中的式子就是
*XIII. P5633 最小度限制生成树
带权二分。视选择与
但若每次二分都对边重新排序,时间复杂度无法承受,可以一开始先进行初始化,则二分 check 内部只需要使用归并排序即可。时间复杂度
注意本题有不使用带权二分的复杂度更优的神仙解法。见 贪心 专题。
*XIV. CF321E Ciel and Gondolas
看到这题笔者首先想到了
猜测答案关于
*XV. 某模拟赛 AK 吧
次询问在 恰好 选出 段的最大子段和。 。
关于 最多 选
恰好选
区间询问考虑线段树,每个区间
根据经典结论,
由于子段的延伸情况仅和区间端点相关,故对每个区间分别记录两个端点总共四种状态。对于左端点为
wqs 二分上下界为
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!