Chương 3·Bài 33

Rerooting DP

Cho một cây, hỏi: tổng khoảng cách từ đỉnh 1 tới mọi đỉnh là bao nhiêu? Một lần quy hoạch động trên cây là xong. Nhưng nếu đề hỏi cùng câu ấy cho mọi đỉnh làm gốc (hay đỉnh xa nhất, số cạnh phải đảo chiều), thì chạy lại nn lần tốn O(n2)O(n^2).

Đổi gốc (rerooting) làm hai lượt. Lượt thứ nhất tính đáp án cho một gốc cố định cùng thông tin của từng cây con. Lượt thứ hai dời gốc dần qua từng cạnh, mỗi lần chỉ sửa phần đóng góp của đúng một cạnh. Tổng cộng O(n)O(n).

Dời gốc

Chọn gốc 1. Gọi szusz_u là kích thước cây con của uu và f(u)f(u) là tổng khoảng cách từ uu tới mọi đỉnh. Lượt dưới lên cho f(1)f(1), là tổng độ sâu của mọi đỉnh.

Bây giờ dời gốc từ uu sang con vv. szvsz_v đỉnh trong cây con của vv gần thêm 1, còn n−szvn - sz_v đỉnh còn lại xa thêm 1. Vậy

f(v)=f(u)−szv+(n−szv).f(v) = f(u) - sz_v + (n - sz_v).

Đi từ gốc xuống theo thứ tự BFS (hoặc thứ tự tintin), mỗi đỉnh tính từ cha trong O(1)O(1).

Nhãn f là tổng khoảng cách từ đỉnh đó tới mọi đỉnh. Bấm một đỉnh để xem phép dời gốc từ cha của nó.

Để tránh đệ quy sâu khi cây là một đường dài 2⋅1052 \cdot 10^5, lấy thứ tự BFS từ gốc: duyệt ngược thứ tự đó là đi từ lá lên (con xong trước cha), duyệt xuôi là đi từ gốc xuống (cha xong trước con).

Hỏi nhanh

Cây có n=10n = 10 đỉnh, f(u)=25f(u) = 25, con vv của uu có szv=3sz_v = 3. f(v)f(v) bằng bao nhiêu?

Ví dụTổng Khoảng Cách Từ Mỗi Điểm

Đề. Cây n≤2⋅105n \le 2 \cdot 10^5 đỉnh. Với mỗi đỉnh, in tổng khoảng cách tới mọi đỉnh.

Lời giải. Với gốc 1, tổng khoảng cách f(1)f(1) là tổng độ sâu mọi đỉnh. Dời gốc từ uu sang con vv: szvsz_v đỉnh trong cây con của vv gần thêm 1, n−szvn - sz_v đỉnh còn lại xa thêm 1, nên f(v)=f(u)−szv+(n−szv)f(v) = f(u) - sz_v + (n - sz_v). Cả bài không cần đệ quy: một thứ tự BFS cho phép đi “từ lá lên” bằng cách duyệt ngược, và “từ gốc xuống” bằng cách duyệt xuôi.

Bước 1: BFS lấy thứ tự. ord xếp cha trước con, cùng par và dep. Vòng for trên ord vừa duyệt vừa thêm, chính là BFS dùng ord làm hàng đợi.

C++
ord.push_back(1);
par[1] = 0;
for (ll i = 0; i < (ll)ord.size(); i++) {
    ll u = ord[i];
    for (ll v : g[u])
        if (v != par[u]) {
            par[v] = u;
            dep[v] = dep[u] + 1;
            ord.push_back(v);
        }
}

Bước 2: từ lá lên. Duyệt ord ngược: con xong trước cha, cộng sz lên cha. Cùng vòng đó cộng mọi dep vào f[1].

C++
for (ll i = n - 1; i >= 0; i--) {
    ll u = ord[i];
    sz[u]++;
    if (par[u]) sz[par[u]] += sz[u];
    f[1] += dep[u];
}

