DP專題-盲點掃蕩:樹形 DP
1. 前言
本篇文章是作者寫的第 3 篇樹形 DP 博文,對樹形 DP 這一演算法做一個複習與總結,同時進行盲點掃蕩。
2. 題單
題單:
- 普通樹形 DP:
- P4395 [BOI2003]Gem 氣墊車
- 揹包類樹形 DP:
- P3698 [CQOI2017]小Q的棋盤
- P3177 [HAOI2015]樹上染色
- P1273 有線電視網
- 換根 DP:
- P3047 [USACO12FEB]Nearby Cows G
普通樹形 DP
P4395 [BOI2003]Gem 氣墊車
首先一種顯然的想法是直接 1/2 染色。
但是很遺憾這個做法是錯的,可以構造出如下反例:
顯然 1,2 這兩個節點應該一個填 2 一個填 3,但如果只是 1/2 染色就會得到錯誤答案。
因此考慮樹形 DP。
設 \(f_{i,j}\) 表示在第 \(i\) 個點填 \(j\) 數字的時候的最小花費。
那麼就有轉移方程:
\[f_{u,j}=j+\sum_{u \to v}\min\{f_{v,k} | k \ne j\} \]這個方程還是比較好理解的吧qwq
到這裡會出現兩條路:
- 如果寫法是數字與點數同階,那麼複雜度是 \(O(n^3)\) 的。為了降下複雜度,可以只存最大值與次大值及其填的數,這樣可以優化轉移。
- 當然也可以手動調整一下最大的數字上限,題解區有人說過最大數為 \(\log n\),但是我不會證qwq,我採用的是這種方法。
My Code:
/* ========= Plozia ========= Author:Plozia Problem:P4395 [BOI2003]Gem 氣墊車 Date:2021/5/26 ========= Plozia ========= */ #include <bits/stdc++.h> typedef long long LL; const int MAXN = 10000 + 10; const LL INF = 0x7f7f7f7f7f7f7f7f; int n, Head[MAXN], cnt_Edge = 1; LL f[MAXN][70]; struct node { int to, Next; } Edge[MAXN << 1]; int Read() { int sum = 0, fh = 1; char ch = getchar(); for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1; for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48); return sum * fh; } LL Max(LL fir, LL sec) { return (fir > sec) ? fir : sec; } LL Min(LL fir, LL sec) { return (fir < sec) ? fir : sec; } void add_Edge(int x, int y) { ++cnt_Edge; Edge[cnt_Edge] = (node){y, Head[x]}; Head[x] = cnt_Edge; } void DP(int now, int father) { for (int i = 1; i <= 50; ++i) f[now][i] = i; bool flag = 0; for (int i = Head[now]; i; i = Edge[i].Next) { int u = Edge[i].to; if (u == father) continue ; DP(u, now); flag = 1; } if (flag) for (int i = 1; i <= 50; ++i) f[now][i] = i; for (int i = Head[now]; i; i = Edge[i].Next) { int u = Edge[i].to; if (u == father) continue ; for (int j = 1; j <= 50; ++j) { LL sum = INF; for (int k = 1; k <= 50; ++k) if (j != k) sum = Min(sum, f[u][k]); f[now][j] += sum; } } } int main() { n = Read(); for (int i = 1; i < n; ++i) { int x = Read(), y = Read(); add_Edge(x, y); add_Edge(y, x); } DP(1, -1); LL ans = INF; for (int i = 1; i <= 50; ++i) ans = Min(ans, f[1][i]); printf("%lld\n", ans); return 0; }
P3698 [CQOI2017]小Q的棋盤
文中的 \(n,v\) 與題中的 \(n,v\) 意義剛好相反。
這道題有兩種方法:普通樹形 DP 和貪心。
樹形 DP 的做法參見題解區,其複雜度為 \(O(nv)\),這裡只講複雜度更低的 \(O(n)\) 做法。
首先考慮求一下從 0 開始的最長鏈長度,記為 \(l\)。
如果 \(l>v\),這說明不能用 \(v\) 步走完這條最長鏈,那麼答案就是 \(v+1\),因為不可能有更優的走法使得經過點數大於 \(v+1\)。
如果 \(l \leq v\),這說明能用 \(v\) 步走完這條最長鏈,剩餘步數 \(v-(l-1)=v-l+1\)。
那麼剩下的步數怎麼辦呢?
有一個關鍵點:在走完最長鏈之後,每多走一個點至少需要耗費兩步,過去一步,回來一步。
因此我們可以將剩下的 \(v-l+1\) 拿去走這些點,可以走 \(\left\lfloor\dfrac{v-l+1}{2}\right\rfloor\) 個點。
因此此處答案就是 \(l+\left\lfloor\dfrac{v-l+1}{2}\right\rfloor\)。
有的人會問了:如果你走到了最長鏈底端,那麼不是回去需要耗費更多的步數嗎?
實際上你可以將多走點的過程看作是在走最長鏈的過程中出去走點,這樣就是剛好一個點兩步。
需要注意總共點數只有 \(n\),因此這種情況還要與 \(n\) 取最小值。
那麼為什麼這個演算法就是正確的呢?
首先前面已經說過,除走的鏈上所有點外,每額外走一個點至少需要 2 步,而鏈上只需要 1 步。
所以我們需要使鏈上的點數儘量大,所以就求最長鏈。
另一方面,為使點數儘量大,我們需要剛好兩步一個點,而該方案的可行性上面已經解釋過。
綜上,該演算法能夠使點數最大,演算法正確。
注意一點:求的不是直徑,而是以 0 為起點的最長鏈。
My Code:
/*
========= Plozia =========
Author:Plozia
Problem:P3698 [CQOI2017]小Q的棋盤
Date:2021/5/25
========= Plozia =========
*/
#include <bits/stdc++.h>
typedef long long LL;
const int MAXN = 100 + 10;
int n, v, f1[MAXN], f2[MAXN], Head[MAXN], cnt_Edge = 1, ans;
struct node { int to, val, Next; } Edge[MAXN << 1];
int Read()
{
int sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48);
return sum * fh;
}
int Max(int fir, int sec) { return (fir > sec) ? fir : sec; }
int Min(int fir, int sec) { return (fir < sec) ? fir : sec; }
void add_Edge(int x, int y, int z) { ++cnt_Edge; Edge[cnt_Edge] = (node){y, z, Head[x]}; Head[x] = cnt_Edge; }
void dfs(int now, int father, int dis)
{
if (dis > ans) ans = dis;
for (int i = Head[now]; i; i = Edge[i].Next)
{
int u = Edge[i].to;
if (u == father) continue ;
dfs(u, now, dis + 1);
}
}
int main()
{
n = Read(), v = Read();
for (int i = 1; i < n; ++i)
{
int x = Read(), y = Read();
add_Edge(x, y, 1); add_Edge(y, x, 1);
}
dfs(0, -1, 1);
if (ans > v) printf("%d\n", v + 1);
else printf("%d\n", Min(n, (v + ans + 1) / 2));
return 0;
}
揹包類樹形 DP
簡要說一下揹包類樹形 DP 的一般套路:
設 \(f_{i,j}\) 表示以 \(i\) 為根的子樹中選取 \(j\) 個符合題目要求的節點的答案,轉移的時候一般利用刷表法轉移,列舉 \(j,k\) 表示前面已經處理過的子樹中選 \(j\) 個,當前子樹中選 \(k\) 個,對 \(f_{i,j+k}\) 刷錶轉移。
需要注意的是轉移之前要臨時存一下 \(f_{i,j+k}\),避免干擾。
當然你也可以採用改變迴圈順序來避免這些問題,但是存一下可以減少思維量與出錯率(萬一迴圈順序錯了呢?)。
這裡有必要提一下筆者的寫程式碼習慣:
- 採用刷表法,這會讓你減少大量的思維量,而且方程容易推對,不易出錯。
- 轉移的時候採用一個 \(g\) 陣列臨時存下 \(f_{i,j+k}\),因為這樣就無需考慮迴圈順序,在 OI 中可以防止因迴圈順序出錯而導致的失分。
P3177 [HAOI2015]樹上染色
設 \(f_{i,j}\) 表示在第 \(i\) 棵子樹中,選取 \(k\) 個點染成黑色點時可以得到的最大收益。
首先我們需要注意到一個性質:對於一組黑色點對 \((u,v)\),設其經過邊 \(e\),那麼其對答案的貢獻為 \(e.val\)。
那麼因此假設在 \(e\) 的一邊有 \(l\) 個黑色點,另一邊就有 \(k-l\) 個黑色點,於是這些黑色點對對答案的貢獻為 \(e.val \times l \times (k-l)\)。
根據上述性質,我們可以將距離和計算轉變為對一條邊兩邊的點數的計算。
於是對於 \(u\),我們有轉移方程(採用刷表法):
\[f_{u,j+l}=\max\{g_j+f_{v,l}+ t \times val|u \to v\} \]其中 \(g\) 是在轉移之前臨時儲存的 \(f\) 以防止因為迴圈順序出現轉移錯誤,\(t=l \times (k-l)+(Size_v-l) \times (n-Size_v-(k-l))\),也就是兩邊的黑色點對與白色點對的數量,\(Size_v\) 表示以 \(v\) 為根節點的子樹大小。
初值為對任意節點 \(v\),\(f_{v,0}=f_{v,1}=0\)。
對於列舉 \(j,l\):
需要注意的是 \(j \leq Size_u,l \leq Size_v,j + l \leq k\)。
其中 \(Size_u\) 在轉移時的定義並不是子樹大小,而是已經其子樹中已經遍歷過的節點總數(包括自身)。
如果看不懂的話,看看程式碼就好了。
為什麼不能 \(j \leq k,l \leq k, j + l \leq k\) 呢?
因為這樣的複雜度是假的,為 \(O(n^2k)\),其瓶頸在於沒有限定揹包容量。
而按照上述這樣限定 \(j,k\) 的範圍就可以做到 \(O(n^2)\)。
複雜度證明如下:
考慮任意一組同色點對 \((u,v)\)。
實際上對於這一組同色點對 \((u,v)\),其對答案的貢獻只可能在其最近公共祖先(LCA)處貢獻一次,而任意兩個點的 LCA 是唯一的。
因此考慮從計算點對貢獻的角度計算複雜度,由於每一組同色點對只會被計算一次,而這樣的點對至多隻有 \(n^2\) 組,於是複雜度為 \(O(n^2)\),證畢。
注意開 long long
,尤其是 max
函式。
My Code:
/*
========= Plozia =========
Author:Plozia
Problem:P3177 [HAOI2015]樹上染色
Date:2021/5/26
========= Plozia =========
*/
#include <bits/stdc++.h>
typedef long long LL;
const int MAXN = 2000 + 10;
int n, k, Head[MAXN], cnt_Edge = 1, Size[MAXN];
LL f[MAXN][MAXN], g[MAXN];
struct node { int to; LL val; int Next; } Edge[MAXN << 1];
int Read()
{
int sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48);
return sum * fh;
}
LL Max(LL fir, LL sec) { return (fir > sec) ? fir : sec; }
LL Min(LL fir, LL sec) { return (fir < sec) ? fir : sec; }
void add_Edge(int x, int y, LL z) { ++cnt_Edge; Edge[cnt_Edge] = (node){y, z, Head[x]}; Head[x] = cnt_Edge; }
void DP(int now, int father)
{
Size[now] = 1; f[now][0] = f[now][1] = 0;
for (int i = Head[now]; i; i = Edge[i].Next)
{
int u = Edge[i].to;
if (u == father) continue ;
DP(u, now);
for (int j = 0; j <= k; ++j) g[j] = f[now][j];
for (int j = 0; j <= Size[now] && j <= k; ++j)
for (int l = 0; l <= Size[u] && j + l <= k; ++l)
f[now][j + l] = Max(f[now][j + l], g[j] + f[u][l] + (1ll * l * (k - l) + 1ll * (Size[u] - l) * (n - Size[u] - k + l)) * Edge[i].val);
Size[now] += Size[u];
}
}
int main()
{
n = Read(), k = Read(); memset(f, -0x3f, sizeof(f));
if (n - k < k) k = n - k;
for (int i = 1; i < n; ++i)
{
int x = Read(), y = Read(), z = Read();
add_Edge(x, y, z); add_Edge(y, x, z);
}
DP(1, -1); printf("%lld\n", f[1][k]); return 0;
}
P1273 有線電視網
設 \(f_{i,j}\) 表示在第 \(i\) 棵子樹當中選取 \(j\) 個使用者時的最大收益,那麼我們的答案就是所有 \(f_{1,i}>0\) 中最大的 \(i\)。
考慮如下轉移方程:
設當前列舉邊為 \(u \to v\),則有:
\[f_{u,j+k}=\max(f_{u,j+k},g_{j}+f_{v,k}-val) \]其中 \(val\) 是這條邊的邊權。
初值為對於所有葉子節點 \(v\),\(f_{v,1}=a_v\)。
My Code:
/*
========= Plozia =========
Author:Plozia
Problem:P1273 有線電視網
Date:2021/5/25
========= Plozia =========
*/
#include <bits/stdc++.h>
typedef long long LL;
const int MAXN = 3000 + 10;
int n, m, a[MAXN], Head[MAXN], cnt_Edge = 1, f[MAXN][MAXN], g[MAXN][MAXN], Size[MAXN];
struct node { int to, val, Next; } Edge[MAXN << 1];
int read()
{
int sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48);
return sum * fh;
}
int Max(int fir, int sec) { return (fir > sec) ? fir : sec; }
int Min(int fir, int sec) { return (fir < sec) ? fir : sec; }
void add_Edge(int x, int y, int z) { ++cnt_Edge; Edge[cnt_Edge] = (node){y, z, Head[x]}; Head[x] = cnt_Edge; }
void dfs(int now, int father)
{
f[now][0] = 0; Size[now] = 1;
if (now >= n - m + 1 && now <= n) { f[now][1] = a[now]; return ; }
for (int i = Head[now]; i; i = Edge[i].Next)
{
int u = Edge[i].to;
if (u == father) continue ;
dfs(u, now);
for (int j = 0; j <= m - 1; ++j) g[now][j] = f[now][j];
for (int j = 0; j <= Size[u]; ++j)
for (int k = 0; k <= Size[now]; ++k)
f[now][j + k] = Max(f[now][j + k], f[u][j] - Edge[i].val + g[now][k]);
Size[now] += Size[u];
}
}
int main()
{
n = read(), m = read();
for (int i = 1; i <= n - m; ++i)
{
int k = read();
while (k--)
{
int y = read(), z = read();
add_Edge(i, y, z); add_Edge(y, i, z);
}
}
for (int i = n - m + 1; i <= n; ++i) a[i] = read();
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= m - 1; ++j)
f[i][j] = -0x3f3f3f3f;
dfs(1, 1);
for (int i = m - 1; i >= 0; --i)
if (f[1][i] >= 0) { printf("%d\n", i); return 0; }
printf("0\n"); return 0;
}
換根 DP
換根 DP 的一般題型是對於所有 \(i\) 作為根節點,需要求出一類問題的答案。
其一般套路如下:
首先指定一個點為根節點,做一遍只考慮子樹內的樹形 DP。
然後從這個點重新 DFS 遍歷整棵樹,自頂向下考慮父節點對這個節點的答案的影響,重新計算一遍以得到正確的答案,這個過程中通常會用到一點容斥的思想。
P3047 [USACO12FEB]Nearby Cows G
考慮換根 DP。
第一遍樹形 DP:
設 \(f_{i,j}\) 表示距離第 \(i\) 個節點為 \(j\) 的所有節點的權值和。
那麼對於 \(u\) 節點,有一個簡單的轉移方程:
\[f_{i,j}=\sum_{u \to v}f_{v,j-1} \]初值為對於所有葉子節點 \(v\),\(f_{v,0}=a_v\)。
第二遍 DFS:
我們已經得到了 \(f_{i,j}\),則對於根節點而言其答案就是 \(f_{i,k}\)。
考慮非根節點的父親 \(u\) 對兒子 \(v\) 的影響:
假設我們當前需要處理 3 號點的 \(f_{3,j}\) 的正確答案,那麼考慮父節點對 3 號點的貢獻應該為 \(f_{1,j-1}\)。
但是如果這樣做,其子節點 7 的答案可能會被重複計算,因此我們還需要減去 \(f_{3,j-2}\)。
這樣就可以得到正確的 \(f_{3,j}\) 了。
My Code:
/*
========= Plozia =========
Author:Plozia
Problem:P3047 [USACO12FEB]Nearby Cows G
Date:2021/5/25
========= Plozia =========
*/
#include <bits/stdc++.h>
typedef long long LL;
const int MAXN = 1e5 + 10;
int n, k, Head[MAXN], cnt_Edge = 1, f[MAXN][30], a[MAXN];
struct node { int to, Next; } Edge[MAXN << 1];
int read()
{
int sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48);
return sum * fh;
}
int Max(int fir, int sec) { return (fir > sec) ? fir : sec; }
int Min(int fir, int sec) { return (fir < sec) ? fir : sec; }
void add_Edge(int x, int y) { ++cnt_Edge; Edge[cnt_Edge] = (node){y, Head[x]}; Head[x] = cnt_Edge; }
void dfs(int now, int father)
{
f[now][0] = a[now];
for (int i = Head[now]; i; i = Edge[i].Next)
{
int u = Edge[i].to;
if (u == father) continue ;
dfs(u, now);
for (int j = 1; j <= k; ++j)
f[now][j] += f[u][j - 1];
}
}
void Change_Root(int now, int father)
{
for (int i = Head[now]; i; i = Edge[i].Next)
{
int u = Edge[i].to;
if (u == father) continue ;
for (int j = k; j >= 2; --j) f[u][j] -= f[u][j - 2];//注意是逆序!
//當然如果你懶也可以開一個 g 陣列臨時存一下 f
for (int j = 1; j <= k; ++j) f[u][j] += f[now][j - 1];
Change_Root(u, now);
}
}
int main()
{
n = read(), k = read();
for (int i = 1; i < n; ++i)
{
int x = read(), y = read();
add_Edge(x, y); add_Edge(y, x);
}
for (int i = 1; i <= n; ++i) a[i] = read();
dfs(1, 0); Change_Root(1, 0);
for (int i = 1; i <= n; ++i)
{
int sum = 0;
for (int j = 0; j <= k; ++j) sum += f[i][j];
//注意這裡要求字首和
printf("%d\n", sum);
}
return 0;
}
3. 總結
樹形 DP 相對別的 DP 還是比較套路的,大體分成如下幾種:
- 普通樹形 DP。
- 揹包類樹形 DP。
- 換根 DP。