DP kết hợp cấu trúc dữ liệu
Rất nhiều công thức quy hoạch động có dạng " bằng min, max hoặc tổng của trên một tập thỏa điều kiện": nằm trong một cửa sổ, , thuộc đoạn ... Cài đặt thẳng thì mỗi tốn một vòng for , tổng . Nếu nhận ra hình dạng của tập , vòng for đó biến thành một truy vấn trên cấu trúc dữ liệu (deque, Fenwick, cây phân đoạn, ngăn xếp, bảng nhảy nhị phân), đưa bài xuống hoặc .
Trang này là bước tiếp theo sau khi đã nắm quy hoạch động cơ bản và các cấu trúc Fenwick, cây phân đoạn, ngăn xếp / deque đơn điệu.
Ý tưởng / Trực giác
Tách công thức thành "tập " và "phép gộp"
Viết lại mọi công thức chuyển trạng thái dưới dạng
trong đó là min, max hoặc tổng, là tập các được phép chuyển tới , và là chi phí chuyển. Vòng for ngây thơ duyệt hết . Muốn tăng tốc, cần trả lời ba câu hỏi:
- có hình dạng gì? Một đoạn chỉ số trượt dần sang phải, một đoạn chỉ số bất kỳ, một tiền tố theo giá trị, hay "mọi trong phạm vi của phần tử lớn nhất gần nhất"?
- tách được không? Nếu thì chỉ cần lưu vào cấu trúc tại vị trí , còn cộng sau. Nếu là tích thì đó là địa bàn của DP tối ưu hóa (Convex Hull Trick). Nếu thay đổi theo cho cả một khối (ví dụ ) thì dùng ngăn xếp đơn điệu gộp các khối (mẫu 4), hoặc cây phân đoạn lười khi có thêm biên trái (mẫu 5).
- Phép gộp có tính chất gì? Tổng và min/max đều gộp được theo đoạn. Tổng còn trừ được, nên với tổng có thể dùng Fenwick cho truy vấn đoạn bất kỳ; min/max không trừ được nên truy vấn đoạn bất kỳ cần cây phân đoạn (hoặc Fenwick chỉ khi luôn hỏi tiền tố).
Khi đã có câu trả lời, chọn cấu trúc theo bảng:
| Hình dạng | Ví dụ | Cấu trúc | Mỗi bước |
|---|---|---|---|
| Cửa sổ chỉ số , hai biên đều không lùi | nhảy tối đa ô | deque đơn điệu | khấu hao |
| Tiền tố theo giá trị: | LIS, đếm dãy con tăng | Fenwick trên giá trị đã nén | |
| Đoạn chỉ số bất kỳ | mỗi ô có tầm nhảy riêng | cây phân đoạn min/max, cập nhật điểm | |
| Mọi , chi phí là max/min của đoạn | chia dãy, mỗi nhóm tính theo phần tử lớn nhất | ngăn xếp đơn điệu mang theo cực trị tiền tố | khấu hao |
| Như trên nhưng thêm biên trái | xếp sách lên giá có bề rộng | cây phân đoạn lười giữ cả hàm theo | |
| Chuyển trạng thái lặp lại nhiều bước theo con trỏ cố định | nhảy tham lam nhiều lần, mỗi truy vấn một đoạn | nhảy nhị phân, bảng thưa |
Vì sao đúng: cấu trúc chỉ là "bộ nhớ đệm" của vòng for
Cấu trúc dữ liệu không thay đổi công thức, nó chỉ trả lời đúng câu hỏi mà vòng for trả lời, miễn là tại thời điểm truy vấn cho nó chứa chính xác các với , không thiếu, không thừa. Mọi lỗi của kỹ thuật này đều là vi phạm câu đó, nên trước khi code hãy trả lời:
- Thứ tự tính: duyệt theo thứ tự nào để mọi đã có ? Thường là chỉ số tăng dần; với phụ thuộc các (ví dụ đi sang phải) thì duyệt ngược.
- Thứ tự cập nhật: được đưa vào cấu trúc sau khi tính xong, và trước khi tính các cần nó. Truy vấn cho không được thấy (nó chưa có, hoặc là giá trị rỗng) và không được thấy các chưa tính.
- Điều kiện thứ hai "miễn phí": khi gồm hai điều kiện ( và ), một điều kiện được bảo đảm bởi thứ tự duyệt (chỉ những đã duyệt mới có mặt trong cấu trúc), điều kiện còn lại được bảo đảm bởi truy vấn (tiền tố theo giá trị). Đây là mẹo trung tâm: duyệt theo một chiều, đánh chỉ số cấu trúc theo chiều kia. Có ba điều kiện thì cần thêm một tầng như CDQ.
Ví dụ chạy tay: đếm dãy con tăng bằng Fenwick
Đếm số dãy con tăng ngặt (khác rỗng) của , đồng thời tính độ dài dãy con tăng dài nhất (LIS).
Gọi là số dãy con tăng kết thúc tại , là độ dài LIS kết thúc tại :
Điều kiện lo bằng thứ tự duyệt; điều kiện thành tiền tố theo hạng: nén giá trị hạng , rồi hỏi tổng / max trên hạng với là hạng của . Bảng dưới vẽ mảng theo hạng mà Fenwick đang biểu diễn (Fenwick trả về tổng tiền tố của mảng cntTree và max tiền tố của mảng lenTree), ô vừa thay đổi đánh dấu *:
hạng: 1 2 3 4
giá trị: 1 2 3 4
i=1 a=3 r=3 hỏi hạng 1..2: sum=0, max=0 -> cnt=1, len=1
cntTree [ 0 0 1* 0 ]
lenTree [ 0 0 1* 0 ]
i=2 a=1 r=1 hỏi hạng 1..0: rỗng -> cnt=1, len=1
cntTree [ 1* 0 1 0 ]
lenTree [ 1* 0 1 0 ]
i=3 a=3 r=3 hỏi hạng 1..2: sum=1, max=1 -> cnt=2, len=2 ({3}, {1,3})
cntTree [ 1 0 3* 0 ] (cộng dồn: 1 + 2)
lenTree [ 1 0 2* 0 ] (lấy max: max(1, 2))
i=4 a=2 r=2 hỏi hạng 1..1: sum=1, max=1 -> cnt=2, len=2 ({2}, {1,2})
cntTree [ 1 2* 3 0 ]
lenTree [ 1 2* 2 0 ]
i=5 a=4 r=4 hỏi hạng 1..3: sum=6, max=2 -> cnt=7, len=3
cntTree [ 1 2 3 7*]
lenTree [ 1 2 2 3*]
Tổng số dãy con tăng = 1 + 1 + 2 + 2 + 7 = 13, LIS = 3 (ví dụ 1, 2, 4)
Hai điểm đáng chú ý ở bước :
- trùng giá trị với . Vì hỏi hạng (không gồm ), không được tính là phần tử đứng trước, đúng với yêu cầu tăng ngặt. Nếu đề là không giảm thì hỏi .
- Hỏi trước, cập nhật sau. Với tăng ngặt, truy vấn chỉ tới hạng nên thứ tự này chưa lộ ra; nhưng với bài không giảm (hỏi tới hạng ), nếu đưa phần tử vào cây trước khi hỏi thì dãy kết thúc tại bị ghép với chính nó.
Kiểm tra bằng tay: dãy kết thúc tại là , (hai cách chọn số ), , , , , khớp.
Cài đặt
Mẫu 1: cửa sổ trượt trên chỉ số (deque đơn điệu)
Bài: các hòn đá có phí , xuất phát ở ô , đích ở ô (phí ), mỗi bước nhảy xa tối đa ô. Phí nhỏ nhất?
Cả hai biên của cửa sổ chỉ tăng, nên deque giữ các chỉ số có tăng dần từ đầu tới cuối: đầu deque là min của cửa sổ, phần tử nào vừa cũ hơn vừa tệ hơn một phần tử mới thì không bao giờ còn được chọn và bị bỏ.
#include <bits/stdc++.h>
using namespace std;
// Stones 1..n with cost c[i]; start at 0, finish at n+1; each jump at most k.
// dp[i] = c[i] + min(dp[j]) for i-k <= j <= i-1.
int main() {
int n, k;
cin >> n >> k;
vector<long long> c(n + 2, 0), dp(n + 2);
for (int i = 1; i <= n; i++) cin >> c[i];
deque<int> dq; // indices, dp increasing from front to back
dp[0] = 0;
dq.push_back(0);
for (int i = 1; i <= n + 1; i++) {
while (dq.front() < i - k) dq.pop_front(); // drop expired j
dp[i] = c[i] + dp[dq.front()]; // best j in window
while (!dq.empty() && dp[dq.back()] >= dp[i]) dq.pop_back();
dq.push_back(i); // i becomes a candidate
}
cout << dp[n + 1] << "\n";
}
Phần lý thuyết deque đơn điệu và phiên bản ngắn của mẫu này có ở ngăn xếp và deque đơn điệu. Dạng thường gặp hơn trong đề thi là nhiều tầng: chọn đúng vị trí, mỗi vị trí liên tiếp có ít nhất một vị trí được chọn, tổng lớn nhất. Khi đó : mỗi tầng chạy một deque riêng trên tầng (đã tính xong, cố định), tổng thay vì .
#include <bits/stdc++.h>
using namespace std;
const long long NEG = LLONG_MIN / 4;
// Choose exactly x positions, every k consecutive contain one chosen; maximize sum.
// f[c][i] = a[i] + max f[c-1][j], i-k <= j <= i-1 (j = 0 is the virtual start).
int main() {
int n, k, x;
cin >> n >> k >> x;
vector<long long> a(n + 1);
for (int i = 1; i <= n; i++) cin >> a[i];
vector<long long> prev(n + 1, NEG), cur(n + 1);
prev[0] = 0; // layer 0: only the start
for (int c = 1; c <= x; c++) {
deque<int> dq; // j of layer c-1, prev[] decreasing
cur.assign(n + 1, NEG);
for (int i = 1; i <= n; i++) {
int j = i - 1; // j enters the window
while (!dq.empty() && prev[dq.back()] <= prev[j]) dq.pop_back();
dq.push_back(j);
while (dq.front() < i - k) dq.pop_front();
if (prev[dq.front()] > NEG) cur[i] = prev[dq.front()] + a[i];
}
swap(prev, cur);
}
long long ans = NEG;
// last pick within the last k positions; max(1, ...) keeps the index valid when k > n
for (int i = max(1, n - k + 1); i <= n; i++) ans = max(ans, prev[i]);
cout << (ans <= NEG ? -1 : ans) << "\n";
}
Mẫu 2: điều kiện trên giá trị (Fenwick trên giá trị đã nén)
Đây là đoạn code của ví dụ chạy tay: một cây Fenwick tổng (đếm dãy con tăng, modulo) và một cây Fenwick max (LIS). Fenwick max dùng được vì luôn hỏi tiền tố và giá trị tại mỗi hạng chỉ tăng.
#include <bits/stdc++.h>
using namespace std;
const long long MOD = 1'000'000'007;
// Fenwick for prefix sums (mod) and prefix max, indexed 1..m.
struct FenwickSum {
int m; vector<long long> t;
FenwickSum(int m) : m(m), t(m + 1, 0) {}
void add(int i, long long v) { for (; i <= m; i += i & -i) t[i] = (t[i] + v) % MOD; }
long long sum(int i) { long long s = 0; for (; i > 0; i -= i & -i) s += t[i]; return s % MOD; }
};
struct FenwickMax {
int m; vector<int> t;
FenwickMax(int m) : m(m), t(m + 1, 0) {}
void upd(int i, int v) { for (; i <= m; i += i & -i) t[i] = max(t[i], v); }
int get(int i) { int r = 0; for (; i > 0; i -= i & -i) r = max(r, t[i]); return r; }
};
int main() {
int n;
cin >> n;
vector<long long> a(n);
for (auto &x : a) cin >> x;
// Coordinate compression: rank in 1..m.
vector<long long> vals(a.begin(), a.end());
sort(vals.begin(), vals.end());
vals.erase(unique(vals.begin(), vals.end()), vals.end());
int m = vals.size();
FenwickSum cntTree(m);
FenwickMax lenTree(m);
long long total = 0;
int best = 0;
for (int i = 0; i < n; i++) {
int r = lower_bound(vals.begin(), vals.end(), a[i]) - vals.begin() + 1;
// Query strictly smaller values: ranks 1..r-1. Query BEFORE update.
long long cnt = (1 + cntTree.sum(r - 1)) % MOD; // 1 = subsequence {a[i]} alone
int len = 1 + lenTree.get(r - 1);
cntTree.add(r, cnt);
lenTree.upd(r, len);
total = (total + cnt) % MOD;
best = max(best, len);
}
cout << best << " " << total << "\n"; // LIS length, number of increasing subsequences
}
Bản Python cho phần đếm (đọc toàn bộ dữ liệu một lần cho nhanh):
import sys
from bisect import bisect_left
def main():
data = sys.stdin.buffer.read().split()
n = int(data[0])
a = list(map(int, data[1:1 + n]))
MOD = 10**9 + 7
vals = sorted(set(a))
m = len(vals)
tree = [0] * (m + 1) # Fenwick of counts by rank
total = 0
for x in a:
r = bisect_left(vals, x) + 1
# prefix sum over ranks 1..r-1 (strictly smaller values)
i, s = r - 1, 0
while i > 0:
s += tree[i]
i -= i & -i
cnt = (1 + s) % MOD
total = (total + cnt) % MOD
i = r
while i <= m:
tree[i] = (tree[i] + cnt) % MOD
i += i & -i
print(total)
main()
So với LIS bằng lower_bound ở trang quy hoạch động, cách Fenwick dài hơn nhưng tổng quát hơn: đổi "độ dài" thành "tổng trọng số", "số cách", "độ dài kèm số cách đạt" (lưu cặp và gộp) đều chỉ là đổi phép gộp của cây.
Mẫu 3: đoạn chỉ số bất kỳ (cây phân đoạn, cập nhật điểm)
Bài: ô chỉ nhận bước nhảy dài từ tới , tức . Các đoạn này không trượt đều (biên trái có thể lùi), nên deque không dùng được. Cây phân đoạn min trên chỉ số , mỗi tính xong thì gán vào vị trí .
#include <bits/stdc++.h>
using namespace std;
const long long INF = LLONG_MAX / 4;
// Iterative segment tree: point assign, range min on [l, r].
struct SegMin {
int n; vector<long long> t;
SegMin(int n) : n(n), t(2 * n, INF) {}
void set(int i, long long v) {
for (t[i += n] = v; i > 1; i >>= 1) t[i >> 1] = min(t[i], t[i ^ 1]);
}
long long query(int l, int r) { // inclusive
long long res = INF;
for (l += n, r += n + 1; l < r; l >>= 1, r >>= 1) {
if (l & 1) res = min(res, t[l++]);
if (r & 1) res = min(res, t[--r]);
}
return res;
}
};
// Position i (1..n) can be reached from any j in [i - hi[i], i - lo[i]].
// dp[0] = 0; dp[i] = c[i] + min dp[j]. Print dp[n] or -1.
int main() {
int n;
cin >> n;
vector<long long> c(n + 1);
vector<int> lo(n + 1), hi(n + 1);
for (int i = 1; i <= n; i++) cin >> c[i] >> lo[i] >> hi[i];
SegMin seg(n + 1);
vector<long long> dp(n + 1, INF);
dp[0] = 0;
seg.set(0, 0);
for (int i = 1; i <= n; i++) {
int L = max(0, i - hi[i]), R = min(i - 1, i - lo[i]); // only j < i
if (L <= R) {
long long best = seg.query(L, R);
if (best < INF) dp[i] = best + c[i];
}
seg.set(i, dp[i]); // update AFTER computing dp[i]
}
cout << (dp[n] >= INF ? -1 : dp[n]) << "\n";
}
Các ô chưa đến được giữ giá trị INF trong cây; phải kiểm tra best < INF trước khi cộng, nếu không INF + c[i] có thể tràn hoặc thành một giá trị "hợp lệ" giả.
Mẫu 4: ngăn xếp đơn điệu mang theo cực trị (chi phí là max của đoạn)
Bài: chia thành các nhóm liên tiếp, mỗi nhóm được thưởng . Tổng thưởng lớn nhất?
Với cố định, là hàm bậc thang theo : các chia thành khối, mỗi khối có cùng giá trị max, và ranh giới các khối chính là ngăn xếp đơn điệu giảm của ("phần tử lớn hơn gần nhất bên trái"). Mỗi phần tử ngăn xếp lưu:
H: giá trị max chung của khối;bestDp: trong khối;prefBest: của khối này và mọi khối bên dưới.
Khi thêm , các khối có H bị gộp vào khối mới (max của chúng giờ là ). Các khối bên dưới không đổi nên prefBest của chúng vẫn đúng, và đọc ngay ở đỉnh.
#include <bits/stdc++.h>
using namespace std;
// Split h[1..n] into consecutive groups. Each group earns max(group) - C.
// dp[i] = max over 0 <= j < i of dp[j] + max(h[j+1..i]) - C.
struct Block {
long long H; // max(h[j+1..i]) shared by every j in this block
long long bestDp; // max dp[j] over j in this block
long long prefBest; // max(bestDp + H) over this block and all blocks below it
};
int main() {
int n;
long long C;
cin >> n >> C;
vector<long long> h(n + 1), dp(n + 1);
for (int i = 1; i <= n; i++) cin >> h[i];
dp[0] = 0;
vector<Block> st; // H strictly decreasing bottom -> top
for (int i = 1; i <= n; i++) {
long long bestDp = dp[i - 1]; // new block holds only j = i-1
// Blocks with H <= h[i] now all have max h[i]: merge them into the new block.
while (!st.empty() && st.back().H <= h[i]) {
bestDp = max(bestDp, st.back().bestDp);
st.pop_back();
}
long long below = st.empty() ? LLONG_MIN : st.back().prefBest;
st.push_back({h[i], bestDp, max(below, bestDp + h[i])});
dp[i] = st.back().prefBest - C; // best over ALL j < i
}
cout << dp[n] << "\n";
}
Chạy tay với , (ngăn xếp ghi từ đáy lên đỉnh dạng (H, bestDp, prefBest)):
| gộp | ngăn xếp sau bước | |||
|---|---|---|---|---|
| 1 | 2 | (2,0,2) |
||
| 2 | 9 | bỏ (2,0,2) |
(9,0,9) |
|
| 3 | 1 | (9,0,9) (1,4,9) |
||
| 4 | 3 | bỏ (1,4,9) |
(9,0,9) (3,4,9) |
|
| 5 | 8 | bỏ (3,4,9) |
(9,0,9) (8,4,12) |
|
| 6 | 4 | (9,0,9) (8,4,12) (4,7,12) |
Đáp án , ví dụ nhóm và : . Chú ý: với bài cực tiểu tổng các max, các không âm và không có ràng buộc nào khác thì lấy cả dãy làm một nhóm luôn tối ưu (khi , tổng các max không nhỏ hơn max toàn dãy; nếu có âm thì điều này không còn đúng); mẫu này chỉ đáng dùng khi có yếu tố kéo ngược lại, như phí ở đây, hoặc voi16_doncay nơi ngăn xếp giữ các khoảng đổ lồng nhau và mỗi phần tử mang từ đáy tới nó.
Mẫu 5: giữ cả hàm theo trong cây phân đoạn lười
Thêm ràng buộc biên trái vào mẫu 4 thì prefBest "từ đáy" không còn dùng được (các khối dưới cùng có thể đã ra khỏi cửa sổ). Bài xếp sách: sách theo thứ tự, cao , rộng ; mỗi giá chứa một đoạn liên tiếp có tổng bề rộng , chiều cao giá là sách cao nhất. Tổng chiều cao nhỏ nhất:
với nhỏ nhất sao cho (hai con trỏ).
Ý tưởng: cây phân đoạn trên chiều lưu nguyên hàm cho hiện tại. Khi tăng:
- xuất hiện với giá trị (gán điểm);
- mỗi khối bị bật khỏi ngăn xếp đơn điệu có max tăng từ lên : cộng đoạn lên đúng đoạn của khối đó;
- là min trên đoạn .
Đây là dạng "một chiều của trạng thái được xoay vào cây phân đoạn": thay vì tính lại cho mọi , ta chỉ sửa phần thay đổi giữa và . Mỗi sách vào và ra ngăn xếp một lần, nên tổng số phép cộng đoạn là .
#include <bits/stdc++.h>
using namespace std;
const long long INF = LLONG_MAX / 4;
// Lazy segment tree over j = 0..n: range add, range min.
struct LazySeg {
int n; vector<long long> mn, lz;
LazySeg(int n) : n(n), mn(4 * n, INF), lz(4 * n, 0) {}
void apply(int x, long long v) { mn[x] += v; lz[x] += v; }
void push(int x) {
if (lz[x]) { apply(2 * x, lz[x]); apply(2 * x + 1, lz[x]); lz[x] = 0; }
}
void add(int x, int l, int r, int ql, int qr, long long v) {
if (qr < l || r < ql) return;
if (ql <= l && r <= qr) { apply(x, v); return; }
push(x);
int m = (l + r) / 2;
add(2 * x, l, m, ql, qr, v); add(2 * x + 1, m + 1, r, ql, qr, v);
mn[x] = min(mn[2 * x], mn[2 * x + 1]);
}
void assign(int x, int l, int r, int p, long long v) { // point set
if (l == r) { mn[x] = v; return; }
push(x);
int m = (l + r) / 2;
if (p <= m) assign(2 * x, l, m, p, v); else assign(2 * x + 1, m + 1, r, p, v);
mn[x] = min(mn[2 * x], mn[2 * x + 1]);
}
long long query(int x, int l, int r, int ql, int qr) {
if (qr < l || r < ql) return INF;
if (ql <= l && r <= qr) return mn[x];
push(x);
int m = (l + r) / 2;
return min(query(2 * x, l, m, ql, qr), query(2 * x + 1, m + 1, r, ql, qr));
}
};
// Books in order, heights H, widths W, shelf width L.
// dp[i] = min over lo(i) <= j < i of dp[j] + max(H[j+1..i]).
// Segment tree position j stores val[j] = dp[j] + max(H[j+1..i]) for the current i.
int main() {
int n;
long long L;
cin >> n >> L;
vector<long long> H(n + 1), W(n + 1), dp(n + 1);
for (int i = 1; i <= n; i++) cin >> H[i] >> W[i];
LazySeg seg(n + 1);
vector<int> st; // book indices, H decreasing; st[t] owns j in [st[t-1], st[t]-1]
int lo = 0; // smallest valid j
long long width = 0; // sum W[lo+1..i]
dp[0] = 0;
for (int i = 1; i <= n; i++) {
// j = i-1 enters with max(H[i..i]) = H[i]
seg.assign(1, 0, n, i - 1, dp[i - 1] + H[i]);
// pop books shorter than H[i]; their j-ranges now have max H[i]
while (!st.empty() && H[st.back()] <= H[i]) {
int t = st.back(); st.pop_back();
int from = st.empty() ? 0 : st.back(); // j in [from, t-1] had max H[t]
seg.add(1, 0, n, from, t - 1, H[i] - H[t]);
}
st.push_back(i);
width += W[i];
while (width > L) width -= W[++lo]; // two pointers: j >= lo
dp[i] = seg.query(1, 0, n, lo, i - 1);
}
cout << dp[n] << "\n";
}
Với ví dụ , sách chương trình in : giá , , cao .
Cùng tư tưởng "quét một chiều, giữ cả hàm theo chiều kia trong cây phân đoạn" áp dụng cho cả những bài không phải QHĐ theo nghĩa hẹp: quét đầu phải , giữ cho mọi đầu trái , mỗi sự kiện là một phép cộng đoạn (xem quét đường thẳng).
Với QHĐ hai chiều mà chuyển từ hàng sang hàng chỉ là "cộng một hằng số lên một đoạn cột, lấy min một đoạn cột", ta cũng giữ một hàng trong cây lười và cập nhật dần thay vì tính lại cả hàng. Ví dụ cụ thể: có cột, qua mỗi bước thì mọi cột trong đoạn bị phạt thêm , tức
và sau mỗi bước cần biết (phí nhỏ nhất nếu dừng ở một cột trong đoạn cho phép). Tính lại cả hàng tốn mỗi bước, tổng ; giữ hàng hiện tại trong cây lười của mẫu 5 thì mỗi bước là một add đoạn và một query đoạn, tổng . Hàng không cần lưu riêng: cây luôn là hàng mới nhất.
Mẫu 6: nhảy nhiều bước theo con trỏ cố định (nhảy nhị phân)
Khi chuyển trạng thái là với không phụ thuộc truy vấn, nhưng điểm dừng phụ thuộc truy vấn, thì không tính một mảng cho từng truy vấn mà dựng bảng nhảy lặp lần.
Bài: truy vấn , chia thành ít đoạn nhất sao cho mỗi đoạn gồm các phần tử đôi một khác nhau. Tham lam lấy đoạn dài nhất từ là tối ưu, và điểm bắt đầu đoạn kế không phụ thuộc (chừng nào ). Vậy đáp án là số lần nhảy còn nằm trong , cộng .
#include <bits/stdc++.h>
using namespace std;
// Query (L, R): fewest segments splitting a[L..R] so that every segment has distinct values.
// Greedy takes the longest valid segment from L: next start = lim[L] (independent of R while lim[L] <= R).
// f(L, R) = 1 + f(lim[L], R); answer many queries with binary lifting on lim.
int main() {
int n, q;
cin >> n >> q;
vector<int> a(n + 2);
for (int i = 1; i <= n; i++) cin >> a[i];
const int LOG = 18; // 2^18 > 2e5
vector<vector<int>> up(LOG, vector<int>(n + 2, n + 1));
map<int, int> nextPos; // value -> next occurrence to the right
up[0][n + 1] = n + 1;
int lim = n + 1; // lim(i) = first index j > i that repeats inside a[i..j]
for (int i = n; i >= 1; i--) {
auto it = nextPos.find(a[i]);
if (it != nextPos.end()) lim = min(lim, it->second);
nextPos[a[i]] = i;
up[0][i] = lim; // greedy segment is [i, lim-1]
}
for (int k = 1; k < LOG; k++)
for (int i = 1; i <= n + 1; i++) up[k][i] = up[k - 1][up[k - 1][i]];
while (q--) {
int L, R;
cin >> L >> R;
int cnt = 0, cur = L;
for (int k = LOG - 1; k >= 0; k--)
if (up[k][cur] <= R) { cur = up[k][cur]; cnt += 1 << k; } // jump while still inside
cout << cnt + 1 << "\n"; // last segment [cur, R]
}
}
Họ hàng của mẫu này: nếu tính theo tầng và tầng trước đã cố định, truy vấn min/max đoạn trên tầng trước có thể dùng bảng thưa mỗi truy vấn thay cho cây phân đoạn; nếu trọng số chuyển trạng thái là max trên đường đi của cây, dùng nhảy nhị phân kèm max (như voi25_ship).
Độ phức tạp
| Mẫu | Thời gian | Bộ nhớ | Lý do |
|---|---|---|---|
| 1. Deque cửa sổ | ; nhiều tầng | mỗi chỉ số vào deque một lần, ra tối đa một lần | |
| 2. Fenwick giá trị | nén , mỗi một truy vấn và một cập nhật | ||
| 3. Cây phân đoạn điểm | mỗi một truy vấn đoạn và một gán điểm | ||
| 4. Ngăn xếp mang cực trị | mỗi vào và bị gộp tối đa một lần; đọc đáp án ở đỉnh | ||
| 5. Cây lười | phép cộng đoạn (mỗi lần bật ngăn xếp một phép) và truy vấn, mỗi phép | ||
| 6. Nhảy nhị phân | dựng bảng tầng; mỗi truy vấn đi qua tầng |
So với vòng for ngây thơ (hoặc cho mẫu 6), với ta đi từ xuống cỡ phép.
So sánh với các kỹ thuật tối ưu QHĐ khác
| Kỹ thuật | Tận dụng điều gì | Khi nào dùng |
|---|---|---|
| DP + cấu trúc dữ liệu (trang này) | Hình dạng tập (cửa sổ, tiền tố giá trị, đoạn); chi phí tách được hoặc thay đổi theo khối | Công thức có "min/max/tổng trên các thỏa điều kiện" |
| DP tối ưu hóa (CHT, chia để trị, Knuth) | Cấu trúc của hàm chi phí: tích (đường thẳng), bất đẳng thức tứ giác, điểm chia tối ưu đơn điệu | Tập thường là "mọi " nhưng không tách được |
| Slope Trick | là hàm lồi tuyến tính từng khúc theo một biến giá trị | Trạng thái có một chiều giá trị liên tục, chuyển là tiền tố và cộng |
Hai nhóm đầu hay đi cùng nhau: Li Chao Tree là "cây phân đoạn chứa đường thẳng", dùng khi với không đơn điệu; nếu tập còn bị giới hạn thêm (một đoạn chỉ số, một tiền tố giá trị) thì đặt Li Chao vào từng nút của cây phân đoạn. Còn slope trick đáng dùng khi mỗi bước làm thay đổi trên toàn miền (cộng , lấy min tiền tố); nếu mỗi bước chỉ một điểm thay đổi thì Fenwick hay cây phân đoạn theo giá trị (mẫu 2) là đủ.
⚠️ Lỗi thường gặp
- Cập nhật trước khi truy vấn. Đưa (hoặc giá trị rỗng của nó) vào cấu trúc rồi mới hỏi cho làm tự chuyển tới chính nó. Với tổng thì đếm sai, với max thì tự nuôi chính nó. Quy tắc: hỏi, tính, rồi mới cập nhật. Riêng mẫu 1 và 5, phải được đưa vào trước khi hỏi cho vì nó thuộc cửa sổ của ; đọc kỹ tập có chứa hay không.
- Ngặt và không ngặt khi có giá trị trùng. Tăng ngặt: hỏi hạng . Không giảm: hỏi và cập nhật sau khi hỏi. Nhầm hai trường hợp là lỗi phổ biến nhất của mẫu 2; luôn thử một test toàn số bằng nhau (với số dãy con tăng ngặt là , không giảm là ).
- Quên nén giá trị hoặc nén sai. Giá trị tới không làm chỉ số Fenwick được. Sau khi
sort+unique, hạng làlower_bound(...) + 1(Fenwick đánh số từ ). Quên+1thì giá trị nhỏ nhất có hạng , vàadd(0, v)lặp vô hạn vìi += i & -ivớii = 0giữ nguyên : chương trình treo (bị chấm quá thời gian), chứ không chỉ lệch một hạng; đồng thời truy vấnsum(r - 1)vớir - 1 = -1lặng lẽ trả về . Ngược lại, dùngupper_bound(...) + 1thì mọi hạng lệch lên một, giá trị lớn nhất nhận hạng và bịaddbỏ qua, còn truy vấn lại gồm cả giá trị bằng , trùng đúng lỗi ngặt/không ngặt ở trên. - Fenwick max cho truy vấn đoạn hoặc giá trị giảm. Fenwick max chỉ đúng khi luôn hỏi tiền tố và giá trị tại mỗi vị trí chỉ tăng. Cần trên bất kỳ hoặc có gán giảm thì dùng cây phân đoạn.
- Dùng deque khi biên trái có thể lùi. Deque đơn điệu đúng nhờ "phần tử đã bỏ không bao giờ quay lại cửa sổ". Nếu không đơn điệu (mẫu 3), một chỉ số đã bị bỏ có thể lại thuộc và bị thiếu, đáp án sai mà không có dấu hiệu gì. Kiểm tra biên trái và biên phải đều không giảm trước khi chọn deque.
INFtràn hoặc trạng thái không đến được bị coi là . Cộng chi phí vàoLLONG_MAXtràn thành số âm, thành giá trị tốt nhất. DùngINF = LLONG_MAX / 4và kiểm tra trước khi cộng; với bài max thì khởi tạo chứ không phải , nếu không một chuỗi "bắt đầu giữa chừng" sẽ lọt vào.- Modulo khi đếm. Tổng trong Fenwick phải lấy modulo ở mỗi phép cộng; khi gộp hàm
sumcộng tối đa số nhỏ hơn MOD nênlong longkhông tràn, nhưng nhớ lấy modulo trước khi trả về. - Tưởng mẫu 4 là hoặc mẫu 5 là . Vòng
whilebật ngăn xếp lồng trongfornhưng tổng số lần bật chỉ (khấu hao). Ngược lại, nếu cập nhật cây bằng cách duyệt từng trong khối bị bật thì mới thật sự thành ; phải dùng một phép cộng đoạn cho cả khối. - Bảng nhảy nhị phân thiếu điểm hút. Trong mẫu 6, vị trí "ngoài mảng" () phải trỏ vào chính nó ở mọi tầng, và điều kiện nhảy là
up[k][cur] <= Rchứ không phải< R: đích của một bước là điểm bắt đầu đoạn kế tiếp, còn nằm trong nghĩa là còn cần thêm đoạn.
Biến thể / Mở rộng
- Nhiều điều kiện cùng lúc. lấy từ có , và : duyệt theo chỉ số, Fenwick theo , còn thì cần một tầng chia để trị CDQ hoặc Fenwick 2 chiều.
- Chuyển trạng thái trên đồ thị có cấu trúc. Khi các cạnh là " có nhãn là tiền tố của nhãn " hay " thuộc danh sách cặp", cấu trúc cần dùng là trie (lưu max tại nút) hoặc chia căn theo bậc; ý tưởng vẫn là "gom theo nhóm để hỏi nhanh" (như voi21_bonus).
- Điều kiện là ước chung, số nguyên tố chung. Khi chuyển được tới nếu , gom theo từng thừa số nguyên tố: giữ trên các đã duyệt có ; lấy max của trên các thừa số nguyên tố của (chỉ vài số), rồi cập nhật lại các đó. Chỉ khi có thêm một điều kiện giá trị (ví dụ còn cần ) mới phải thay bằng một cây Fenwick riêng cho mỗi . Bao hàm loại trừ (hay hàm Möbius) theo ước chỉ dùng được khi phép gộp là tổng/đếm (ví dụ đếm các có ), vì cần phép trừ; với max/min thì không áp dụng được. Điều kiện hình chữ nhật trên lưới (, ) thì duyệt theo hàng và Fenwick theo cột.
- Chuyển trạng thái chỉ đổi một ô. Nếu mỗi bước chỉ sửa tại một (ví dụ trạng thái là "đang đứng ở đỉnh nào"), không cần cấu trúc cho mà cần cấu trúc để tính trọng số chuyển (max trên đường đi của cây bằng nhảy nhị phân, LCA).
- Truy vấn offline trên đoạn. Khi mỗi truy vấn là "QHĐ trên đoạn ", thường cố định đầu phải, quét và giữ đáp án cho mọi đầu trái trong cây phân đoạn (mẫu 5), trả lời truy vấn theo thứ tự đầu phải (xử lý truy vấn offline); nếu cần trả lời online, dùng cây phân đoạn bền vững.
- Chi phí dạng tích. không tách thành tổng, cần Convex Hull Trick / Li Chao Tree.
Bài tập luyện
Luyện kỹ thuật
- Dãy Con Tăng Dài Nhất (lis) (Trung cấp) khởi động mẫu 2: viết LIS bằng Fenwick max trên giá trị đã nén thay cho
lower_bound, kiểm tra test toàn số bằng nhau để chắc hỏi đúng hạng . - Dãy Con Tăng II (lis2) (Kỳ cựu) đếm số dãy con tăng ngặt modulo : đúng code Fenwick tổng ở ví dụ chạy tay.
- Giá sách (Khó) (bookshlfgo) (Kỳ cựu) chia sách thành các giá với ràng buộc bề rộng, chi phí là sách cao nhất: mẫu 5 (ngăn xếp đơn điệu + cây phân đoạn lười + hai con trỏ cho biên trái).
- Đăng lại ảnh mèo (bản khó) (kitpics) (Kỳ cựu) chọn đúng ảnh sao cho mỗi ảnh liên tiếp có một ảnh được chọn: QHĐ nhiều tầng, mỗi tầng một deque cửa sổ trượt, .
- Sereja và các dãy con (sersub) (Kỳ cựu) đếm theo các dãy con không giảm phân biệt theo giá trị: Fenwick trên giá trị (hỏi hạng ), nhưng khi gặp lại một giá trị phải thay đóng góp cũ của nó thay vì cộng thêm, để không đếm trùng.
Đề VOI
- Phân đoạn (voi05_segpar) (Nâng cao) VOI 2005: chặt nhị phân giá trị đáp án; bước kiểm tra là một QHĐ trên tiền tố mà tập hợp lệ được mô tả bằng điều kiện trên giá trị tổng tiền tố, nên gom bằng Fenwick trên giá trị đã nén (mẫu 2).
- Đường đi trên lưới (voi06_path) (Nâng cao) VOI 2006: QHĐ đếm đường đi theo ô, gom tổng theo ước bằng Fenwick theo cột, còn điều kiện "có ước chung" xử lý bằng bao hàm-loại trừ.
- Người giao hàng (voi25_ship) (Nâng cao) VOI 2025: QHĐ "đang đứng ở đảo nào" mỗi nhiệm vụ chỉ sửa một ô; phần cần cấu trúc dữ liệu là trọng số chuyển (max trên đường đi của cây), lấy bằng nhảy nhị phân kèm max. Kỹ thuật ở đây phục vụ việc tính chi phí chuyển chứ không phải truy vấn trên tập .
- Đoạn số (voi22_sseq) (Nâng cao) VOI 2022: không phải QHĐ theo nghĩa hẹp, nhưng là đúng tư tưởng mẫu 5. Quét đầu phải theo thứ tự tăng, giữ giá trị của mọi đầu trái (đã nén) trong một cây phân đoạn; mỗi đoạn góp một phép cộng lên một tiền tố, cẩn thận thời điểm lấy max.
- Đốn cây (voi16_doncay) (Kỳ cựu) VOI 2016: QHĐ trên tiền tố với hai loại chuyển (cây đổ trái, cây đổ phải); với chuyển "đổ phải", các khoảng ảnh hưởng lồng nhau hoặc rời nhau, nên giữ chúng trong một ngăn xếp mà mỗi phần tử mang từ đáy lên (mẫu 4). Cây phân đoạn min cũng đủ cho .
- Phần thưởng (voi21_bonus) (Kỳ cựu) VOI 2021: QHĐ từ phải sang trái, trên các thẻ nối được (số ứng với việc dừng ở thẻ ). Hai loại cạnh cần hai cấu trúc khác nhau để gom : một cây cho điều kiện về nhãn, và cách xử lý theo bậc (nặng/nhẹ) cho điều kiện về cặp số.
- Bài tập đêm giáng sinh (voi24_noel) (Chuyên gia) VOI 2024: số cách phân đoạn là một QHĐ tổng trên cửa sổ; phần lớn quãng đường là các bước "chắc chắn chọn đoạn dài nhất" theo một con trỏ không phụ thuộc truy vấn, nên nhảy qua bằng bảng nhảy nhị phân (mẫu 6), chỉ tính QHĐ thật trên một đoạn ngắn cuối cùng.
Bình luận