Bước 3: dời gốc. Duyệt xuôi từ phần tử thứ hai; cha luôn có f trước con.

C++
for (ll i = 1; i < n; i++) {
    ll v = ord[i];
    f[v] = f[par[v]] - sz[v] + (n - sz[v]);
}

Tổng tới khoảng 2⋅10102 \cdot 10^{10}, cần ll.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 2e5 + 5;
ll n, par[N], dep[N], sz[N], f[N];
vector<ll> g[N], ord;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n;
    for (ll i = 1; i < n; i++) {
        ll u, v;
        cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);
    }
    ord.push_back(1);
    par[1] = 0;
    for (ll i = 0; i < (ll)ord.size(); i++) {
        ll u = ord[i];
        for (ll v : g[u])
            if (v != par[u]) {
                par[v] = u;
                dep[v] = dep[u] + 1;
                ord.push_back(v);
            }
    }
    for (ll i = n - 1; i >= 0; i--) {
        ll u = ord[i];
        sz[u]++;
        if (par[u]) sz[par[u]] += sz[u];
        f[1] += dep[u];
    }
    for (ll i = 1; i < n; i++) {
        ll v = ord[i];
        f[v] = f[par[v]] - sz[v] + (n - sz[v]);
    }
    for (ll u = 1; u <= n; u++) cout << f[u] << ' ';
    cout << '\n';
}
5
1 2
2 3
2 4
4 5

Độ phức tạp: ba vòng tuyến tính: O(n)O(n).

Cạnh có hướng

Khi mỗi cạnh có một chi phí phụ thuộc vào chiều nhìn từ gốc, dời gốc qua cạnh (u,v)(u, v) chỉ đổi chiều nhìn của đúng cạnh đó; mọi cạnh khác vẫn được nhìn từ phía cũ. Đáp án mới bằng đáp án cũ trừ chi phí cạnh theo chiều cũ, cộng chi phí theo chiều mới. Lưu mỗi cạnh hai lần trong danh sách kề kèm chi phí theo chiều duyệt là đủ.

Ví dụTrạm Phát Trung Tâm

Đề. Cây n≤2⋅105n \le 2 \cdot 10^5 đỉnh với các cạnh có hướng. Chọn một trạm làm trung tâm và đảo ít cạnh nhất để từ đó đi được tới mọi trạm. In số cạnh ít nhất và mọi trạm đạt được, tăng dần.

Lời giải. Với gốc cố định, cạnh nào trỏ ngược chiều từ gốc ra (từ con về cha) thì phải đảo, nên đáp án là số cạnh như vậy. Dời gốc từ uu sang con vv chỉ làm đúng cạnh (u,v)(u, v) đổi chiều nhìn: chi phí của nó đổi từ cc thành 1−c1 - c, mọi cạnh khác giữ nguyên.

Bước 1: lưu cạnh hai lần. Cạnh u→vu \to v lưu thành (v, 0) ở uu (đi đúng chiều, không tốn) và (u, 1) ở vv (đi ngược, phải đảo).

C++
g[u].push_back({v, 0});
g[v].push_back({u, 1});

Bước 2: BFS từ gốc 1. pc[v] là chi phí của cạnh nối vv với cha theo chiều cha xuống con; cộng chúng vào f[1].

C++
for (pair<ll, ll> e : g[u])
    if (e.first != par[u]) {
        par[e.first] = u;
        pc[e.first] = e.second;
        f[1] += e.second;
        ord.push_back(e.first);
    }

Bước 3: dời gốc.

C++
for (ll i = 1; i < n; i++) {
    ll v = ord[i];
    f[v] = f[par[v]] - pc[v] + (1 - pc[v]);
}

