DP 进阶课件
DP 进阶
紊莫
目录
-
前言
-
树形 DP
-
状压 DP
-
数位 DP
-
概率 DP 和 期望 DP
-
计数 DP
-
DP 方法
-
DP 题选讲
前言
前置知识是基本的动态规划思想,并且能解决一些简单的线性 DP。
课件是精简过后的,很多内容没有做补充,以及题目开头带星号的为选做题。
关于本节内容的疑问欢迎通过 QQ(2243496855)联系我。
树形 DP
树形 DP,即在树上进行的 DP。
由于遍历树固有的递归性质,树形 DP 一般都是递归进行的。
我们将从一些经典模型和例子开始。
求所有点的子树大小
如题,这是一个非常简单常见,却又能一窥树形 DP 形式的题目,也许大家已经遇见了这样的问题。
void dfs(int u) {
size[u] = 1; // 动态规划需要的初值
for (int v : son[u]) {
dfs(v); // 树形 DP 的递归形式
size[u] += size[v]; // 儿子信息的合并
}
}
树上最大独立集
给出一个 \(n\) 个点的树,每个点有权值 \(r_i\),要求选出一个点集权值和最大。
使得没有一条边两端的点同时被选。
要求时间复杂度 \(O(n)\)。
常见的树形 DP 都会记录 \(f_{u,*}\) 表示:仅考虑以 \(u\) 为根的子树,\(u\) 这个点在 \(*\) 条件下的答案(视情况分析)。
在这个题里,可以这样设计状态 \(f_{u,0 / 1}\) 分别表示不选和选了 \(u\) 的情况下的答案。
因为树天生具有一种无后效性,想要合并儿子的信息只需要儿子那一个点的信息。
也就是子树内做完 DP 之后,只需要保留根的信息就好了,同时采用 dfs 的方法来 DP。
转移和代码如下:
void dfs(int u) {
f[u][0] = 0, f[u][1] = r[u];
for (int v : son[u]) {
dfs(v);
f[u][0] += max(f[v][0], f[v][1]); // 若 u 不选,则儿子可选可不选,取大者。
f[u][1] += f[v][0]; // 若 u 选上,则儿子只能不选。
}
}
树上背包
树上的背包问题,简单来说就是背包问题与树形 DP 的结合。
在此之前需要了解基本的背包模型和背包的合并。
例题
给出一棵 \(n\) 个点的树,每个点有权值 \(a_i\),要求选了一个点必然要选其父亲。
求出选择 \(m\) 个点能得到的最大权值和。
数据范围:\(1 \le n, m \le 2000\)。
背包问题一般会记录当前选了几个元素。
仿照这个方法,也可以设计出树形背包的状态,\(f_{u,i}\) 表示以 \(u\) 为根的子树内选出了 \(i\) 个点的最大权值和。
一个子树如果选了点,则一定选了子树的根,那么可以看作是当前点一开始有一个大小为 \(1\) 的背包,将其和每个儿子对应的背包合并起来。
const int N = 2005;
int n, m, a[N], f[N][N], siz[N], g[N];
vector<int> G[N];
void dfs(int u) {
f[u][1] = a[u], siz[u] = 1;
for (int v : G[u]) {
dfs(v);
for (int i = 1; i <= siz[u]; i++)
for (int j = 0; j <= siz[v]; j++)
g[i + j] = max(g[i + j], f[u][i] + f[v][j]);
siz[u] += siz[v];
for (int i = 1; i <= siz[u]; i++)
f[u][i] = g[i], g[i] = 0;
}
}
下面,我指出这段代码的复杂度为 \(O(n^2)\) 的。
关键在于分析中间的两重循环,发现这实际上是遍历了两个不交子树内的点,并将其合并起来,且之后再也不会合并它们。
假设两个点被合并一次就连一条边,那么形成的就是一个 \(n\) 个点的完全图,自然是 \(O(n^2)\) 的。
值得一提的是,这个背包第二维的大小为 \(m \le n\),若不和 \(n\) 同阶时,树形背包的复杂度实际上是 \(O(nm)\) 的。
也可以用上文的思路来看,看作是只和最近的 \(m\) 个点合并即可。
高维树形背包的复杂度
设有 \(k\) 维关于子树大小的背包,则复杂度为 \(O(n^{2k})\),常数一般会很小。
[NOIP2024] 树的遍历
去年同期讲课是 NOIP2022 建造军营。大家也可以看看。
题目太长了,详见原题面。
解法
这里只提供一种做法,可能和你的思路不同。
首先,\(k=1\) 的时候答案是 \(\prod(d_i-1)!\),把每个点的出边连成一个链,起点固定,\(k\) 大的时候考虑容斥。
答案是对于每条从叶子到叶子且经过至少一条起始边的路径集合 \(S\):
这里认为 \(0\) 的逆元是 \(1\)。
这是因为一条链会导致路径上的点起点和终点都固定,相当于贡献变成 \(\prod(d_i-2)!\)。
现在的问题可以看作是每个点有点权,求满足条件的路径的点权乘积的和,用 DP 来解决。
解法
在 LCA 处合并,懒得写了,找个同学说说看。
换根 DP
树形 DP 中的换根 DP 问题又被称为二次扫描,通常不会指定树的根,并且根的变化会对一些值,例如子结点深度和、点权和等产生影响。
通常需要两次 DFS,第一次 DFS 预处理深度等子树中的信息,在第二次 DFS 开始运行换根 DP,求出以每个点为根的答案。
P3478 [POI2008] STA-Station
给定一个 \(n\) 个点的树,请求出一个点,使得以这个点为根时,所有点的深度之和最大。
要求时间复杂度 \(O(n)\)。
解法
一般我们设 \(f_i\) 和 \(g_i\) 分别表示 \(i\) 的子树内的深度之和,\(i\) 的子树外深度之和。
可以 DFS 两遍求出 \(f_i\) 和 \(g_i\)。
代码
void dfs(int u, int fa) {
sz[u] = 1;
for (int v : G[u]) if (v != fa) {
dfs(v, u), sz[u] += sz[v];
f[u] += f[v], sum[u] += f[v] + sz[v];
}
f[u] += sz[u] - 1;
}
void dfs2(int u, int fa) {
for (int v : G[u]) if (v != fa) {
g[v] = g[u] + sum[u] - f[v] - sz[v] + (n - sz[v]);
// 这里是做了差分,遇到不可差分的信息可以预处理前后缀。
dfs2(v, u);
}
}
CF708C
给定一颗树,你有一次将树改造的机会,改造的意思是删去一条边,再加入一条边,保证改造后还是一棵树。
请问有多少点可以通过改造,成为这颗树的重心?(如果以某个点为根,每个子树的大小都不大于 \(\dfrac{n}{2}\),则称某个点为重心)
要求复杂度 \(O(n)\)。
解法
考虑原本非重心的点,一定有且仅有一个子树大小超过 \(\dfrac{n}{2}\),考虑从中选取最大的不超过一半的子树,若剩下的部分合法,则这个点合法。
对于子树内的情况,一遍 dfs 解决,对于子树外的情况,答案需要来自其他子树,而最大值是不能差分的,所以可以维护前后缀信息合并得到。
另一种方法是维护最大值和次大值。
状压 DP
状压 DP 是动态规划的一种,通过将状态压缩为整数来达到优化转移的目的,其实和其他 DP 没有本质区别。
P1896 [SCOI2005] 互不侵犯
在 \(n\times n\) 的棋盘里面放 \(k\) 个国王(\(1 \leq n \leq 9, 1 \leq k \leq n \times n\)),使他们互不攻击,共有多少种摆放方案。
国王能攻击到它上下左右,以及左上左下右上右下八个方向上附近的各一个格子,共 \(8\) 个格子。
解法
一行的国王安放情况只和上一行和这一行其他国王有关。

