Cho một dãy, hỏi tổng một đoạn, và đôi khi sửa một phần tử. Tổng tiền tố không chịu được việc sửa, còn cây phân đoạn (bài 17) thì dài dòng cho những bài lạ.
Ta chia dãy phần tử thành các khối dài khoảng , vậy có khoảng khối. Mọi đoạn đều gồm vài phần tử lẻ ở hai đầu và một dãy khối nguyên ở giữa: phần tử lẻ xử lý từng cái, khối nguyên dùng thông tin đã gộp sẵn. Mỗi thao tác tốn , chậm hơn cây phân đoạn, nhưng khối giữ được cả những thứ không gộp bằng phép kết hợp: một mảng đã sắp, một bảng đếm, một con trỏ nhảy.
Cùng ý tưởng ấy còn một dạng khác: chọn ngưỡng , xử lý phần “nhỏ” bằng bảng tính sẵn và phần “lớn” bằng duyệt thẳng, để cả hai phía đều tốn .
Khối
Chọn độ dài khối , đánh số từ 1 và đặt id[i] = (i - 1) / B. Mỗi khối giữ một giá trị gộp, chẳng hạn tổng s[k]. Câu hỏi nằm trong một khối thì duyệt thẳng; ngược lại tách thành mảnh trái từ tới hết khối của , các khối nguyên ở giữa, và mảnh phải từ đầu khối của tới . Hai mảnh có không quá phần tử, giữa có không quá khối, nên mỗi câu hỏi tốn .
Tổng hai số hạng nhỏ nhất khi , còn . Với là khoảng 900 phép mỗi câu.
Gán chỉ làm đổi tổng một khối: s[id[p]] += x - a[p], . Fenwick làm được việc này nhanh hơn, nhưng khung khối là thứ ta dùng lại ở mọi mục sau.
Hỏi nhanh
Dãy dài , chọn khối dài . Một câu hỏi tổng đoạn tốn khoảng bao nhiêu phép trong trường hợp xấu nhất?
Hai mảnh biên tốn tới phép, nhưng giữa có tới khối. Khối quá ngắn làm phần giữa trội lên; chọn thì tổng chỉ khoảng 3000 phép.
Ví dụTổng Đoạn Linh Hoạt
Đề. Dãy số, thao tác: gán , hoặc in tổng .
Lời giải. Chia dãy thành các khối dài và giữ tổng của mỗi khối. Một đoạn bất kỳ gồm tối đa hai mảnh biên lẻ và một dãy khối nằm trọn, mỗi phần tối đa bước.
Bước 1: chia khối. id[i] = (i - 1) / B là khối của vị trí ; s[k] là tổng khối , cộng dồn ngay khi đọc.
B = max(1LL, (ll)sqrt(n));
for (ll i = 1; i <= n; i++) {
cin >> a[i];
id[i] = (i - 1) / B;
s[id[i]] += a[i];
}
Bước 2: tổng một đoạn. Cùng khối thì cộng thẳng. Khác khối thì ba phần: vòng đầu chạy từ tới hết khối của , vòng giữa cộng tổng các khối nằm trọn, vòng cuối chạy lùi từ tới đầu khối của .
ll get(ll l, ll r) {
ll res = 0;
if (id[l] == id[r]) {
for (ll i = l; i <= r; i++) res += a[i];
return res;
}
for (ll i = l; id[i] == id[l]; i++) res += a[i];
for (ll k = id[l] + 1; k < id[r]; k++) res += s[k];
for (ll i = r; id[i] == id[r]; i--) res += a[i];
return res;
}
Bước 3: gán một phần tử. Sửa tổng khối bằng phần chênh y - a[x] rồi mới ghi giá trị mới, .
s[id[x]] += y - a[x];
a[x] = y;
Tổng có thể tới , phải dùng ll.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, q, B, a[N], id[N], s[N];
ll get(ll l, ll r) {
ll res = 0;
if (id[l] == id[r]) {
for (ll i = l; i <= r; i++) res += a[i];
return res;
}
for (ll i = l; id[i] == id[l]; i++) res += a[i];
for (ll k = id[l] + 1; k < id[r]; k++) res += s[k];
for (ll i = r; id[i] == id[r]; i--) res += a[i];
return res;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> q;
B = max(1LL, (ll)sqrt(n));
for (ll i = 1; i <= n; i++) {
cin >> a[i];
id[i] = (i - 1) / B;
s[id[i]] += a[i];
}
while (q--) {
ll t, x, y;
cin >> t >> x >> y;
if (t == 1) {
s[id[x]] += y - a[x];
a[x] = y;
} else cout << get(x, y) << '\n';
}
}
5 5
2 1 3 4 5
2 2 5
1 3 10
2 1 3
1 5 6
2 4 5
13
13
10
Độ phức tạp: gán , câu hỏi .
Chọn đúng thật sự quan trọng:
Khối quá ngắn chậm gấp khoảng 8 lần, khối quá dài chậm gấp khoảng 27 lần so với . Chọn cho hai phía bằng nhau.
Nhãn khối
Cộng vào cả một đoạn cũng dùng cách tách ấy. Khối nguyên chỉ ghi vào nhãn chung tag[k], nghĩa là “mọi phần tử của khối còn phải cộng thêm tag[k]”. Phần tử ở hai mảnh biên sửa trực tiếp. Giá trị thật của là a[i] + tag[id[i]], và mỗi cập nhật cũng .
Nhãn có thể là phép cộng, phép gán, hay cặp (nhân, cộng) cho hàm . Khi sửa lẻ một phần tử của khối đang mang nhãn không giao hoán với phép sửa, áp nhãn xuống cả khối trước.
Khối giữ dữ liệu đã sắp. Để đếm trên đoạn số phần tử nhỏ hơn , mỗi khối giữ thêm một bản sao đã sắp các giá trị của nó. Cộng cả khối không đổi thứ tự trong khối, nên chỉ cần tăng nhãn; khối nguyên trả lời bằng một lần lower_bound với . Mảnh biên bị sửa thì sắp lại khối đó, tốn . Phần giữa tốn cho các lần chặt nhị phân, nên hai phía vẫn cân ở . Nếu phần giữa chỉ tốn thì nên chọn . Ước lượng hai phía rồi chọn cho chúng bằng nhau.
Ví dụBao Nhiêu Số Bé Hơn
Đề. Dãy số và thao tác: cộng vào đoạn , hoặc đếm trên số phần tử nhỏ hơn hẳn .
Lời giải. Mỗi khối giữ nhãn cộng tag[k] và vector v[k] các giá trị gốc của khối đã sắp (chưa cộng nhãn). Cộng vào cả khối không làm đổi thứ tự trong khối, nên chỉ tăng nhãn; chỉ mảnh biên mới làm thứ tự đổi và phải sắp lại. Phần tử của khối nằm trọn nhỏ hơn khi giá trị gốc nhỏ hơn , đếm bằng một lần lower_bound. tới nên tới , vẫn vừa ll.
Bước 1: dựng lại một khối. L[k], R[k] là vị trí đầu, cuối của khối; rebuild chép các giá trị gốc rồi sắp, .
void rebuild(ll k) {
v[k].clear();
for (ll i = L[k]; i <= R[k]; i++) v[k].push_back(a[i]);
sort(v[k].begin(), v[k].end());
}
Bước 2: mảnh biên. add chỉ được gọi cho một mảnh nằm trong một khối: sửa a[i] rồi dựng lại khối đó. calc đếm thẳng trên mảnh biên, so giá trị thật a[i] + tag với ngưỡng.
void add(ll l, ll r, ll c) {
for (ll i = l; i <= r; i++) a[i] += c;
rebuild(id[l]);
}
ll calc(ll l, ll r, ll x) {
ll res = 0;
for (ll i = l; i <= r; i++) res += (a[i] + tag[id[i]] < x);
return res;
}
Bước 3: hai loại thao tác. Cộng: hai mảnh biên qua add, khối giữa tăng tag. Đếm: hai mảnh biên qua calc, khối giữa đếm bằng lower_bound trên v[k] với ngưỡng đã trừ nhãn.
for (ll k = id[x] + 1; k < id[y]; k++)
res += lower_bound(v[k].begin(), v[k].end(), w - tag[k]) - v[k].begin();
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 5e4 + 5;
ll n, B, a[N], id[N], L[N], R[N], tag[N];
vector<ll> v[N];
void rebuild(ll k) {
v[k].clear();
for (ll i = L[k]; i <= R[k]; i++) v[k].push_back(a[i]);
sort(v[k].begin(), v[k].end());
}
void add(ll l, ll r, ll c) {
for (ll i = l; i <= r; i++) a[i] += c;
rebuild(id[l]);
}
ll calc(ll l, ll r, ll x) {
ll res = 0;
for (ll i = l; i <= r; i++) res += (a[i] + tag[id[i]] < x);
return res;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
B = max(1LL, (ll)sqrt(n));
for (ll i = 1; i <= n; i++) {
cin >> a[i];
id[i] = (i - 1) / B;
R[id[i]] = i;
if (!L[id[i]]) L[id[i]] = i;
}
for (ll k = 0; k <= id[n]; k++) rebuild(k);
for (ll t = 1; t <= n; t++) {
ll op, l, r, c;
cin >> op >> l >> r >> c;
ll x = l, y = r;
if (op == 0) {
if (id[x] == id[y]) add(x, y, c);
else {
add(x, R[id[x]], c);
for (ll k = id[x] + 1; k < id[y]; k++) tag[k] += c;
add(L[id[y]], y, c);
}
continue;
}
ll w = c * c, res;
if (id[x] == id[y]) res = calc(x, y, w);
else {
res = calc(x, R[id[x]], w) + calc(L[id[y]], y, w);
for (ll k = id[x] + 1; k < id[y]; k++)
res += lower_bound(v[k].begin(), v[k].end(), w - tag[k]) - v[k].begin();
}
cout << res << '\n';
}
}
5
1 4 2 8 3
1 1 5 2
0 2 4 -2
1 1 4 1
1 2 5 3
1 3 3 0
3
1
4
0
Độ phức tạp: mỗi thao tác : hai lần dựng lại khối hoặc lần chặt nhị phân.
Ngưỡng
Nhiều bài có một tham số trong mỗi câu hỏi (bước nhảy, modulo, kích thước tập), và cách giải tốt tùy lớn hay nhỏ.
- : đối tượng liên quan chỉ có cỡ phần tử, nên duyệt thẳng.
- : chỉ có giá trị khác nhau, nên tính sẵn một bảng cho từng giá trị, hoặc gom các câu hỏi cùng lại xử lý một lượt.
Chọn thì cả hai phía đều mỗi thao tác, tổng cộng .
Hỏi nhanh
Câu hỏi “tổng các với chia dư ” trên dãy dài . Vì sao với có thể duyệt thẳng?
Các chỉ số cùng dư theo cách nhau đúng , nên có không quá chỉ số; cho dưới chỉ số. Bộ nhớ chỉ là lý do không tính sẵn cho mọi , còn duyệt được nhanh là nhờ số chỉ số ít.
Ví dụTổng Theo Số Dư
Đề. Mảng phần tử ban đầu bằng 0, thao tác: cộng vào , hoặc in tổng các với .
Lời giải. Chia câu hỏi theo modulo tại ngưỡng . Modulo nhỏ thì có ít, nên giữ sẵn đáp án cho mọi cặp với ; modulo lớn thì dãy chỉ có ít số, cộng thẳng.
Bước 1: cộng vào . Sửa và ô của bảng: với mỗi , phần tử thuộc nhóm dư x % d.
a[x] += y;
for (int d = 1; d <= S; d++) sm[d][x % d] += y;
Bước 2: câu hỏi. thì đọc thẳng sm[x][y]; thì cộng các với , không quá số.
else if (x <= S) cout << sm[x][y] << '\n';
else {
ll res = 0;
for (ll i = y; i <= M; i += x) res += a[i];
cout << res << '\n';
}
Biến vòng d, x, y khai báo int để phép chia lấy dư là phép 32 bit. Vòng trong chạy lần nên chênh lệch lộ rõ:
Dùng long long chậm gấp khoảng 3 lần. Vòng lặp nóng có phép chia lấy dư thì ưu tiên int.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll M = 5e5, S = 700;
ll q, a[M + 5], sm[S + 1][S + 1];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> q;
while (q--) {
int t, x, y;
cin >> t >> x >> y;
if (t == 1) {
a[x] += y;
for (int d = 1; d <= S; d++) sm[d][x % d] += y;
} else if (x <= S) cout << sm[x][y] << '\n';
else {
ll res = 0;
for (ll i = y; i <= M; i += x) res += a[i];
cout << res << '\n';
}
}
}
7
1 3 5
1 8 4
1 10 -2
2 5 3
2 4 0
1 8 -1
2 2 0
9
4
1
Ước lượng phép tính: mỗi thao tác khoảng phép.
Nhảy khỏi khối
Mỗi ô chỉ sang một ô ở bên phải, và có thể bị sửa. Mô phỏng thẳng một đường nhảy tốn tới bước. Chia dãy thành khối và với mỗi ô lưu ba thứ: nxt[i] là ô đầu tiên ngoài khối của mà đường đi từ tới, cnt[i] là số bước để tới đó, lst[i] là ô cuối cùng trong khối trước khi rời. Chúng tính được từ phải sang trong khối: nếu còn trong khối thì kế thừa từ ô đó, ngược lại tự rời khối sau một bước.
Một câu hỏi đi theo nxt, mỗi bước qua ít nhất một khối, nên tốn . Sửa chỉ ảnh hưởng các ô cùng khối từ về trái, tính lại .
Ví dụNhững Bước Nhảy Thoát Mảng
Đề. ô, ô có , đứng ở thì nhảy tới , dừng khi vượt . thao tác: gán , hoặc bắt đầu ở và in ô cuối cùng còn trong mảng cùng tổng số bước nhảy.
Lời giải. Chia ô thành khối dài . Với mỗi ô giữ ba thứ: nxt[i] là ô đầu tiên ngoài khối mà đường nhảy từ tới, cnt[i] là số bước để tới đó, lst[i] là ô cuối cùng trong khối trên đường đi. Câu hỏi đi theo nxt, mỗi bước qua ít nhất một khối. Gán chỉ ảnh hưởng các ô cùng khối và đứng trước . Với ví dụ, khối dài 2: từ ô 1 đường đi qua ba lần nhảy theo nxt.
Bước 1: tính một ô. Nhảy một bước tới . Ra ngoài khối (hoặc ngoài mảng) thì rời khối sau 1 bước và ô cuối trong khối là chính . Còn trong khối thì kế thừa từ , cộng thêm 1 bước.
void calc(ll i) {
ll j = i + a[i];
if (j > n || id[j] != id[i]) {
nxt[i] = j;
cnt[i] = 1;
lst[i] = i;
} else {
nxt[i] = nxt[j];
cnt[i] = cnt[j] + 1;
lst[i] = lst[j];
}
}
Bước 2: dựng và sửa. Dựng ban đầu duyệt từ về 1, nên luôn đã tính. Gán thì tính lại từ về đầu khối, .
for (ll i = n; i >= 1; i--) calc(i);
cin >> a[p];
for (ll i = p; i >= L[id[p]]; i--) calc(i);
Bước 3: câu hỏi. Cộng cnt, nhớ lst của bước cuối để in ô cuối cùng còn trong mảng.
ll steps = 0, last = p;
while (p <= n) {
steps += cnt[p];
last = lst[p];
p = nxt[p];
}
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, m, B, a[N], id[N], L[N], nxt[N], cnt[N], lst[N];
void calc(ll i) {
ll j = i + a[i];
if (j > n || id[j] != id[i]) {
nxt[i] = j;
cnt[i] = 1;
lst[i] = i;
} else {
nxt[i] = nxt[j];
cnt[i] = cnt[j] + 1;
lst[i] = lst[j];
}
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
B = max(1LL, (ll)sqrt(n));
for (ll i = 1; i <= n; i++) {
cin >> a[i];
id[i] = (i - 1) / B;
if (!L[id[i]]) L[id[i]] = i;
}
for (ll i = n; i >= 1; i--) calc(i);
while (m--) {
ll t, p;
cin >> t >> p;
if (t == 0) {
cin >> a[p];
for (ll i = p; i >= L[id[p]]; i--) calc(i);
continue;
}
ll steps = 0, last = p;
while (p <= n) {
steps += cnt[p];
last = lst[p];
p = nxt[p];
}
cout << last << ' ' << steps << '\n';
}
}
6 5
2 3 1 2 1 4
1 1
0 3 4
1 1
1 2
0 6 1
6 4
3 2
6 3
Độ phức tạp: gán , câu hỏi vì qua tối đa khối.
Đỉnh nặng, đỉnh nhẹ
Mục này dùng vài chữ của chương 3: bậc của một đỉnh là số cạnh nối với nó. Mỗi cạnh góp 1 vào bậc của cả hai đầu, nên tổng mọi bậc là . Vì vậy đồ thị cạnh có không quá đỉnh bậc lớn hơn .
Gọi chúng là đỉnh nặng, còn lại là đỉnh nhẹ. Duyệt hàng xóm của một đỉnh nhẹ tốn không quá . Đỉnh nặng thì ít, có thể giữ cho mỗi đỉnh nặng một bảng hay một bộ đếm riêng. Với cả hai phía đều .
Một cách dùng gọn: hướng mỗi cạnh từ đầu có bậc nhỏ hơn sang đầu có bậc lớn hơn (bằng nhau thì theo chỉ số). Đồ thị thành không có chu trình, và mỗi đỉnh có không quá cạnh đi ra: một đỉnh có cạnh đi ra thì cả hàng xóm đó có bậc ít nhất bằng nó, tức ít nhất , nên .
Ví dụTam Giác Trong Mạng Lưới
Đề. Đồ thị đơn đỉnh, cạnh. Đếm số tam giác.
Lời giải. Hướng mỗi cạnh từ đầu có bậc nhỏ hơn sang đầu bậc lớn hơn (bằng nhau thì theo chỉ số); đồ thị hướng này không có chu trình. Mỗi tam giác có đúng một đỉnh mà cả hai cạnh của tam giác đều đi ra từ nó, nên được đếm đúng một lần ở đỉnh đó. Mỗi đỉnh có không quá cạnh đi ra: đỉnh có cạnh ra thì mỗi đỉnh nó đi tới có bậc ít nhất , nên .
Bước 1: đếm bậc rồi hướng cạnh.
for (ll i = 1; i <= m; i++) {
ll u = eu[i], v = ev[i];
if (deg[u] > deg[v] || (deg[u] == deg[v] && u > v)) swap(u, v);
g[u].push_back(v);
}
Bước 2: đếm tam giác ở đỉnh có hai cạnh ra. Đánh dấu các đỉnh đi tới bằng mark[v] = u; dùng chính làm dấu nên khỏi xóa mảng giữa các lượt. Với mỗi mà đi tới, mỗi mà đi tới và đã được đánh dấu là một tam giác .
for (ll u = 1; u <= n; u++) {
for (ll v : g[u]) mark[v] = u;
for (ll v : g[u])
for (ll w : g[v])
if (mark[w] == u) ans++;
}
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5, M = 2e5 + 5;
ll n, m, deg[N], eu[M], ev[M], mark[N];
vector<ll> g[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (ll i = 1; i <= m; i++) {
cin >> eu[i] >> ev[i];
deg[eu[i]]++;
deg[ev[i]]++;
}
for (ll i = 1; i <= m; i++) {
ll u = eu[i], v = ev[i];
if (deg[u] > deg[v] || (deg[u] == deg[v] && u > v)) swap(u, v);
g[u].push_back(v);
}
ll ans = 0;
for (ll u = 1; u <= n; u++) {
for (ll v : g[u]) mark[v] = u;
for (ll v : g[u])
for (ll w : g[v])
if (mark[w] == u) ans++;
}
cout << ans << '\n';
}
5 6
1 2
2 3
1 3
3 4
4 5
3 5
2
Độ phức tạp: mỗi cạnh tốn số cạnh ra của , không quá : tổng , khoảng phép đơn giản.
Khi đi thi
- cỡ và khối cần giữ thứ không gộp được bằng một phép kết hợp (mảng đã sắp, bảng đếm, con trỏ nhảy): chia căn; là thoải mái, với hằng số lớn thì nên thử nhanh trước.
- Viết riêng trường hợp cùng khối, và giữ một quy ước chỉ số duy nhất cho
id, đầu khối, cuối khối. - Đếm chi phí hai phía với bất kỳ rồi chọn cho chúng bằng nhau; hằng số thực tế khác nhau nên thử vài giá trị quanh mức cân bằng nếu sát giới hạn thời gian.
Tóm tắt
- Khối dài : đoạn bất kỳ là hai mảnh biên dài không quá và không quá khối nguyên; cho .
- Nhãn khối cho cập nhật đoạn; giá trị thật là giá trị lưu cộng nhãn.
- Khối giữ dữ liệu đã sắp để đếm theo ngưỡng; sửa mảnh biên thì dựng lại khối.
- Ngưỡng : tham số nhỏ dùng bảng hay gom câu hỏi, tham số lớn duyệt thẳng.
- Con trỏ nhảy ra khỏi khối biến đường đi dài thành bước, cập nhật .
- Trên đồ thị: ít đỉnh nặng, đỉnh nhẹ có ít hàng xóm; hướng cạnh theo bậc cho mỗi đỉnh không quá cạnh ra.
Bài tập
Đọc thêm
- Chia căn và ứng dụngVNOI Wiki
- Sqrt Decompositioncp-algorithms
- 分块思想OI Wiki
- Square Root DecompositionUSACO Guide