In giá trị nhỏ nhất rồi mọi đỉnh đạt nó theo thứ tự tăng.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 2e5 + 5;
ll n, par[N], pc[N], f[N];
vector<pair<ll, ll>> g[N];
vector<ll> ord;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n;
    for (ll i = 1; i < n; i++) {
        ll u, v;
        cin >> u >> v;
        g[u].push_back({v, 0});
        g[v].push_back({u, 1});
    }
    ord.push_back(1);
    for (ll i = 0; i < (ll)ord.size(); i++) {
        ll u = ord[i];
        for (pair<ll, ll> e : g[u])
            if (e.first != par[u]) {
                par[e.first] = u;
                pc[e.first] = e.second;
                f[1] += e.second;
                ord.push_back(e.first);
            }
    }
    for (ll i = 1; i < n; i++) {
        ll v = ord[i];
        f[v] = f[par[v]] - pc[v] + (1 - pc[v]);
    }
    ll best = *min_element(f + 1, f + n + 1);
    cout << best << '\n';
    for (ll u = 1; u <= n; u++)
        if (f[u] == best) cout << u << ' ';
    cout << '\n';
}
5
2 1
2 3
4 2
4 5

Độ phức tạp: O(n)O(n).

Phần phía trên

Không phải đại lượng nào cũng dời được bằng cộng trừ. Với khoảng cách xa nhất, đáp án của vv là max của hai phần:

  • Phía dưới downvdown_v: đường xa nhất đi vào cây con của vv.
  • Phía trên upvup_v: đường xa nhất đi qua cha uu, tức upv=1+max⁡(upu,đường xa nhaˆˊt từ u xuoˆˊng một con khaˊc v)up_v = 1 + \max(up_u, \text{đường xa nhất từ } u \text{ xuống một con khác } v).

Phép max không trừ ngược được, nên ở mỗi đỉnh giữ hai giá trị lớn nhất của các con, cùng con cho giá trị lớn nhất. Nếu vv chính là con tốt nhất thì dùng giá trị thứ hai, không thì dùng giá trị thứ nhất.

Khuôn chung: downdown tính từ lá lên, upup tính từ gốc xuống, và phần “mọi con trừ một” lấy từ tóm tắt các con (hai giá trị lớn nhất cho max, tổng trừ đi cho tổng, tiền tố và hậu tố cho phép gộp tùy ý).

Lượt 1 tính d1/d2 từ lá lên, lượt 2 tính up từ gốc xuống; cạnh xanh nối tới con cho nhánh dài nhất.

Hỏi nhanh

Đỉnh uu có ba con với đường xa nhất đi xuống lần lượt dài 55, 55 và 22 (đã tính cạnh từ uu). Đối với con thứ nhất, phần “xa nhất qua uu nhưng không vào con thứ nhất” đi xuống bao xa?

Ví dụĐiểm Xa Nhất Trên Cây

Đề. Cây n≤2⋅105n \le 2 \cdot 10^5 đỉnh. Với mỗi đỉnh, in khoảng cách tới đỉnh xa nhất.

Lời giải. Đỉnh xa nhất từ vv hoặc nằm dưới vv (trong cây con), hoặc nằm phía trên (đi lên cha rồi đi đâu đó). Phần dưới tính từ lá lên; phần trên tính từ gốc xuống. Phần trên của vv là lên cha 1 bước, rồi hoặc đi tiếp lên trên cha, hoặc xuống một nhánh khác của cha. Nếu nhánh tốt nhất của cha chính là vv thì phải lấy nhánh tốt nhì, nên mỗi đỉnh giữ hai giá trị lớn nhất.

Bước 1: hai nhánh dài nhất. d1[u], d2[u] là hai giá trị lớn nhất của 1+d1c1 + d1_c trên các con cc (0 nếu thiếu), bc[u] là con cho d1[u]. Duyệt ngược ord, mỗi con đưa ứng viên lên cha.

C++
for (ll i = n - 1; i >= 1; i--) {
    ll v = ord[i], u = par[v], x = d1[v] + 1;
    if (x > d1[u]) {
        d2[u] = d1[u];
        d1[u] = x;
        bc[u] = v;
    } else if (x > d2[u]) d2[u] = x;
}

