【演算法學習筆記】動態規劃與資料結構的結合,在樹上做DP

RioTian 發表於 2021-08-06
演算法 資料結構

前置芝士:Here

本文是基於 OI wiki 上的文章加以修改完成,感謝社群的轉載支援和其他方面的支援

樹形 DP,即在樹上進行的 DP。由於樹固有的遞迴性質,樹形 DP 一般都是遞迴進行的。

基礎

以下面這道題為例,介紹一下樹形 DP 的一般過程。

例題 洛谷 P1352 沒有上司的舞會

題目描述

某大學有 $n$ 個職員,編號為 $1 \sim N$。他們之間有從屬關係,也就是說他們的關係就像一棵以校長為根的樹,父結點就是子結點的直接上司。現在有個週年慶宴會,宴會每邀請來一個職員都會增加一定的快樂指數 $a_i$,但是呢,如果某個職員的上司來參加舞會了,那麼這個職員就無論如何也不肯來參加舞會了。所以,請你程式設計計算,邀請哪些職員可以使快樂指數最大,求最大的快樂指數。


我們可以定義 \(f(i,0/1)\) 代表以 \(i\) 為根的子樹的最優解(第二維的值為 0 代表 \(i\) 不參加舞會的情況,1 代表 \(i\) 參加舞會的情況)。

顯然,我們可以推出下面兩個狀態轉移方程(其中下面的 \(x\) 都是 \(i\) 的兒子):

  • \(f(i,0) = \sum\max \{f(x,1),f(x,0)\}\)(上司不參加舞會時,下屬可以參加,也可以不參加)
  • \(f(i,1) = \sum{f(x,0)} + a_i\)(上司參加舞會時,下屬都不會參加)

我們可以通過 DFS,在返回上一層時更新當前結點的最優解。

程式碼
const int N = 1e4 + 10;
vector tr[N];
int f[N][2], v[N], Happy[N], n;
void dfs(int u) {
    f[u][0] = 0; f[u][1] = Happy[u];
    for (auto v : tr[u]) {
        dfs(v);
        f[u][0] += max(f[v][0], f[v][1]);
        f[u][1] += f[v][0];
    }
}
int main() {
    cin.tie(nullptr)->sync_with_stdio(false);
    cin >> n;
    for (int i = 1; i <= n; ++i) cin >> Happy[i];
    for (int i = 1, x, y; i < n; ++i) {
        cin >> x >> y;
        v[x] = 1;// x has a father
        tr[y].push_back(x);
    }
    int root;
    for (int i = 1; i <= n; ++i)
        if (!v[i]) {root = i; break;}
    dfs(root);
    cout << max(f[root][0], f[root][1]) << "\n";
}

相關練習

樹上揹包

樹上的揹包問題,簡單來說就是揹包問題與樹形 DP 的結合。

例題 洛谷 P2014 CTSC1997 選課

題目描述

現在有 $n$ 門課程,第 $i$ 門課程的學分為 $a_i$,每門課程有零門或一門先修課,有先修課的課程需要先學完其先修課,才能學習該課程。
一位學生要學習 $m$ 門課程,求其能獲得的最多學分數。
$n,m \le 300$


每門課最多隻有一門先修課的特點,與有根樹中一個點最多隻有一個父親結點的特點類似。

因此可以想到根據這一性質建樹,從而所有課程組成了一個森林的結構。為了方便起見,我們可以新增一門 \(0\) 學分的課程(設這個課程的編號為 \(0\)),作為所有無先修課課程的先修課,這樣我們就將森林變成了一棵以 \(0\) 號課程為根的樹。

我們設 \(f(u,i,j)\) 表示以 \(u\) 號點為根的子樹中,已經遍歷了 \(u\) 號點的前 \(i\) 棵子樹,選了 \(j\) 門課程的最大學分。

轉移的過程結合了樹形 DP 和揹包 DP 的特點,我們列舉 \(u\) 點的每個子結點 \(v\),同時列舉以 \(v\) 為根的子樹選了幾門課程,將子樹的結果合併到 \(u\) 上。

記點 \(x\)​ 的兒子個數為 \(s_x\)​,以 \(x\)​ 為根的子樹大小為 \(siz_x\)​,很容易寫出下面的轉移方程:

\[f(u,i,j)=\max_{v,k \leq j,k \leq siz_v} f(u,i-1,j-k)+f(v,s_v,k) \]

注意上面轉移方程中的幾個限制條件,這些限制條件確保了一些無意義的狀態不會被訪問到。

\(f\) 的第二維可以很輕鬆地用滾動陣列的方式省略掉,注意這時需要倒序列舉 \(j\) 的值。

我們可以證明,該做法的時間複雜度為 \(O(nm)\)[1]