如上的安放方式可以被表示为 \(101001_{(2)}\),那么设 \(f_{i,S}\) 表示安放了前 \(i\) 行,第 \(i\) 行的状态为 \(S\) 的方案数。
转移是 \(f_{i-1,s} \to f_{i,t}\),满足 \(s,t\) 不矛盾,复杂度是 \(O(n4^n)\) 的。
[NOIP2017 提高组] 宝藏
题意有点长,详见原题面。
解法
限于题目要求的深度限制,我们无法做到单点扩展,需要逐层扩展,那么暴力枚举状态是 \(4^n\) 的,利用枚举子集的方法可以做到 \(3^n\)。
for (int t = s; t; t = (t - 1) & s) {
// 此时的 t 就是 s 的一个非空子集
}
当然写个暴力 dfs 也是一样的,顺便还能分析复杂度,这样写只是简单一点。
CF1767E
简化版问题:给出一个 \(n \le 40\) 个点的图,每个点有权值,求其最大权独立集。
解法
考虑折半搜索,先算出前一部分和后一部分的答案,那么对于后一部分的一个状态 \(s\),会导致前一部分有些点不能选,其他可能是任意的,也就是查询前一部分 DP 数组的子集最大值。
由于最大值的可重复贡献性,其实你无论怎么维护都行,但是如果是计数性的,就需要不重不漏,那么下面给出一个可行的方法,称其为高维前缀和。
高维前缀和
二维前缀和的常见写法是 \(2^2\) 的容斥。
但是其实可以这样写:
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
s[i][j] += s[i - 1][j];
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
s[i][j] += s[i][j - 1];
即对于每一维分别做前缀和。
一个二进制状态可以看作是 \(n\) 维数组,每一维的大小是 \(2\)。
写法
for (int i = 0; i < n; i++)
for (int s = 0; s < 1 << n; s++)
if (s >> i & 1) f[s] = max(f[s], f[(1 << i) ^ s]);
计数型问题(不能重复贡献)一例:CF1221G。
数位 DP
此类问题的特征比较明显,常见的形式是数 \([l,r]\) 内满足某个条件的数字个数,这个数字可能很大。
数位 DP 的基本原理(from OI-wiki):
考虑人类计数的方式,最朴素的计数就是从小到大开始依次加一。但我们发现对于位数比较多的数,这样的过程中有许多重复的部分。
例如,从 7000 数到 7999、从 8000 数到 8999、和从 9000 数到 9999 的过程非常相似,它们都是后三位从 000 变到 999,不一样的地方只有千位这一位,所以我们可以把这些过程合并起来,用 DP 的方式进行状态转移。
P2602 [ZJOI2010] 数字计数
给定两个正整数 \(a\) 和 \(b\),求在 \([a,b]\) 中的所有整数中,每个数码(\(0 \sim 9\))各出现了多少次。
\(1 \le a \le b \le 10^{12}\)。
解法
首先差分一下,变成求解 \([1,r]\) 的答案。
发现唯一的一个问题是前导零和当前是否顶着 \(r\) 的上界,记录这两者即可。
常见的实现方法是记忆化搜索。
代码
下面的代码来自我两三年前写的东西。
int p[20], len, f[20][2][20][10][2]; // p 表示数位
int dfs(int pos, int flag, int sum, int d, int qdl) {
if (pos == 0) return sum;
int &t = f[pos][flag][sum][d][qdl];
if (t != 0) return t;
int mx = flag == 1 ? p[pos] : 9;
for (int i = 0; i <= mx; i++) {
int add = i == d && (!(i == 0 && qdl == 1));
t += dfs(pos - 1, (flag == 1 && i == p[pos]), sum + add, d, (qdl && i == 0));
}
return t;
}
有一点不太提倡,就是记忆化的时候在对于 flag 和 qdl(前导零)的处理,合理的处理方法应该是在 flag = qdl = 0 的时候记忆化。
CF55D
一个数字 \(x\) 被认为是美丽的,当且仅当 \(x\in\mathbb{Z^+}\) 并且对于 \(x\) 的每一个非零位上的数 \(y\),都有 \(y|x\)。
你需要帮助他算出在区间 \([l,r]\) 中有多少个数是美丽的。
\(t\) 组数据,\(1\le t\le 10,1\le l\le r\le 9\times 10^{18}\)。
本题数位 DP 部分交给大家独立思考。
否则会 TLE
记忆化的本质就是要对那些重复的 \(0\) 到 \(999\) 之类的计数的合并,这样才可以复用(不用清空 DP 数组)。
另一个问题是 DP 的时候初始化最好不要是 \(0\),否则一些问题中可能答案也是 \(0\),程序误以为没有计算过,导致复杂度寄了。
概率 DP 和 期望 DP
前置知识:概率和期望,高斯消元法。
由于概率和期望联系紧密,且常常以期望 DP 的形式出现,所以下面会多讲一点期望相关。
虽然随机变量的期望在大纲中是超纲的,但是平时运用还是非常广泛的。
概率 DP
这类题目采用顺推,也就是从初始状态推向结果。
和一般的 DP 类似,难点依然是对状态转移方程的刻画,只是这类题目经过了概率论知识的包装。
CF148D
袋子里有 \(w\) 只白鼠和 \(b\) 只黑鼠 ,A 和 B 轮流从袋子里抓,谁先抓到白色谁就赢。
每轮操作如下,直至袋子为空:
- A 随机抓一只老鼠(不放回)
- B 随机抓一只老鼠(不放回)
- 随机跑出来一只老鼠(若存在)
如果两个人都没有抓到白色则 B 赢。A 先抓,问 A 赢的概率。
\(0\le w,b\le 1000\)。
解法
设 \(f_{i,j}\) 表示当前有 \(i\) 个白鼠,\(j\) 个黑鼠时的先手获胜概率。
考虑一轮可能的情况:
- 先手摸到白色,赢了。
- 先手摸到黑色,后手摸到白色,输了。
- 先手黑,后手黑,跑出白,转移到 \(f_{i-1,j-2}\)。
- 先手黑,后手黑,跑出黑,转移到 \(f_{i,j-3}\)。
期望 DP
下面的题目都带有“期望”。
常见的离散期望可以表示为某个事件的权值乘发生的概率之和。
一些期望题就是符合条件的方案数除以总方案数。
另一些考察期望线性性的题就比较难了,需要合理的拆分期望。
Sushi
现有 \(N(1 \le N \le 300)\) 个盘子,编号为 \(1,2,3,\dots,N\)。第 \(i\) 个盘子中放有 \(a_i(1 \le a_i\le 3)\) 个寿司。
接下来每次执行以下操作,直至吃完所有的寿司。从第 \(1,2,3,\dots,N\) 个盘子中任选一个盘子,吃掉其中的一个寿司。若没有寿司则不吃。
若将所有寿司吃完,请问此时操作次数的数学期望是多少?
解法
其实和概率 DP 没有太大的区别,设 \(f_{i,j,k}\) 表示有 \(i\) 个盘子里有一个寿司,\(j\) 个盘子里有两个寿司,\(k\) 个盘子里有三个寿司时吃完所有寿司的期望。
合并同类项后转移即可。
P8208 [THUPC2022 初赛] 骰子旅行
给出一张 \(n \le 100\) 个点,\(m \le 5000\) 条边的有向图,起点为 \(s_0\),开始随机游走 \(t\) 次。
记经过的点依次为 \(s_0,s_1,s_2,\dots,s_t\),对于一个 \(s_i\) 若其不是第一次出现,上一次出现为 \(s_j\),则答案加上 \(s_{j+1}\)。
求答案的期望,对 \(998244353\) 取模。
期望的线性性
形式化的表述是 \(E(ax+by) = aE(x)+bE(y)\),但是记这个东西没啥意思,直接把它当成计数问题中拆贡献的方法就好了。
对这个题来说,我们对于从起点开始走 \(i\) 步后到达 \(u\),再经过 \((u, v)\),最后到达终点这个过程来计算。
那么要知道的是从起点走 \(i\) 步到 \(u\) 的概率,和从 \(v\) 开始走 \(j\) 步且经过 \(u\) 的概率(容斥变成不经过)就好了。
NOIP2024模拟赛 航行(sail)
从左到右有 \(n\) 条航道,标号分别为 \(1,2,\dots,n\)。
航道 \(1\) 的左侧以及航道 \(n\) 的右侧皆为岸上,对于每条航道 \(i\) 有参数 \(p_i\)。
在第 \(0\) 时刻,你的速度 \(v=0\)。接下来的每一个时刻,若你处在位置 \(i\),有 \(p_i\) 的概率你的速度 \(v\) 减少 \(1\) ,有 \(1-p_i\) 的概率 \(v\) 增加 \(1\)。接着,你的位置从 \(i\) 变为 \(i+v\),并来到下一时刻。
若某时有 \(i \notin[1,n]\) 则表明你上岸了,停止航行。
若第 \(0\) 时刻,你处在位置 \(i\),你的期望上岸时间是多少?对每一个 \(i\in[1,n]\) 求出答案模 \(998244353\) 意义下的结果,保证答案存在。若你始终无法上岸,输出 \(−1\)。
提示
建议先思考 \(n \le 40\),然后思考 \(n \le 500\)。
做法
朴素的想法是设 \(f_{i,v}\) 表示以 \(v\) 的初速度从 \(i\) 出发,到达终点的期望步数,答案是 \(f_{i,0}\)。
这样的话,存在一个问题,转移有后效性,一般解决这种问题都采用高斯消元法,即将每个 DP 值看作一个未知数,通过若干个方程来解出一组解,在 \(n\le 40\) 时选取有用的状态可以通过。
如果你写出 DP 方程来观察,则会发现 \(v\neq 0\) 时,不存在后效性,也就是可以变成若干 \(f_{x,0}\) 的线性组合,提前做一个 DP 预处理即可。
这种方法相当于是设立了主元,从而使得复杂度降为 \(O(n^3)\)。
[ABC189F] Sugoroku2
有一个人想从 \(0\) 号格子走到 \(N\) 号格子,每轮等概率从 \([1, M]\) 中选择一个正整数 \(x\),从当前位置 \(i\) 到位置 \(i + x\),当前位置大于等于 \(N\) 则游戏结束,有 \(K\) 个特殊位置,到特殊位置会直接传送回位置 \(0\)(不算轮数),问期望轮数。
\(1 \le N, M \le 10^5\),\(0 \le K \le 10\)
待定系数法
设 \(f_i\) 表示从 \(i\) 开始走到终点的期望步数。
那么 \(f_i\) 可以用 \(kf_0 + c\) 来表示,最后解出 \(f_0\) 即可。
DP 方法
下面我会介绍一些特殊类型的 dp 技巧。
排列问题
通常是不给出排列,形如求满足某条件的所有排列的权值之和的问题。
因为排列每个数字恰好出现一次,所以很难用常见的方法 DP,因此这是很需要积累 Tricks 的一类题目,下面就将介绍其中的一部分。
逐步生成
排列的关键是找到一个生成顺序,使得已生成部分的数不需要逐个记录,而只需抽象成少量的几个量。最基本的排列生成顺序如下:
- 从左往右依次确定绝对值。
- 从小到大依次确定绝对位置。
- 从左往右依次确定相对大小(在前缀中的排名)。
- 从小到大依次插入已生成的排列中。
当然大小,左右都是可以对调的,作为最常见的方法,可以在遇到问题的时候首先考虑。
Permutation
给定一个由 < 和 > 组成,长为 \(n-1\) 的的字符串 \(s\)。
对于任意满足 \(1 \le i \le n-1\) 的字符 \(s_i\),如果 \(s_i\) 是 < 则 \(p_i<p_{i+1}\),否则 \(p_i>p_{i+1}\)。
求满足这样的性质的长为 \(n\) 的排列 \(p\) 的方案数,对 \(998244353\) 取模。
数据范围:\(1 \le n \le 3000\)。
解法 1
这里采用 从左往右依次确定相对大小(在前缀中的排名)的方法求解。
设 \(f_{i,j}\) 表示前 \(i\) 个数字确定后 \(p_i\) 为第 \(j\) 小的数字的方案数量。
如果要 \(p_{i-1} < p_i\) 那么 \(p_{i-1}\) 在前 \(i-1\) 个数字中只能是第 \(1, 2, \cdots, j - 1\) 大。
否则,\(p_{i-1}\) 在前 \(i-1\) 个数字中可以是第 \(j, j + 1, \cdots, i - 1\) 大。
前缀和优化转移即可,该问题还可以上树,做到相同复杂度。
解法 2
利用容斥,钦定几个 > 不合法,剩下的会形成若干个连续段,答案就是一个多重组合数,利用 DP 求解系数即可。
CF1806D - DSU Master
给出长度为 \(n - 1\) 的序列 \(a\),对于一个长度为 \(n - 1\) 的排列 \(p\),定义它的价值如下:
有一个初始无边的并查集,按照 \(p_1, \ldots, p_{n-1}\) 的顺序合并并查集 \(p_i\) 和 \(p_i+1\),如果 \(a_{p_i}=0\),那么将 \(p_i+1\) 所在的并查集的根并到 \(p_i\) 所在的并查集的根上,否则将 \(p_i\) 并所在并查集到 \(p_i+1\) 所在的并查集的根上,进行完所有操作后 \(1\) 的儿子的个数。
你需要对于所有 \(k\in[1,n-1]\) 求出长度为 \(k\) 的 \(k!\) 种排列顺序的价值和,对 \(998244353\) 取模。
数据范围:\(1\le n\le 5\times 10^5\),\(0 \le a_i \le 1\)。
解法
根据连边方式,一个连通块始终是值域上的一个区间。
那么首先拆贡献,考虑 \(p_i = x\) 什么时候会产生贡献,首先要 \(a_x = 0\),然后连 \((x, x + 1)\),要求是 \(1 \sim x\) 已经连通,也就是在 \(p_1, p_2, \cdots, p_{i-1}\) 中出现了 \(1\sim x-1\),\(a_x = 1\) 的时候满足这个条件会消除后续所有贡献。
那么设 \(ans_i\) 表示 \(1\sim i\) 的所有排列的答案,\(f_i\) 表示若 \(a_{i+1}=0\) 的额外贡献,考虑用 从小到大依次插入已生成的排列中 的方法。
解法
转移考虑枚举 \(i\) 的位置,显然放在前 \(i-1\) 个空隙中不影响答案,\(a_i = 0\) 时放在最后有额外的贡献,否则会把答案清零。
括号序列问题
解决这类问题,考虑几个对括号序列经典的刻画。
- 把左括号看成 \(1\),右括号看成 \(-1\),一个括号序列合法当且仅当前缀和 \(\ge 0\) 且总和为 \(0\)。
- 把上述过程画在折线图中。
- 考虑递归的定义,空串是合法的括号串,\((S)\) 和 \(S_1S_2\) 也是合法的括号串。
大多数问题用这些方法即可解决。
NFLSOJ P12848 令人闻风丧胆的英文名称(bracket)
给出一个长度为 \(n\) 的括号串。
针对这个串 \(S\),她发出了 \(q\) 次询问,每次给定 \(l,r\),希望你告诉她有多少 \(l\le x\le y\le r\) 满足:
\(S[l,r]\) 去掉 \(S[x,y]\) 后是合法的括号串。需要注意的是,空串也是合法的括号串。
数据范围:\(n\le 2500\),\(q\le 10^6\)。
解法
只看 \(O(n^2)\) 的做法,设 \(f_{l,r}\) 表示 \(l,r\) 的答案。
有性质:\(l,r\) 要么互相匹配,要么至少有一对和原序列中的匹配括号去匹配,证明简单,交给大家完成。
for (int l = n; l; l--)
for (int r = l; r <= n; r++) {
f[l][r] = 1;
if (nxt[l] < r) // nxt 是左括号对应右括号的位置
f[l][r] += f[nxt[l] + 1][r];
if (pre[r] > l) // pre 是右括号对应左括号的位置
f[l][r] += f[l][pre[r] - 1];
if (nxt[l] + 1 < pre[r])
f[l][r] -= f[nxt[l] + 1][pre[r] - 1]; // 容斥一下
if (s[l] == '(' && s[r] == ')')
f[l][r] += f[l + 1][r - 1];
}
slope trick
上次省选讲课时对 slope trick 的介绍有些模糊不清。
现在是详细的介绍。
介绍
这是一种管理连续凸函数 \(f:\mathbb{R} \to \mathbb{R}\) 的技巧,常在 DP 中使用,注意和斜率优化区分开来。
下面将从常见分段凸函数入手,探究对于凸函数能进行怎样的操作,并解决一些算法问题。
连续分段凸函数
- 一个连续分段凸函数的每一段斜率应该是 \([l,l+1,\cdots,r-1,r]\)。
- 此处要求斜率均为整数,和数学上的定义不同。
连续分段凸函数
为了方便和统一形式,认为可以存在斜率为 \(0\) 的线段。
- 在 \((-\infty, a]\) 中斜率为 \(-2\)。
- 在 \([a, b]\) 中斜率为 \(-1\)。
- 在 \([b, b]\) 中斜率为 \(0\)。
- 在 \([b, c]\) 中斜率为 \(1\)。
- 在 \([c, c]\) 中斜率为 \(2\)。
- 在 \([c, +\infty)\) 中斜率为 \(3\)。
常用的简单凸函数
- \(f(x) = |x - a|\)。
- \(f(x) = \max(0, x - a)\),下文中简记为 \((x-a)_+\)。
- \(f(x) = \max(0, a - x)\),下文中简记为 \((a-x)_+\)。
- 这样的凸函数之和也是凸函数。
维护这个凸函数的方式
这是 slope trick 的主体部分。通过维护斜率变化点(简称拐点)的多重集合,从而简洁高效的维护特定的函数操作。
对于一个凸函数 \(f\),记:
- \(\min_f\) 表示 \(f\) 的最小值。
- \(L\) 表示斜率小于 \(0\) 的部分的斜率变化点的多重集合,其大小为最左边直线的斜率的相反数。
- \(R\) 表示斜率大于 \(0\) 的部分的斜率变化点的多重集合,其大小为最右边直线的斜率。
- \(l_0\) 表示 \(L\) 中的最大值,若 \(L\) 为空则 \(l_0 = -\infty\)。
- \(r_0\) 表示 \(R\) 中的最小值,若 \(R\) 为空则 \(r_0 = +\infty\)。