Bước 2: phần phía trên. up1=0up_1 = 0; duyệt xuôi, upv=1+max⁡(upu,nhaˊnh toˆˊt nhaˆˊt của u khoˆng qua v)up_v = 1 + \max(up_u, \text{nhánh tốt nhất của } u \text{ không qua } v).

C++
for (ll i = 1; i < n; i++) {
    ll v = ord[i], u = par[v];
    up[v] = 1 + max(up[u], bc[u] == v ? d2[u] : d1[u]);
}

Đáp án của vv là max⁡(d1v,upv)\max(d1_v, up_v).

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 2e5 + 5;
ll n, par[N], d1[N], d2[N], bc[N], up[N];
vector<ll> g[N], ord;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n;
    for (ll i = 1; i < n; i++) {
        ll u, v;
        cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);
    }
    ord.push_back(1);
    for (ll i = 0; i < (ll)ord.size(); i++) {
        ll u = ord[i];
        for (ll v : g[u])
            if (v != par[u]) {
                par[v] = u;
                ord.push_back(v);
            }
    }
    for (ll i = n - 1; i >= 1; i--) {
        ll v = ord[i], u = par[v], x = d1[v] + 1;
        if (x > d1[u]) {
            d2[u] = d1[u];
            d1[u] = x;
            bc[u] = v;
        } else if (x > d2[u]) d2[u] = x;
    }
    for (ll i = 1; i < n; i++) {
        ll v = ord[i], u = par[v];
        up[v] = 1 + max(up[u], bc[u] == v ? d2[u] : d1[u]);
    }
    for (ll u = 1; u <= n; u++) cout << max(d1[u], up[u]) << ' ';
    cout << '\n';
}
5
1 2
2 3
2 4
4 5

Độ phức tạp: O(n)O(n).

Ví dụMiền Kết Nối Tốt Nhất

Đề. Cây n≤2⋅105n \le 2 \cdot 10^5 đỉnh trắng hoặc đen. Với mỗi đỉnh uu, tìm max của (số trắng − số đen) trên các tập liên thông chứa uu.

Lời giải. Gán val=+1val = +1 cho trắng, −1-1 cho đen; bài thành tìm tập liên thông chứa uu có tổng valval lớn nhất. Từ lá lên: dpu=valu+∑max⁡(0,dpc)dp_u = val_u + \sum \max(0, dp_c) là tập tốt nhất chứa uu trong cây con của nó, chỉ lấy nhánh con khi nhánh đó có lợi. Từ gốc xuống: tập chứa vv còn có thể kéo thêm phần phía cha. Giá trị phần đó là đáp án của cha bỏ đi phần mà vv đã góp, tức ansu−max⁡(0,dpv)ans_u - \max(0, dp_v); phép gộp là tổng nên bỏ đóng góp của một con chỉ là phép trừ.

Bước 1: từ lá lên. dp khởi tạo bằng ±1\pm 1 ngay khi đọc; duyệt ngược ord, cộng phần có lợi lên cha.

C++
for (ll i = n - 1; i >= 1; i--) {
    ll v = ord[i];
    dp[par[v]] += max(0LL, dp[v]);
}

Bước 2: từ gốc xuống. Đáp án của gốc chính là dp[1]; con vv lấy thêm phần phía cha nếu nó dương.

