Nhiều bài quy hoạch động có công thức đúng ngay từ đầu nhưng quá chậm: lấy max hay tổng trên mọi thỏa một điều kiện, tức . Bài này không thêm trạng thái mới mà làm cho bước chuyển nhanh hơn. Quan sát xem tập các được xét có hình dạng gì (một tiền tố của miền giá trị, một đoạn liền, một cửa sổ trượt), rồi chọn cấu trúc trả lời đúng câu hỏi đó: mảng đuôi, Fenwick, tổng tiền tố, hàng đợi đơn điệu, cây phân đoạn.
Mảng đuôi cho dãy con tăng
Công thức quen thuộc của dãy con tăng dài nhất (bài 12) là trên các có , . Đổi góc nhìn: thay vì nhớ độ dài tốt nhất kết thúc tại từng vị trí, nhớ với mỗi độ dài phần tử cuối nhỏ nhất của một dãy con tăng dài , gọi là tail[k - 1] (đánh số từ 0). Đuôi càng nhỏ thì càng dễ nối thêm, nên các dãy có đuôi lớn hơn cùng độ dài bị trội và không cần nhớ.
Mảng tail tăng nghiêm ngặt: một dãy dài có đuôi chứa một dãy dài có đuôi nhỏ hơn . Đọc , tìm vị trí đầu tiên trong tail có giá trị bằng tìm kiếm nhị phân. Không có thì nối vào dãy dài nhất, tail dài thêm 1; có thì là đuôi nhỏ hơn cho độ dài đó, ghi đè. Độ dài cuối của tail là đáp án.
tail không phải một dãy con thật của đầu vào: các giá trị trong nó có thể đến từ những dãy khác nhau. Muốn in một dãy con tối ưu, lưu thêm với mỗi vị trí của phần tử đứng trước nó lúc chèn, rồi lần ngược. Dãy con không giảm thì dùng upper_bound thay cho lower_bound.
Hỏi nhanh
Đang có tail = 1 2 6 9 và đọc tiếp số 4. Mảng tail thành gì?
Phần tử đầu tiên không nhỏ hơn 4 là 6, ở vị trí độ dài 3. Số 4 nối sau dãy dài 2 có đuôi 2, cho một dãy dài 3 có đuôi nhỏ hơn, nên ghi đè 6. tail luôn tăng và độ dài chỉ tăng khi số mới lớn hơn mọi đuôi.
Ví dụNhững Mốc Tăng Dần
Đề. Dãy số. Tìm độ dài dãy con tăng nghiêm ngặt dài nhất.
Lời giải. Mảng đuôi với lower_bound, . Công thức cần phép ở giới hạn này. Với ví dụ, tail lần lượt là 5, 1, 1 4, 1 2, 1 2 3, 1 2 3 9, 1 2 3 6, 1 2 3 6 7.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
ll n;
vector<ll> tail;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) {
ll x;
cin >> x;
ll p = lower_bound(tail.begin(), tail.end(), x) - tail.begin();
if (p == (ll)tail.size()) tail.push_back(x);
else tail[p] = x;
}
cout << tail.size() << '\n';
}
8
5 1 4 2 3 9 6 7
5
Fenwick trên miền giá trị
Chuyển tiếp “lấy max (hay tổng) trên mọi có ” có hai điều kiện. Điều kiện tự thỏa nếu xử lý từ trái sang và chỉ ghi vào cấu trúc sau khi tính xong. Điều kiện thành một tiền tố trên miền giá trị: lập Fenwick (bài 16) chỉ số theo giá trị, ghi vào ô , hỏi tiền tố . Giá trị lớn thì nén về trước.
Fenwick hỏi tiền tố được cả max (khi giá trị trong ô chỉ tăng) lẫn tổng. Cùng khuôn đó giải dãy con tăng có trọng số lớn nhất, đếm số dãy con tăng, đếm dãy con tăng có đúng phần tử (mỗi độ dài một Fenwick). Điều kiện là hai tiền tố, hoặc một tiền tố và một hậu tố, trên miền giá trị; khi đó dùng cây phân đoạn hỏi đoạn.
Ví dụVườn Hoa Có Thứ Tự
Đề. bông hoa, bông cao (một hoán vị của ) và có giá trị . Giữ lại các bông có chiều cao tăng dần từ trái sang, tổng giá trị lớn nhất.
Lời giải. trên , . Fenwick max theo chiều cao: hỏi tiền tố , rồi ghi vào ô . Đáp án là max mọi , tới , cần ll. Với ví dụ, bông thứ 6 cao 3 lấy max của các bông cao 1, 2 là 30 và cộng 30.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 2e5 + 5;
ll n, h[N], b[N], bit[N];
void update(ll pos, ll val) {
for (; pos <= n; pos += pos & -pos) bit[pos] = max(bit[pos], val);
}
ll get(ll pos) {
ll res = 0;
for (; pos > 0; pos -= pos & -pos) res = max(res, bit[pos]);
return res;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) cin >> h[i];
for (ll i = 1; i <= n; i++) cin >> b[i];
ll ans = 0;
for (ll i = 1; i <= n; i++) {
ll cur = b[i] + get(h[i] - 1);
update(h[i], cur);
ans = max(ans, cur);
}
cout << ans << '\n';
}
6
4 1 5 2 6 3
8 10 4 20 7 30
60
Ví dụĐếm Cách Chọn Tăng
Đề. Dãy số tới . Đếm số dãy con không rỗng tăng nghiêm ngặt (khác nhau theo tập vị trí), modulo .
Lời giải. là số dãy con tăng kết thúc tại : chính nó cộng mọi dãy kết thúc tại có . Nén giá trị, Fenwick tổng modulo: hỏi tiền tố hạng , cộng 1, ghi vào ô . Hai giá trị bằng nhau có cùng hạng, nên tiền tố tự loại chúng. Với ví dụ, , tổng 9.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 2e5 + 5, MOD = 1e9 + 7;
ll n, x[N], v[N], bit[N];
void update(ll pos, ll val) {
for (; pos <= n; pos += pos & -pos) bit[pos] = (bit[pos] + val) % MOD;
}
ll get(ll pos) {
ll res = 0;
for (; pos > 0; pos -= pos & -pos) res = (res + bit[pos]) % MOD;
return res;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) {
cin >> x[i];
v[i] = x[i];
}
sort(v + 1, v + n + 1);
ll ans = 0;
for (ll i = 1; i <= n; i++) {
ll r = lower_bound(v + 1, v + n + 1, x[i]) - v;
ll cur = (get(r - 1) + 1) % MOD;
update(r, cur);
ans = (ans + cur) % MOD;
}
cout << ans << '\n';
}
4
2 1 2 3
9
Chuyển tiếp từ một đoạn
Khi các hợp lệ của tạo thành một đoạn liền và cần tổng (hay chỉ cần biết có tồn tại), tổng tiền tố của chính mảng trả lời trong : tính xong thì cập nhật . Hai đầu thường đơn điệu theo , tìm bằng hai con trỏ (bài 06) hoặc tìm kiếm nhị phân. Cùng ý tưởng làm đếm số cách đi bậc thang với bước tối đa thành , và đếm dãy nằm giữa hai biên thành tổng tiền tố theo giá trị.
Ví dụChia Nhóm Theo Độ Chênh
Đề. thẻ có giá trị. Hỏi có chia được toàn bộ thẻ thành các nhóm, mỗi nhóm ít nhất thẻ và chênh lệch lớn nhất trong nhóm không quá , hay không.
Lời giải. Sắp tăng dần; có cách chia thì có cách chia mà mỗi nhóm là một đoạn liền của dãy đã sắp (đổi chỗ hai thẻ lệch giữa hai nhóm không làm hỏng điều kiện). : thẻ đầu chia được. Nhóm cuối là , cần và , tức với là thẻ đầu tiên có . Vậy đúng khi có đúng với trong đoạn ; tăng theo nên dùng con trỏ, còn đoạn thì hỏi bằng tổng tiền tố của . Với ví dụ, sau khi sắp là , chia sau thẻ thứ 3.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 5e5 + 5;
ll n, k, d, a[N], ok[N], pre[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> k >> d;
for (ll i = 1; i <= n; i++) cin >> a[i];
sort(a + 1, a + n + 1);
ok[0] = pre[0] = 1;
for (ll i = 1, lo = 1; i <= n; i++) {
while (a[i] - a[lo] > d) lo++;
ll L = lo - 1, R = i - k;
if (R >= L) ok[i] = pre[R] - (L ? pre[L - 1] : 0) > 0;
pre[i] = pre[i - 1] + ok[i];
}
cout << (ok[n] ? "YES" : "NO") << '\n';
}
7 3 2
12 1 11 3 10 2 12
YES
Cửa sổ trượt và hàng đợi đơn điệu
Chuyển tiếp lấy max trên một cửa sổ trượt theo không dùng được tổng tiền tố. Hàng đợi đơn điệu (bài 06) giữ các chỉ số trong cửa sổ theo thứ tự, giá trị giảm dần: thêm chỉ số mới ở cuối thì bỏ hết các chỉ số cuối có giá trị không lớn hơn, vì chúng vừa cũ hơn vừa kém hơn; chỉ số đầu hàng rời cửa sổ thì bỏ ở đầu. Đầu hàng luôn là max của cửa sổ. Mỗi chỉ số vào và ra một lần, nên cả lượt thay cho .
Hỏi nhanh
Hàng đợi đơn điệu cho max đang chứa các chỉ số có giá trị (từ đầu tới cuối). Thêm một chỉ số mới có giá trị 7, bỏ ở cuối mọi giá trị không lớn hơn nó. Hàng đợi thành gì?
Bỏ 4, rồi bỏ cả 7 cũ vì nó không lớn hơn và rời cửa sổ trước chỉ số mới. Gặp 9 lớn hơn thì dừng và đẩy chỉ số mới vào. Số 9 vẫn ở lại vì nó lớn hơn, dù cũ hơn.
Ví dụChọn Ảnh Phủ Mọi Cửa Sổ
Đề. ảnh có độ đẹp. Chọn đúng ảnh sao cho mọi đoạn ảnh liên tiếp chứa ít nhất một ảnh được chọn, tổng độ đẹp lớn nhất; không chọn được thì in .
Lời giải. Điều kiện tương đương: ảnh chọn đầu tiên ở vị trí , ảnh cuối ở vị trí , và hai ảnh liên tiếp cách nhau không quá . là tổng lớn nhất khi chọn ảnh, ảnh cuối ở : trên , với và mọi trạng thái khác của tầng 0 là để ép ảnh đầu ở vị trí . Mỗi tầng một lượt hàng đợi đơn điệu, tổng ; chỉ giữ hai tầng để bộ nhớ .
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 5005, NEG = -1e18;
ll n, k, x, a[N], f[N], g[N], dq[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> k >> x;
for (ll i = 1; i <= n; i++) cin >> a[i];
for (ll i = 1; i <= n; i++) f[i] = NEG;
for (ll j = 1; j <= x; j++) {
ll h = 0, t = 0;
g[0] = NEG;
for (ll i = 1; i <= n; i++) {
while (t > h && f[dq[t - 1]] <= f[i - 1]) t--;
dq[t++] = i - 1;
while (dq[h] < i - k) h++;
g[i] = f[dq[h]] == NEG ? NEG : f[dq[h]] + a[i];
}
for (ll i = 0; i <= n; i++) f[i] = g[i];
}
ll ans = NEG;
for (ll i = n - k + 1; i <= n; i++) ans = max(ans, f[i]);
cout << (ans == NEG ? -1 : ans) << '\n';
}
6 3 2
4 9 1 8 2 7
17
Cây phân đoạn giữ cả mảng quy hoạch động
Có bài mà mỗi bước không chỉ tính một mới, mà còn làm thay đổi cả một đoạn các giá trị cũ: thêm một khoảng có trọng số thì mọi lựa chọn “điểm chọn cuối nằm trong khoảng” cùng được cộng trọng số. Đặt cả mảng lên cây phân đoạn có cộng đoạn lười và hỏi max đoạn (bài 17): mỗi bước là một câu hỏi max, một lần gán điểm và vài lần cộng đoạn, mỗi thao tác.
Khuôn này còn giải chia dãy thành đoạn với giá trị mỗi đoạn là số phần tử khác nhau: khi thêm , mọi đoạn bắt đầu sau lần xuất hiện trước của được cộng 1.
Ví dụChọn Điểm Để Kích Hoạt Các Khoảng
Đề. vị trí, khoảng có trọng số (có thể âm). Chọn một tập vị trí; mỗi khoảng chứa ít nhất một vị trí được chọn cho điểm . Tìm tổng điểm lớn nhất.
Lời giải. Mỗi khoảng chỉ cần biết vị trí được chọn cuối cùng không quá của nó có hay không. Gọi là điểm tốt nhất khi vị trí chọn cuối là (: chưa chọn gì), chỉ tính các khoảng đã kết thúc. Quét từ 1 tới :
- Chọn thì vị trí chọn trước đó có thể là bất kỳ : .
- Mỗi khoảng kết thúc ở được kích hoạt đúng khi vị trí chọn cuối nằm trong : cộng vào .
Đáp án là max toàn mảng sau khi quét hết. Hai thao tác là “gán một ô bằng max một đoạn” và “cộng một đoạn”, nên dùng cây phân đoạn max có thẻ cộng.
Bước 1: cây max với thẻ cộng. Như cây lười ở bài 17: apply cộng vào giá trị và thẻ của nút; down đẩy thẻ cho hai con. Ở đây nút lưu max nên cộng vào cả đoạn làm max tăng đúng , không cần biết độ dài đoạn.
void apply(ll id, ll val) {
st[id] += val;
lz[id] += val;
}
void down(ll id) {
if (!lz[id]) return;
apply(id * 2, lz[id]);
apply(id * 2 + 1, lz[id]);
lz[id] = 0;
}
Bước 2: gom khoảng theo đầu phải. at[r] là danh sách các khoảng kết thúc ở , mỗi khoảng lưu .
for (ll i = 1; i <= m; i++) {
ll l, r, a;
cin >> l >> r >> a;
at[r].push_back({l, a});
}
Bước 3: quét. Cây đánh số . Gán bằng cách cộng max của vào ô : ô chưa được chạm tới trước đó nên đang bằng 0. Rồi cộng trọng số của từng khoảng kết thúc ở .
for (ll i = 1; i <= n; i++) {
update(1, 0, n, i, i, get(1, 0, n, 0, i - 1));
for (pair<ll, ll> p : at[i]) update(1, 0, n, p.first, i, p.second);
}
cout << get(1, 0, n, 0, n) << '\n';
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 2e5 + 5, NEG = -1e18;
ll n, m, st[4 * N], lz[4 * N];
vector<pair<ll, ll>> at[N];
void apply(ll id, ll val) {
st[id] += val;
lz[id] += val;
}
void down(ll id) {
if (!lz[id]) return;
apply(id * 2, lz[id]);
apply(id * 2 + 1, lz[id]);
lz[id] = 0;
}
void update(ll id, ll l, ll r, ll u, ll v, ll val) {
if (v < l || r < u) return;
if (u <= l && r <= v) {
apply(id, val);
return;
}
down(id);
ll mid = (l + r) >> 1;
update(id * 2, l, mid, u, v, val);
update(id * 2 + 1, mid + 1, r, u, v, val);
st[id] = max(st[id * 2], st[id * 2 + 1]);
}
ll get(ll id, ll l, ll r, ll u, ll v) {
if (v < l || r < u) return NEG;
if (u <= l && r <= v) return st[id];
down(id);
ll mid = (l + r) >> 1;
return max(get(id * 2, l, mid, u, v), get(id * 2 + 1, mid + 1, r, u, v));
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (ll i = 1; i <= m; i++) {
ll l, r, a;
cin >> l >> r >> a;
at[r].push_back({l, a});
}
for (ll i = 1; i <= n; i++) {
update(1, 0, n, i, i, get(1, 0, n, 0, i - 1));
for (pair<ll, ll> p : at[i]) update(1, 0, n, p.first, i, p.second);
}
cout << get(1, 0, n, 0, n) << '\n';
}
4 4
1 2 5
2 4 7
1 4 -4
3 3 -10
8
Mỗi vị trí và mỗi khoảng tốn : tổng .
Khi đi thi
- Viết công thức trước, rồi hỏi: tập được xét là gì? Tiền tố giá trị thì Fenwick, đoạn liền cần tổng thì tổng tiền tố, cửa sổ cần max thì hàng đợi đơn điệu, còn cả đoạn giá trị cũ bị sửa thì cây phân đoạn lười.
- Ghi vào cấu trúc sau khi tính xong, để không tự chuyển từ chính nó; với điều kiện nghiêm ngặt trên giá trị, hai phần tử bằng nhau phải không nhìn thấy nhau.
- Trạng thái không hợp lệ gán và kiểm trước khi cộng; hai tầng liên tiếp là đủ thì chỉ giữ hai mảng.
Tóm tắt
- Mảng đuôi giữ phần tử cuối nhỏ nhất cho mỗi độ dài; dãy con tăng dài nhất trong .
- “Mọi trước có giá trị nhỏ hơn” là câu hỏi tiền tố trên Fenwick theo giá trị đã nén, cho cả max lẫn tổng.
- Chuyển tiếp từ đoạn liền dùng tổng tiền tố của mảng ; cửa sổ trượt cần max dùng hàng đợi đơn điệu.
- Khi một bước sửa cả đoạn giá trị cũ, giữ mảng trên cây phân đoạn có cộng đoạn lười.
Bài tập
Đọc thêm
- Longest increasing subsequencecp-algorithmsMảng đuôi, truy vết và cách dùng cây phân đoạn.
- Longest Increasing SubsequenceUSACO GuideMảng đuôi kèm bài luyện từ USACO, CSES và Codeforces.
- Hàng đợi hai đầu và bài toán max-min trên đoạn tịnh tiếnVNOI WikiHàng đợi đơn điệu viết bằng tiếng Việt.
- 单调队列/单调栈优化OI WikiTối ưu quy hoạch động bằng hàng đợi và ngăn xếp đơn điệu.