Kỹ thuật đổi gốc (Rerooting)
Kỹ thuật đổi gốc (rerooting) giải lớp bài toán: tính một đại lượng quy hoạch động trên cây cho mọi đỉnh làm gốc. Ví dụ: tổng khoảng cách từ mỗi đỉnh tới mọi đỉnh khác, khoảng cách xa nhất từ mỗi đỉnh, số tập đỉnh liên thông chứa mỗi đỉnh. Cách ngây thơ chạy lại DFS từ từng gốc tốn ; đổi gốc chỉ cần hai lượt duyệt cây và làm trong (nhân thêm chi phí một phép gộp nếu phép gộp không phải ).
Trang DP trên cây đã có một mục ngắn về đổi gốc với công thức tổng khoảng cách. Trang này đi sâu hơn: vì sao hai lượt là đủ, vì sao phải loại đóng góp của chính đỉnh con, cách xử lý khi phép gộp không có phép ngược (max, min, tích modulo), một khuôn mẫu tổng quát dùng lại được cho nhiều bài, và cách cài đặt không đệ quy cho lớn.
Ý tưởng / Trực giác
Cách ngây thơ và chỗ lãng phí
Giả sử ta đã biết giải bài toán khi cây có một gốc cố định: một DFS tính cho cây con của từ các của con . Muốn có đáp án cho mọi gốc, cách ngây thơ là lặp DFS đó lần: .
Nhưng khi đổi gốc từ sang một đỉnh kề , hình dạng "cây có gốc" gần như không đổi. Chỉ một cạnh bị đảo chiều:
gốc tại p gốc tại v
p v
/ | \ / | \
a b v ===> x y p
/ \ / \
x y a b
Các cây con của vẫn y nguyên. Chỉ có hai "cây con" thay đổi: cây con của (nay là cả cây) và cây con của (nay là cây bỏ đi phần của ). Vì vậy đáp án ở phải tính được từ đáp án ở bằng một lượng công việc nhỏ, không cần duyệt lại cả cây.
Hai lượt: down và up
Treo cây ở đỉnh . Với mỗi đỉnh , gọi là cha của . Cạnh chia cây thành hai phần:
- Phần dưới của : cây con của . Đặt là giá trị DP của phần này, coi là gốc.
- Phần trên của : mọi đỉnh còn lại, tức cây bỏ đi cây con của . Đặt là giá trị DP của phần này, coi là gốc.
1
|
... <- phần trên của v: gốc tại p, KHÔNG chứa cây con của v
| (tổng hợp thành up[v])
p
/ | \
s1 s2 v <- s1, s2: anh em của v
/ \
c1 c2 <- phần dưới của v (down[v])
Khi làm gốc, các "nhánh" của chính là: cây con của từng con (đã có ) và phần trên (có ). Như vậy:
Và phần trên của một con của gồm: , các anh em của , và phần trên của :
Từ đó có thứ tự tính rõ ràng:
- Lượt xuống (từ lá lên gốc): cần của các con, nên duyệt con trước cha. Đây chính là DP trên cây quen thuộc.
- Lượt lên (từ gốc xuống lá): cần của cha và của anh em (đã có từ lượt 1), nên duyệt cha trước con. Đỉnh gốc không có phần trên.
Tên gọi hơi ngược nhưng dễ nhớ: nhìn xuống cây con, nhìn lên phía cha. Thông tin của chảy từ gốc xuống lá.
Vì sao phải loại đóng góp của chính
mô tả phần cây không chứa cây con của . Nếu vô tình gộp cả vào , ta đếm cây con của hai lần: một lần ở phần dưới, một lần "vòng lên cha rồi đi xuống lại chính mình". Với bài đỉnh xa nhất, điều đó tương ứng với một "đường đi" đi lại một cạnh hai lần, không phải đường đi đơn trên cây, và cho kết quả lớn giả (xem ví dụ chạy tay bên dưới).
Nói cách khác: đứng ở , với mỗi con ta cần gộp của tất cả nhánh trừ đúng một nhánh. Đây là câu hỏi cốt lõi của kỹ thuật.
Gộp "tất cả trừ một": khi nào trừ được, khi nào không
Giả sử có nhánh với giá trị (đã qua cạnh) , và phép gộp là .
- Phép gộp có phép ngược (cộng, XOR, nhân với số khác modulo nguyên tố): tính tổng một lần rồi "bỏ" bằng phép ngược. Với tổng khoảng cách, đó là phép trừ, và ta được công thức gọn .
- Phép gộp không có phép ngược (max, min, gcd, tích modulo có thể bằng ): không "trừ" được. Ví dụ bỏ đi một số vẫn là , còn bỏ đi là : chỉ biết giá trị max thì không suy ra được.
Với trường hợp thứ hai, dùng tiền tố / hậu tố:
Chỉ cần kết hợp (associative) và có phần tử trung hòa (identity) để làm , . Không cần giao hoán, không cần phép ngược. Mỗi đỉnh tốn phép gộp, tổng trên cả cây là .
Với max/min còn một mẹo rẻ hơn: giữ hai giá trị tốt nhất và con đạt giá trị tốt nhất. Bỏ đi con : nếu là con tốt nhất thì dùng giá trị tốt nhì, ngược lại dùng giá trị tốt nhất. Mẹo này dựa vào tính chất riêng của max/min (kết quả gộp luôn bằng một trong các phần tử, nên bỏ một phần tử thì đáp án chỉ có thể là tốt nhất hoặc tốt nhì); nó cho cùng kết quả với tiền tố/hậu tố nhưng rẻ hơn về bộ nhớ, và không áp dụng được cho phép gộp tổng quát như gcd hay tích.
Khuôn mẫu: merge, add_edge, finalize
Rất nhiều bài đổi gốc có chung cấu trúc. Tách DP trên cây thành ba thao tác:
| Thao tác | Ý nghĩa |
|---|---|
add_edge(t) |
Nhận giá trị DP của một cây con có gốc , trả về "đóng góp" của nhánh đó khi treo dưới một cạnh vào đỉnh cha. |
merge(a, b) |
Gộp đóng góp của hai nhánh. Phải kết hợp, có phần tử trung hòa IDENTITY (tức là một monoid). |
finalize(acc, v) |
Thêm chính đỉnh vào kết quả gộp các nhánh, ra giá trị DP của cây gốc . |
Khi đó , và đổi gốc chỉ là: tại mỗi đỉnh, danh sách nhánh gồm các con và phần trên; đáp án là gộp tất cả, còn của từng con là gộp tất cả trừ con đó. Ba bài kinh điển viết theo khuôn mẫu:
| Bài | Giá trị DP | IDENTITY |
merge |
add_edge |
finalize |
|---|---|---|---|---|---|
| Đỉnh xa nhất | độ sâu lớn nhất | giữ nguyên | |||
| Tổng khoảng cách | cặp | cộng từng thành phần | |||
| Số tập liên thông chứa gốc | số cách mod | giữ nguyên |
Giải thích dòng cuối: một tập liên thông chứa gốc , với mỗi con , hoặc không lấy gì trong cây con của (1 cách), hoặc lấy một tập liên thông chứa ( cách). Các con độc lập nên nhân lại: .
Ví dụ chạy tay
Dùng chung một cây 5 đỉnh, treo ở đỉnh :
1
/ \
2 3
/ \
4 5
Tổng khoảng cách: đổi gốc bằng phép trừ
Lượt xuống. là số đỉnh cây con, là tổng khoảng cách từ tới các đỉnh trong cây con. Đi qua cạnh , mọi đỉnh trong cây con xa thêm , nên .
| Đỉnh (thứ tự từ lá lên) | ||
|---|---|---|
| 4 | 1 | 0 |
| 5 | 1 | 0 |
| 3 | 3 | |
| 2 | 1 | 0 |
| 1 | 5 |
Lượt lên. Tại gốc, . Dời gốc từ sang con : đỉnh trong cây con gần thêm , còn đỉnh kia xa thêm :
ans[1] = 6
ans[2] = ans[1] + 5 - 2*1 = 9
ans[3] = ans[1] + 5 - 2*3 = 5
ans[4] = ans[3] + 5 - 2*1 = 8
ans[5] = ans[3] + 5 - 2*1 = 8
Kiểm tra : khoảng cách từ tới là , tổng . Ở đây ta không cần tính tường minh vì phép cộng có phép trừ: "tất cả trừ cây con " chính là trừ đi phần của .
Đỉnh xa nhất: tường minh và lỗi quên loại chính mình
Đặt = độ sâu lớn nhất trong cây con (tính từ ), = đường đi dài nhất xuất phát từ mà cạnh đầu tiên đi lên cha. Đáp án và
với quy ước (đứng ở gốc, "phần trên" rỗng, đi được cạnh).
Lưu ý về quy ước. Riêng mục này và đoạn code "Khoảng cách xa nhất từ mỗi đỉnh" bên dưới dùng quy ước đã đi qua cạnh (số hạng ở đầu công thức), vì như vậy gọn hơn. Khuôn mẫu tổng quát và lỗi "Nhầm vai trò của " ở phần sau dùng quy ước chưa qua cạnh ( là DP gốc tại , cạnh được cộng bằng add_edge khi dùng ở ). Hai quy ước đều đúng, không mâu thuẫn; điều quan trọng là chọn một và cộng cạnh đúng một lần.
Lượt xuống: , , . Tại mỗi đỉnh giữ hai giá trị lớn nhất:
| Đỉnh | các | tốt nhất (con) | tốt nhì |
|---|---|---|---|
| 1 | con 2: , con 3: | (con 3) | |
| 3 | con 4: , con 5: | (con 4) |
Lượt lên (từ gốc xuống):
up[2] = 1 + max(up[1]=0, anh em tốt nhất của 2 = 2 (con 3)) = 3
up[3] = 1 + max(up[1]=0, anh em tốt nhất của 3: con 3 là tốt nhất,
nên lấy tốt nhì = 1 (con 2)) = 2
up[4] = 1 + max(up[3]=2, tốt nhì của 3 = 1) = 3
up[5] = 1 + max(up[3]=2, tốt nhất của 3 = 1 (con 4)) = 3
| Đỉnh | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 2 | 0 | 1 | 0 | 0 | |
| 0 | 3 | 2 | 3 | 3 | |
| 2 | 3 | 2 | 3 | 3 |
Nếu quên loại chính mình và luôn lấy "tốt nhất" của cha: , suy ra . Sai, vì giá trị đó đến từ chính nhánh của : "đường đi" đi lại cạnh . Đáp án đúng là .
Tiền tố / hậu tố tại một đỉnh: đếm tập liên thông
Bài: với mỗi đỉnh , đếm số tập đỉnh liên thông chứa . Dùng khuôn mẫu dòng cuối của bảng trên: .
Lượt xuống: , , .
Tại đỉnh 1 (gốc, chỉ có hai nhánh là con và con ):
nhánh i : 1 (con 2) 2 (con 3)
x_i = down+1 : 2 5
pre : 1 2 10 (pre_0 = 1 là phần tử trung hòa)
suf : 10 5 1 (suf_3 = 1)
ans[1] = pre_2 = 10
up[2] = pre_0 * suf_2 = 1 * 5 = 5 (tập liên thông chứa 1, không dùng đỉnh 2)
up[3] = pre_1 * suf_3 = 2 * 1 = 2 (chỉ {1} và {1, 2})
Tại đỉnh 3 (ba nhánh: phần trên qua cha , con , con ):
nhánh i : 1 (cha, up[3]) 2 (con 4) 3 (con 5)
x_i : 2 + 1 = 3 2 2
pre : 1 3 6 12
suf : 12 4 2 1
ans[3] = pre_3 = 12
up[4] = pre_1 * suf_3 = 3 * 2 = 6
up[5] = pre_2 * suf_4 = 6 * 1 = 6
Tiếp tục: , , . Kết quả khớp với vét cạn. Trong bài này nếu đáp án lấy modulo và một , cách "chia cho " sẽ hỏng, còn tiền tố/hậu tố thì không bị ảnh hưởng.
Cài đặt
Duyệt không đệ quy bằng thứ tự BFS
Với cây đường thẳng , DFS đệ quy sâu tầng và dễ tràn stack. Đổi gốc không cần thứ tự DFS thật sự, chỉ cần:
- lượt xuống: con trước cha;
- lượt lên: cha trước con.
Thứ tự BFS từ gốc thỏa "cha trước con", đảo ngược lại thì thỏa "con trước cha". Vì thế mọi chương trình dưới đây đều lấy thứ tự BFS một lần rồi duyệt xuôi, ngược bằng vòng for, không đệ quy.
Tổng khoảng cách tới mọi đỉnh (C++)
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
scanf("%d", &n);
vector<vector<int>> adj(n + 1);
for (int i = 0; i < n - 1; i++) {
int a, b;
scanf("%d %d", &a, &b);
adj[a].push_back(b);
adj[b].push_back(a);
}
// BFS order from root 1: parents always appear before children
vector<int> order, par(n + 1, 0);
order.reserve(n);
order.push_back(1);
par[1] = -1;
for (int i = 0; i < (int)order.size(); i++) {
int u = order[i];
for (int v : adj[u])
if (v != par[u]) { par[v] = u; order.push_back(v); }
}
vector<long long> sz(n + 1, 1), down(n + 1, 0), ans(n + 1, 0);
// Pass 1 (bottom-up): reverse BFS order = children before parent
for (int i = n - 1; i >= 1; i--) {
int v = order[i], p = par[v];
sz[p] += sz[v];
down[p] += down[v] + sz[v]; // every vertex below v gets 1 edge farther
}
// Pass 2 (top-down): move the root across edge p - v
ans[1] = down[1];
for (int i = 1; i < n; i++) {
int v = order[i], p = par[v];
ans[v] = ans[p] + n - 2 * sz[v]; // sz[v] vertices get closer, n - sz[v] get farther
}
for (int v = 1; v <= n; v++) printf("%lld%c", ans[v], v == n ? '\n' : ' ');
}
Tổng khoảng cách (Python)
Python cũng nên tránh đệ quy: giới hạn đệ quy mặc định chỉ khoảng tầng.
import sys
def main():
data = sys.stdin.buffer.read().split()
n = int(data[0])
adj = [[] for _ in range(n + 1)]
for i in range(n - 1):
a, b = int(data[1 + 2 * i]), int(data[2 + 2 * i])
adj[a].append(b)
adj[b].append(a)
# BFS order instead of recursion (no recursion limit issues)
par = [0] * (n + 1)
par[1] = -1
order = [1]
for u in order:
for v in adj[u]:
if v != par[u]:
par[v] = u
order.append(v)
sz = [1] * (n + 1)
down = [0] * (n + 1)
for v in reversed(order[1:]):
p = par[v]
sz[p] += sz[v]
down[p] += down[v] + sz[v]
ans = [0] * (n + 1)
ans[1] = down[1]
for v in order[1:]:
ans[v] = ans[par[v]] + n - 2 * sz[v]
sys.stdout.write(" ".join(map(str, ans[1:])) + "\n")
main()
Khoảng cách xa nhất từ mỗi đỉnh: giữ hai giá trị tốt nhất
Code này dùng quy ước đã đi qua cạnh cha , giống ví dụ chạy tay ở trên.
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
scanf("%d", &n);
vector<vector<int>> adj(n + 1);
for (int i = 0; i < n - 1; i++) {
int a, b;
scanf("%d %d", &a, &b);
adj[a].push_back(b);
adj[b].push_back(a);
}
vector<int> order, par(n + 1, 0);
order.push_back(1);
par[1] = -1;
for (int i = 0; i < (int)order.size(); i++) {
int u = order[i];
for (int v : adj[u])
if (v != par[u]) { par[v] = u; order.push_back(v); }
}
// best1[u], best2[u]: two largest values of down[c] + 1 over children c
// who1[u]: the child that gives best1[u]
vector<int> best1(n + 1, 0), best2(n + 1, 0), who1(n + 1, 0), up(n + 1, 0);
for (int i = n - 1; i >= 1; i--) {
int v = order[i], p = par[v];
int cand = best1[v] + 1; // best1[v] == down[v]
if (cand > best1[p]) { best2[p] = best1[p]; best1[p] = cand; who1[p] = v; }
else if (cand > best2[p]) best2[p] = cand;
}
// up[v]: longest path starting at v whose first edge goes to the parent
up[1] = 0;
for (int i = 1; i < n; i++) {
int v = order[i], p = par[v];
int sibling = (who1[p] == v) ? best2[p] : best1[p]; // exclude v itself
up[v] = 1 + max(up[p], sibling);
}
for (int v = 1; v <= n; v++)
printf("%d%c", max(best1[v], up[v]), v == n ? '\n' : ' ');
}
Lưu ý dòng who1[p] == v: so sánh theo chỉ số con, không so theo giá trị. Nếu hai con cùng đạt giá trị tốt nhất thì best2[p] cũng bằng giá trị đó, nên kết quả vẫn đúng.
Khuôn mẫu tổng quát với tiền tố / hậu tố
Chỉ phần đầu (giữa hai dòng comment Problem-specific) thay đổi theo bài. Ví dụ dưới đây đếm số tập đỉnh liên thông chứa mỗi đỉnh, modulo . Muốn đổi sang bài khác, thay bốn định nghĩa theo bảng ở mục Ý tưởng.
#include <bits/stdc++.h>
using namespace std;
// ---- Problem-specific part: count connected vertex sets containing the root
// dp[v] = prod over children (dp[c] + 1)
const long long MOD = 1'000'000'007LL;
using T = long long; // DP value of a rooted subtree
using A = long long; // accumulator (monoid element)
const A IDENTITY = 1; // neutral element of merge
A merge(A x, A y) { return x * y % MOD; } // associative combine
A add_edge(T child) { return (child + 1) % MOD; } // child subtree hung under an edge
T finalize(A acc, int /*v*/) { return acc; } // add the vertex itself
// ---- End of problem-specific part
int main() {
int n;
scanf("%d", &n);
vector<vector<int>> adj(n + 1);
for (int i = 0; i < n - 1; i++) {
int a, b;
scanf("%d %d", &a, &b);
adj[a].push_back(b);
adj[b].push_back(a);
}
vector<int> order, par(n + 1, 0);
order.push_back(1);
par[1] = 0;
for (int i = 0; i < (int)order.size(); i++) {
int u = order[i];
for (int v : adj[u])
if (v != par[u]) { par[v] = u; order.push_back(v); }
}
// Pass 1: down[v] = DP of the subtree of v (rooted at 1)
vector<T> down(n + 1), up(n + 1), ans(n + 1);
for (int i = n - 1; i >= 0; i--) {
int v = order[i];
A acc = IDENTITY;
for (int c : adj[v]) if (c != par[v]) acc = merge(acc, add_edge(down[c]));
down[v] = finalize(acc, v);
}
// Pass 2: up[v] = DP of the "rest of the tree" rooted at par[v], v's subtree removed
vector<A> contrib, pre, suf;
for (int u : order) {
const vector<int>& nb = adj[u];
int k = nb.size();
contrib.assign(k, IDENTITY);
for (int i = 0; i < k; i++)
contrib[i] = (nb[i] == par[u]) ? add_edge(up[u]) : add_edge(down[nb[i]]);
pre.assign(k + 1, IDENTITY);
suf.assign(k + 1, IDENTITY);
for (int i = 0; i < k; i++) pre[i + 1] = merge(pre[i], contrib[i]);
for (int i = k - 1; i >= 0; i--) suf[i] = merge(contrib[i], suf[i + 1]);
ans[u] = finalize(pre[k], u); // all neighbours are children now
for (int i = 0; i < k; i++) {
int c = nb[i];
if (c == par[u]) continue;
up[c] = finalize(merge(pre[i], suf[i + 1]), u); // everything except c
}
}
for (int v = 1; v <= n; v++) printf("%lld%c", ans[v], v == n ? '\n' : ' ');
}
Hai điểm tinh tế trong khuôn mẫu:
- được lưu dưới dạng giá trị DP (đã
finalizetại ), không phải đóng góp. Khi dùng ở đỉnh , nó lại đi quaadd_edgenhư mọi nhánh khác. Nhờ vậy phần trên và các cây con được đối xử hoàn toàn đối xứng. - Tại gốc,
par[1] = 0không trùng đỉnh nào, nên danh sách nhánh của gốc chỉ gồm các con, đúng như mong muốn.
Để dùng cho tổng khoảng cách, thay phần đầu bằng kiểu cặp:
struct P { long long sum, cnt; };
using T = P; using A = P;
const A IDENTITY = {0, 0};
A merge(A x, A y) { return {x.sum + y.sum, x.cnt + y.cnt}; }
A add_edge(T c) { return {c.sum + c.cnt, c.cnt}; } // every vertex gets 1 edge farther
T finalize(A acc, int) { return {acc.sum, acc.cnt + 1}; } // the root itself: distance 0
và in ans[v].sum. Cả ba biến thể (xa nhất, tổng khoảng cách, tập liên thông) đã được đối chiếu với vét cạn trên cây ngẫu nhiên nhỏ.
Độ phức tạp
- Thời gian . Lấy thứ tự BFS mất . Lượt xuống duyệt mỗi cạnh một lần. Ở lượt lên, đỉnh bậc tốn cho mảng đóng góp, tiền tố, hậu tố và cho các con; tổng . Nếu mỗi
mergetốn (ví dụ gộp mảng độ dài ) thì tổng là . - Bộ nhớ . Danh sách kề phần tử, các mảng , , , , cỡ . Các mảng
pre,sufdùng lại giữa các đỉnh, lớn nhất bằng bậc lớn nhất . Không có stack đệ quy. - So với cách ngây thơ: lần DFS là , với là phép tính, không thể chạy kịp.
⚠️ Lỗi thường gặp
- Không loại đóng góp của chính con khi tính . Triệu chứng: đáp án lớn hơn đúng (bài max) hoặc đếm trùng (bài tổng, bài đếm) ở những đỉnh là "con tốt nhất" của cha. Cách tránh: mọi phải là gộp của tất cả nhánh của cha trừ nhánh ; kiểm tra lại trên ví dụ nhỏ như cây 5 đỉnh ở trên.
- Dùng phép chia modulo để "bỏ" một nhánh. Với tích modulo, có thể chia hết cho (với lớn điều này hoàn toàn có thể xảy ra, và đề dạng này thường có test bẫy). Khi đó không tồn tại nghịch đảo và chia sẽ cho kết quả rác. Cách tránh: dùng tiền tố/hậu tố, hoặc lưu riêng số thừa số bằng cùng tích các thừa số khác .
- Trừ với max/min. Không thể "bỏ" một phần tử khỏi max bằng phép trừ. Dùng hai giá trị tốt nhất kèm chỉ số con, hoặc tiền tố/hậu tố.
- Cập nhật hai giá trị tốt nhất sai. Các biến thể sai hay gặp: chỉ lưu
best1mà không lưubest2(con tốt nhất không có giá trị thay thế); khicand > best1[p]thì gánbest1mới nhưng quên dờibest1cũ xuốngbest2; hoặc chỉ cập nhậtbest2khicand < best1[p](bỏ sót trường hợp hai con bằng nhau, làmbest2nhỏ hơn thực tế). Lời khuyên: cập nhật đúng khuônif (cand > best1) {...} else if (cand > best2) {...}như code trên, và lưuwho1[p]để sowho1[p] == vtheo chỉ số. So theo giá trị (down[v] + 1 == best1[p]thì dùngbest2) cũng cho đáp án đúng khibest2được cập nhật đầy đủ, nhưng so theo chỉ số dễ kiểm tra hơn và vẫn đúng khi mở rộng sang giữ thêm thông tin kèm theo con. - Tính lại "tất cả trừ một" bằng vòng lặp qua mọi anh em. Trông như nhưng thực chất là : cây hình sao cho phép tính, TLE. Phải dùng tiền tố/hậu tố hoặc phép ngược.
- Tràn stack với DFS đệ quy. Cây đường thẳng làm hai lượt đệ quy sâu tầng; nhiều máy chấm chỉ có stack vài MB, bị RTE khó đoán. Dùng thứ tự BFS như trên.
- Tràn số
int. Tổng khoảng cách trên cây đường thẳng tới khoảng . Khai báolong longcho , , và cẩn thận biểu thứcn - 2 * sz[v]khiszlàint(kết quả thì nhỏ, nhưng các phép cộng vàoansphải làlong long). - Chọn sai phần tử trung hòa. Với max của các giá trị có thể âm,
IDENTITYphải là chứ không phải ; nếu không, một đỉnh lá hoặc gốc không có phần trên sẽ nhận giá trị giả. Tương tự, với tích thì trung hòa là , với tổng là . - Nhầm vai trò của . Trong khuôn mẫu tổng quát, là DP của phần trên gốc tại cha , chưa đi qua cạnh . Nếu ở chỗ này đã cộng cạnh, rồi đến lại
add_edgethêm lần nữa, sẽ lệch một cạnh (off-by-one) ở mọi đỉnh trừ gốc. (Mục đỉnh xa nhất dùng quy ước ngược lại, đã qua cạnh, nên ở đó không cộng thêm gì. Dùng quy ước nào cũng được, miễn cạnh được cộng đúng một lần.) - Quên trường hợp . Không có cạnh nào; các vòng
for (int i = 1; i < n; i++)không chạy, đáp án của đỉnh duy nhất phải là giá trị của cây một đỉnh ( khoảng cách, tập liên thông...).
Biến thể / Mở rộng
- Cạnh có trọng số. Cho
add_edgenhận thêm trọng số cạnh:add_edge(t, w). Với tổng khoảng cách có trọng số: ; với đỉnh xa nhất: . Nhớ lưu trọng số cạnh cha của mỗi đỉnh để dùng lại ở lượt lên. - Tổng có trọng số đỉnh. Bài "chi phí lớn nhất" thay bằng tổng trọng số đỉnh trong cây con, công thức chuyển gốc thành với là tổng toàn cây, là tổng trong cây con .
- Moment bậc cao. Tổng bình phương khoảng cách: giữ bộ ba vì . Tổng quát, cần các moment bậc .
- Đáp án theo cạnh. Nhiều bài hỏi "cắt cạnh thì hai nửa ra sao": đó chính là cặp , không cần gì thêm.
- Đổi gốc cho DP dạng mảng. Nếu là một mảng (ví dụ số đỉnh ở mỗi khoảng cách ),
mergetốn và tổng là . Với DP cái túi mà mảng dài bằng kích thước cây con (xem Knapsack trên cây), đổi gốc thường không còn rẻ, vì phần trên có kích thước gần ở mọi đỉnh. - Khi đổi gốc không đủ. Nếu đại lượng phụ thuộc cả hai đầu đường đi theo kiểu không tách được (ví dụ trọng số nhỏ nhất trên đường nhân với bình phương độ dài), thường phải chuyển sang phân rã centroid.
- Đổi gốc cho truy vấn cây con. Khi gốc thay đổi theo truy vấn và cần tổng trên "cây con" theo gốc mới, dùng Euler tour kèm LCA hoặc binary lifting để tìm con của đỉnh về phía gốc mới, rồi lấy phần bù trên mảng Euler. Đó là bài toán khác: ở đây ta cần đáp án cho mọi gốc cùng lúc, còn ở đó mỗi truy vấn một gốc.
- Nền tảng. Nếu chưa quen DP một gốc cố định (tập độc lập, đường kính, ghép cặp), đọc DP trên cây trước.
Bài tập luyện
Luyện kỹ thuật
Danh sách xếp theo độ khó trên hệ thống. Nếu mới làm quen đổi gốc, nên làm treedist2 trước treedist1: công thức phép trừ của treedist2 đơn giản hơn, dù rating của nó cao hơn một chút.
- Khoảng cách xa nhất từ mỗi đỉnh (treedist1) (Nâng cao) phép gộp là max nên không trừ được: luyện cách giữ hai giá trị tốt nhất kèm chỉ số con để tính .
- Tổng khoảng cách từ mỗi đỉnh (treedist2) (Nâng cao) đổi gốc bằng phép trừ, cách tiếp cận dễ nhất để bắt đầu: , chú ý
long long. - Cây có chi phí lớn nhất (treecost) (Kỳ cựu) tổng khoảng cách có trọng số đỉnh: thay kích thước cây con bằng tổng trọng số cây con trong công thức chuyển gốc, rồi lấy max trên mọi đỉnh.
- Sinh Cây Ngẫu Nhiên (randtree) (Kỳ cựu) đưa xác suất về tổng theo mọi gốc của , rồi chia cho ; khi dời gốc qua một cạnh chỉ hai kích thước thay đổi, nên đổi gốc với tích modulo và nghịch đảo (ở đây kích thước luôn khác nên chia được).
- Nâng cấp đường (roadimp) (Chuyên gia) cho mọi thủ đô; có thể chia hết cho nên bắt buộc dùng tiền tố/hậu tố thay cho nghịch đảo.
Đề VOI
Trong kho đề VOI hiện có, chưa có bài nào mà lời giải đầy đủ dựa trên đổi gốc. Bài dưới đây dùng đổi gốc cho một phần các subtask.
- Mạng truyền tin (voi24_netw) (Kỳ cựu) VOI 2024: cần một đại lượng cho mọi máy chủ , nhưng lời giải đầy đủ dùng phân rã centroid. Đổi gốc là chìa khóa cho các subtask trung gian: khi mọi , hãy nghĩ xem mỗi cây con cần giữ những tổng nào để dời gốc qua một cạnh mà vẫn cập nhật được tổng bình phương khoảng cách; khi nhỏ, thử tách thành tổng theo các ngưỡng trọng số.
Bình luận