C++
ans[1] = dp[1];
for (ll i = 1; i < n; i++) {
    ll v = ord[i];
    ans[v] = dp[v] + max(0LL, ans[par[v]] - max(0LL, dp[v]));
}
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 2e5 + 5;
ll n, par[N], dp[N], ans[N];
vector<ll> g[N], ord;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n;
    for (ll i = 1; i <= n; i++) {
        ll c;
        cin >> c;
        dp[i] = c ? 1 : -1;
    }
    for (ll i = 1; i < n; i++) {
        ll u, v;
        cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);
    }
    ord.push_back(1);
    for (ll i = 0; i < (ll)ord.size(); i++) {
        ll u = ord[i];
        for (ll v : g[u])
            if (v != par[u]) {
                par[v] = u;
                ord.push_back(v);
            }
    }
    for (ll i = n - 1; i >= 1; i--) {
        ll v = ord[i];
        dp[par[v]] += max(0LL, dp[v]);
    }
    ans[1] = dp[1];
    for (ll i = 1; i < n; i++) {
        ll v = ord[i];
        ans[v] = dp[v] + max(0LL, ans[par[v]] - max(0LL, dp[v]));
    }
    for (ll u = 1; u <= n; u++) cout << ans[u] << ' ';
    cout << '\n';
}
5
1 0 1 1 0
1 2
2 3
2 4
4 5

Độ phức tạp: O(n)O(n).

Tiền tố, hậu tố

Khi phép gộp không có phép ngược (tích theo modulo không nguyên tố, max, GCD), không thể “bỏ đóng góp của một con” bằng phép chia hay phép trừ. Thay vào đó, ở mỗi đỉnh uu với các con c1,…,ckc_1, \dots, c_k, tính tích tiền tố preipre_i của ii con đầu và tích hậu tố sufisuf_i từ con ii về cuối.

Phần “mọi con trừ cic_i” là prei−1⋅sufi+1pre_{i-1} \cdot suf_{i+1}. Tổng kích thước các mảng này là tổng số con, tức n−1n - 1, nên vẫn O(n)O(n).

Ví dụCụm Đen Chứa Đỉnh

Đề. Cây n≤105n \le 10^5 đỉnh, số m≤109m \le 10^9 không nhất thiết nguyên tố. Với mỗi đỉnh uu, đếm số tập đỉnh liên thông chứa uu, theo modulo mm.

Lời giải. Từ lá lên: dpudp_u, tích các (dpc+1)(dp_c + 1) trên mọi con cc, là số tập liên thông chứa uu trong cây con của nó, vì mỗi con hoặc không lấy gì, hoặc lấy một tập liên thông chứa nó. Từ gốc xuống: upcup_c là số tập liên thông chứa uu nằm ngoài cây con của cc, bằng (upu+1)(up_u + 1) nhân với tích các (dpc′+1)(dp_{c'} + 1) trên mọi con c′≠cc' \ne c của uu, với upup của gốc là 0. Đáp án của cc là dpc⋅(upc+1)dp_c \cdot (up_c + 1): phần dưới chứa cc, nhân với lựa chọn có lấy phía trên hay không.

Khác ví dụ trước, phép gộp là tích theo modulo mm không nhất thiết nguyên tố, nên không “chia ngược” được để bỏ một con. Thay vào đó dùng tích tiền tố và hậu tố của các con.

Bước 1: danh sách con. ch[u] dựng trong BFS để dùng lại cho tiền tố và hậu tố.

C++
for (ll v : g[u])
    if (v != par[u]) {
        par[v] = u;
        ch[u].push_back(v);
        ord.push_back(v);
    }

Bước 2: từ lá lên.

C++
for (ll i = n - 1; i >= 0; i--) {
    ll u = ord[i];
    dp[u] = 1;
    for (ll c : ch[u]) dp[u] = dp[u] * (dp[c] + 1) % m;
}

Bước 3: từ gốc xuống bằng tiền tố, hậu tố. Ở mỗi uu, pre[j] là tích của jj con đầu, suf[j] là tích từ con jj tới cuối. Tích mọi con trừ con thứ jj là pre[j] * suf[j + 1], đúng với mọi mô-đun.

C++
for (ll i = 0; i < n; i++) {
    ll u = ord[i], k = ch[u].size();
    vector<ll> pre(k + 1, 1), suf(k + 2, 1);
    for (ll j = 0; j < k; j++) pre[j + 1] = pre[j] * (dp[ch[u][j]] + 1) % m;
    for (ll j = k - 1; j >= 0; j--) suf[j] = suf[j + 1] * (dp[ch[u][j]] + 1) % m;
    for (ll j = 0; j < k; j++) up[ch[u][j]] = (up[u] + 1) % m * pre[j] % m * suf[j + 1] % m;
}
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 1e5 + 5;
ll n, m, par[N], dp[N], up[N];
vector<ll> g[N], ch[N], ord;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> m;
    for (ll i = 1; i < n; i++) {
        ll u, v;
        cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);
    }
    ord.push_back(1);
    for (ll i = 0; i < (ll)ord.size(); i++) {
        ll u = ord[i];
        for (ll v : g[u])
            if (v != par[u]) {
                par[v] = u;
                ch[u].push_back(v);
                ord.push_back(v);
            }
    }
    for (ll i = n - 1; i >= 0; i--) {
        ll u = ord[i];
        dp[u] = 1;
        for (ll c : ch[u]) dp[u] = dp[u] * (dp[c] + 1) % m;
    }
    for (ll i = 0; i < n; i++) {
        ll u = ord[i], k = ch[u].size();
        vector<ll> pre(k + 1, 1), suf(k + 2, 1);
        for (ll j = 0; j < k; j++) pre[j + 1] = pre[j] * (dp[ch[u][j]] + 1) % m;
        for (ll j = k - 1; j >= 0; j--) suf[j] = suf[j + 1] * (dp[ch[u][j]] + 1) % m;
        for (ll j = 0; j < k; j++) up[ch[u][j]] = (up[u] + 1) % m * pre[j] % m * suf[j + 1] % m;
    }
    for (ll u = 1; u <= n; u++) cout << dp[u] * (up[u] + 1) % m << '\n';
}
5 100
1 2
2 3
2 4
4 5

