P4383 林克卡特树 题解
题意:给定带边权的树,要切掉 \(k\) 条边,再任意连上 \(k\) 条边权为 \(0\) 的边。问最优策略下得到的树的边权最大值。\(n,k\le 3\times 10^5\)。
【问题转化】
切掉 \(k\) 条边后会变成 \(k+1\) 个连通块,之后的连边一定会把这 \(k+1\) 个连通块的直径连起来。所以相当于问把原树分成恰好 \(k+1\) 个连通块后所有直径的和最大。
再转化一下,等价于在原树里找 \(k+1\) 条不相交的路径,要求和最大。
下文中直接用 \(k\) 指代原本的 \(k+1\)。
处理树上的最优,考虑树形 DP。
按照套路,\(dp_{i,j}\) 表示从 \(i\) 的子树内选恰好 \(j\) 条不相交的路径,和最大是多少。
然鹅发现这个玩意 \(dp_{fa}\) 和 \(dp_{son}\) 之间不能很好的建立联系。按理想中的思路,DP 方程应该根据父结点是否加入子结点的路径里面分类。但是现在没法分类,究其原因是我们的状态描述过于宽泛,我们无法通过状态描述得知子结点处的路径的状态。
对于状态描述信息太少的情况,我们一如既往地升维。(这么朴素的想法,是否能想得到呢?)
新增一维描述子树根处路径的状态。\(dp_{i,j,0/1/2}\) 表示从 \(i\) 的子树内选恰好 \(j\) 条不相交的路径,且 \(i\) "不属于任何一条路径"/"属于一条路径且 \(i\) 在这条路径的端点处"/"属于一条路径且 \(i\) 不在这条路径的端点处"。为了方便更新,令 \(dp_{i,j,3}=\max(dp_{i,j,0/1/2})\)。
我们允许一个点作为一个 "退化链" 存在。
【状态转移】
(转移方程和细节在参考题解中写了而且写的很好)
\(dp_{i,j,k}\) 表示在 \(i\) 的子树内选了 \(j\) 条不相交的路径,且 \(i\) 不属于任何路径/是路径的一端/是路径的中间结点。
认为一个单个结点构成的退化链既可以作为路径的一端,也可以作为路径的中间结点。
设当前结点为 \(u\),当前循环到的子结点为 \(v\)。
先不考虑让 \(u\) 自己独立做一个退化链的情况,原因后文有提到。也就是先只考虑 \(v\) 的方案合并到 \(u\) 的情况。
-
更新 \(dp[u][i][0]\)。\(v\) 的路径也不可能上到 \(u\) 这里。(否则 \(u\) 属于了 \(v\) 的那条路径)让 \(v\) 的子树自治即可。
\[dp[u][i][0]=\max_{j=0}^i(dp[u][i][0],dp[u][i-j][0]+dp[v][j][0]),i=0\sim k \] -
更新 \(dp[u][i][1]\)。有两种可能:\(u\) 之前就属于路径的一端,\(v\) 不干涉 \(u\);\(u\) 之前不属于任何路径,现在属于 \(v\) 延长上来的路径。第二种情况要求 \(v\) 能延长上来,自然要求 \(v\) 是路径的一端。
对于这种情况,显然应有 \(i\ge 1\)。
\[dp[u][i][1]=\max_{j=0}^{i-1}(dp[u][i][1],dp[u][i-j][1]+dp[v][j][3]) \]这是第一种情况的转移方程。注意 \(j\le i-1\),因为要之前就有路径。
\[dp[u][i][1]=\max_{j=1}^{i}(dp[u][i][1],dp[u][i-j][0]+dp[v][j][1]+w_{u,v}) \]这是第二种情况的转移方程。注意 \(j\ge 1\),因为要 \(v\) 有路径能延长。
这里本还有一种让 \(u\) 自己作为一条退化链的情况,但是我们上面说了先不考虑这种情况。
-
更新 \(dp[u][i][2]\)。有两种可能:\(u\) 之前就属于一条路径的中间结点;\(u\) 之前属于一条路径的一端,现在 \(v\) 又延长上来。
\[dp[u][i][2]=\max_{j=0}^{i-1}(dp[u][i][2],dp[u][i-j][2]+dp[v][j][3]) \]这是第一种情况的转移方程。
\[dp[u][i][2]=\max_{j=1}^{i}(dp[u][i][2],dp[u][i+1-j][1]+dp[v][j][1]+w_{u,v}) \]这是第二种情况的转移方程。注意观察:\(dp[u][i+1-j][1]\),为什么是 \(i+1-j\)?因为最终是 \(i\) 条路径,\(u\) 之前的路径现在和 \(v\) 的路径合并了,减少了一条。减少一条后是 \(i\) 条路径,说明之前应有 \(i+1-j\) 条路径。
这里同样也省略了一种让 \(u\) 自己作为一条退化链的情况。
-
让 \(u\) 自己作为一条退化链的情况。
为什么最后处理这种情况?
如果将这种情况设为初值(或者在循环中更新这种情况),在更新 \(dp[u][i][2]\) 的第二种情况时,我们会认为 \(u\) 在 "之前的退化链 \(u\) 加上 \(v\) 延展过来的路径" 这构成的新路径的中间。但显然 \(u\) 应该是这条路径的一端。
因此我们最后处理这种情况。\(u\) 自己作为一条退化链,要求了 \(u\) 一开始就不能有任何路径涉及到它,所以这种情况都根据 \(dp[u][i][0]\) 转移而来。
\[dp[u][i][1]=\max(dp[u][i][1],dp[u][i-1][0]) \]\[dp[u][i][2]=\max(dp[u][i][2],dp[u][i-1][0]) \] -
更新 \(dp[u][i][3]=\max(dp[u][i][0/1/2])\)。
以上就是转移方程的所有。
在实现时还有诸多细节需要注意。例如转移方程(就和大多数树形 DP 一样)会自己更新自己,如果不采取在循环顺序上的技巧,就要额外开数组保存原本的信息以避免重复更新。
如果使用倒序循环避免重复更新,还要特别注意一个地方:更新 \(dp[u][i][2]\) 的第二种情况,可能会涉及到从 \(dp[u][i+1-j][1]\) 转移过来。而 \(i+1-j\) 是可以取到 \(i\) 的!!! 如果先更新了 \(dp[u][i][1]\) 再更新 \(dp[u][i][2]\),也会导致多次更新。
复杂度和树形背包一样是 \(O(nk)\) 的,可以通过 \(60\%\) 的数据。
点击查看代码
#include <bits/stdc++.h>
using namespace std;
typedef int ll;
const ll N = 3e5 + 5, inf = 0x3f3f3f3f3f3f3f3f;
int n, K;
struct Edge {
ll to, val;
Edge(ll t = 0, ll v = 0) {
to = t, val = v;
}
};
vector<Edge> e[N];
ll dp[N][105][5]; //dp[i][j][k]:i子树里恰好选j条不相交的路径且i处度数为k的最大和,dp[i][j][3]为dp[i][j][0/1/2]max
void dfs(int x, int pr) {
dp[x][0][0] = 0;
for (int i = 0; i < e[x].size(); i++)
if (e[x][i].to != pr) {
dfs(e[x][i].to, x); //先把子树的求了
ll y = e[x][i].to, z = e[x][i].val;
for (int j = K; j >= 1; j--) {
for (int k = j; k >= 1; k--) {
dp[x][j][2] = max(dp[x][j][2],
max(dp[x][j - k][2] + dp[y][k][3], dp[x][j + 1 - k][1] + dp[y][k][1] + z));
}
//k=j
dp[x][j][0] = max(dp[x][j][0], dp[x][j - j][0] + dp[y][j][3]);
dp[x][j][1] = max(dp[x][j][1], dp[x][j - j][0] + dp[y][j][1] + z);
for (int k = j - 1; k >= 1; k--) {
dp[x][j][0] = max(dp[x][j][0], dp[x][j - k][0] + dp[y][k][3]);
dp[x][j][1] = max(dp[x][j][1],
max(dp[x][j - k][1] + dp[y][k][3], dp[x][j - k][0] + dp[y][k][1] + z));
}
//k=0不用变化
}
//j=0只有dp[x][0][0]已经算过
}
for (int j = 1; j <= K; j++) { //x成为退化链
dp[x][j][1] = max(dp[x][j][1], dp[x][j - 1][0]);
dp[x][j][2] = max(dp[x][j][2], dp[x][j - 1][0]);
}
for (int j = 0; j <= K; j++)
dp[x][j][3] = max(max(dp[x][j][0], dp[x][j][1]), dp[x][j][2]);dp[x][k][2]);
}
int main() {
cin >> n >> K;
K++;
for (ll i = 1, u, v, w; i < n; i++) {
cin >> u >> v >> w;
e[u].push_back(Edge(v, w));
e[v].push_back(Edge(u, w));
}
memset(dp, ~inf, sizeof dp);
dfs(1, 0);
cout << dp[1][K][3];
return 0;
}
【优化】
复杂度是 \(O(nk)\) 的。如何优化?
感性理解一下,因为割掉每一条边都是独立的,所以我们一定会优先割掉能使答案增加最多/减少最少的边。对应到图像上,不可能有 "凹" 的部分,否则将方案调整可以更优。
因此可以 wqs 二分优化。
wqs 二分优化 DP 的题目,除了计算最优值,往往还需要计算最优值是取了多少个(或者说最多/最少取多少个),因此建议把 wqs 二分的 DP 状态定义成一个结构体,用重载运算符的方式写。
【wqs 二分的经典坑点】
想清楚二分结束后要选用的是 \(l\) 作为斜率还是 \(r\)。
其实这里唯一会影响到的情况只有一种:就是 \((ans,f(ans))\) 在一个斜率相等段上。我们就假设如果二分到这个相等的斜率会怎么样。
如果我们二分到相等的斜率 \(slope\),按照代码(这里按照我写的)我会选择尽量多的 \(0\) 代价,所以我找到的应该是这个斜率段的最右端 \(p\),而 \(p>ans\) 所以我的程序会认为二分的斜率小了,所以会让 \(l=slope\)。
所以最后调用的是 \(l\) 而不是 \(r\)。
【AC Code】
时长:3.5h,主要时间在于写 DP 转移方程,调代码反而只用 1h。
点击查看代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll N = 3e5 + 5, inf = 0x3f3f3f3f3f3f3f3f;
int n, K;
struct Edge {
ll to, val;
Edge(ll t = 0, ll v = 0) {
to = t, val = v;
}
};
vector<Edge> e[N];
struct Node {
ll dp, cnt;
Node(ll d = 0, ll c = 0) {
dp = d;
cnt = c;
}
};
bool operator<(Node a, Node b) {
if (a.dp != b.dp)
return a.dp < b.dp;
return a.cnt < b.cnt;
}
Node operator+(Node a, Node b) {
return Node(a.dp + b.dp, a.cnt + b.cnt);
}
Node operator+(Node a, ll b) {
return Node(a.dp + b, a.cnt);
}
Node max(Node a, Node b) {
if (a < b)
return b;
return a;
}
Node dp[N][5];
void dfs(int x, int pr, ll mid) {
dp[x][0] = Node(0, 0);
for (int i = 0; i < e[x].size(); i++)
if (e[x][i].to != pr) {
dfs(e[x][i].to, x, mid); //先把子树的求了
int y = e[x][i].to, z = e[x][i].val;
Node t0 = dp[x][0], t1 = dp[x][1], t2 = dp[x][2];
//dp[x][0]
dp[x][0] = max(dp[x][0], t0 + dp[e[x][i].to][3]);
//dp[x][1]
dp[x][1] = max(dp[x][1], t1 + dp[e[x][i].to][3]);
dp[x][1] = max(dp[x][1], t0 + dp[e[x][i].to][1] + e[x][i].val);
//dp[x][2]
dp[x][2] = max(dp[x][2], t2 + dp[e[x][i].to][3]);
dp[x][2] = max(dp[x][2], Node(t1.dp + dp[e[x][i].to][1].dp + mid + e[x][i].val, t1.cnt + dp[e[x][i].to][1].cnt - 1));
}
//x成为退化链,开一段新的链
dp[x][1] = max(dp[x][1], Node(dp[x][0].dp - mid, dp[x][0].cnt + 1));
dp[x][2] = max(dp[x][2], Node(dp[x][0].dp - mid, dp[x][0].cnt + 1));
dp[x][3] = max(max(dp[x][0], dp[x][1]), dp[x][2]);
}
ll chk(ll mid) {
for (int i = 1; i <= n; i++) {
dp[i][0] = dp[i][1] = dp[i][2] = dp[i][3] = Node(~inf, ~inf);
}
dfs(1, 0, mid);
return dp[1][3].cnt;
}
int main() {
cin >> n >> K;
K++;
for (int i = 1, u, v, w; i < n; i++) {
cin >> u >> v >> w;
e[u].push_back(Edge(v, w));
e[v].push_back(Edge(u, w));
}
ll l = -1e12, r = 1e12;
while (r - l > 1) {
ll mid = (l + r) / 2; //mid变大了会导致选的边数变少
if (chk(mid) == K) {
cout << dp[1][3].dp + mid * K << endl;
return 0;
}
if (chk(mid) < K) {//mid大了
r = mid;
}
else
l = mid;
}
chk(l);
cout << dp[1][3].dp + l * K;
return 0;
}