Chương 3·Bài 3.2.2

Small to Large Merging

Cây n≤105n \le 10^5 đỉnh gốc 11, mỗi đỉnh có một màu. Với mỗi đỉnh vv, tìm tổng các màu xuất hiện nhiều lần nhất trong cây con của vv, trong O(nlog⁡2n)O(n \log^2 n).

Đếm lại từ đầu cho từng cây con tốn O(n2)O(n^2) 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 log⁡2n\log_2 n 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 cv≤nc_v \le n; với mỗi vv, gọi các màu có số lần xuất hiện lớn nhất trong cây con của vv là màu chiếm ưu thế, và tính tổng của chúng.

Mỗi đỉnh uu 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 uu, cùng hai số: tần suất lớn nhất mxumx_u và tổng các màu đạt tần suất đó smusm_u. Thêm kk lần màu cc vào bảng thì số lần của cc tăng lên ff; nếu f>mxuf > mx_u thì cc là màu chiếm ưu thế duy nhất, còn nếu f=mxuf = mx_u thì cộng thêm cc 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 uu sau khi đã có bảng của mọi con:

  1. Chọn con bb có bảng lớn nhất, lấy luôn bảng đó làm bảng của uu. Trong C++, swap(cnt[u], cnt[b]) đổi hai map trong O(1)O(1), không chép từng phần tử.
  2. Thêm màu của chính uu.
  3. Đổ từng phần tử của bảng các con còn lại vào bảng của uu, rồi xóa bảng đó.
Chứng minh

Xét một lần xuất hiện của màu ở đỉnh xx. 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 nn, nên nó bị chuyển nhiều nhất log⁡2n\log_2 n lần. Có nn lần xuất hiện, nên tổng số lần chuyển là O(nlog⁡n)O(n \log n), mỗi lần tốn O(log⁡n)O(\log 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 n≤105n \le 10^5 đỉnh gốc 11, đỉnh ii có màu ci≤nc_i \le n. 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 uu. Khi đổ bảng của con vv 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 vv để giải phóng bộ nhớ. Tổng các màu có thể tới n⋅n=1010n \cdot n = 10^{10}, nên dùng long long.

Code đầy đủ
#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

Độ phức tạp: O(nlog⁡2n)O(n \log^2 n), với n=105n = 10^5 cần khoảng 3⋅1073 \cdot 10^7 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 n=105n = 10^5 đỉnh, mỗi đỉnh một màu khác nhau, chương trình chạy ra sao?

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 nn, độ sâu tới nn), 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 log⁡\log, thường được gọi là sack (hay DSU on tree).

Gọi con nặng của uu 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 uu là đoạn liền [tinu,toutu][tin_u, tout_u] trong mảng ordord, với ordtord_t là đỉnh có tintin bằng tt. Hàm dfs2(u, keep) để lại trong mảng đếm đúng các đỉnh của cây con uu nếu keep đúng, và để lại mảng rỗng nếu keep sai:

  1. Gọi dfs2(v, false) cho mọi con nhẹ vv: tính xong đáp án của chúng rồi xóa sạch mảng đếm.
  2. Gọi dfs2(h, true) cho con nặng hh: mảng đếm giữ nguyên cây con của hh.
  3. 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 uu. Lúc này mảng đếm chứa đúng cây con của uu; trả lời các câu hỏi gắn với uu.
  4. Nếu keep sai thì xóa mọi đỉnh của cây con uu khỏi mảng đếm.
Cạnh cam nối mỗi đỉnh với con nặng, con có cây con lớn nhất; số nhỏ dưới đỉnh là kích thước cây con.1289511672103411214121812112
Cạnh cam nối mỗi đỉnh với con nặng, con có cây con lớn nhất; số nhỏ dưới đỉnh là kích thước cây con.
Chứng minh

Một đỉnh xx 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à xx nằm trong cây con của một con nhẹ. Đi từ xx 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ừ xx lên gốc có nhiều nhất log⁡2n\log_2 n cạnh nhẹ, và xx bị thêm, xóa O(log⁡n)O(\log n) lần. Tổng cộng O(nlog⁡n)O(n \log n) thao tác trên mảng đếm.

Ví dụHọ Hàng Cùng Bậc

Đề. Rừng n≤105n \le 10^5 người, mỗi người có nhiều nhất một cha. Có m≤105m \le 10^5 câu hỏi (v,p)(v, p): đếm số người khác vv có cùng tổ tiên bậc pp với vv (tổ tiên bậc pp là đỉnh đi lên pp lần từ vv); nếu vv không có tổ tiên bậc pp thì đáp án là 00.