程式碼
const int N = 310;
vectore[N];
int f[N][N], s[N], n, m;
void dfs(int x) {
    f[x][0] = 0;
    for (int v : e[x]) { // 迴圈子節點(物品)
        dfs(v);
        for (int t = m; t >= 0; --t)     // 倒序迴圈當前選課總門數(當前揹包體積)
            for (int j = 0; j <= t; ++j) // 迴圈更深子樹上的選課門數(組內物品)
                f[x][t] = max(f[x][t], f[x][t - j] + f[v][j]);
        /* 或者
            for (int j = t; j >= 0; j--)
                if (t + j <= m)
                    f[x][t+j] = max(f[x][t+j], f[x][t] + f[y][j]);
            這兩種寫法j分別用了正序和倒序迴圈
            是為了正確處理組內體積為0的物品(本題正序倒序都可以AC是因為體積為0的物品價值恰好也為0)
            請讀者結合0/1揹包問題中DP的“階段”理論思考 */
    }
    if (x != 0) // x不為0時,選修x本身需要佔用1門課,並獲得相應學分
        for (int t = m; t > 0; t--) f[x][t] = f[x][t - 1] + s[x];
}
int main() {
    cin.tie(nullptr)->sync_with_stdio(false);
    cin >> n >> m;
    for (int i = 1, x; i <= n; ++i) {
        cin >> x >> s[i];
        e[x].push_back(i);
    }
    memset(f, 0xcf, sizeof(f)); // -inf
    dfs(0);
    cout << f[0][m] << "\n";
}

相關練習

換根 DP

樹形 DP 中的換根 DP 問題又被稱為二次掃描,通常不會指定根結點,並且根結點的變化會對一些值,例如子結點深度和、點權和等產生影響。

通常需要兩次 DFS,第一次 DFS 預處理諸如深度,點權和之類的資訊,在第二次 DFS 開始執行換根動態規劃。

接下來以一些例題來帶大家熟悉這個內容。

例題 [POI2008]STA-Station

題目描述

給定一個 $n$ 個點的樹,請求出一個結點,使得以這個結點為根時,所有結點的深度之和最大。


注意題目的樣例給的輸出是錯誤,正確的輸出是 \(24\)

不妨令 \(u\) 為當前結點,\(v\) 為當前結點的子結點。首先需要用 \(s_i\) 來表示以 \(i\) 為根的子樹中的結點個數,並且有 \(s_u=1+\sum s_v\)。顯然需要一次 DFS 來計算所有的 \(s_i\),這次的 DFS 就是預處理,我們得到了以某個結點為根時其子樹中的結點總數。

考慮狀態轉移,這裡就是體現"換根"的地方了。令 \(f_u\) 為以 \(u\) 為根時,所有結點的深度之和。

\(f_v\leftarrow f_u\) 可以體現換根,即以 \(u\) 為根轉移到以 \(v\) 為根。顯然在換根的轉移過程中,以 \(v\) 為根或以 \(u\) 為根會導致其子樹中的結點的深度產生改變。具體表現為:

  • 所有在 \(v\) 的子樹上的結點深度都減少了一,那麼總深度和就減少了 \(s_v\)

  • 所有不在 \(v\) 的子樹上的結點深度都增加了一,那麼總深度和就增加了 \(n-s_v\)

根據這兩個條件就可以推出狀態轉移方程 \(f_v = f_u - s_v + n - s_v=f_u + n - 2 \times s_v\)

於是在第二次 DFS 遍歷整棵樹並狀態轉移 \(f_v=f_u + n - 2 \times s_v\),那麼就能求出以每個結點為根時的深度和了。最後只需要遍歷一次所有根結點深度和就可以求出答案。

程式碼
using pii = pair;
const int N = 2e5 + 10;
vectore[N];
int f[N], d[N], ff[N];
void dfs(int u, int fa) {
    for (auto [v, w] : e[u]) {
        if (v == fa) continue;
        dfs(v, u);
        if (d[v] == 1) f[u] += w;
        else f[u] += min(f[v], w);
    }
}
void dfs1(int u, int fa) {
    ff[u] = f[u];
    for (auto [v, w] : e[u]) {
        if (v == fa)continue;
        if (d[v] == 1) {
            f[u] -= w;
            f[v] += min(f[u], w);
        } else {
            f[u] -= min(w, f[v]);
            f[v] += min(w, f[u]);
        }
        dfs1(v, u);
    }
}
int main() {
    cin.tie(nullptr)->sync_with_stdio(false);
    int _; for (cin >> _; _--;) {
        int n; cin >> n;
        for (int i = 1; i <= n; ++i) {
            e[i].clear();
            d[i] = f[i] = ff[i] = 0;
        }
        for (int i = 1, u, v, w; i < n; ++i) {
            cin >> u >> v >> w;
            e[u].push_back({v, w});
            e[v].push_back({u, w});
            d[u]++, d[v]++;
        }
        dfs(1, -1);
        dfs1(1, -1);
        int ans = 0;
        for (int i = 1; i <= n; ++i) ans = max(ans, ff[i]);
        cout << ans << "\n";
    }
}

相關練習

參考資料與註釋


  1. 子樹合併揹包型別的 dp 的複雜度證明 - LYD729 的 CSDN 部落格 ↩︎