补全 DP 优化的最后一块拼图: 降维打击

比利♂海灵顿

2022-01-13 16:45:57

Personal

# Convex_Optimisation 凸优化 (Convex Optimisation), 和凸包优化(Convex Hull Optimisation)不同, 凸包优化强调的是构造凸包, 然后求出对应斜率切线的截距, 以此来转移, 所以凸包优化又叫斜率优化. 当然这里的凸优化和数学中的凸最小化也不同, 因为数学中求的是凸函数的最小值. 动态规划的凸优化则是用二分, 二分一个惩罚值, 将 $n$ 个元素取 $m$ 个的最大值降维成 $n$ 个元素取任意个的最大值的方法. 所以动态规划中的凸优化又叫带权二分. 因为是由王钦石在他的 2012 年国家集训队论文中提出的, 因此这种方法又叫 WQS 二分. 这貌似是我学的最后一种 DP 优化方式, 补全了 DP 优化的最后一张拼图. 由于凸优化是把状态降维, 不同于斜率优化和四边形不等式是优化转移, 所以我又把它称为 "降维打击". 接下来结合具体题目看一下它的应用 ## [EPOI2018](https://www.luogu.com.cn/problem/P4383) > 八省 (Eight Province, EP) 联考 一棵边带权的树, 权值是或正或负的整数, 简单路径的权值是组成路径的边的权值和, 选 $m$ 条边删除, 任意连 $m$ 条零权边, 形成一棵新的树, 问新树的路径的最大权值. 因为节点是没有权值的, 如果已经选好了最优的断边方案, 一定可以在加边后找到一条路径, 连接所有 $m + 1$ 个连通块, 并且权值为需要求的最大权值. 如果存在一条最大权值的路径, 它不包含连通块 $x$ 内的点, 那么路径中一定只有 $m - 1$ 条后来加的边, 连接了 $m$ 个连通块. 我们把不被路径包含的那条后来加的边删掉, 连接路径末端和连通块 $x$ 内任意一点, 组成一条长度增长 $1$ 的路径. 因为新加的边权为 $0$, 因此权值仍然是最大权值. 所以本题转化为将原树断成 $m + 1$ 个连通块后, 每个连通块内选一条权值最大的路径, 然后将这 $m + 1$ 条路径用 $m$ 条零权边首尾相连成一条路径, 使这条路径权值最大. 最后转化为树上选 $m + 1$ 条节点不相交的路径, 使这些路径权值和最大. 设计树形 DP, f_{i, j, 0/1/2} 表示 $i$ 的子树中, 选 $j$ 条节点不相交的路径的最大权值和, 最后一维为 $0$ 表示节点 $i$ 不被任何路径包含, 为 $1$ 表示作为某路径端点, 为 $2$ 表示被某路径包含但不是端点. $$ \begin{aligned} f_{i, j, 0} &= \max_{k = 0}^j(f_{i, j, 0}, f_{i, j - k, 0} + f_{Son, k, 0/1/2})\\ f_{i, j, 1} &= \max_{k = 0}^j(f_{i, j, 1}, f_{i, j - k, 1} + f_{Son, k, 0/1/2}, f_{i, j - k, 0} + f_{Son, k, 1} + V_{i, Son})\\ f_{i, j, 2} &= \max_{k = 0}^j(f_{i, j, 2}, f_{i, j - k, 2} + f_{Son, k, 0/1/2}, f_{i, j - k + 1, 1} + f_{Son, k, 1} + V_{i, Son})\\ \end{aligned} $$ 状态 $O(nm)$, 转移 $O(m)$, 总复杂度 $O(nm^2)$ 写出来之后只能得 $35'$, 开 `-O2` 可以拿到 $60'$, LOJ 上可以拿 $35'$, 开了 `-O2` 也是 $60'$, 比 Luogu 快. 貌似比赛的时候这样写的都有 $60'$, Day1 T3 拿到 $60'$ 很满意了. ```cpp unsigned m, n; unsigned A, B, D, t; int C; unsigned Cnt(0), Ans(0), Tmp(0); struct Node { Node* Fa; vector <pair<Node*, int>> E; long long f[105][3]; }N[300005]; inline void DFS(Node* x) { memset(x->f, 0xaf, ((m + 1) * 3) << 3); x->f[0][0] = x->f[1][1] = 0; for (auto i : x->E) if (i.first != x->Fa) { Node* Cur(i.first); Cur->Fa = x, DFS(Cur); for (unsigned j(m); j; --j) { for (unsigned k(1); k <= j; ++k) { long long Mx(max(Cur->f[k][0], max(Cur->f[k][1], Cur->f[k][2]))); if (Mx < 0) continue; if (Cur->f[k][1] >= 0) { x->f[j][2] = max(x->f[j][2], x->f[j - k + 1][1] + Cur->f[k][1] + i.second); x->f[j][1] = max(x->f[j][1], x->f[j - k][0] + Cur->f[k][1] + i.second); } x->f[j][2] = max(x->f[j][2], x->f[j - k][2] + Mx); x->f[j][1] = max(x->f[j][1], x->f[j - k][1] + Mx); x->f[j][0] = max(x->f[j][0], x->f[j - k][0] + Mx); } } } } signed main() { n = RD(), m = RD() + 1; for (unsigned i(1); i < n; ++i) { A = RD(), B = RD(), C = RDsg(); N[A].E.push_back(make_pair(N + B, C)); N[B].E.push_back(make_pair(N + A, C)); } DFS(N + 1); printf("%lld\n", max(N[1].f[m][0], max(N[1].f[m][1], N[1].f[m][2]))); return Wild_Donkey; } ``` 如果我们输出大样例中, 不同的 $m$ 下的答案, 把它们画在图像上 (图中横坐标拉长到原来的 $1000$ 倍): ![image.png](https://s2.loli.net/2022/01/12/Ymoq5is8h1FEQlT.png) 发现答案上凸 (Convex Upward), 或者说, 下凹 (Concave Downward) 去掉问题中对路径数量的限制, 将状态降维成 $f_{i, 0/1/2}$, 表示 $i$ 的子树中, 选择若干个节点不相交的路径, $0/1/2$ 的意义和之前相同, 得到的最大权值和. $$ \begin{aligned} f_{i, 0} &= \max_{k = 0}^j(f_{i, 0}, f_{i, 0} + f_{Son, 0/1/2})\\ f_{i, 1} &= \max_{k = 0}^j(f_{i, 1}, f_{i, 1} + f_{Son, 0/1/2}, f_{i, 0} + f_{Son, 1} + V_{i, Son})\\ f_{i, 2} &= \max_{k = 0}^j(f_{i, 2}, f_{i, 2} + f_{Son, 0/1/2}, f_{i, 1} + f_{Son, 1} + V_{i, Son})\\ \end{aligned} $$ 降维打击强就强在, 当转移需要枚举一维状态的时候, 降一维状态相当于把复杂度降了两维, 所以这样一次 DP 的时间就变成了 $O(n)$. 如果我们记录每个状态具体选择的个数 $g$, 那么 DP 值就变成了二元组 $(g, f)_{i, 0/1/2}$. DP 结束后, 我们就可以在上图得到一个点, 横坐标是个数, 纵坐标是权值和. 而且很显然我们求出的是上图中最高的点. (废话, 不然这个 DP 求的是什么) 当我们每增加一个新的路径, 就把答案减 $c$, 那么我们求出的纵坐标将是 $f - gc$ 能取到的最大值, 如果把答案加上 $gc$, 就能得到另一个坐标. 这便是答案序列和直线 $y = cx + b$ 的切点. 把转移方程进行细微修改: $$ \begin{aligned} f_{i, 0} &= \max_{k = 0}^j(f_{i, 0}, f_{i, 0} + f_{Son, 0/1/2})\\ f_{i, 1} &= \max_{k = 0}^j(f_{i, 1}, f_{i, 1} + f_{Son, 0/1/2}, f_{i, 0} + f_{Son, 1} + V_{i, Son})\\ f_{i, 2} &= \max_{k = 0}^j(f_{i, 2}, f_{i, 2} + f_{Son, 0/1/2}, f_{i, 1} + f_{Son, 1} + V_{i, Son} + c)\\ \end{aligned} $$ 因为答案上凸, 所以切点的横坐标一定随 $c$ 的增加而减小, 因此我们只要二分 $c$, 就可以求出横坐标为 $m$ 时的纵坐标. $c$ 的范围是 $[-nV, nV]$, 因此总复杂度是 $O(n \log (nV))$. 不过因为答案序列也可能存在连续几个点共线的情况, 这时 $c$ 变化 $1$ 就会让横坐标变化不少, 不能精准定位 $m$, 但是因为共线, 而且共线的众多点中, 两端的点是可以被二分到的, 所以直接用直线上的两点确定直线, 然后代入横坐标求值即可. ```cpp long long Ans[300005]; unsigned m, n; unsigned A, B, D, t; long long L(-300000000000), R(300000000000), C; unsigned Cnt(0), Tmp(0); struct Node { Node* Fa; vector <pair<Node*, int>> E; long long f[3]; unsigned g[3]; }N[300005]; inline void DFS(Node* x) { x->f[0] = x->g[0] = 0, x->g[1] = 1, x->f[1] = -C, x->g[2] = x->f[2] = -100000000000000000; for (auto i : x->E) if (i.first != x->Fa) { Node* Cur(i.first); Cur->Fa = x, DFS(Cur); long long Des(x->f[1] + Cur->f[1] + i.second + C); if (Cur->f[0] > 0) x->f[2] += Cur->f[0], x->g[2] += Cur->g[0]; if (x->f[2] < Des) x->f[2] = Des, x->g[2] = x->g[1] + Cur->g[1] - 1; if (Cur->f[0] > 0) x->f[1] += Cur->f[0], x->g[1] += Cur->g[0]; Des = x->f[0] + Cur->f[1] + i.second; if (x->f[1] < Des) x->f[1] = Des, x->g[1] = x->g[0] + Cur->g[1]; if (Cur->f[0] > 0) x->f[0] += Cur->f[0], x->g[0] += Cur->g[0]; } if (x->f[1] > x->f[0]) x->f[0] = x->f[1], x->g[0] = x->g[1]; if (x->f[2] > x->f[0]) x->f[0] = x->f[2], x->g[0] = x->g[2]; } signed main() { n = RD(), m = RD() + 1; for (unsigned i(1); i < n; ++i) { A = RD(), B = RD(), C = RDsg(); N[A].E.push_back(make_pair(N + B, C)); N[B].E.push_back(make_pair(N + A, C)); } B = 0, D = n; while (L <= R) { C = ((L + R) >> 1); DFS(N + 1), A = N[1].g[0]; Ans[A] = N[1].f[0] + C * A; if (A == m) { printf("%lld\n", Ans[m]);return 0; } if (A > m) L = C + 1, D = A; else R = C - 1, B = A; } printf("%lld\n", Ans[B] + (Ans[D] - Ans[B]) / (D - B) * (m - B)); return Wild_Donkey; } ``` 值得注意的几点: - 二分下界是负数. - 如果 $\infin$ 设置过大, 可能导致 $-\infin + -\infin = \infin$ 出现. - $\infin$ 设置过小可能导致 $-Cg$ 比 $-\infin$ 还要劣. ## [IOI2000](https://www.luogu.com.cn/problem/P6246) [原版](https://www.luogu.com.cn/blog/Wild-Donkey/luogu4767-ioi2000-you-ju) 加强版增加了数据范围, $O(n^2)$ 算法将无法通过此题. 仍然是把数量限制去掉来降维, 状态 $f_i$ 表示前 $i$ 个村庄都被覆盖, 每个邮局计算 $c$ 的惩罚值, 到邮局的距离之和最小值. 设 $Sum_i$ 表示前 $i$ 个村庄的坐标之和. $$ \begin{aligned} f_i &= \min_{j = 0}^{i - 1}(f_j + Sum_i + Sum_j - Sum_{\lfloor \frac {i + j}2 \rfloor} - Sum_{\lceil \frac {i + j}2 \rceil}) + C\\ f_i &= \min_{j = 0}^{i - 1}(f_j + Sum_j - Sum_{\lfloor \frac {i + j}2 \rfloor} - Sum_{\lceil \frac {i + j}2 \rceil}) + Sum_i + C \end{aligned} $$ 根据原版可知, 这个 DP 具有决策单调性, 所以我们可以记录每个状态可以作为哪个区间的最优决策来 $O(n\log n)$ 来做. 因为在 $m$ 不同时, 答案是下凸的, 所以如果二分 $C$, 就可以求出在不同乘法值下的最小花费和对应的邮局数量, 且邮局数量随着 $C$ 的增加单调不增, 最后可以得到 $m$ 个邮局对应的答案. $C$ 的范围是 $[0, \sum a]$, 复杂度 $O(n\log n\log(\sum a))$. ```cpp unsigned long long f[500005], Sum[500005], L, R, C; unsigned long long LAns, RAns, Ans; unsigned Stack[500005][3], STop(0); unsigned a[500005], g[500005], m, n; unsigned A, B, D, t, LPos, RPos, Pos; unsigned Cnt(0), Tmp(0); inline unsigned long long Trans(unsigned x, unsigned y) { return f[y] + Sum[y] + Sum[x] + C - Sum[(x + y) >> 1] - Sum[(x + y + 1) >> 1]; } inline long long Calc() { STop = 0, Stack[++STop][0] = 1, Stack[STop][1] = n, Stack[STop][2] = 0; for (unsigned i(1), j(1); i <= n; ++i) { while (Stack[j][1] < i) ++j; f[i] = Trans(i, Stack[j][2]); g[i] = g[Stack[j][2]] + 1; while ((STop > j) && (Trans(Stack[STop][0], Stack[STop][2]) >= Trans(Stack[STop][0], i))) --STop; unsigned BL(Stack[STop][0]), BR(Stack[STop][1] + 1), BMid, Bef(Stack[STop][2]); while (BL ^ BR) { BMid = ((BL + BR) >> 1); if (Trans(BMid, Bef) < Trans(BMid, i)) BL = BMid + 1; else BR = BMid; } Stack[STop][1] = BL - 1; if (BL <= n) Stack[++STop][0] = BL, Stack[STop][1] = n, Stack[STop][2] = i; } return f[n] - g[n] * C; } signed main() { n = RD(), m = RD(); for (unsigned i(1); i <= n; ++i) a[i] = RD(); sort(a + 1, a + n + 1); for (unsigned i(1); i <= n; ++i) Sum[i] = Sum[i - 1] + a[i]; L = 0, C = R = Sum[n], LPos = 1, RPos = n, LAns = Calc(), RAns = 0; while (L <= R) { C = ((L + R) >> 1); Ans = Calc(), Pos = g[n]; if (Pos == m) { printf("%llu\n", Ans);return 0; } if (Pos < m) R = C - 1, LPos = Pos, LAns = Ans; else L = C + 1, RPos = Pos, RAns = Ans; } printf("%llu\n", LAns - (LAns - RAns) / (RPos - LPos) * (m - LPos)); return Wild_Donkey; } ``` ## 总结 其实凸优化的过程有点求导的几何意义的那种感觉了, 最后多点共线又多少带点微分中值定理的意义, 所以可能也可以称其为微分优化. (误