Lời giải. Gọi aa là tổ tiên bậc pp của vv. Những người cùng tổ tiên bậc pp với vv chính là các đỉnh trong cây con của aa có cùng độ sâu với vv. Vậy câu hỏi trở thành: trong cây con của aa, có bao nhiêu đỉnh ở độ sâu depvdep_v; trừ đi 11 cho chính vv.

Để tìm aa, trong lượt DFS đầu tiên ta giữ mảng pathpath, với pathdpath_d là tổ tiên ở độ sâu dd của đỉnh đang xét; khi tới vv thì a=pathdepv−pa = path_{dep_v - p} nếu depv≥pdep_v \ge p. Câu hỏi được gắn vào đỉnh aa. 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 aa, đáp án của câu hỏi là số đỉnh ở độ sâu depvdep_v trừ 11.

Code đầy đủ
#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

Độ phức tạp: O((n+m)log⁡n)O((n + m) \log n), với n=m=105n = m = 10^5 cần khoảng 4⋅1064 \cdot 10^6 phép tính.

Mảng pathpath 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 kk lần”, ngoài mảng cntccnt_c (số lần của màu cc) ta giữ thêm gefge_f, số màu có ít nhất ff lần xuất hiện. Thêm một đỉnh màu cc: tăng cntccnt_c lên ff rồi tăng gefge_f, vì màu cc vừa đạt mốc ff. Bớt một đỉnh màu cc: giảm gecntcge_{cnt_c} trước rồi mới giảm cntccnt_c. Cả hai đều O(1)O(1), và đáp án là gekge_k.

Ví dụMàu Xuất Hiện Đủ Nhiều

Đề. Cây n≤105n \le 10^5 đỉnh gốc 11, màu mỗi đỉnh tới 10510^5. Có q≤105q \le 10^5 câu hỏi (v,k)(v, k): trong cây con của vv, có bao nhiêu màu xuất hiện ít nhất kk 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 (v,k)(v, k) được trả lời ngay khi xử lý xong vv ở bước 3. Chú ý kk có thể lớn hơn mọi số lần xuất hiện, nên mảng gege cần đủ 105+110^5 + 1 ô và các ô lớn mặc định bằng 00.

Code đầy đủ
#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

Độ phức tạp: O((n+q)log⁡n)O((n + q) \log n), với n=q=105n = q = 10^5 cần khoảng 4⋅1064 \cdot 10^6 phép tính.

Hỏi nhanh

Trong hàm del ở trên, một bạn đổi thứ tự thành giảm cntccnt_c trước rồi mới giảm gege ở ô mới: ge[--cnt[c[x]]]--. Chương trình sai ở đâu?

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ứ kk 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 O(nlog⁡n)O(n \log n). 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 n≤105n \le 10^5 người, mỗi người có một tên (tối đa 2020 chữ cái, có thể trùng nhau). Có m≤105m \le 10^5 câu hỏi (v,k)(v, k): đếm số tên khác nhau của các hậu duệ cách vv đúng kk đời.

Lời giải. Các hậu duệ cách vv đúng kk đời là các đỉnh trong cây con của vv có độ sâu depv+kdep_v + k. Đá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 dd một map stdst_d 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ề 00. Khi mảng chứa đúng cây con của vv, đáp án là số tên trong stdepv+kst_{dep_v + k}, tức là kích thước của map đó. Độ sâu depv+kdep_v + k có thể vượt nn, khi đó đáp án là 00.

Mỗi lần thêm, bớt tốn O(log⁡n)O(\log n) vì map, nên cả bài tốn O(nlog⁡2n)O(n \log^2 n), vẫn đủ nhanh với n=105n = 10^5.

Code đầy đủ
#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

Độ phức tạp: O((n+m)log⁡2n)O((n + m) \log^2 n), với n=m=105n = m = 10^5 cần khoảng 3⋅1073 \cdot 10^7 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, set và đổ nhỏ vào lớn.
  • Lấy bảng của con lớn nhất bằng swap (O(1)O(1)), 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 log⁡2n\log_2 n 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 O(log⁡n)O(\log n) lần vì đường lên gốc có O(log⁡n)O(\log n) 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 kk lần bằng mảng gefge_f cập nhật cùng cntccnt_c.
  • Câu hỏi phức tạp hơn đếm thì gộp cây phân đoạn động, tổng O(nlog⁡n)O(n \log n) nút.

Bài tập

Đọc thêm