通过拐点集合恢复原函数
还能知道在 \(x\in[l_0,r_0]\) 处 \(f(x) = \min_f\)。
对于函数的操作及其时间复杂度
获取最小值:\(O(1)\)
无论是 \(\min_f\) 还是取到最小值的区间 \([l_0,r_0]\)(借助优先队列),都可以轻松得到。
给函数加上常数 \(a\):\(O(1)\)
只需要将 \(\min_f \leftarrow \min_f + a\) 即可。
给函数加上 \((x-a)_+\):\(O(\log n)\)
显然这个函数操作过后会在 \(a\) 处加入一个拐点,斜率最小值不变,最大值加一。
所以可以将 \(S = L\cup R\cup {a}\) 的前 \(|L|\) 个元素放到新的 \(L'\) 中,后 \(|R|+1\) 个元素放到 \(R'\) 中,以优先队列为例,实现可以是:
L.push(a), R.push(L.top()), L.pop();
另一种考虑是分讨 \(l_0 和 a\) 的大小关系,直接决定放入 \(L\) 或是 \(R\) 中。
现在考虑对于 \(\min_f\) 的贡献,显然 \(l_0\) 仍一定取到最新的 \(\min_f\),那么 \(\min_f\) 的更新量就是 \(f(x)\) 在 \(l_0\) 处的更新量,\(\min_f \leftarrow \min_f + (l_0 - a)_+\)。
给函数加上 \((a-x)_+\):\(O(\log n)\)
相似的,有 \(\min_f \leftarrow \min_f + (a - r_0)_+\)。
R.push(a), L.push(R.top()), R.pop();
给函数加上 \(|x - a|\):\(O(\log n)\)
因为 \(|x - a| = (x-a)_++(a-x)_+\),所以以任意顺序做两次上面的操作即可。
此外,加上一次函数可以看作是这些操作在 \(a=\pm \infty\) 处的操作。
前缀 \(\min\) 操作。
设 \(g(x) = \min_{y\le x}f(y)\),然后 \(g \to f\) 的操作称为前缀 \(\min\),同理有后缀 \(\min\)。

