Cây đỉnh gốc , mỗi đỉnh có một màu. Với mỗi đỉnh , tìm tổng các màu xuất hiện nhiều lần nhất trong cây con của , trong .
Đếm lại từ đầu cho từng cây con tốn trên một cây hình đường thẳng. Thay vào đó, mỗi đỉnh giữ một bảng đếm màu của cây con mình, dựng bằng cách gộp bảng của các con. Mẹo nằm ở chiều gộp: luôn đổ bảng nhỏ vào bảng lớn. Mỗi lần một màu bị chuyển sang bảng khác, bảng mới chứa nó lớn ít nhất gấp đôi bảng cũ, nên một màu chỉ bị chuyển nhiều nhất lần. Cùng ý tưởng đó, nếu giữ lại kết quả của con có cây con lớn nhất và chỉ làm lại phần còn lại, ta được một cách cài nhanh hơn chỉ dùng mảng đếm, rất hợp với các bài thống kê trên mọi cây con.
IĐổ tập nhỏ vào tập lớn
Bài toán ở đầu bài: mỗi đỉnh có màu ; với mỗi , gọi các màu có số lần xuất hiện lớn nhất trong cây con của là màu chiếm ưu thế, và tính tổng của chúng.
Mỗi đỉnh giữ một bảng cnt[u] (một map từ màu tới số lần xuất hiện) của cây con , cùng hai số: tần suất lớn nhất và tổng các màu đạt tần suất đó . Thêm lần màu vào bảng thì số lần của tăng lên ; nếu thì là màu chiếm ưu thế duy nhất, còn nếu thì cộng thêm vào tổng. Số lần xuất hiện chỉ tăng nên không bao giờ phải tìm lại tần suất lớn nhất từ đầu.
Để dựng bảng của sau khi đã có bảng của mọi con:
- Chọn con có bảng lớn nhất, lấy luôn bảng đó làm bảng của . Trong C++,
swap(cnt[u], cnt[b])đổi haimaptrong , không chép từng phần tử. - Thêm màu của chính .
- Đổ từng phần tử của bảng các con còn lại vào bảng của , rồi xóa bảng đó.
Chứng minh
Xét một lần xuất hiện của màu ở đỉnh . Mỗi lần nó bị chuyển sang bảng khác, nó đang nằm trong một bảng không phải lớn nhất, và được đổ vào bảng của con lớn nhất cùng cha; sau khi gộp, bảng chứa nó có kích thước ít nhất gấp đôi bảng cũ. Kích thước bảng không vượt , nên nó bị chuyển nhiều nhất lần. Có lần xuất hiện, nên tổng số lần chuyển là , mỗi lần tốn cho map.
Ở đây kích thước bảng là số màu khác nhau, chưa hẳn là số đỉnh của cây con, nhưng lập luận trên vẫn đúng: một màu bị đổ vào bảng lớn hơn thì số màu của bảng mới ít nhất gấp đôi số màu của bảng chứa nó trước đó.
Ví dụTổng Các Màu Chiếm Ưu Thế
Đề. Cây đỉnh gốc , đỉnh có màu . Với mỗi đỉnh, in tổng các màu chiếm ưu thế trong cây con của nó.
Lời giải. Làm đúng ba bước trên trong một lượt DFS, xử lý các con trước rồi mới tới . Khi đổ bảng của con vào, mỗi cặp (màu, số lần) được thêm một lần với cả số lần đó. Sau khi gộp xong thì xóa bảng của để giải phóng bộ nhớ. Tổng các màu có thể tới , nên dùng long long.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, c[N], mx[N], sm[N], ans[N];
vector<ll> g[N];
map<ll, ll> cnt[N];
void add(ll u, ll col, ll k) {
ll f = (cnt[u][col] += k);
if (f > mx[u]) mx[u] = f, sm[u] = col;
else if (f == mx[u]) sm[u] += col;
}
void dfs(ll u, ll p) {
ll big = 0;
for (ll v : g[u])
if (v != p) {
dfs(v, u);
if (!big || cnt[v].size() > cnt[big].size()) big = v;
}
if (big) {
swap(cnt[u], cnt[big]);
mx[u] = mx[big];
sm[u] = sm[big];
}
add(u, c[u], 1);
for (ll v : g[u])
if (v != p && v != big) {
for (pair<ll, ll> e : cnt[v]) add(u, e.first, e.second);
cnt[v].clear();
}
ans[u] = sm[u];
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) cin >> c[i];
for (ll i = 1; i < n; i++) {
ll a, b;
cin >> a >> b;
g[a].push_back(b);
g[b].push_back(a);
}
dfs(1, 0);
for (ll i = 1; i <= n; i++) cout << ans[i] << (i == n ? '\n' : ' ');
}
7
1 2 2 3 3 3 2
1 2
1 3
2 4
2 5
3 6
3 7
5 3 2 3 3 3 2
Độ phức tạp: , với cần khoảng phép tính.
Hỏi nhanh
Ở bước 1, một bạn chép bảng của con lớn nhất bằng phép gán cnt[u] = cnt[big] thay cho swap. Trên một cây hình đường thẳng đỉnh, mỗi đỉnh một màu khác nhau, chương trình chạy ra sao?
Phép gán chép từng phần tử của bảng nguồn, nên đỉnh ở độ sâu tốn khoảng bước; cộng lại trên cây hình đường thẳng là cỡ . swap chỉ đổi hai con trỏ bên trong, , nên đỉnh nhận bảng của con nặng gần như miễn phí.
Kết quả thì vẫn đúng: phép gán tạo một bản sao riêng, không dùng chung.
IIGiữ lại con nặng
Cách trên cần mỗi đỉnh một map. Khi giá trị nhỏ (màu tới , độ sâu tới ), có một cách chỉ dùng một mảng đếm toàn cục và nhanh hơn một bậc , thường được gọi là sack (hay DSU on tree).
Gọi con nặng của là con có cây con lớn nhất (nhiều đỉnh nhất), các con còn lại là con nhẹ. Ta duyệt cây theo thứ tự Euler (bài 3.2.1) để cây con của là đoạn liền trong mảng , với là đỉnh có bằng . Hàm dfs2(u, keep) để lại trong mảng đếm đúng các đỉnh của cây con nếu keep đúng, và để lại mảng rỗng nếu keep sai:
- Gọi
dfs2(v, false)cho mọi con nhẹ : tính xong đáp án của chúng rồi xóa sạch mảng đếm. - Gọi
dfs2(h, true)cho con nặng : mảng đếm giữ nguyên cây con của . - Thêm lại mọi đỉnh của các con nhẹ (duyệt đoạn Euler của chúng) và thêm chính . Lúc này mảng đếm chứa đúng cây con của ; trả lời các câu hỏi gắn với .
- Nếu
keepsai thì xóa mọi đỉnh của cây con khỏi mảng đếm.
Chứng minh
Một đỉnh chỉ bị thêm hay xóa thêm lần nữa ở bước 3, 4 khi một tổ tiên của nó được xử lý mà nằm trong cây con của một con nhẹ. Đi từ lên gốc, mỗi lần đi qua một cạnh tới con nhẹ thì kích thước cây con ít nhất gấp đôi, vì con nặng ở cùng cha lớn hơn hoặc bằng con nhẹ đó. Vậy trên đường từ lên gốc có nhiều nhất cạnh nhẹ, và bị thêm, xóa lần. Tổng cộng thao tác trên mảng đếm.
Ví dụHọ Hàng Cùng Bậc
Đề. Rừng người, mỗi người có nhiều nhất một cha. Có câu hỏi : đếm số người khác có cùng tổ tiên bậc với (tổ tiên bậc là đỉnh đi lên lần từ ); nếu không có tổ tiên bậc thì đáp án là .
Lời giải. Gọi là tổ tiên bậc của . Những người cùng tổ tiên bậc với chính là các đỉnh trong cây con của có cùng độ sâu với . Vậy câu hỏi trở thành: trong cây con của , có bao nhiêu đỉnh ở độ sâu ; trừ đi cho chính .
Để tìm , trong lượt DFS đầu tiên ta giữ mảng , với là tổ tiên ở độ sâu của đỉnh đang xét; khi tới thì nếu . Câu hỏi được gắn vào đỉnh . Lượt DFS thứ hai là sack với mảng đếm theo độ sâu: khi mảng đếm chứa đúng cây con của , đáp án của câu hỏi là số đỉnh ở độ sâu trừ .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, m, timer, r[N], dep[N], sz[N], heavy[N], tin[N], tout[N], ord[N], path[N], cnt[N], ans[N];
vector<ll> g[N];
vector<pair<ll, ll>> qs[N], at[N];
void dfs1(ll u) {
path[dep[u]] = u;
for (pair<ll, ll> q : qs[u])
if (dep[u] >= q.first) at[path[dep[u] - q.first]].push_back({dep[u], q.second});
sz[u] = 1;
tin[u] = ++timer;
ord[timer] = u;
for (ll v : g[u]) {
dep[v] = dep[u] + 1;
dfs1(v);
sz[u] += sz[v];
if (!heavy[u] || sz[v] > sz[heavy[u]]) heavy[u] = v;
}
tout[u] = timer;
}
void dfs2(ll u, bool keep) {
for (ll v : g[u])
if (v != heavy[u]) dfs2(v, false);
if (heavy[u]) dfs2(heavy[u], true);
for (ll v : g[u])
if (v != heavy[u])
for (ll t = tin[v]; t <= tout[v]; t++) cnt[dep[ord[t]]]++;
cnt[dep[u]]++;
for (pair<ll, ll> q : at[u]) ans[q.second] = cnt[q.first] - 1;
if (!keep)
for (ll t = tin[u]; t <= tout[u]; t++) cnt[dep[ord[t]]]--;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) {
cin >> r[i];
if (r[i]) g[r[i]].push_back(i);
}
cin >> m;
for (ll i = 1; i <= m; i++) {
ll v, p;
cin >> v >> p;
qs[v].push_back({p, i});
}
for (ll i = 1; i <= n; i++)
if (!r[i]) dfs1(i);
for (ll i = 1; i <= n; i++)
if (!r[i]) dfs2(i, false);
for (ll i = 1; i <= m; i++) cout << ans[i] << (i == m ? '\n' : ' ');
}
8
0 1 1 2 2 0 6 6
6
4 1
4 2
2 2
7 1
6 1
3 1
1 1 0 1 0 1
Độ phức tạp: , với cần khoảng phép tính.
Mảng dùng chung cho mọi cây của rừng, vì mỗi cây được duyệt riêng và các ô chỉ được đọc sau khi đã ghi trong cùng lượt duyệt. Mỗi cây được gọi dfs2(gốc, false) để mảng đếm sạch trước khi sang cây sau.
IIITruy vấn ngoại tuyến trên cây con
Sack đặc biệt hợp với các câu hỏi biết trước (ngoại tuyến) trên cây con: gom câu hỏi theo đỉnh, và khi mảng đếm vừa chứa đúng cây con của đỉnh đó thì trả lời. Ta chỉ cần biết cách thêm và bớt một đỉnh, và đọc đáp án từ các mảng đang giữ.
Một mẹo hay gặp: khi câu hỏi là “có bao nhiêu màu xuất hiện ít nhất lần”, ngoài mảng (số lần của màu ) ta giữ thêm , số màu có ít nhất lần xuất hiện. Thêm một đỉnh màu : tăng lên rồi tăng , vì màu vừa đạt mốc . Bớt một đỉnh màu : giảm trước rồi mới giảm . Cả hai đều , và đáp án là .
Ví dụMàu Xuất Hiện Đủ Nhiều
Đề. Cây đỉnh gốc , màu mỗi đỉnh tới . Có câu hỏi : trong cây con của , có bao nhiêu màu xuất hiện ít nhất lần.
Lời giải. Sack như ví dụ trên, với thao tác thêm, bớt một đỉnh như vừa mô tả. Câu hỏi được trả lời ngay khi xử lý xong ở bước 3. Chú ý có thể lớn hơn mọi số lần xuất hiện, nên mảng cần đủ ô và các ô lớn mặc định bằng .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, q, timer, c[N], sz[N], heavy[N], tin[N], tout[N], ord[N], cnt[N], ge[N], ans[N];
vector<ll> g[N];
vector<pair<ll, ll>> qs[N];
void dfs1(ll u, ll p) {
sz[u] = 1;
tin[u] = ++timer;
ord[timer] = u;
for (ll v : g[u])
if (v != p) {
dfs1(v, u);
sz[u] += sz[v];
if (!heavy[u] || sz[v] > sz[heavy[u]]) heavy[u] = v;
}
tout[u] = timer;
}
void add(ll x) {
ge[++cnt[c[x]]]++;
}
void del(ll x) {
ge[cnt[c[x]]--]--;
}
void dfs2(ll u, ll p, bool keep) {
for (ll v : g[u])
if (v != p && v != heavy[u]) dfs2(v, u, false);
if (heavy[u]) dfs2(heavy[u], u, true);
for (ll v : g[u])
if (v != p && v != heavy[u])
for (ll t = tin[v]; t <= tout[v]; t++) add(ord[t]);
add(u);
for (pair<ll, ll> e : qs[u]) ans[e.second] = ge[e.first];
if (!keep)
for (ll t = tin[u]; t <= tout[u]; t++) del(ord[t]);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> q;
for (ll i = 1; i <= n; i++) cin >> c[i];
for (ll i = 1; i < n; i++) {
ll a, b;
cin >> a >> b;
g[a].push_back(b);
g[b].push_back(a);
}
for (ll i = 1; i <= q; i++) {
ll v, k;
cin >> v >> k;
qs[v].push_back({k, i});
}
dfs1(1, 0);
dfs2(1, 0, true);
for (ll i = 1; i <= q; i++) cout << ans[i] << '\n';
}
7 6
1 2 1 2 3 1 3
1 2
1 3
2 4
2 5
3 6
3 7
1 1
1 2
2 2
3 2
4 1
3 4
3
3
1
1
1
0
Độ phức tạp: , với cần khoảng phép tính.
Hỏi nhanh
Trong hàm del ở trên, một bạn đổi thứ tự thành giảm trước rồi mới giảm ở ô mới: ge[--cnt[c[x]]]--. Chương trình sai ở đâu?
Khi màu có lần xuất hiện và bị bớt một lần, nó không còn đạt mốc nữa nhưng vẫn đạt mốc . Vì vậy phải giảm , tức là đọc trước khi giảm. Viết ngược lại thì bị giảm oan, còn vẫn đếm cả màu .
Mảng không bao giờ âm vì ta chỉ bớt những đỉnh đã thêm. Lỗi cũng không chỉ nằm ở : mọi mốc đều có thể bị lệch.
IVGộp cây phân đoạn
Khi câu hỏi trên cây con phức tạp hơn đếm, chẳng hạn “phần tử nhỏ thứ trong cây con”, ta có thể cho mỗi đỉnh một cây phân đoạn trên miền giá trị (bài 1.8.1), chỉ tạo những nút thật sự dùng tới, rồi gộp cây của các con vào cha bằng một hàm đệ quy: gộp hai nút cùng vị trí thì gộp hai con trái, gộp hai con phải; một bên rỗng thì lấy luôn bên kia. Mỗi lần gộp hai nút có thật làm mất đi một nút, nên tổng chi phí của mọi lần gộp không vượt quá tổng số nút đã tạo, tức . Cách này dài hơn sack, nên chỉ dùng khi bảng đếm không đủ để trả lời câu hỏi.
Bài nâng cao
Xem 1 bài nâng cao
Ví dụTên Khác Nhau Ở Đúng Một Tầng
Đề. Rừng người, mỗi người có một tên (tối đa chữ cái, có thể trùng nhau). Có câu hỏi : đếm số tên khác nhau của các hậu duệ cách đúng đời.
Lời giải. Các hậu duệ cách đúng đời là các đỉnh trong cây con của có độ sâu . Đánh số lại các tên bằng một map từ xâu sang số. Sack với mỗi độ sâu một map từ tên tới số lần xuất hiện trong cây con đang xét; thêm đỉnh thì tăng, bớt đỉnh thì giảm và xóa tên khi số lần về . Khi mảng chứa đúng cây con của , đáp án là số tên trong , tức là kích thước của map đó. Độ sâu có thể vượt , khi đó đáp án là .
Mỗi lần thêm, bớt tốn vì map, nên cả bài tốn , vẫn đủ nhanh với .
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, m, timer, r[N], nm[N], dep[N], sz[N], heavy[N], tin[N], tout[N], ord[N], ans[N];
vector<ll> g[N];
vector<pair<ll, ll>> qs[N];
map<string, ll> id;
map<ll, ll> st[2 * N];
void dfs1(ll u) {
sz[u] = 1;
tin[u] = ++timer;
ord[timer] = u;
for (ll v : g[u]) {
dep[v] = dep[u] + 1;
dfs1(v);
sz[u] += sz[v];
if (!heavy[u] || sz[v] > sz[heavy[u]]) heavy[u] = v;
}
tout[u] = timer;
}
void add(ll x) {
st[dep[x]][nm[x]]++;
}
void del(ll x) {
if (--st[dep[x]][nm[x]] == 0) st[dep[x]].erase(nm[x]);
}
void dfs2(ll u, bool keep) {
for (ll v : g[u])
if (v != heavy[u]) dfs2(v, false);
if (heavy[u]) dfs2(heavy[u], true);
for (ll v : g[u])
if (v != heavy[u])
for (ll t = tin[v]; t <= tout[v]; t++) add(ord[t]);
add(u);
for (pair<ll, ll> q : qs[u]) {
ll d = dep[u] + q.first;
ans[q.second] = d < 2 * N ? st[d].size() : 0;
}
if (!keep)
for (ll t = tin[u]; t <= tout[u]; t++) del(ord[t]);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) {
string s;
cin >> s >> r[i];
if (!id.count(s)) {
ll k = id.size() + 1;
id[s] = k;
}
nm[i] = id[s];
if (r[i]) g[r[i]].push_back(i);
}
cin >> m;
for (ll i = 1; i <= m; i++) {
ll v, k;
cin >> v >> k;
qs[v].push_back({k, i});
}
for (ll i = 1; i <= n; i++)
if (!r[i]) dfs1(i);
for (ll i = 1; i <= n; i++)
if (!r[i]) dfs2(i, false);
for (ll i = 1; i <= m; i++) cout << ans[i] << '\n';
}
8
anna 0
bob 1
bob 1
cara 2
dora 2
cara 3
emma 0
bob 7
6
1 1
1 2
2 1
3 1
7 1
4 2
1
2
2
1
1
0
Độ phức tạp: , với cần khoảng phép tính.
Khi đi thi
- Bài “với mỗi cây con, thống kê một điều gì đó” mà không có cập nhật: nghĩ tới sack hoặc đổ tập nhỏ vào lớn trước khi nghĩ tới cấu trúc phức tạp. Giá trị nhỏ thì dùng mảng đếm toàn cục với sack; giá trị lớn hay cần thứ tự thì dùng
map,setvà đổ nhỏ vào lớn. - Lấy bảng của con lớn nhất bằng
swap(), không bằng phép gán; đổ xong bảng nhỏ thì xóa nó đi. - Với sack, nhớ rằng chỉ con nặng được giữ lại; mọi con nhẹ đều bị xóa rồi thêm lại, và thứ tự “con nhẹ trước, con nặng sau” là bắt buộc.
Tóm tắt
- Đổ tập nhỏ vào tập lớn: mỗi phần tử bị chuyển nhiều nhất lần, vì mỗi lần chuyển bảng chứa nó ít nhất gấp đôi.
- Sack giữ lại con nặng, thêm lại các con nhẹ theo đoạn Euler; mỗi đỉnh bị thêm, xóa lần vì đường lên gốc có cạnh nhẹ.
- Câu hỏi biết trước trên cây con được gom theo đỉnh và trả lời khi mảng đếm chứa đúng cây con đó.
- Đếm số màu đạt ít nhất lần bằng mảng cập nhật cùng .
- Câu hỏi phức tạp hơn đếm thì gộp cây phân đoạn động, tổng nút.
Bài tập
Đọc thêm
- [Tutorial] Sack (dsu on tree)Codeforces
- Small-To-Large MergingUSACO Guide
- 树上启发式合并OI Wiki
- 线段树合并OI Wiki