Độ phức tạp: mỗi đỉnh tốn số con của nó: O(n)O(n).

Code trên khai báo hai vector mới ở mỗi đỉnh cho gọn. Khi cây cỡ triệu đỉnh, việc cấp phát ấy đáng kể:

Tích tiền tố, hậu tố của các con, cây 1 triệu đỉnh, trung vị 5 lần chạy, i7-10850H, g++ -O2.mảng dùng chung140 msvector mới cho mỗi đỉnh298 ms · gấp 2,1 lần
Tính tích tiền tố, hậu tố của các con trên cây 1 triệu đỉnh, đo trên i7-10850H, g++ -O2.

Dùng hai mảng toàn cục chung cho mọi đỉnh nhanh hơn khoảng 2 lần. Vòng lặp chạy cỡ triệu lần thì tránh khai báo vector bên trong.

Khi đi thi

  • “Với mỗi đỉnh làm gốc” hay “với mỗi đỉnh, đáp án trên cả cây”: viết quy hoạch động cho một gốc trước, rồi hỏi khi dời gốc qua một cạnh thì phần nào thay đổi.
  • Phép gộp là tổng thì trừ ngược; là max thì giữ hai giá trị lớn nhất; còn lại dùng tiền tố và hậu tố của các con.
  • Dùng thứ tự BFS thay cho đệ quy để khỏi tràn ngăn xếp trên cây hình đường; kiểm công thức dời gốc bằng một chương trình O(n2)O(n^2) trên cây nhỏ.

Tóm tắt

  • Lượt dưới lên tính đáp án cho gốc 1 và thông tin cây con; lượt trên xuống dời gốc qua từng cạnh.
  • Tổng khoảng cách: f(v)=f(u)−szv+(n−szv)f(v) = f(u) - sz_v + (n - sz_v); có trọng số: F(v)=F(u)+S−2wvF(v) = F(u) + S - 2 w_v.
  • Chi phí theo chiều cạnh: dời gốc chỉ đổi chiều nhìn của một cạnh.
  • Phần phía trên upvup_v tính từ cha và các con khác của cha: hai giá trị lớn nhất cho max, tiền tố và hậu tố khi không có phép ngược.

Bài tập

Đọc thêm