Nhiều quy hoạch động có chuyển tiếp dạng , hoặc đưa về được dạng đó sau khi khai triển một bình phương. Với mỗi , biểu thức là một đường thẳng theo ; là giá trị nhỏ nhất của các đường tại . Tập các đường chỉ cần giữ phần bao dưới: những đường từng là nhỏ nhất ở một khoảng nào đó. Khi hệ số góc thêm vào đơn điệu và điểm hỏi cũng đơn điệu, bao dưới giữ bằng một hàng đợi hai đầu và mỗi bước tốn trung bình, đưa về . Đây là kỹ thuật bao lồi đường thẳng (convex hull trick, CHT).
Chuyển tiếp là cực tiểu của các đường thẳng
Viết chuyển tiếp thành , trong đó và chỉ phụ thuộc , và chỉ phụ thuộc . Mỗi đã tính xong cho một đường thẳng ; hỏi là hỏi đường nào thấp nhất tại . Tập các đường thấp nhất theo tạo thành một bao dưới lồi: đi từ trái sang phải, hệ số góc của đường đang thấp nhất giảm dần. Một đường nằm hoàn toàn trên bao không bao giờ cần nữa.
Ví dụDãy Chuyển Tiếp Đơn Điệu
Đề. Hai dãy tăng ngặt () và giảm ngặt (), . , . In .
Lời giải. Mỗi cho một đường thẳng ; tính là hỏi giá trị nhỏ nhất của các đường tại . Hệ số góc giảm ngặt khi tăng, điểm hỏi tăng ngặt: đúng trường hợp hàng đợi hai đầu ở mục sau. Ở đây viết sẵn khung đó.
Bước 1: đường nào vô dụng. Bao lồi lưu trong hai mảng K (hệ số góc), M (tung độ gốc), dùng như hàng đợi hai đầu từ hd tới tl - 1. Đường giữa vô dụng khi giao điểm của với nằm không xa hơn giao điểm của với ; so bằng nhân chéo để khỏi chia, và tích tới nên dùng __int128.
bool bad(ll x, ll y, ll z) {
return (__int128)(M[z] - M[x]) * (K[x] - K[y]) <= (__int128)(M[y] - M[x]) * (K[x] - K[z]);
}
Bước 2: thêm đường vào cuối. Ghi đường mới vào ô tl; chừng nào đường áp chót vô dụng thì chép đường mới đè lên nó và lùi tl; cuối cùng tăng tl.
void add(ll k, ll m) {
K[tl] = k, M[tl] = m;
while (tl - hd >= 2 && bad(tl - 2, tl - 1, tl)) {
K[tl - 1] = K[tl], M[tl - 1] = M[tl];
tl--;
}
tl++;
}
Bước 3: hỏi ở đầu. Điểm hỏi tăng dần nên đường tốt nhất chỉ dịch sang phải: bỏ đường đầu khi đường thứ hai đã không tệ hơn tại .
ll get(ll x) {
while (tl - hd >= 2 && K[hd + 1] * x + M[hd + 1] <= K[hd] * x + M[hd]) hd++;
return K[hd] * x + M[hd];
}
Bước 4: quy hoạch động. Hỏi tại được , rồi thêm đường của chính cho các bước sau.
add(b[1], dp[1]);
for (ll i = 2; i <= n; i++) {
dp[i] = get(a[i]);
add(b[i], dp[i]);
}
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 1e5 + 5;
ll n, a[N], b[N], dp[N], K[N], M[N], hd, tl;
bool bad(ll x, ll y, ll z) {
return (__int128)(M[z] - M[x]) * (K[x] - K[y]) <= (__int128)(M[y] - M[x]) * (K[x] - K[z]);
}
void add(ll k, ll m) {
K[tl] = k, M[tl] = m;
while (tl - hd >= 2 && bad(tl - 2, tl - 1, tl)) {
K[tl - 1] = K[tl], M[tl - 1] = M[tl];
tl--;
}
tl++;
}
ll get(ll x) {
while (tl - hd >= 2 && K[hd + 1] * x + M[hd + 1] <= K[hd] * x + M[hd]) hd++;
return K[hd] * x + M[hd];
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) cin >> a[i];
for (ll i = 1; i <= n; i++) cin >> b[i];
dp[1] = 0;
add(b[1], dp[1]);
for (ll i = 2; i <= n; i++) {
dp[i] = get(a[i]);
add(b[i], dp[i]);
}
cout << dp[n] << '\n';
}
4
1 2 4 7
9 6 3 0
57
Mỗi đường vào và ra hàng đợi một lần: .
Hàng đợi hai đầu khi mọi thứ đơn điệu
Khi đường được thêm theo hệ số góc giảm dần (cho bài min), đường mới luôn thấp nhất ở rất xa bên phải, nên được đặt vào cuối bao. Đường áp chót giữa (trước nó) và (đường mới) trở nên vô dụng khi giao điểm của và nằm không xa hơn giao điểm của và về bên trái: lúc đó trên mọi , hoặc hoặc thấp hơn . So hai giao điểm bằng nhân chéo, không chia:
Khi điểm hỏi tăng dần, đường tốt nhất chỉ dịch sang phải trên bao: bỏ đường đầu khi đường thứ hai đã không tệ hơn tại hiện tại. Mỗi đường vào và ra hàng đợi một lần, tổng . Nếu điểm hỏi không đơn điệu, giữ nguyên bao và tìm kiếm nhị phân đường tốt nhất, mỗi câu. Nếu cả hệ số góc cũng không đơn điệu, dùng cây Li Chao (bài 75).
Hỏi nhanh
Bao đang có đường , (hệ số góc giảm dần), thêm đường có hệ số góc nhỏ hơn nữa. Khi nào bỏ ?
chỉ thấp nhất trên đoạn từ giao điểm với tới giao điểm với . Nếu vượt xuống dưới sớm hơn hay cùng lúc với , đoạn đó rỗng và vô dụng mãi về sau, vì đường mới chỉ thêm vào bên phải.
Ví dụHệ Số Sau Mỗi Trạm
Đề. trạm có tải , hệ số . Xử lý trạm với hệ số tốn rồi hệ số thành . Phải xử lý trạm ; tìm chi phí nhỏ nhất.
Lời giải. là chi phí nhỏ nhất khi trạm là trạm xử lý gần nhất: . Đường ban đầu ứng với chưa xử lý trạm nào, hệ số vẫn là ; đường của có hệ số góc , tung độ gốc . Hệ số góc không tăng, điểm hỏi không giảm: dùng khung hàng đợi của ví dụ trước. Đáp án là của trạm cuối, vì trạm bắt buộc phải xử lý.
Bước 1: hai đường cùng hệ số góc. Hệ số góc chỉ không tăng nên có thể bằng nhau. Khi đó chỉ giữ đường có tung độ gốc nhỏ hơn; không xử lý trường hợp này thì bad sẽ nhân với hiệu hệ số góc bằng 0 và cho kết quả sai.
void add(ll k, ll m) {
if (tl > hd && K[tl - 1] == k) {
if (M[tl - 1] <= m) return;
tl--;
}
K[tl] = k, M[tl] = m;
while (tl - hd >= 2 && bad(tl - 2, tl - 1, tl)) {
K[tl - 1] = K[tl], M[tl - 1] = M[tl];
tl--;
}
tl++;
}
Bước 2: quy hoạch động. bad và get như ví dụ trước.
add(x, 0);
ll dp = 0;
for (ll i = 1; i <= n; i++) {
dp = get(s[i]);
add(f[i], dp);
}
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 2e5 + 5;
ll n, x, s[N], f[N], K[N], M[N], hd, tl;
bool bad(ll a, ll b, ll c) {
return (__int128)(M[c] - M[a]) * (K[a] - K[b]) <= (__int128)(M[b] - M[a]) * (K[a] - K[c]);
}
void add(ll k, ll m) {
if (tl > hd && K[tl - 1] == k) {
if (M[tl - 1] <= m) return;
tl--;
}
K[tl] = k, M[tl] = m;
while (tl - hd >= 2 && bad(tl - 2, tl - 1, tl)) {
K[tl - 1] = K[tl], M[tl - 1] = M[tl];
tl--;
}
tl++;
}
ll get(ll q) {
while (tl - hd >= 2 && K[hd + 1] * q + M[hd + 1] <= K[hd] * q + M[hd]) hd++;
return K[hd] * q + M[hd];
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> x;
for (ll i = 1; i <= n; i++) cin >> s[i];
for (ll i = 1; i <= n; i++) cin >> f[i];
add(x, 0);
ll dp = 0;
for (ll i = 1; i <= n; i++) {
dp = get(s[i]);
add(f[i], dp);
}
cout << dp << '\n';
}
3 5
1 3 6
4 2 1
27
.
Khai triển bình phương
Chi phí kiểu hay không phải đường thẳng theo , nhưng khai triển ra thì có: . Phần chỉ phụ thuộc , đưa ra ngoài min; phần còn lại là đường có hệ số góc , tung độ gốc , hỏi tại . Dãy tăng thì hệ số góc giảm và điểm hỏi tăng, đúng trường hợp hàng đợi hai đầu. Với chia dãy thành đoạn và chi phí bình phương tổng đoạn, làm tương tự với tổng tiền tố . Giá trị trung gian có thể tới nhân , nên phép so sánh giao điểm cần __int128.
Hỏi nhanh
Chuyển tiếp . Sau khi khai triển, đường của có hệ số góc và tung độ gốc là gì?
. Phần chỉ phụ thuộc nên ra ngoài min; phần nhân với là hệ số góc ; phần chỉ phụ thuộc , cùng , là tung độ gốc.
Ví dụBậc Đá Tăng Dần
Đề. bậc với độ cao tăng ngặt tới . Nhảy từ tới tốn . Tìm chi phí nhỏ nhất từ bậc 1 tới bậc .
Lời giải. Khai triển bình phương: . Phần trong ngoặc là đường thẳng của tại : hệ số góc (giảm dần), tung độ gốc . Độ cao tăng ngặt nên không có hai đường cùng hệ số góc, add không cần bước kiểm đó.
Bước 1: khung hàng đợi bad, add, get như ví dụ đầu.
Bước 2: quy hoạch động. Bậc 1 có . Mỗi bậc hỏi tại (tăng dần), cộng , rồi thêm đường của chính nó.
add(-2 * h[1], h[1] * h[1]);
for (ll i = 2; i <= n; i++) {
dp[i] = get(h[i]) + h[i] * h[i] + c;
add(-2 * h[i], dp[i] + h[i] * h[i]);
}
Đáp án tới khoảng .
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 2e5 + 5;
ll n, c, h[N], dp[N], K[N], M[N], hd, tl;
bool bad(ll a, ll b, ll d) {
return (__int128)(M[d] - M[a]) * (K[a] - K[b]) <= (__int128)(M[b] - M[a]) * (K[a] - K[d]);
}
void add(ll k, ll m) {
K[tl] = k, M[tl] = m;
while (tl - hd >= 2 && bad(tl - 2, tl - 1, tl)) {
K[tl - 1] = K[tl], M[tl - 1] = M[tl];
tl--;
}
tl++;
}
ll get(ll x) {
while (tl - hd >= 2 && K[hd + 1] * x + M[hd + 1] <= K[hd] * x + M[hd]) hd++;
return K[hd] * x + M[hd];
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> c;
for (ll i = 1; i <= n; i++) cin >> h[i];
add(-2 * h[1], h[1] * h[1]);
for (ll i = 2; i <= n; i++) {
dp[i] = get(h[i]) + h[i] * h[i] + c;
add(-2 * h[i], dp[i] + h[i] * h[i]);
}
cout << dp[n] << '\n';
}
4 3
1 2 5 8
28
.
Ví dụChia Bản Thảo
Đề. Nhiều bộ tới hết input. Dãy số không âm và hằng số ; chia dãy thành đoạn, đoạn có tổng tốn . Tìm tổng nhỏ nhất.
Lời giải. Với tổng tiền tố : , . Đường của có hệ số góc , tung độ gốc ; cho đường . Các số có thể bằng 0 nên chỉ không giảm: hai đường liên tiếp có thể cùng hệ số góc, nên dùng bản add có kiểm cùng hệ số góc như ví dụ hệ số trạm.
Bước 1: đọc thẳng vào tổng tiền tố, đặt lại hàng đợi mỗi bộ.
for (ll i = 1; i <= n; i++) {
cin >> S[i];
S[i] += S[i - 1];
}
hd = tl = 0;
add(0, 0);
Bước 2: quy hoạch động. dp chỉ cần giá trị hiện tại, vì đường của đã mang theo nó.
ll dp = 0;
for (ll i = 1; i <= n; i++) {
dp = get(S[i]) + S[i] * S[i] + m;
add(-2 * S[i], dp + S[i] * S[i]);
}
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 5e5 + 5;
ll n, m, S[N], K[N], M[N], hd, tl;
bool bad(ll a, ll b, ll c) {
return (__int128)(M[c] - M[a]) * (K[a] - K[b]) <= (__int128)(M[b] - M[a]) * (K[a] - K[c]);
}
void add(ll k, ll v) {
if (tl > hd && K[tl - 1] == k) {
if (M[tl - 1] <= v) return;
tl--;
}
K[tl] = k, M[tl] = v;
while (tl - hd >= 2 && bad(tl - 2, tl - 1, tl)) {
K[tl - 1] = K[tl], M[tl - 1] = M[tl];
tl--;
}
tl++;
}
ll get(ll x) {
while (tl - hd >= 2 && K[hd + 1] * x + M[hd + 1] <= K[hd] * x + M[hd]) hd++;
return K[hd] * x + M[hd];
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
while (cin >> n >> m) {
for (ll i = 1; i <= n; i++) {
cin >> S[i];
S[i] += S[i - 1];
}
hd = tl = 0;
add(0, 0);
ll dp = 0;
for (ll i = 1; i <= n; i++) {
dp = get(S[i]) + S[i] * S[i] + m;
add(-2 * S[i], dp + S[i] * S[i]);
}
cout << dp << '\n';
}
}
4 2
1 2 1 3
23
Mỗi bộ .
Sắp và bỏ phần tử bị trội
Khi dữ liệu chưa có thứ tự, hãy tìm thứ tự làm chuyển tiếp thành đoạn liên tiếp và làm hệ số góc, điểm hỏi đơn điệu. Với các hình chữ nhật mua theo nhóm, chi phí nhóm là . Một hình nằm gọn trong hình khác (cả lẫn không lớn hơn) đi cùng hình kia mà không tốn thêm, nên bỏ nó. Sau khi bỏ, sắp theo tăng thì giảm ngặt, và nhóm tối ưu luôn là một đoạn liên tiếp: chi phí đoạn là .
Ví dụGom Lô Đất
Đề. lô đất . Mua một nhóm tốn chiều rộng lớn nhất nhân chiều cao lớn nhất của nhóm. Chia thành các nhóm để tổng chi phí nhỏ nhất.
Lời giải. Một lô vừa hẹp hơn vừa thấp hơn một lô khác thì đi chung nhóm với lô đó miễn phí, nên bỏ được. Sau khi bỏ, sắp theo tăng thì giảm ngặt, và một nhóm tốt nhất luôn là một đoạn liên tiếp: nhóm tốn (rộng nhất là lô cuối, cao nhất là lô đầu). Vậy : đường của có hệ số góc giảm dần, điểm hỏi tăng dần.
Bước 1: bỏ lô bị chứa. Sắp theo ; dùng ngăn xếp: lô mới cao không kém lô trên đỉnh thì lô trên đỉnh nằm gọn trong lô mới (hẹp hơn, thấp hơn), bỏ nó.
sort(p + 1, p + n + 1);
for (ll i = 1; i <= n; i++) {
while (m && h[m] <= p[i].second) m--;
m++;
w[m] = p[i].first;
h[m] = p[i].second;
}
Bước 2: quy hoạch động. Đường của là , thêm ngay sau khi có (với là ). Lô cuối không cần thêm đường vì không còn lô nào sau nó.
ll dp = 0;
add(h[1], 0);
for (ll i = 1; i <= m; i++) {
dp = get(w[i]);
if (i < m) add(h[i + 1], dp);
}
Chi phí tới , dùng long long; so sánh giao điểm trong __int128.
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll N = 5e4 + 5;
ll n, m, w[N], h[N], K[N], M[N], hd, tl;
pair<ll, ll> p[N];
bool bad(ll a, ll b, ll c) {
return (__int128)(M[c] - M[a]) * (K[a] - K[b]) <= (__int128)(M[b] - M[a]) * (K[a] - K[c]);
}
void add(ll k, ll v) {
K[tl] = k, M[tl] = v;
while (tl - hd >= 2 && bad(tl - 2, tl - 1, tl)) {
K[tl - 1] = K[tl], M[tl - 1] = M[tl];
tl--;
}
tl++;
}
ll get(ll x) {
while (tl - hd >= 2 && K[hd + 1] * x + M[hd + 1] <= K[hd] * x + M[hd]) hd++;
return K[hd] * x + M[hd];
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++) cin >> p[i].first >> p[i].second;
sort(p + 1, p + n + 1);
for (ll i = 1; i <= n; i++) {
while (m && h[m] <= p[i].second) m--;
m++;
w[m] = p[i].first;
h[m] = p[i].second;
}
ll dp = 0;
add(h[1], 0);
for (ll i = 1; i <= m; i++) {
dp = get(w[i]);
if (i < m) add(h[i + 1], dp);
}
cout << dp << '\n';
}
3
2 5
4 3
6 2
28
cho phần sắp, phần còn lại .
Khi đi thi
- Chuyển tiếp có tích hoặc bình phương hiệu: tách thành phần theo , phần theo và tích chéo; tích chéo cho hệ số góc và điểm hỏi.
- Kiểm tính đơn điệu: hệ số góc thêm vào có giảm (bài min) và điểm hỏi có tăng không. Không đơn điệu điểm hỏi thì tìm kiếm nhị phân trên bao; không đơn điệu hệ số góc thì cây Li Chao (bài 75).
- So giao điểm bằng nhân chéo với
__int128, xử lý hai đường cùng hệ số góc riêng; bài max thì đổi dấu hoặc đảo chiều các bất đẳng thức.
Tóm tắt
- : mỗi là một đường, cần bao dưới tại .
- Hệ số góc giảm, điểm hỏi tăng: hàng đợi hai đầu, thêm ở cuối bỏ đường vô dụng, hỏi ở đầu bỏ đường đã thua; .
- Đường giữa và vô dụng khi .
- Bình phương hiệu khai triển thành đường; sắp và bỏ phần tử bị trội để có thứ tự đơn điệu.
Bài tập
Đọc thêm
- Convex Hull TrickVNOI WikiBài dịch về bao lồi đường thẳng và các ví dụ.
- Convex hull trick and Li Chao treecp-algorithmsBao lồi bằng ngăn xếp và cây Li Chao.
- Convex Hull TrickUSACO GuideDạng chuyển tiếp hay gặp và bài luyện.