【笔记】动态规划:DP 优化、判定转 DP 2023.7.30
7.30 DP
凸相关
决策单调性、斜率优化、凸、四边形不等式,都是凸相关。
前置知识
四边形不等式:交叉小于包含。\(l_1<l_2<r_1<r_2\to w(l_1,r_1)+w(l_2,r_2)\leq w(l_1,r_2)+w(l_2,r_1)\)。
区间包含单调性:包含区间值单调。\(l_1<l_2<r_2<l_1\to w(l_1,r_1)\geq w(l_2,r_2)\)。
满足四边性不等式的 \(w\),在外面套一个凸函数(一阶导数单调增加的函数,例如平方),还是四边形不等式。
若有 \(w(l,r)=f(r)-g(l)\),则满足四边形恒等式。
参考资料:DP的决策单调性优化总结——command_block。
[IOI2000] 邮局
2D/1D 的 DP:\(f(i,j)\) 前 \(i\) 个村庄使用了 \(j\) 个邮局的最小代价。\(f(i,j)=\min_k\{f(k,j-1)+w(k+1,i)\}\)。
\(w(l,r)\) 表示强制这个区间的村庄走到中间一个邮局的总代价,显然邮局建在坐标中位数(注意是中位数)即可。\(w\) 满足四边形不等式和区间包含单调性,\(f\) 决策单调性。
2D/1D 暴力决策单调性
\(opt(i,j)\) 表示 \(f(i,j)\) 从哪个 \(k\) 转移得到。\(opt(i,j-1)\leq opt(i,j)\leq opt(i+1,j)\)。为什么这样?考虑在平面直角坐标系中画出来这些点,状态黑点对应决策红点,画出来是一行斜线,总转移量是 \(O(n)\) 的,最后均摊 \(O(n^2)\)。做的时候使 \(opt(i,j)\) 在 \(opt(i,j-1)\) 到 \(opt(i+1,j)\) 枚举,复杂度分析时一个点会被加一次和减一次,剩下的不超过 \(O(n^2)\)。
扩展
结论:满足四边形不等式的 \(w\),有性质,\(n\) 个物品划分成 \(k\) 段,与划分成 \(k-1\) 段的方案是交错的。在环上时(换起点)也是如此,就是决策点在每个区间中选个点。
[IOI2000] 邮局 加强版
\(f(k)\) 把 \(n\) 个东西划分成 \(K\) 段 / 选出恰好 \(K\) 个物品的最小代价,越多的段代价越小,同时减少量越少,感性理解就是下凸。WQS 二分斜率 \(k\),变成 1D/1D 的 DP,\(g(i)=k+\min_j\{g(j)+w(j+1,i)\}\),然后它又有决策单调性了(不等式两边加两个 \(k\) 没事)。
WQS 二分
WQS 二分本质上就是用斜率 \(k\) 切凸包,假设切了 \((x,y)\),算出来 \(y-kx\),这个 \(kx\) 就是多的贡献,于是可以还原真实值。通过判断 \(x\) 与目标 \(K\) 的关系,不断调整斜率最终切到答案。
-
做 \(g\) 的 DP 时,额外记录 \(g\) 取最小值时用了多少个邮局(记录方案,而不是记录在状态中),这样就知道最后切到的 \(x\) 在哪里。
-
有一个边界问题,最后切了很多个点共线,有一种方法是记录切到最小的 \(x_1\),直到找到最大的 \(x_1\) 满足 \(x_1\leq m\),用它来更新答案。
-
为什么答案一定是整数?1. \(f(x)\) 为整数 2. \(x\) 的定义域为整数 3. 答案的斜率是两个相邻点连接的线的斜率。所以答案的斜率为整数,即 \(\frac{f(K)-f(K-1)}{1}\)。同时斜率上界为 \(f(x)\) 的上界。
1D/1D 队列维护决策单调性
1D/1D 的 DP 中,维护队列 \((l,r,j)\) 表示 \(f_{i\in[l,r]}\) 都应该由 \(f_j\) 转移而来,\(j\leq l\)。队列中 \(j\) 单调,\([l,r]\) 顺次相连。
- 欲求 \(f_i\),那么检查队头 \(r_h<i\) 的 \((l_h,r_h,j_h)\) 的删掉。取出队头进行转移。\(l_h=i\)。
- 试图插入决策 \(i\) 的时候:
- 记队尾为 \((l_t,r_t,j_t)\)。
- 如果对于 \(f[l_t]\) 来说,\(i\) 优于 \(j_t\),删除队尾,\(pos=l_t\),返回第一步。
- 如果对于 \(f[r_t]\) 来说,\(j_t\) 优于 \(i\),\(pos=r_t+1\),去往第五步。
- 在 \([l_t,r_t]\) 中二分一个 \(pos\),使得 \(pos\) 往后是 \(i\) 更优,往前是 \(j_t\) 更优,去往第五步。
- 插入决策 \((pos,n,i)\)。
这样的总复杂度为 \(O(n\log n)\)。
(这里有一个误区就是做 \(f_i\) 时从 \(f_{i-1}\) 的决策点开始枚举,这样是假的,因为枚举 \([p_{i-1},i]\) 的时候,你拿到最优决策点,并不知道是最优决策点,只能继续枚举)
[Gym103860I] Reverse LIS
不超过 \(2k+2\) 个 01010101... 交错排列,能用 \(k\) 次 reverse 操作将其改成 00001111(一次操作缩起来两个)。奇数的情况需要开局特判一次,或者认为前面有不存在的零,或者后面有不存在的一。欲求出每个 \(t\in[0,n]\) 的答案,这里感性理解一下它是凸的,但是点值太多不能 WQS 二分。我们分治,处理 \([l,r]\),先做 \([l,mid],(mid,r]\) 的答案。合并时,设 \(f(0/1,0/1,t)\) 表示 \(t\) 段,左边/右边是 0/1。做类似于 \(f(a,0)*f(0,b)\to f(a,b)\) 的转移,是凸函数的 \((\max,+)\) 的卷积,闵可夫斯基和合并:将所有斜率拿出来归并排序即为答案的斜率。\(O(n\log n)\)。
花花的代码
补充:2D/1D 分治维护决策单调性
考虑分治:假如我们已经有划分成 \(d-1\) 段的答案,欲求所有的 \(f_{i,d}\)。考虑欲求 \(f_{[L,R],d}\),现在有效的决策集合是 \([l,r]\)。我们求出 \(f_{mid,d}\) 的值:用 \([l,r]\) 的决策更新。假如找到一个最优决策在 \(p\)。那么我们断定:\([L,mid)\) 的点的决策为 \([l,p]\),\((mid,R]\) 的点的决策为 \([p,r]\)。
分析复杂度:一共 \(O(\log n)\) 层,每层平摊 \(O(n)\)。我们的 \(V(l,r)\) 的复杂度如何分析?考虑左右指针是独立的;考虑对于一个指针,在分治树上的每一个点恰好踩过一次,所以移动量为 \(O(n\log n)\)。所以总复杂度为 \(O(n\log^2 n)\)。
判定转 DP
大多为设计一个算法用来判定,这个算法包括 dp,贪心,模拟等。然后把目前判定的状态记录下来,也就是记录判定成这种状态的方案数有多少种。难点在于设计判定算法或减少判定所需要记录的东西。
[LOJ6274] 数字
考虑判定,用四个 bool 表示 \(x,y\) 的状态:\(x,y\) 是否与 \(l,r\) 完全一致(是的就有限制,不是就无限制)。但我们不知道这些 bool 表示是否到达最终答案,所以枚举一位后,获得这十六种状态是否能到达的状态,这些状态可能会暴毙,可能获得新的状态传递,最后如果有一个活下来就是答案。
DP?\(dp[i][S]\) 用 \(S\) 把 \(2^{16}\) 种情况都压起来,爆搜就是了。状态数很少,例如当 \(x\) 有两个 bool 是 true 时,其它 \(x\) 不是这样的就不可能出现(\(l,r\) 的前面这么多位全部相同)。转移出去时,先枚举 \(z\),再枚举可能的所有 \(x,y\),枪毙不合法的状态,合法状态 update 一下,继续爆搜。
[UNR#6] 机器人表演
判定答案串 \(T\) 是否合法?考虑拿 \(S\) 进来匹配 \(T\),记录当前 \(S\) 匹配长度 \(len\),当前的 \(T\) 的前缀和 \(sum\),已经搞了 \(i\) 位。记 \(S\) 的前缀和为 \(pre\)(\(0\to 1,1\to -1\)),则那些括号的前缀和为 \(s=sum-pre_{len}\)。
- 如果下一个 \(T\) 的字符为 \(0\),
- 如果 \(S_{len+1}=0\),则 \(len:=len+1,sum:=sum+1\)。
- 否则拿一个左括号进来,\(sum:=sum+1\)。
- 否则 \(T\) 的那一个字符为 \(1\),
- 若 \(S_{len+1}=1\),\(len:=len+1,sum:=sum-1\)。跳过以下判断。
- 否则必须拿右括号进来,若 \(s=sum-pre_{len}>0\) 则没事,更新 \(sum:=sum-1\)。
- 否则拿不进来,反悔,撤销之前匹配的 \(len\)。设 \(link_{len}\) 表示一个地方,跳过去之后 \(S\) 跳过去那一段的和为 \(1\),则 \(len:=link_{len},sum:=sum+1-1\)。
然后暴力 DP 即可。
[AGC022E] Median Replace
考虑判定。\(000\) 直接干掉,\(011,001\) 等价于删 \(0,1\),只剩 \(111\) 删的时候我们赢了。维护 stack,底下一堆 \(1\) 和顶上不超过两个 \(0\)。放 \(0\) 的时候就放,放 \(1\) 的时候如果是 \(01\) 就删,否则存着。最后是若干个 \(1\) 和不超过两个 \(0\) 对线,最多牺牲两个 \(1\) 就能解决问题,但是需要再多一个 \(1\) 垫底。那么可以建立一个有限状态自动机(DFA),接受答案串,判定是否合法,那么在上面的 DP 是简单的。
[NOI2022] 移除石子
- 操作二只会操作长度为 \(3,4,5\) 的区间,因为更长的可以拆出来。
- 一个区间不能做两次操作二。
- 综上,一个点作为 左/右 端点向 右/左 一共最多做三次操作二。
所以可以开始建立一个 DFA,每个状态记录了后面几个点(一直到 \(i+4\))将要被(作为右端点)区间减多少次。转移时,先枚举自己的区间减,计算出 \(a_i\) 真实被区间减多少次,比较一下,得到这一堆石子需要丢多少个石子进去。如果 \(a_i\) 固定,我们就可以在 DFA 上面跑这个东西。(这里取需要最小额外石子的转移)
于是可以使用 DFA 最小化(等价类的点缩成一个,不是缩链)的技巧,把上述 DFA 最小化到约 \(1700\) 个节点,轻松通过。
DFA 最小化 代码 Line 51-79 UOJ748
点击查看代码
#include <bits/stdc++.h>
using namespace std;
using i64 = long long;
constexpr int N = 100, S = 1 << 13, V = 2e6, mod = 998244353;
int n, m, G[V][2], col[V];
string str;
vector<int> vec[V];
pair<int, int> rev[V];
int main() {
freopen("in.txt", "r", stdin);
ios::sync_with_stdio(false), cin.tie(0);
cin >> n >> m;
cin >> str;
array<int, 2> pos{};
for(int i = 0; i < n; i ++) pos[str[i] - '0'] |= 1 << i;
map<int, int> dp[2 * N + 1], new_dp[2 * N + 1];
dp[N][1] = 1;
static int alive[2 * N + 1];
vector<int> pre(n + 1);
for(int i = 1; i <= n; i ++) pre[i] = pre[i - 1] + (str[i - 1] == '1' ? -1 : 1);
for(int i = 0; i < 2 * N + 1; i ++) {
for(int j = 0; j <= n; j ++) {
if(pre[j] <= i - N) alive[i] |= 1 << j;
}
}
static int id[2 * N + 1][S];
int cnt = 0;
for(int j = 0; j < 2 * N + 1; j ++) {
for(int s = 0; s < 1 << n + 1; s++) {
id[j][s] = ++ cnt;
rev[cnt] = {j, s};
}
}
for(int j = 1; j < 2 * N + 1 - 1; j ++) {
for(int s = 0; s < 1 << n + 1; s ++) {
for(int c = 0; c < 2; c ++) {
int _j = j + (c ? -1 : 1);
int _s = (s & pos[c]) << 1;
_s |= alive[_j] & s;
G[id[j][s]][c] = id[_j][_s];
}
}
}
int tot = 2;
for(int j = 0; j < 2 * N + 1; j ++) {
for(int s = 0; s < 1 << n + 1; s ++) {
col[id[j][s]] = ((s >> n & 1) && j == pre[n] + N) + 1;
vec[col[id[j][s]]].emplace_back(id[j][s]);
}
}
while(1) {
int upd = 0;
for(int c = 0; c < 2; c ++) {
int last = tot;
for(int i = 1; i <= last; i ++) {
static int vis[V];
for(auto u : vec[i]) {
int v = col[G[u][c]];
if(!vis[v]) {
vis[v] = ++ tot;
vec[tot].clear();
vec[tot].emplace_back(u);
} else {
vec[vis[v]].emplace_back(u);
}
}
swap(vec[i], vec[tot]);
for(auto u : vec[tot]) {
vis[col[G[u][c]]] = 0;
}
tot --;
}
upd |= tot != last;
for(int i = 1; i <= tot; i ++) {
for(int u : vec[i]) col[u] = i;
}
}
if(!upd) break;
}
static int vis[V];
auto bfs = [&] (int s) {
queue<int>q;
q.emplace(s);
vis[s] = 1;
while(!q.empty()) {
int u = q.front(); q.pop();
for(int c = 0; c < 2; c ++){
int v = G[u][c];
if(!vis[v]) {
q.emplace(v);
vis[v] = 1;
}
}
}
};
bfs(id[N][1]);
vector<int> ord(tot);
iota(ord.begin(), ord.end(), 1);
sort(ord.begin(), ord.end(), [&] (int x, int y) {return rev[vec[x].back()].first < rev[vec[y].back()].first;});
vector<vector<int>> ret;
for(auto i : ord) {
int sum = 0;
for(auto u : vec[i]) {
sum += vis[u];
}
if(!sum) continue;
vector<int> tmp;
for(auto u : vec[i]) {
if(vis[u]) {
tmp.emplace_back(u);
}
}
ret.emplace_back(tmp);
}
cout << ret.size() << '\n';
for(auto vec : ret) {
cout << "-----------------\n";
cout << vec.size() << '\n';
for(auto u : vec) {
cout << rev[u].first << ' ';
for(int j = 0; j <= n; j ++) cout << (rev[u].second >> j & 1);
cout << ' ' << __lg(rev[u].second);
cout << '\n';
}
}
cout << cnt << endl;
return 0;
}
数据结构优化
[NOI2020] 命运
\(dp(u,i)\) 表示子树 \(u\) 中没被满足的最深的限制在深度 \(i\) 的方案数。考虑合并 \(v,u\):
- 若枚举 \((u,v)=0\),则 \(dp(u,i)dp(v,j)\to dp’(u,\max(i,j))\)。
- 若枚举 \((u,v)=1\),则 \(dp(u,i)dp(v,j)\to dp'(u,i)\)。
有值的 DP 状态不超过 \(n\) 个,所以线段树合并维护 DP 值。需要支持区间乘、区间和。
[QOJ4815] Flower's Land
题解 QOJ4815【Flower's Land】 - caijianhong - 博客园 (cnblogs.com)
[CF1416E] Split
\(f_{i,j}\) 表示考虑了 \(i\) 个数,\(last=j\)。令新的 \(a_{i+1}=x+y\),有转移:
- \(x=j\)。则将 \(j:=a_{i+1}-j\),\(w:=w+1\)。
- \(x=y\)。则是单点修改。
- \(j\neq x\neq y\)。暴力删除。
用 set 维护连续段的 DP 值。暴力删除不合法的,在全局打下标翻转标记。并注意到有一个询问全局 min 并更新的是不优的。
[CF1500F] Cupboards Jumps
对差分绝对值 \(a_i=|h_{i+1}-h_{i}|\),那么 \(w_i=\max(a_i,a_{i+1})\) 或 \(w_i=a_i+a_{i+1}\)。然后维护所有可行值,转移有 reverse, insert \(w_i\), insert \([l,r]\)。前两个 deque 维护打全局标记,第三个单独维护。构造时撤销所有操作。
本文来自博客园,作者:caijianhong,转载请注明原文链接:https://www.cnblogs.com/caijianhong/p/17591817.html