直接清空 \(R\) 即可,后缀 \(\min\) 不再赘述。
下面我们通过维护一些数据结构的整体标记实现更多操作。
记 \(\mathrm{add}_L\),\(\mathrm{add}_R\) 表示对 \(L,R\) 的整体加标记,也就是对于一个 \(l\in L\),真实值为 \(l + \mathrm{add}_L\)。
平移函数:\(O(1)\)
设 \(g(x) = f(x-a)\),然后 \(g\to f\) 的操作称为将 \(f\) 向右平移 \(a\) 个单位长度。

\(\mathrm{add}_L \leftarrow \mathrm{add}_L + a, \mathrm{add}_R \leftarrow \mathrm{add}_R + a\) 即可。
区间最小值操作:\(O(1)\)
对于 \(a\le b\),计算:
在函数图像上等价于对 \(L\),\(R\) 分别平移,下面会给出代数上的解释。
- 若 \(x\le l_0 + a\),则 \(x-a\le l_0\),即 \(f\) 在 \([x-b,x-a]\) 上单调递减,所以 \(g(x)=f(x-a)\)。
- 若 \(x\ge r_0 + b\),则 \(x-b\ge r_0\),即 \(f\) 在 \([x-b,x-a]\) 上单调递增,所以 \(g(x)=f(x-b)\)。
- 若 \(l_0 + a\le x\le r_0+b\),则 \([x-b,x-a]\cap[l_0,r_0]\ne \emptyset\),所以 \(g(x)=\min_f\)。
解决一些使用 slope trick 的动态规划题目
下面是一些例题。
CF13C Sequence
给定一个序列,每次操作可以把某个数加上 \(1\) 或减去 \(1\)。要求把序列变成非降数列。最小化操作次数。
要求使用刚刚所学,做到复杂度为 \(O(n\log n)\)。
解法
设 \(f_{i,x}\) 表示前 \(i\) 个数字,以 \(x\) 结尾的最小代价。
将 \(f_i(x)\) 看作是一个函数,一开始为 \(f_0(x)=0\),对这个函数施加操作:
- 做一次前缀 \(\min\)(舍弃 \(R\) 点集)。
- 加上函数 \(|x-a_i|\),拆分成 \((x-a_i)_+\) 和 \((a_i-x)_+\),顺序任意。
#include <bits/stdc++.h>
#define int long long
using namespace std;
int n, a, ans = 0;
priority_queue<int> L;
priority_queue<int, vector<int>, greater<int>> R;
signed main() {
cin >> n;
L.push(-1e9), R.push(1e9); // 哨兵元素
while (n--) {
cin >> a;
while (R.size() > 1) R.pop(); // 保留哨兵元素
ans += max(0ll, a - R.top());
R.push(a), L.push(R.top()), R.pop(); // 加上函数 max(0,a-x)
ans += max(0ll, L.top() - a);
L.push(a), R.push(L.top()), L.pop(); // 加上函数 max(0,x-a)
}
cout << ans << '\n';
return 0;
}
删除显然无用的语句可以进一步简化代码。
#include <bits/stdc++.h>
#define int long long
using namespace std;
int n, a, ans = 0;
priority_queue<int> L;
signed main() {
cin >> n;
while (n--) {
cin >> a;
L.push(a);
ans += max(0ll, L.top() - a);
L.push(a), L.pop();
}
cout << ans << '\n';
return 0;
}
加强!
现在要求修改过后的数字必须在原序列中出现(P4597)。
加强?
考虑最终的序列,若存在一个数 \(a'_i\) 不存在于原序列中,那么在 \(|x-a_i|\) 中必然没有取到最低点,而 \(+1\) 或者 \(-1\) 一定会使得答案更优。
所以最优解一定是满足上述条件的。
加强!
现在要求输出任意一种方案(P4331)。
做法
考虑最后一项必然在 \(l_0=r_0\) 处取到最小值,则前一项一定在 \(\min(l'_0, l_0)\) 处取到最小值。
也就是设 \(x_i\) 为前 \(i\) 项对应的函数的 \(l_0\),那么最终的 \(x_i\),就是 \(\min_{j\ge i}x_j\)。
じょうしょうツリー
给出一棵有根树和每个点的点权,你可以花费 \(k\) 的代价将某个点的权值增大或减小 \(k\)。
现在想使这棵树的每个点的点权都严格大于子节点的点权,请你求出最小的代价。
做法
首先将 \(c_i\) 变成 \(c_i + \mathrm{dep}_i\),这样就转化成了每个点的点权大于等于儿子的权值。
朴素的 DP 方程如下:
\(\min_{y\le x}f_{v,y}\) 是前面提到的前缀 \(\min\),直接舍弃 \(R\) 即可。
现在的问题是如何合并两个凸函数呢?
凸函数的合并
上文提到,可以通过拐点复原函数:
那么凸函数的相加可以看作是 \(\min_f\) 相加,然后施加若干 \((x-a)_+\) 和 \((a-x)_+\)。
我们使用启发式合并,可以做到 \(O(n\log^2n)\),进一步优化则考虑因为没有了 \(R\),所以施加操作等价于 \(L\) 的合并。
使用可并堆可以做到 \(O(n\log n)\),进一步拓展等到 cyf 讲课。
DP 题选讲
这一部分的题目大多数都是选做题,核心在于体会其中的 DP 部分。
*CF960G - Bandit Blues
给你三个正整数 \(n\),\(a\),\(b\),定义 \(A\) 为一个排列中是前缀最大值的数的个数,定义 \(B\) 为一个排列中是后缀最大值的数的个数,求长度为 \(n\) 的排列中满足 \(A = a\) 且 \(B = b\) 的排列个数,答案对 \(998244353\) 取模。
原题数据范围:\(n \le 10^5\),但是你会 \(O(n^2)\) 就可以了。
解法
前缀最大值和后缀最大值是相互影响的,以任何顺序都无法 DP,这个时候去找寻 DP 中的无关性就很重要了。
而把前后分开的方法很简单:钦定全局最大值的位置,一下子问题就变得独立。
对于剩下的部分,要求的是长度为 \(i\) 的排列中前缀最大值个数为 \(a-1\) 或者 \(b-1\),这个怎么 DP 呢?
设 \(f_{i,j}\) 表示 \(1\sim i\) 的排列中前缀最大值个数为 \(j\) 的方案数,排列问题的常见钦定方法中,只有按值从大到小是可行的,此时 \(f_{i,j}=f_{i-1,j-1}+f_{i-1,j}(i-1)\)。
闲话
上文的 DP 式子相当于是第一类斯特林数的递推式,使用 GF 相关技巧可以做到 \(O(n \log n)\),有点超纲,不展开了。
CF559C - Gerald and Giant Chess
给定一个 \(h\times w\) 大小的棋盘,其中有 \(n\) 个点为黑色。
每次只能向右或向下移动,求从 \((1,1)\) 不经过黑色点到达 \((h,w)\) 的方案数。
数据范围:\(1\le h,w \le 10^5\),\(1\le n\le 2000\)。
解法
设 \(f_i\) 表示从 \((1,1)\) 到 \(i\) 这个黑点且不经过其他黑色点的方案数。
考虑容斥计算,枚举 \((1,1)\) 到 \(i\) 的路上第一个黑色点 \(j\),减去 \(f_j\times coef(j\to i)\)。
时间复杂度 \(O(n^2)\)。
CF722E - Research Rover
有一个 \(n\times m\) 的网格图,图中有 \(k\) 个特殊点。
初始时你有一个权值 \(s\),并且只能向下或向右走,每经过一个特殊点会使得 \(s\gets \left \lceil \frac{s}{2} \right \rceil\)。
求从 \((1,1)\) 走到 \((n,m)\) 时拥有权值的期望,对大质数取模。
数据范围:\(1\le n,m \le 10^5\),\(1\le k\le 2000\)。
解法
发现经过 \(O(\log s)\) 次特殊点后就会使得 \(s = 1\),这样只需要计算经过 \(0\sim 20\) 个特殊点的方案即可。
设 \(f_{i,j}\) 表示到 \(i\) 为止经过了 \(j\) 个特殊点的方案,朴素的想法是枚举上一个经过的点。
那么结果是要么算重了,要么为了强制钦定不重(使用上个题的方法),导致复杂度变成 \(O(n^3)\)。
那么再观察一下何时会算重。
把算重的部分计算在 \(f_{k,j}\) 上即可。
*CF2034F2 - Khayyam's Royal Decree (Hard Version)
从 \((n,m)\) 走到 \((0,0)\),每次等概率随机向左走或者向上走,初始有一个权值 \(s=0\),向上走会使得 \(s\gets s+2\),向左走会使得 \(s\gets s + 1\),有 \(k\) 个关键点 \((r_i,b_i)\),走到关键点上会使得 \(s\gets 2s\)。
求结束时 \(s\) 的期望值,对 \(998244353\) 取模。
数据范围:
-
\(k \le 500\)(EZ)。
-
\(k \le 5000\)(HD)。
解法(EZ)
对于 EZ 版本,可以用之前那个题的方法,\(O(n^3)\) 求出两两之间不经过其他点的方案,然后直接计算。
解法(HD)
考虑 HD 版本,考虑一条路径的贡献,设路径为 \(p_0,p_1,\cdots,p_{k+1}\),其中 \(p_0=(0,0),p_{k+1}=(n,m)\)。
考虑 \(2^{k-i}\) 的组合意义,就是在后面的点中任意经过,而且和前面经过的状态无关,也就是我们可以忽略重复的经过!
结束了吗
最后杂题选讲部分到开讲前都有可能更新。
gOoDLUcKaNdhavEfUN。

浙公网安备 33010602011771号