Sqrt Decomposition
Sqrt Decomposition (phân rã căn bậc hai, hay "phân khối") là kỹ thuật chia mảng thành các khối (block) liên tiếp kích thước , và lưu sẵn thông tin tổng hợp (tổng, min, max, ...) của từng khối. Nhờ đó, một truy vấn đoạn hay một cập nhật được trả lời trong thay vì ngây thơ — cài đặt đơn giản hơn Segment Tree nhưng vẫn đủ mạnh cho rất nhiều bài toán truy vấn đoạn.
Ý tưởng / Trực giác
Hãy nghĩ về một truy vấn tổng đoạn . Cách ngây thơ duyệt từng phần tử mất mỗi truy vấn. Segment Tree giảm còn nhưng phải cài đặt cây đệ quy. Sqrt decomposition là điểm cân bằng ở giữa: trả giá nhưng đổi lại cài đặt rất ngắn và dễ tổng quát hoá.
Cốt lõi nằm ở một sự đánh đổi tối ưu. Chia mảng thành các khối cỡ , ta có hai loại đối tượng:
- Khối hoàn chỉnh (nằm trọn trong ): có khối, mỗi khối đã có sẵn giá trị tổng hợp nên trả lời tức thời .
- Phần lẻ ở hai đầu (rìa trái và rìa phải): mỗi đầu nằm gọn trong một khối nên có nhiều nhất phần tử, ta duyệt tay từng phần tử.
Tổng chi phí một truy vấn là . Đây là tổng của hai đại lượng có tích cố định (), nên theo bất đẳng thức AM-GM nó nhỏ nhất khi , tức , cho chi phí . Đó chính là lý do con số "căn " xuất hiện: nó cân bằng hoàn hảo giữa "số khối phải gộp" và "số phần tử lẻ phải duyệt".
Vì sao đúng? Đoạn luôn phân tách duy nhất thành: một mẩu lẻ đầu + một dãy khối hoàn chỉnh ở giữa + một mẩu lẻ cuối. Phép gộp (tổng, min, ...) chỉ cần có tính kết hợp (associative) là ta được phép cộng dồn các mảnh này theo bất kỳ thứ tự nào mà kết quả không đổi. Tổng, XOR, min, max, gcd đều thoả mãn.
Ví dụ chạy tay
Xét mảng , chọn (vì ), bài toán tổng đoạn:
chỉ số: 0 1 2 | 3 4 5 | 6 7 8
a[]: 3 2 4 | 5 1 1 | 5 3 2
khối 0 | khối 1 | khối 2
block_sum: 9 | 7 | 10
(3+2+4) | (5+1+1) | (5+3+2)
Truy vấn tổng : khối của là , khối của là . Vì , ta tách ba phần:
[l=1...........r=7]
a[]: 3 [ 2 4 ]( 5 1 1 )[ 5 3 ] 2
^^^^^^^ ^^^^^^^^^^^ ^^^^^
rìa trái khối GIỮA rìa phải
duyệt tay block_sum duyệt tay
- Rìa trái: các chỉ số từ đến hết khối 0 (chỉ số 2): .
- Khối giữa hoàn chỉnh: chỉ có khối 1, lấy thẳng . (không duyệt phần tử!)
- Rìa phải: từ đầu khối 2 (chỉ số 6) đến : .
Kết quả . Kiểm tra lại: . Đúng.
Cập nhật điểm (đang là 1): chỉ cần sửa khối chứa nó, :
block_sum[1] += (10 - a[4]) = (10 - 1) = +9 → block_sum[1] = 7 + 9 = 16
a[4] = 10
a[]: 3 2 4 | 5 10 1 | 5 3 2
block_sum: 9 | 16 | 10
Mọi truy vấn sau đó tự động phản ánh thay đổi vì khối 1 đã được cập nhật. Cả cập nhật lẫn truy vấn đều chỉ chạm tới ô.
Cài đặt
1. Tổng đoạn + Cập nhật điểm (Range Sum + Point Update)
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 2e5 + 5;
long long a[MAXN], block_sum[1000]; // block_sum[b] = tổng các phần tử của khối b
int B; // kích thước khối
void build(int n) {
B = max(1, (int)sqrt(n)); // B ~ sqrt(N) cân bằng hai chi phí
for (int b = 0; b < 1000; b++) block_sum[b] = 0;
for (int i = 0; i < n; i++)
block_sum[i / B] += a[i]; // i/B = chỉ số khối chứa phần tử i
}
// gán a[i] = val
void update(int i, long long val) {
block_sum[i / B] += val - a[i]; // chỉ chỉnh đúng khối chứa i, O(1)
a[i] = val;
}
// tổng đoạn [l, r]
long long query(int l, int r) {
long long ans = 0;
int bl = l / B, br = r / B;
if (bl == br) { // cùng một khối: duyệt thẳng, không có khối giữa
for (int i = l; i <= r; i++) ans += a[i];
} else {
// rìa trái: từ l đến hết khối bl
for (int i = l; i < (bl + 1) * B; i++) ans += a[i];
// các khối hoàn chỉnh ở giữa: lấy sẵn tổng khối
for (int b = bl + 1; b < br; b++) ans += block_sum[b];
// rìa phải: từ đầu khối br đến r
for (int i = br * B; i <= r; i++) ans += a[i];
}
return ans;
}
2. Cập nhật đoạn + Truy vấn đoạn (lazy theo khối)
Khi cần cộng giá trị lên cả một đoạn, ta gắn một nhãn lười (lazy tag) cho mỗi khối: với khối hoàn chỉnh chỉ cần ghi vào nhãn thay vì sửa từng phần tử.
long long lazy[1000]; // lazy[b] = giá trị cộng thêm ngầm cho mọi phần tử của khối b
// cộng val cho mọi phần tử trong [l, r]
void range_add(int l, int r, long long val) {
int bl = l / B, br = r / B;
if (bl == br) {
for (int i = l; i <= r; i++) a[i] += val; // sửa tay phần lẻ
block_sum[bl] += val * (r - l + 1);
} else {
for (int i = l; i < (bl + 1) * B; i++) a[i] += val;
block_sum[bl] += val * ((bl + 1) * B - l);
for (int b = bl + 1; b < br; b++) { // khối giữa: chỉ ghi nhãn lười
lazy[b] += val;
block_sum[b] += val * (long long)B;
}
for (int i = br * B; i <= r; i++) a[i] += val;
block_sum[br] += val * (r - br * B + 1);
}
}
// tổng đoạn [l, r], nhớ cộng nhãn lười khi đọc phần tử lẻ
long long range_query(int l, int r) {
long long ans = 0;
int bl = l / B, br = r / B;
if (bl == br) {
for (int i = l; i <= r; i++) ans += a[i] + lazy[bl];
} else {
for (int i = l; i < (bl + 1) * B; i++) ans += a[i] + lazy[bl];
for (int b = bl + 1; b < br; b++) ans += block_sum[b]; // block_sum đã gồm lazy
for (int i = br * B; i <= r; i++) ans += a[i] + lazy[br];
}
return ans;
}
Lưu ý mấu chốt: với khối hoàn chỉnh ta dồn cập nhật vào lazy[b] (và đồng bộ block_sum[b]), còn khi đọc phần tử lẻ trong khối /, giá trị thật của nó là a[i] + lazy[khối].
Độ phức tạp
| Thao tác | Thời gian | Vì sao |
|---|---|---|
build |
duyệt một lượt cộng vào khối | |
| Cập nhật điểm | chỉ chạm đúng một khối | |
| Truy vấn đoạn | cho hai rìa + khối giữa, cân tại | |
| Cập nhật đoạn (lazy) | hai rìa duyệt tay , khối giữa ghi nhãn |
Bộ nhớ: cho mảng gốc cộng cho mảng khối (block_sum, lazy). Tổng vẫn là .
Với thì , nên truy vấn tốn cỡ phép tính — chạy thoải mái trong vài giây.
⚠️ Lỗi thường gặp
- Tràn số (overflow). Với và , tổng một đoạn có thể tới , vượt
int. Khai báoblock_sum, biếnansvà mảng giá trị bằnglong long. Đây là lỗi WA kinh điển của bài tổng đoạn. - Quên trường hợp . Khi và nằm cùng một khối thì không có khối hoàn chỉnh ở giữa, vòng
for (b = bl+1; b < br; b++)không chạy nhưng hai vòng rìa lại chồng lấn và đếm trùng/sai. Bắt buộc tách riêng nhánh "cùng khối" duyệt thẳng từ đến . - Sai biên giữa rìa và khối. Rìa trái phải chạy đến hết khối , tức
i < (bl+1)*B, còn rìa phải bắt đầu từ đầu khối , tứci = br*B. Lẫn lộn(bl+1)*Bvớibl*Bgây bỏ sót hoặc đếm lặp phần tử ngay tại ranh giới khối — bug rất khó thấy vì chỉ sai ở vài chỉ số biên. - Khối cuối tràn ra ngoài mảng. thường không chia hết cho nên khối cuối ngắn hơn. Mọi vòng lặp theo khối phải chặn chỉ số
< n(vdmin(n, (b+1)*B)), nếu không sẽ đọc/cộng rác ngoài mảng. - Lazy không đồng bộ với
block_sum. Trong biến thể cộng đoạn, khi ghilazy[b] += valphải đồng thờiblock_sum[b] += val*B; và khi đọc phần tử lẻ phải cộnglazy[khối]. Quên một trong hai khiến truy vấn lệch đúng bằng phần lazy chưa áp. - khi nhỏ.
(int)sqrt(n)có thể ra 0 khi rất bé, gây chia cho 0 ởi / B. LuônB = max(1, (int)sqrt(n)).
Biến thể / Mở rộng
- Gán đoạn + min đoạn (range assign + range min): dùng nhãn "assign" cho khối hoàn chỉnh; khi chạm phần lẻ phải "đẩy nhãn" (push down) bằng cách áp nhãn xuống từng phần tử rồi xoá nhãn, sau đó xây lại thông tin khối. Đây là điểm sqrt decomposition linh hoạt hơn Segment Tree với những phép gộp khó kết hợp lazy.
- Mo's Algorithm: sắp xếp các truy vấn offline theo (khối của , rồi ). Hai con trỏ dịch tổng cộng lần, giải được loại bài "đếm số phần tử phân biệt / tần suất trong đoạn" mà Segment Tree khó xử lý.
- Phân khối trên giá trị (sqrt trên trục giá trị): thay vì chia theo chỉ số, chia miền giá trị thành khối để hỗ trợ truy vấn kiểu "phần tử nhỏ hơn ", "phần tử thứ ".
Chia căn theo bậc trên đồ thị
Ý tưởng "chia căn" không chỉ dùng cho mảng. Trên đồ thị đỉnh, cạnh, ta chọn ngưỡng và chia đỉnh làm hai loại:
- Đỉnh nặng: bậc . Vì tổng bậc là nên có không quá đỉnh nặng.
- Đỉnh nhẹ: bậc , duyệt hết hàng xóm của nó chỉ tốn .
Mẫu dùng phổ biến: với đỉnh nhẹ thì làm "thô" (duyệt hết hàng xóm), còn đỉnh nặng thì xử lý riêng vì chúng rất ít. Ví dụ khi cần "cập nhật giá trị một đỉnh, hỏi tổng trên các hàng xóm của một đỉnh": đỉnh nhẹ đẩy phần chênh lệch (giá trị mới trừ giá trị cũ) sang mọi hàng xóm ( bước), còn khi truy vấn thì lấy phần đã được đẩy sẵn và kéo thêm từ các hàng xóm nặng ( bước). Mỗi thao tác .
⚠️ Với phép max thì cách đẩy chỉ đúng khi giá trị mỗi đỉnh chỉ tăng (ví dụ mảng "kết quả tốt nhất" trong QHĐ): đẩy mới lên hàng xóm luôn an toàn. Nếu giá trị có thể giảm thì không gỡ được giá trị cũ đã đẩy vào max của hàng xóm, phải đổi cấu trúc (ví dụ mỗi đỉnh giữ một multiset hoặc heap có xoá).
Liệt kê tam giác trong
Duyệt thẳng "với mỗi đỉnh, xét mọi cặp hàng xóm" tốn , có thể lên (đồ thị hình sao). Mẹo là định hướng cạnh:
- Xếp hạng đỉnh theo cặp , đảm bảo mọi đỉnh có hạng khác nhau.
- Mỗi cạnh được định hướng từ đỉnh hạng thấp sang đỉnh hạng cao. Đồ thị thu được không có chu trình (hạng tăng dần dọc theo cạnh).
- Mỗi tam giác với hạng trở thành đúng ba cung , , . Vậy với mỗi : đánh dấu mọi thuộc , rồi với mỗi và mỗi , nếu được đánh dấu thì là một tam giác. Mỗi tam giác được đếm đúng một lần (tại đỉnh hạng thấp nhất).
Vì sao mỗi đỉnh có tối đa cạnh ra? Giả sử có cạnh ra. Mọi đích của các cạnh này có hạng cao hơn nên . Vậy tổng bậc ít nhất là , mà tổng bậc bằng , suy ra . Nói theo ngôn ngữ nặng/nhẹ: đỉnh nhẹ có ít cạnh ra vì bậc đã nhỏ; đỉnh nặng chỉ trỏ sang đỉnh nặng khác, mà chỉ có đỉnh nặng.
Chi phí: vòng trong chạy . Phần đánh dấu tốn . Với thì trong trường hợp xấu nhất, còn thực tế thường nhanh hơn nhiều.
Ví dụ chạy tay. Đồ thị 5 đỉnh, cạnh 1-2, 1-3, 2-3, 2-4, 3-4, 3-5, 4-5:
bac: deg1=2 deg2=3 deg3=4 deg4=3 deg5=2
hang (deg, id) tang dan: 1 < 5 < 2 < 4 < 3
cung da dinh huong out(u)
1->2 1->3 out(1) = {2, 3}
2->3 2->4 out(2) = {3, 4}
4->3 out(4) = {3}
5->3 5->4 out(5) = {3, 4}
out(3) = {}
u=1: danh dau {2,3}; v=2: w=3 co dau -> tam giac (1,2,3); w=4 khong
u=2: danh dau {3,4}; v=4: w=3 co dau -> tam giac (2,4,3)
u=4: danh dau {3}; v=3: out rong
u=5: danh dau {3,4}; v=4: w=3 co dau -> tam giac (5,4,3)
Tong: 3 tam giac
Để ý đỉnh 3 bậc lớn nhất có 0 cạnh ra, nên dù nó kề 4 đỉnh, ta không bao giờ duyệt cặp hàng xóm của nó.
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, m;
scanf("%d %d", &n, &m);
vector<int> eu(m), ev(m), deg(n + 1, 0);
for (int i = 0; i < m; i++) {
scanf("%d %d", &eu[i], &ev[i]);
deg[eu[i]]++; deg[ev[i]]++;
}
// rank by (degree, index): orient every edge from lower rank to higher rank
auto lower = [&](int a, int b) {
return deg[a] < deg[b] || (deg[a] == deg[b] && a < b);
};
vector<vector<int>> out(n + 1);
for (int i = 0; i < m; i++) {
int a = eu[i], b = ev[i];
if (lower(a, b)) out[a].push_back(b);
else out[b].push_back(a);
}
vector<int> mark(n + 1, 0);
long long triangles = 0;
for (int u = 1; u <= n; u++) {
for (int v : out[u]) mark[v] = u; // tag out-neighbours of u
for (int v : out[u])
for (int w : out[v])
if (mark[w] == u) triangles++; // u->v, v->w, u->w
}
printf("%lld\n", triangles);
return 0;
}
Code giả sử đồ thị đơn (không khuyên, không cạnh lặp); nếu đề có cạnh lặp thì phải loại trùng trước. Mảng mark ghi số hiệu làm "dấu thời gian" nên không cần xoá sau mỗi lượt. Đoạn code đã được đối chiếu với brute force trên 1000 đồ thị ngẫu nhiên. Muốn liệt kê thay vì đếm, chỉ cần in bộ tại chỗ triangles++.
Ứng dụng: đếm và tìm chu trình ngắn
- Chu trình độ dài 4. Mỗi chu trình có đúng một đỉnh hạng cao nhất . Với mỗi , duyệt các đường mà cả và đều có hạng thấp hơn , đếm số lần tới mỗi bằng mảng
cnt. Hai đường khác nhau tới cùng cho một chu trình; tổng số chu trình là (cộng dồn qua mọi ). Sau mỗi phải đưacntvề 0, nhưng chỉ ở các đã chạm (lưu danh sách các đó, hoặc dùng dấu thời gian như mảngmarkở trên), không được xoá cả mảng. Chi phí: mỗi cạnh với hạng thấp hơn tốn bước, mà nên đó là ; tổng (tính mỗi cạnh vào đầu mút hạng thấp : tổng bằng nhờ bổ đề số cạnh ra ở trên). Nếu chỉ cần tìm một chu trình thì dừng ngay lần thứ hai chạm . - Truy vấn "hai đỉnh có kề nhau không" hàng loạt: cạnh (nếu có) nằm trong của đầu mút hạng thấp hơn, nên chỉ cần quét của đỉnh đó, cỡ mỗi truy vấn (hoặc sắp xếp rồi chặt nhị phân).
- Đẩy/kéo nặng nhẹ như đã mô tả ở đầu mục: cập nhật giá trị một đỉnh và hỏi tổng (hoặc max, nếu giá trị chỉ tăng) trên hàng xóm, mỗi thao tác .
⚠️ Lưu ý: phải so sánh theo cặp chứ không chỉ theo bậc. Nếu chỉ dùng bậc, hai đỉnh cùng bậc không biết định hướng cạnh ra sao; định hướng tuỳ tiện có thể sinh chu trình có hướng và làm đếm lặp hoặc sót tam giác.
Bài tập luyện
- Truy Vấn XOR Đoạn (rxorquery) — (Trung cấp) Tổng-XOR đoạn tĩnh: bài nhập môn để luyện đúng khung "rìa lẻ + khối giữa" với phép gộp kết hợp.
- Truy Vấn Tổng Đoạn Động (dynrsum) — (Trung cấp) Tổng đoạn + cập nhật điểm: bài kinh điển nhất của sqrt decomposition, cũng để rèn phản xạ dùng
long long. - Truy Vấn Nhỏ Nhất Đoạn Động (dynrmin) — (Trung cấp) Đổi phép gộp từ tổng sang min: luyện cách lưu min từng khối và xử lý cập nhật điểm khi giá trị tăng/giảm.
- Nhóm Bạn Bò (cowdep) — (Kỳ cựu) Kết hợp phân khối với hai con trỏ / chặt nhị phân, tập áp dụng sqrt decomposition vào bài tổ hợp không hiển nhiên.
- Truy Vấn Bầu Cử (electqry) — (Kỳ cựu) Cập nhật giá trị rồi truy vấn cực trị toàn cục: minh hoạ phân khối trên trục tần suất/giá trị, vượt khả năng lazy của Segment Tree.
- Đồ thị nhiều màu (colgraph) — (Chuyên gia) Phân khối kết hợp DSU trên đồ thị: bài nâng cao cho thấy sức mạnh tổng quát của ý tưởng "chia thành phần".
Đề VOI
- Hành trình du lịch (voi12_travel) (Kỳ cựu) VOI 2012: tìm chu trình độ dài 4, xem mục chia căn theo bậc (đề cũng qua được bằng cách khác không cần chia căn).
- Phần thưởng (voi21_bonus) (Kỳ cựu) VOI 2021: một phần của bài là QHĐ cần max trên các giá trị kề trong đồ thị cặp số; dùng đẩy/kéo theo đỉnh nặng, nhẹ (kết hợp thêm trie cho phần nhãn).
Bình luận