1. 程式人生 > 其它 >DP專題-盲點掃蕩:樹形 DP

DP專題-盲點掃蕩:樹形 DP

目錄

1. 前言

本篇文章是作者寫的第 3 篇樹形 DP 博文,對樹形 DP 這一演算法做一個複習與總結,同時進行盲點掃蕩。

2. 題單

題單:

普通樹形 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

到這裡會出現兩條路:

  1. 如果寫法是數字與點數同階,那麼複雜度是 \(O(n^3)\) 的。為了降下複雜度,可以只存最大值與次大值及其填的數,這樣可以優化轉移。
  2. 當然也可以手動調整一下最大的數字上限,題解區有人說過最大數為 \(\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}\),避免干擾。

當然你也可以採用改變迴圈順序來避免這些問題,但是存一下可以減少思維量與出錯率(萬一迴圈順序錯了呢?)。

這裡有必要提一下筆者的寫程式碼習慣:

  1. 採用刷表法,這會讓你減少大量的思維量,而且方程容易推對,不易出錯。
  2. 轉移的時候採用一個 \(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。