Dãy không đổi, nhiều câu hỏi min trên đoạn: duyệt từng đoạn tốn mỗi câu, câu trên dãy phần tử là phép so sánh. Bảng thưa (sparse table) tính sẵn đáp án cho mọi đoạn có độ dài là lũy thừa của hai, mất ; sau đó mỗi câu hỏi chỉ đọc hai ô bảng. Cái giá là dãy không được thay đổi: cần cập nhật thì dùng cây phân đoạn (bài 17).
Bảng cho các đoạn dài lũy thừa hai
Gọi st[k][i] là giá trị nhỏ nhất của đoạn dài bắt đầu tại , tức . Mức 0 là chính dãy, st[0][i] = a[i]. Một đoạn dài là hai nửa liền nhau dài , nên
Dựng lần lượt từ mức thấp lên, mỗi mức có không quá ô và có mức: thời gian và bộ nhớ. Với cần 18 mức, khoảng ô.
Trả lời một đoạn. Đoạn dài , lấy . Khối ở đầu trái và khối ở đầu phải đều nằm trong đoạn, và vì nên hai khối phủ kín đoạn, có thể chồng lên nhau. Phần chồng được xét hai lần nhưng không làm sai min, nên đáp án là min(st[k][l], st[k][r - (1 << k) + 1]). Tính sẵn lg[x] bằng lg[x] = lg[x / 2] + 1 để không phải gọi hàm log trên số thực.
Hỏi nhanh
Hỏi min trên đoạn dài 6 bằng hai khối dài 4, hai khối chồng nhau hai phần tử. Vì sao kết quả vẫn đúng?
Phần tử nhỏ nhất của đoạn nằm trong ít nhất một khối, nên min hai khối không lớn hơn nó; mỗi khối lại nằm trong đoạn, nên min hai khối không nhỏ hơn nó. Xét trùng một phần tử chẳng ảnh hưởng gì. Hai khối thường chồng nhau, phần chồng không nhất thiết chứa min, và không có bước trừ nào.
Ví dụMức Thấp Nhất
Đề. Dãy số không đổi, câu hỏi: in giá trị nhỏ nhất trên đoạn .
Lời giải. Dựng bảng thưa như trên, mỗi câu hỏi đọc hai ô.
Bước 1: dựng bảng. st[k][i] là min của phần tử bắt đầu ở ; mức ghép hai khối liền nhau của mức . Chỉ dựng các ô có , tức khối còn nằm trong dãy. lg[i] tính sẵn bằng lg[i / 2] + 1, để câu hỏi khỏi gọi hàm log.
for (ll i = 2; i <= n; i++) lg[i] = lg[i / 2] + 1;
for (ll k = 1; k < LG; k++)
for (ll i = 1; i + (1 << k) - 1 <= n; i++)
st[k][i] = min(st[k - 1][i], st[k - 1][i + (1 << (k - 1))]);
Bước 2: trả lời một đoạn. Với , hai khối dài , một bắt đầu ở , một kết thúc ở , phủ trọn đoạn và có thể chồng nhau; min không bận tâm phần chồng. Cả hai khối đều nằm trong nên không bao giờ đọc ô ngoài dãy.
ll get(ll l, ll r) {
ll k = lg[r - l + 1];
return min(st[k][l], st[k][r - (1 << k) + 1]);
}
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 2e5 + 5, LG = 18;
ll n, q, lg[N], st[LG][N];
ll get(ll l, ll r) {
ll k = lg[r - l + 1];
return min(st[k][l], st[k][r - (1 << k) + 1]);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> q;
for (ll i = 1; i <= n; i++) cin >> st[0][i];
for (ll i = 2; i <= n; i++) lg[i] = lg[i / 2] + 1;
for (ll k = 1; k < LG; k++)
for (ll i = 1; i + (1 << k) - 1 <= n; i++)
st[k][i] = min(st[k - 1][i], st[k - 1][i + (1 << (k - 1))]);
while (q--) {
ll l, r;
cin >> l >> r;
cout << get(l, r) << '\n';
}
}
7 4
8 3 6 2 7 2 5
1 3
2 6
4 4
5 7
3
2
2
2
Bảng ô dựng trong , mỗi câu hỏi .
Phép gộp nào dùng được
Cách trả lời bằng hai khối chồng nhau cần phép gộp kết hợp (gộp theo thứ tự nào cũng được) và lũy đẳng, . Min và max thỏa, GCD thỏa vì , AND và OR theo bit cũng thỏa. Có thể giữ nhiều bảng song song, chẳng hạn một bảng min và một bảng max cho độ chênh của đoạn.
Tổng thì không: phần chồng bị cộng hai lần. Tổng trên đoạn tĩnh đã có tổng tiền tố (bài 02) trả lời ; nếu buộc dùng bảng cho phép gộp không lũy đẳng, tách thành các khối rời nhau theo các bit của , mất mỗi câu. Bảng thưa cũng không hợp với cập nhật: một phần tử nằm trong tới ô ở mức , tổng cộng khoảng ô phải sửa.
Với GCD, mỗi câu hỏi tốn thêm cho phép tính GCD, là giá trị lớn nhất, nhưng vẫn nhanh hơn nhiều so với cây phân đoạn vì chỉ có một phép gộp.
Hỏi nhanh
Phép gộp nào không dùng được với cách hỏi bằng hai khối chồng nhau?
Hai khối chồng nhau làm một số phần tử được gộp hai lần. Max, GCD và OR không đổi khi gộp lặp một phần tử, còn tổng thì cộng phần chồng hai lần. Tổng trên dãy tĩnh dùng tổng tiền tố.
Ví dụHai Cận Trong Đoạn
Đề. Dãy số không đổi, câu hỏi: in cả giá trị nhỏ nhất lẫn lớn nhất trên đoạn .
Lời giải. Hai bảng dựng cùng lúc, mn theo min và mx theo max, dùng chung mảng lg. Mỗi câu hỏi tính và vị trí khối thứ hai j một lần, rồi đọc bốn ô, hai ô mỗi bảng.
ll k = lg[r - l + 1], j = r - (1 << k) + 1;
cout << min(mn[k][l], mn[k][j]) << ' ' << max(mx[k][l], mx[k][j]) << '\n';
Với ví dụ, đoạn dài 4 nên và hai khối trùng nhau, cho và .
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 1e5 + 5, LG = 17;
ll n, m, lg[N], mn[LG][N], mx[LG][N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (ll i = 1; i <= n; i++) {
cin >> mn[0][i];
mx[0][i] = mn[0][i];
}
for (ll i = 2; i <= n; i++) lg[i] = lg[i / 2] + 1;
for (ll k = 1; k < LG; k++)
for (ll i = 1; i + (1 << k) - 1 <= n; i++) {
mn[k][i] = min(mn[k - 1][i], mn[k - 1][i + (1 << (k - 1))]);
mx[k][i] = max(mx[k - 1][i], mx[k - 1][i + (1 << (k - 1))]);
}
while (m--) {
ll l, r;
cin >> l >> r;
ll k = lg[r - l + 1], j = r - (1 << k) + 1;
cout << min(mn[k][l], mn[k][j]) << ' ' << max(mx[k][l], mx[k][j]) << '\n';
}
}
6 3
9
4
7
1
8
3
1 4
3 6
5 5
1 9
1 8
8 8
Dựng , mỗi câu hỏi .
Ví dụKhác Ước Chung
Đề. Dãy số nguyên dương. Mỗi câu hỏi : gọi là GCD của đoạn, đếm số vị trí trong đoạn có .
Lời giải. GCD của đoạn chia hết mọi phần tử của đoạn, nên phần tử khác chính là phần tử lớn hơn ; đáp án là độ dài đoạn trừ số phần tử bằng đúng . Việc còn lại là lấy và đếm số lần xuất hiện trong . Với đoạn là : , chỉ một vị trí bằng 3, đáp án 3.
Bước 1: bảng thưa GCD. Như bảng min, thay min bằng __gcd. GCD lũy đẳng () nên hai khối chồng nhau vẫn cho đúng.
ll get(ll l, ll r) {
ll k = lg[r - l + 1];
return __gcd(st[k][l], st[k][r - (1 << k) + 1]);
}
Bước 2: các vị trí theo giá trị. Sắp các cặp (giá trị, chỉ số) tăng dần. Các vị trí cùng giá trị nằm liền nhau và tăng theo chỉ số, nên hai lần lower_bound với và chặn đúng các vị trí có giá trị và chỉ số trong .
ll g = get(l, r);
ll same = lower_bound(v + 1, v + n + 1, make_pair(g, r + 1)) - lower_bound(v + 1, v + n + 1, make_pair(g, l));
cout << r - l + 1 - same << '\n';
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 1e5 + 5, LG = 17;
ll n, q, lg[N], st[LG][N];
pair<ll, ll> v[N];
ll get(ll l, ll r) {
ll k = lg[r - l + 1];
return __gcd(st[k][l], st[k][r - (1 << k) + 1]);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) {
cin >> st[0][i];
v[i] = {st[0][i], i};
}
sort(v + 1, v + n + 1);
for (ll i = 2; i <= n; i++) lg[i] = lg[i / 2] + 1;
for (ll k = 1; k < LG; k++)
for (ll i = 1; i + (1 << k) - 1 <= n; i++)
st[k][i] = __gcd(st[k - 1][i], st[k - 1][i + (1 << (k - 1))]);
cin >> q;
while (q--) {
ll l, r;
cin >> l >> r;
ll g = get(l, r);
ll same = lower_bound(v + 1, v + n + 1, make_pair(g, r + 1)) - lower_bound(v + 1, v + n + 1, make_pair(g, l));
cout << r - l + 1 - same << '\n';
}
}
7
6 10 15 6 9 3 6
5
1 3
1 4
4 7
1 1
1 7
3
4
3
0
7
Dựng , mỗi câu hỏi cho hai lần chặt nhị phân.
Tìm kiếm nhị phân theo đầu phải
Cố định đầu trái và kéo dài đầu phải : min chỉ giảm, max chỉ tăng, GCD chỉ giảm hoặc giữ nguyên (mỗi lần đổi ít nhất chia đôi, nên chỉ đổi không quá lần), AND chỉ mất bit, OR chỉ thêm bit. Mọi điều kiện kiểu “max − min ”, “GCD ”, “AND ” vì thế đúng trên một khúc đầu các giá trị rồi sai mãi về sau. Với mỗi , tìm kiếm nhị phân (bài 05) vị trí xa nhất còn thỏa, mỗi bước kiểm bằng một câu hỏi trên bảng: tổng .
Hai con trỏ (bài 06) cũng làm được khi đầu trái và đầu phải cùng tiến, nhưng cần một cấu trúc gộp được khi bỏ phần tử ở đầu trái, như hàng đợi đơn điệu cho min/max; với GCD không có cách bỏ phần tử đơn giản, nên bảng thưa cộng tìm kiếm nhị phân là cách gọn nhất.
Ví dụĐoạn Ổn Định Dài Nhất
Đề. Dãy số và số . Đoạn ổn định nếu max − min không vượt . In độ dài lớn nhất của đoạn ổn định, số đoạn dài đúng , rồi liệt kê chúng theo đầu trái tăng dần.
Lời giải. Với mỗi đầu trái , max − min của không giảm khi tăng (max chỉ tăng, min chỉ giảm khi kéo dài), nên tìm kiếm nhị phân xa nhất còn ổn định, gọi là . Đoạn ổn định dài nhất bắt đầu tại là ; độ dài lớn nhất toàn bài là max của , và mỗi đoạn dài đúng bắt đầu tại một có , nên duyệt tăng dần là có đúng thứ tự cần in. Với ví dụ, ba đầu trái 1, 4, 7 cho đoạn dài 3.
Bước 1: kiểm một đoạn trong . Hai bảng thưa min và max như ví dụ trước; ok(l, r) đọc max và min của đoạn rồi so hiệu với .
bool ok(ll l, ll r) {
ll t = lg[r - l + 1], j = r - (1 << t) + 1;
return max(mx[t][l], mx[t][j]) - min(mn[t][l], mn[t][j]) <= k;
}
Bước 2: chặt nhị phân cho mỗi đầu trái. Đoạn một phần tử luôn ổn định nên res bắt đầu bằng .
for (ll i = 1; i <= n; i++) {
ll l = i, r = n, res = i;
while (l <= r) {
ll mid = (l + r) >> 1;
if (ok(i, mid)) l = (res = mid) + 1;
else r = mid - 1;
}
R[i] = res;
best = max(best, res - i + 1);
}
Bước 3: đếm và in. Lượt thứ hai đi lại tăng dần, đếm rồi in các đoạn có độ dài bằng best.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 1e5 + 5, LG = 17;
ll n, k, lg[N], mn[LG][N], mx[LG][N], R[N];
bool ok(ll l, ll r) {
ll t = lg[r - l + 1], j = r - (1 << t) + 1;
return max(mx[t][l], mx[t][j]) - min(mn[t][l], mn[t][j]) <= k;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> k;
for (ll i = 1; i <= n; i++) {
cin >> mn[0][i];
mx[0][i] = mn[0][i];
}
for (ll i = 2; i <= n; i++) lg[i] = lg[i / 2] + 1;
for (ll t = 1; t < LG; t++)
for (ll i = 1; i + (1 << t) - 1 <= n; i++) {
mn[t][i] = min(mn[t - 1][i], mn[t - 1][i + (1 << (t - 1))]);
mx[t][i] = max(mx[t - 1][i], mx[t - 1][i + (1 << (t - 1))]);
}
ll best = 0, cnt = 0;
for (ll i = 1; i <= n; i++) {
ll l = i, r = n, res = i;
while (l <= r) {
ll mid = (l + r) >> 1;
if (ok(i, mid)) l = (res = mid) + 1;
else r = mid - 1;
}
R[i] = res;
best = max(best, res - i + 1);
}
for (ll i = 1; i <= n; i++)
if (R[i] - i + 1 == best) cnt++;
cout << best << ' ' << cnt << '\n';
for (ll i = 1; i <= n; i++)
if (R[i] - i + 1 == best) cout << i << ' ' << R[i] << '\n';
}
9 2
4 6 5 9 8 7 1 2 3
3 3
1 3
4 6
7 9
Dựng , mỗi đầu trái lần kiểm : tổng .
Ví dụĐoạn Cùng Phần Dư
Đề. Nhiều test, tổng , các số đôi một khác nhau và tới . Tìm đoạn dài nhất mà có một làm mọi phần tử trong đoạn cùng số dư khi chia cho .
Lời giải. Mọi phần tử cùng dư theo khi và chỉ khi chia hết mọi hiệu kề nhau trong đoạn, tức chia hết GCD của các hiệu đó. Đặt ; đoạn tốt khi . GCD không tăng khi kéo dài, nên với mỗi tìm kiếm nhị phân xa nhất. Các số khác nhau nên , và đoạn một phần tử luôn tốt. Với test đầu, hiệu là , đoạn hiệu có GCD 3, cho đoạn dài 4.
Bước 1: bảng thưa GCD trên mảng hiệu. lg tính một lần cho mọi test; mỗi test dựng lại bảng trên mảng hiệu dài .
for (ll i = 1; i <= m; i++) st[0][i] = abs(a[i + 1] - a[i]);
for (ll k = 1; k < LG; k++)
for (ll i = 1; i + (1 << k) - 1 <= m; i++)
st[k][i] = __gcd(st[k - 1][i], st[k - 1][i + (1 << (k - 1))]);
Bước 2: chặt nhị phân cho mỗi . res = i - 1 nghĩa là chưa có nào: đoạn tốt dài nhất bắt đầu ở chỉ có 1 phần tử. Đoạn hiệu ứng với đoạn số , dài .
for (ll i = 1; i <= m; i++) {
ll l = i, r = m, res = i - 1;
while (l <= r) {
ll mid = (l + r) >> 1;
if (get(i, mid) > 1) l = (res = mid) + 1;
else r = mid - 1;
}
ans = max(ans, res - i + 2);
}
Với không có hiệu nào, vòng lặp không chạy và ans giữ 1.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 2e5 + 5, LG = 18;
ll n, a[N], lg[N], st[LG][N];
ll get(ll l, ll r) {
ll k = lg[r - l + 1];
return __gcd(st[k][l], st[k][r - (1 << k) + 1]);
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
for (ll i = 2; i < N; i++) lg[i] = lg[i / 2] + 1;
ll T;
cin >> T;
while (T--) {
cin >> n;
for (ll i = 1; i <= n; i++) cin >> a[i];
ll m = n - 1, ans = 1;
for (ll i = 1; i <= m; i++) st[0][i] = abs(a[i + 1] - a[i]);
for (ll k = 1; k < LG; k++)
for (ll i = 1; i + (1 << k) - 1 <= m; i++)
st[k][i] = __gcd(st[k - 1][i], st[k - 1][i + (1 << (k - 1))]);
for (ll i = 1; i <= m; i++) {
ll l = i, r = m, res = i - 1;
while (l <= r) {
ll mid = (l + r) >> 1;
if (get(i, mid) > 1) l = (res = mid) + 1;
else r = mid - 1;
}
ans = max(ans, res - i + 2);
}
cout << ans << '\n';
}
}
3
6
1 8 5 11 17 4
1
10
5
6 10 14 9 21
4
1
3
Mỗi test cả dựng lẫn chặt nhị phân, cộng một thừa số cho phép tính GCD.
Bảng thưa hai chiều
Ý tưởng mở rộng nguyên vẹn sang ma trận. Khi mọi câu hỏi là hình vuông, gọi mn[k][i][j] là min của hình vuông cạnh có góc trên trái ; nó là min của bốn hình vuông cạnh ở bốn góc. Hình vuông cạnh được phủ bởi bốn hình vuông cạnh với , đặt ở bốn góc của nó; chúng chồng nhau, nhưng min và max không bận tâm. Bộ nhớ , mỗi câu hỏi .
Hình chữ nhật tùy ý cần hai chỉ số mũ riêng cho hàng và cột, st[a][b][i][j] cho khối , tốn bộ nhớ; chỉ dùng khi ma trận nhỏ, cỡ .
Ví dụĐộ Chênh Trên Ô Vuông
Đề. Ma trận với và một cạnh cố định. Mỗi câu hỏi là hình vuông có góc trên trái ; in max − min trong hình vuông đó. Có tới câu hỏi.
Lời giải. Bảng thưa hai chiều cho hình vuông: ô mn[k][i][j] là min của hình vuông cạnh góc trên trái , và tương tự mx. Bốn hình vuông cạnh đặt ở bốn góc phủ trọn hình vuông cạnh khi . Duyệt thẳng ô mỗi câu là phép khi . Với câu hỏi hình vuông gồm , độ chênh .
Bước 1: dựng hai bảng. Hình vuông cạnh ghép từ bốn hình vuông cạnh , góc trên trái lệch nhau theo hàng và theo cột.
for (ll k = 1; k <= lg[n]; k++) {
ll h = 1 << (k - 1);
for (ll i = 1; i + 2 * h - 1 <= n; i++)
for (ll j = 1; j + 2 * h - 1 <= n; j++) {
mn[k][i][j] = min({mn[k - 1][i][j], mn[k - 1][i + h][j], mn[k - 1][i][j + h], mn[k - 1][i + h][j + h]});
mx[k][i][j] = max({mx[k - 1][i][j], mx[k - 1][i + h][j], mx[k - 1][i][j + h], mx[k - 1][i + h][j + h]});
}
}
Bước 2: trả lời. Mọi câu hỏi cùng cạnh , nên và độ dời s = b - 2^k tính một lần ngoài vòng. Bốn góc là , , , .
ll k = lg[b], s = b - (1 << k);
while (q--) {
ll x, y;
cin >> x >> y;
ll lo = min({mn[k][x][y], mn[k][x + s][y], mn[k][x][y + s], mn[k][x + s][y + s]});
ll hi = max({mx[k][x][y], mx[k][x + s][y], mx[k][x][y + s], mx[k][x + s][y + s]});
cout << hi - lo << '\n';
}
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 255;
ll n, b, q, lg[N], mn[8][N][N], mx[8][N][N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> b >> q;
for (ll i = 1; i <= n; i++)
for (ll j = 1; j <= n; j++) {
cin >> mn[0][i][j];
mx[0][i][j] = mn[0][i][j];
}
for (ll i = 2; i <= n; i++) lg[i] = lg[i / 2] + 1;
for (ll k = 1; k <= lg[n]; k++) {
ll h = 1 << (k - 1);
for (ll i = 1; i + 2 * h - 1 <= n; i++)
for (ll j = 1; j + 2 * h - 1 <= n; j++) {
mn[k][i][j] = min({mn[k - 1][i][j], mn[k - 1][i + h][j], mn[k - 1][i][j + h], mn[k - 1][i + h][j + h]});
mx[k][i][j] = max({mx[k - 1][i][j], mx[k - 1][i + h][j], mx[k - 1][i][j + h], mx[k - 1][i + h][j + h]});
}
}
ll k = lg[b], s = b - (1 << k);
while (q--) {
ll x, y;
cin >> x >> y;
ll lo = min({mn[k][x][y], mn[k][x + s][y], mn[k][x][y + s], mn[k][x + s][y + s]});
ll hi = max({mx[k][x][y], mx[k][x + s][y], mx[k][x][y + s], mx[k][x + s][y + s]});
cout << hi - lo << '\n';
}
}
4 2 4
1 5 2 4
7 3 6 0
8 9 1 2
4 6 5 3
1 1
1 3
2 2
3 3
6
6
8
4
Mỗi bảng ô, khoảng 4 MB; dựng , câu hỏi .
Khi đi thi
- Dãy tĩnh, câu hỏi min, max, GCD, AND, OR trên đoạn, số câu hỏi lớn hoặc câu hỏi nằm trong vòng tìm kiếm nhị phân: bảng thưa nhanh và ngắn hơn cây phân đoạn; có cập nhật thì đổi sang cây phân đoạn.
- Ước lượng bộ nhớ trước: số
lllà khoảng 29 MB cho một bảng; hai bảng hoặc có thể cầnint. - Bài “đoạn dài nhất / đếm đoạn thỏa điều kiện trên min, max, GCD” thường là: cố định đầu trái, điều kiện đơn điệu theo đầu phải, tìm kiếm nhị phân trên bảng.
Tóm tắt
st[k][i]là kết quả trên , dựng bằng hai nửa của mức dưới trong .- Đoạn được phủ bởi hai khối dài ở hai đầu; đúng với mọi phép gộp kết hợp và lũy đẳng.
- Tổng không lũy đẳng: dùng tổng tiền tố; bảng không hỗ trợ cập nhật.
- Hàm trên đoạn đơn điệu theo đầu phải nên bảng thưa ghép tốt với tìm kiếm nhị phân.
- Hai chiều: bốn hình vuông chồng nhau phủ một hình vuông.
Bài tập
int cho đỡ bộ nhớ.map theo giá trị.Đọc thêm
- Range Minimum Query (RMQ)VNOI WikiBảng thưa và các cách khác cho RMQ, viết bằng tiếng Việt.
- Sparse Tablecp-algorithmsCông thức dựng, câu hỏi tổng bằng khối rời và câu hỏi min .
- ST 表OI WikiĐiều kiện kết hợp và lũy đẳng, có bài luyện Luogu P3865.
- Static Range QueriesUSACO GuideCác cách trả lời câu hỏi trên dãy tĩnh, gồm bảng thưa và chia để trị.