Binary Lifting
Binary Lifting (nâng nhị phân, còn gọi là Sparse Table trên cây) là kỹ thuật tiền xử lý một cây cố định để trả lời cực nhanh các truy vấn về tổ tiên: tìm tổ tiên thứ của một đỉnh, tìm tổ tiên chung gần nhất (LCA — Lowest Common Ancestor) của hai đỉnh, tính khoảng cách giữa hai đỉnh, hay max/min trọng số trên đường đi.
Cách ngây thơ — leo từng cạnh một lên cây — mất cho mỗi truy vấn (cây hình chuỗi cao tới ). Binary Lifting bỏ ra tiền xử lý để rồi mỗi truy vấn chỉ còn . Với , đây là khác biệt giữa phép tính (quá hạn) và (tức thì).
Ý tưởng / Trực giác
Vấn đề cốt lõi: ta muốn "nhảy lên tầng" trên cây thật nhanh. Quan sát then chốt là mọi số nguyên đều viết được dưới dạng tổng các luỹ thừa của 2 (biểu diễn nhị phân). Ví dụ . Vậy nếu ta biết cách nhảy đúng bước thì để nhảy 13 bước ta chỉ cần ghép 3 cú nhảy: , rồi , rồi .
Số luỹ thừa của 2 không vượt chỉ là , nên mỗi cú "nhảy bước" cần nhiều nhất cú nhảy con.
Để các cú nhảy con thực hiện trong , ta dựng trước bảng:
Điểm đẹp là bảng này tự xây bằng quy hoạch động trên chính nó: nhảy bước = nhảy bước rồi lại nhảy bước:
Nghĩa là: từ leo nửa quãng đường () tới một đỉnh trung gian, rồi từ đó leo tiếp nửa còn lại. Vì , kết quả đúng bằng tổ tiên cách bước.
Vì sao LCA cũng dùng được kỹ thuật này? Ý tưởng LCA hai bước:
- Đưa hai đỉnh về cùng độ sâu bằng cách cho đỉnh sâu hơn nhảy lên (đúng bài toán tổ tiên thứ ).
- Khi đã cùng độ sâu mà chưa trùng nhau, ta cho cả hai cùng nhảy lên từng cú giảm dần. Mẹo: chỉ nhảy khi (hai đỉnh vẫn ở hai nhánh khác nhau). Cứ "nhảy cao nhất mà chưa gặp nhau", cuối cùng cả hai dừng ngay dưới LCA đúng một bước, và chính là LCA. Đây thực chất là tìm kiếm nhị phân trên độ cao: ta dò ranh giới giữa "chưa tới tổ tiên chung" và "đã vượt qua tổ tiên chung".
Ví dụ chạy tay
Xét cây gốc tại :
1 dep
/ \ 0
2 3 1
/ / \ 2
4 5 6
\ 3
7
Quan hệ cha: cha(2)=cha(3)=1; cha(4)=2; cha(5)=cha(6)=3; cha(7)=6.
Bảng up[u][k] sau tiền xử lý (quy ước tổ tiên của gốc là chính nó, đánh số bắt đầu từ 0). Cột chính là cha; cột là "ông" (nhảy 2 bước); nhảy 4 bước:
| (cha) | (2 bước) | (4 bước) | |
|---|---|---|---|
| 1 | 1 | 1 | 1 |
| 2 | 1 | 1 | 1 |
| 3 | 1 | 1 | 1 |
| 4 | 2 | 1 | 1 |
| 5 | 3 | 1 | 1 |
| 6 | 3 | 1 | 1 |
| 7 | 6 | 3 | 1 |
Cách điền ô : . Đúng: từ 7 nhảy 2 bước là .
Truy vấn kth_ancestor(7, 3) — tổ tiên cách đỉnh 7 đúng 3 bước. Phân tích :
k=0 bit=1 -> u = up[7][0] = 6 (đã đi 1 bước: 7 -> 6)
k=1 bit=1 -> u = up[6][1] = 1 (đi thêm 2 bước: 6 -> 3 -> 1)
k>=2 bit=0 -> bỏ qua
kết quả = 1
Kiểm tra: , đúng 3 cạnh. ✓
Truy vấn lca(4, 7): , .
Bước 1 — cân độ sâu: 7 sâu hơn, nhảy lên bước: . Giờ xét cặp , cùng .
Bước 2 — nhảy đồng thời từ lớn xuống nhỏ:
k=2: up[4][2]=1, up[6][2]=1 -> bằng nhau -> KHÔNG nhảy
k=1: up[4][1]=1, up[6][1]=1 -> bằng nhau -> KHÔNG nhảy
k=0: up[4][0]=2, up[6][0]=3 -> khác nhau -> NHẢY: u=2, v=3
Dừng. LCA . ✓ (đường giao tại đỉnh 1).
Cài đặt
Quy ước: đỉnh đánh số từ 1, gốc root. Đặt tổ tiên của gốc là chính nó (up[root][k] = root) để mọi cú nhảy vượt gốc đều "dừng lại" ở gốc — tránh truy cập chỉ số ngoài mảng.
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 2e5 + 5;
const int LOG = 18; // 2^18 = 262144 > 2*10^5
vector<int> adj[MAXN];
int up[MAXN][LOG]; // up[u][k] = tổ tiên cách u đúng 2^k bước
int dep[MAXN];
// DFS lặp/đệ quy điền up và dep. p là cha của u.
void dfs(int u, int p) {
up[u][0] = p; // nhảy 1 bước = cha
for (int k = 1; k < LOG; k++)
up[u][k] = up[ up[u][k-1] ][k-1]; // DP: nửa + nửa
for (int v : adj[u])
if (v != p) { // không quay lại cha
dep[v] = dep[u] + 1;
dfs(v, u);
}
}
// Tổ tiên cách u đúng k bước: tách k theo nhị phân, nhảy từng bit bật
int kth_ancestor(int u, int k) {
for (int i = 0; i < LOG; i++)
if ((k >> i) & 1) // bit thứ i bật?
u = up[u][i];
return u;
}
int lca(int u, int v) {
if (dep[u] < dep[v]) swap(u, v); // u là đỉnh sâu hơn
u = kth_ancestor(u, dep[u] - dep[v]); // cân độ sâu
if (u == v) return u; // v đã là tổ tiên của u
for (int k = LOG - 1; k >= 0; k--) // nhảy cao -> thấp
if (up[u][k] != up[v][k]) { // còn ở hai nhánh -> nhảy
u = up[u][k];
v = up[v][k];
}
return up[u][0]; // cha chung = LCA
}
int dist(int u, int v) {
return dep[u] + dep[v] - 2 * dep[lca(u, v)]; // số cạnh trên đường đi
}
Khởi tạo và gọi:
// Sau khi đọc cây vào adj:
dep[root] = 0;
up[root][0] = root; // tổ tiên của gốc là chính nó (chốt chặn)
dfs(root, root);
Lưu ý đệ quy: với tới và cây hình chuỗi,
dfsđệ quy có thể tràn ngăn xếp (stack overflow). Khi cần, hãy viết DFS khử đệ quy bằng stack, hoặc xử lý đỉnh theo thứ tự BFS rồi điềnup(vì cha luôn được xử lý trước con).
Kiểm tra u có là tổ tiên của v và đỉnh thứ trên đường đi :
bool is_ancestor(int u, int v) {
return dep[u] <= dep[v] && lca(u, v) == u;
}
// Đỉnh thứ k (tính từ 0, bắt đầu tại u) trên đường đi u -> v
int kth_on_path(int u, int v, int k) {
int l = lca(u, v);
int up_len = dep[u] - dep[l]; // số cạnh nhánh u -> l
if (k <= up_len) return kth_ancestor(u, k); // còn trên nhánh u
int down = (dep[u] + dep[v] - 2*dep[l]) - k; // đếm ngược từ v
return kth_ancestor(v, down);
}
Biến thể max trọng số cạnh trên đường đi — gắn thêm bảng mx song song với up:
int mxw[MAXN][LOG]; // mxw[u][k] = max trọng số cạnh trong 2^k bước đi lên từ u
void dfs2(int u, int p, int w) { // w = trọng số cạnh (u, p)
up[u][0] = p;
mxw[u][0] = w;
for (int k = 1; k < LOG; k++) {
up[u][k] = up[ up[u][k-1] ][k-1];
mxw[u][k] = max(mxw[u][k-1], mxw[ up[u][k-1] ][k-1]); // gộp 2 nửa
}
for (auto [v, wv] : adj[u]) // adj lưu cặp (đỉnh, trọng số)
if (v != p) { dep[v] = dep[u] + 1; dfs2(v, u, wv); }
}
int max_on_path(int u, int v) {
int res = INT_MIN;
if (dep[u] < dep[v]) swap(u, v);
int diff = dep[u] - dep[v];
for (int k = 0; k < LOG; k++)
if ((diff >> k) & 1) { res = max(res, mxw[u][k]); u = up[u][k]; }
if (u == v) return res;
for (int k = LOG - 1; k >= 0; k--)
if (up[u][k] != up[v][k]) {
res = max({res, mxw[u][k], mxw[v][k]});
u = up[u][k]; v = up[v][k];
}
return max({res, mxw[u][0], mxw[v][0]}); // cộng nốt 2 cạnh tới LCA
}
Độ phức tạp
- Tiền xử lý — thời gian : với mỗi trong đỉnh ta điền ô của bảng
up, mỗi ô tính trong nhờ công thức DP "nửa + nửa". - Mỗi truy vấn — :
kth_ancestorduyệt bit;lcagọikth_ancestormột lần rồi quét mức một lần nữa, vẫn . - Bộ nhớ — : bảng
upcó phần tử. Với , thì cỡ sốintMB — vừa thoải mái. Mỗi bảng phụ (nhưmxw) tốn thêm chừng đó. Nếu lưu nhiều bảng phụ kiểulong long, hãy canh giới hạn bộ nhớ.
So với cách leo từng cạnh (/truy vấn), tổng chi phí truy vấn giảm từ xuống .
⚠️ Lỗi thường gặp
LOGquá nhỏ. Cần , tức . Nếu mà đặtLOG = 17() thìkth_ancestorvới lớn sẽ nhảy thiếu, LCA sai âm thầm. Quy tắc an toàn:LOG = 18cho ,LOG = 20cho .Quên chốt chặn ở gốc. Nếu để
up[root][0]là 0/giá trị rác thay vìroot, khi nhảy vượt qua gốc bạn truy cậpup[0][...](đỉnh không tồn tại) hoặc nhảy vào vùng nhớ rác → kết quả LCA sai hoặc lỗi truy cập bộ nhớ. Luôn đặtup[root][0] = root.Tràn số khi tính khoảng cách/tổng có trọng số.
distđếm cạnh thìintđủ, nhưng nếu cộng độ dài cạnh (trọng số tới , tổng tới như bàirunaway) phải dùnglong long. Cộngintsẽ tràn và ra số âm. Tách rõ "số cạnh" (int) và "tổng độ dài" (long long).Vòng lặp LCA sai chiều. Bước nhảy đồng thời phải đi từ
k = LOG-1xuống0(cú nhảy lớn trước). Nếu lặp từ nhỏ lên lớn, bạn có thể nhảy vọt qua LCA rồi không sửa lại được → kết quả sai. Lý do: ta đang tìm "đỉnh cao nhất mà hai bên vẫn khác nhau", phải thử bước lớn trước.Đỉnh này là tổ tiên của đỉnh kia. Sau khi cân độ sâu, nếu
u == vphải trả về ngay. Bỏ qua kiểm tra này thì vòng nhảy đồng thời sẽ chạy với hai đỉnh đã trùng và cho ra cha của LCA (sai một mức).Quên cân độ sâu trước. Vòng "nhảy khi
up[u][k] != up[v][k]" chỉ đúng khi cùng độ sâu. Gọi nó khi lệch độ sâu sẽ cho kết quả vô nghĩa.Tràn ngăn xếp do DFS đệ quy. Cây hình chuỗi sâu làm đệ quy đổ. Dùng DFS lặp hoặc thứ tự BFS khi lớn.
Biến thể / Mở rộng
- LCA bằng Euler Tour + Sparse Table: truy vấn sau tiền xử lý, nhưng cài đặt phức tạp hơn. Binary Lifting thắng ở chỗ dễ mở rộng sang "tổ tiên thứ " và "gộp giá trị trên đường đi".
- Nhảy theo điều kiện (tìm kiếm nhị phân trên cây): thay vì nhảy đúng bước, ta nhảy xa nhất mà còn thoả một tính chất đơn điệu — ví dụ bài
runaway: nhảy lên/xuống xa nhất sao cho tổng độ dài . Mẫu: quét từ lớn xuống, nếu cú nhảy không vi phạm thì thực hiện. - Gộp toán tử bất kỳ: bảng phụ có thể lưu max, min, tổng, hoặc tích ma trận — miễn phép gộp có tính kết hợp.
- Cây thay đổi động (thêm/xoá cạnh) thì Binary Lifting không còn hợp; cân nhắc Link-Cut Tree hoặc Heavy-Light Decomposition.
Bài tập luyện
- Tổ tiên chung thấp nhất (LCA) (compqry2) — (Advanced) Bài LCA thuần tuý: dựng
uprồi trả lời truy vấn LCA — đúng "hello world" của Binary Lifting. - Truy vấn khoảng cách (distqry) — (Advanced) Tính khoảng cách hai đỉnh qua công thức , luyện kết hợp LCA với độ sâu.
- Vận Chuyển Phép Thuật (trucktsp) — (Advanced) Maximin trên đường đi: dựng cây khung lớn nhất (Kruskal) rồi Binary Lifting lưu min trọng số cạnh trên đường đi — biến thể "gộp giá trị".
- Cú Bay Đêm (running) — (Veteran) Mỗi con cú đi theo đường đi trên cây; cần LCA để tách đường thành hai nhánh lên/xuống, kết hợp difference-on-tree để đếm.
- Chạy trốn khỏi chuồng (runaway) — (Veteran) Dùng Binary Lifting để nhảy xa nhất theo điều kiện (tổng quãng đường ) — minh hoạ kỹ thuật tìm kiếm nhị phân trên cây với độ dài tới (cẩn thận tràn số).