Biến đổi một dãy số thành dãy không giảm, sao cho tổng độ lệch nhỏ nhất. DP theo giá trị thì có tới trạng thái mỗi bước.
Nhưng hàm chi phí theo trạng thái ở đây lồi, từng đoạn thẳng, độ dốc nguyên, nên chỉ cần nhớ các điểm gãy (nơi độ dốc tăng 1) thay vì từng giá trị. Hai heap (bên trái và bên phải điểm cực tiểu), cộng thêm giá trị nhỏ nhất và hai độ dời lười, đủ để cộng , lấy min tiền tố hay tịnh tiến hàm trong . Kỹ thuật này gọi là slope trick.
Hai heap và điểm gãy
Hàm lồi; độ dốc tăng 2 tại mỗi . Coi : mỗi số hạng góp một điểm gãy. Lưu:
- heap trái (lấy lớn nhất): các điểm gãy bên trái đoạn cực tiểu, độ dốc ở xa bên trái;
- heap phải (lấy nhỏ nhất): các điểm gãy bên phải, độ dốc ở xa bên phải;
- : giá trị nhỏ nhất. Đoạn cực tiểu là .
Cộng : nếu thì giá trị nhỏ nhất tăng ; đẩy vào rồi chuyển phần tử lớn nhất của sang . Cộng thì làm đối xứng. Hai phép này giữ đúng bất biến “mọi phần tử không lớn hơn mọi phần tử ”.
Trong hình, gõ thêm hay bớt số: mỗi điểm gãy làm độ dốc tăng 2. Nửa nhỏ nằm ở (xanh dương), nửa lớn ở (xanh lá), đoạn phẳng ở giữa.
Hỏi nhanh
Với , số làm nhỏ nhất và giá trị nhỏ nhất là gì?
Ba điểm gãy, trung vị là 5; .
Ví dụĐiểm Cân Bằng
Đề. ban đầu bằng 0. truy vấn: cộng , hoặc in số nguyên nhỏ nhất làm nhỏ nhất cùng .
Lời giải. là hàm lồi tuyến tính từng khúc. Giữ các điểm gãy trong hai heap: L (lớn nhất) chứa điểm gãy bên trái đoạn cực tiểu, R (nhỏ nhất) chứa điểm gãy bên phải, cùng mn là giá trị nhỏ nhất. cộng thẳng vào mn. Lính canh , để khỏi kiểm heap rỗng.
Bước 1: cộng là cộng hai hàm. Khối đầu là : nếu nằm bên trái top(L) thì giá trị nhỏ nhất tăng top(L) - a; đẩy vào L, chuyển phần tử lớn nhất của L sang R. Khối sau đối xứng với .
mn += b;
mn += max(0LL, L.top() - a);
L.push(a);
R.push(L.top()), L.pop();
mn += max(0LL, a - R.top());
R.push(a);
L.push(R.top()), R.pop();
Bất biến “mọi phần tử của L không lớn hơn mọi phần tử của R” được giữ sau mỗi khối, nên đoạn cực tiểu luôn là và mn là giá trị trên đoạn đó.
Bước 2: trả lời. In top(L): điểm nguyên nhỏ nhất của đoạn cực tiểu.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll INF = 2e18;
priority_queue<ll> L;
priority_queue<ll, vector<ll>, greater<ll>> R;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
int q;
cin >> q;
L.push(-INF), R.push(INF);
ll mn = 0;
while (q--) {
int t;
cin >> t;
if (t == 1) {
ll a, b;
cin >> a >> b;
mn += b;
mn += max(0LL, L.top() - a);
L.push(a);
R.push(L.top()), L.pop();
mn += max(0LL, a - R.top());
R.push(a);
L.push(R.top()), R.pop();
} else {
cout << L.top() << ' ' << mn << '\n';
}
}
}
5
1 4 3
1 8 -1
2
1 2 5
2
4 6
4 13
Độ phức tạp: mỗi truy vấn .
Dãy không giảm
DP chọn dãy không giảm gần nhất có dạng
Phép san phẳng mọi đoạn dốc lên: heap phải bị xóa sạch. Vậy chỉ cần heap trái. Cộng khi không có heap phải: đẩy vào ; nếu thì chi phí tăng , bỏ đỉnh và đẩy thêm lần nữa (một bản của thay cho điểm gãy vừa bị đẩy sang phải rồi bị xóa).
Trong hình, heap trái có đỉnh tô xanh. Mỗi số nhỏ hơn đỉnh làm tốn và thay đỉnh bằng .
Dãy tăng nghiêm ngặt đổi về không giảm bằng : nguyên tương đương .
Hỏi nhanh
Vì sao sau mỗi bước chỉ cần heap trái?
Sau , mọi độ dốc dương thành 0; điểm gãy bên phải không còn ý nghĩa.
Ví dụBậc Thang Nguyên
Đề. , . Chọn nguyên tăng nghiêm ngặt, nhỏ nhất.
Lời giải. Trừ khỏi để đổi dãy tăng nghiêm ngặt thành không giảm, như đã giải thích. là chi phí nhỏ nhất khi ; mỗi bước lấy min tiền tố rồi cộng . Phép lấy min tiền tố xóa sạch phía phải ở mỗi bước, nên chỉ cần heap trái.
Đẩy trước, rồi nếu đỉnh heap vẫn lớn hơn thì cộng chi phí, bỏ đỉnh và đẩy thêm một bản . Hai bản thay cho điểm gãy cũ: một bản của bên trái, một bản thay cho điểm vừa bị dời sang phải rồi bị xóa.
a -= i;
L.push(a);
if (L.top() > a) {
ans += L.top() - a;
L.pop();
L.push(a);
}
Chi phí tới , dùng long long.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n;
cin >> n;
priority_queue<ll> L;
ll ans = 0;
for (int i = 1; i <= n; i++) {
ll a;
cin >> a;
a -= i;
L.push(a);
if (L.top() > a) {
ans += L.top() - a;
L.pop();
L.push(a);
}
}
cout << ans << '\n';
}
5
5 4 3 8 8
5
. Heap ở đây được gọi hàng triệu lần, nên loại heap quan trọng:
priority_queue nhanh hơn multiset khoảng 6 lần: heap trên mảng liền nhau, còn cây đỏ đen cấp phát từng nút. Quy tắc: chỉ cần lấy phần tử lớn nhất thì dùng priority_queue.
Heap hoàn tác
Cùng ý tưởng nhìn theo hướng tham lam: mỗi phần tử heap là một quyết định còn có thể sửa. Bài mua bán mỗi ngày tối đa một cổ phiếu: duyệt giá ; nếu giá mua rẻ nhất còn trong heap là thì bán, lãi , rồi đẩy vào heap hai lần. Một bản là “hoàn tác”: nếu sau này gặp giá , lấy bản đó ra nghĩa là bán ở thay vì , lãi thêm . Bản còn lại là ngày làm điểm mua mới. Không bán thì chỉ đẩy một lần.
Theo slope trick, đây là hàm lợi nhuận lõm theo số cổ phiếu đang giữ; mỗi phần tử heap là một điểm gãy.
Ví dụNhịp Giá
Đề. ngày, giá . Mỗi ngày mua một, bán một hoặc không làm gì; cuối cùng không giữ cổ phiếu. Lợi nhuận lớn nhất.
Lời giải. Heap nhỏ nhất q chứa các giá có thể là điểm mua. Gặp giá lớn hơn giá rẻ nhất thì bán ngay, nhưng để lại một bản “hoàn tác” cho phép dời ngày bán về sau.
Giá lớn hơn top: lãi p - top, bỏ giá đó, rồi đẩy một lần làm bản hoàn tác. Sau đó (ngoài khối if) đẩy thêm một lần làm điểm mua của chính ngày đó.
if (!q.empty() && q.top() < p) {
ans += p - q.top();
q.pop();
q.push(p);
}
q.push(p);
Lấy bản hoàn tác ra ở một ngày về sau nghĩa là bán ở thay vì : lãi thêm đúng , cộng với lãi đã tính cho ra lãi của giao dịch dời ngày bán. Bản nào còn lại cuối cùng ứng với không giao dịch, không làm đổi đáp án.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n;
cin >> n;
priority_queue<ll, vector<ll>, greater<ll>> q;
ll ans = 0;
for (int i = 0; i < n; i++) {
ll p;
cin >> p;
if (!q.empty() && q.top() < p) {
ans += p - q.top();
q.pop();
q.push(p);
}
q.push(p);
}
cout << ans << '\n';
}
6
5 1 4 2 7 3
8
Độ phức tạp: .
Tịnh tiến hai nửa
Phép kéo nửa trái sang trái và nửa phải sang phải , đoạn cực tiểu dài thêm . Không sửa từng phần tử: giữ hai độ dời , ; giá trị thật là giá trị lưu cộng độ dời, và khi đẩy vào thì trừ độ dời đi.
Cộng sau đó chia ba trường hợp: nằm trong đoạn cực tiểu thì đẩy vào cả hai heap; lệch trái thì giá trị nhỏ nhất tăng , chuyển sang và đẩy hai lần vào ; lệch phải làm đối xứng.
Ví dụDải Hình Nối Liền
Đề. khoảng , dịch ngang với chi phí bằng độ dài dịch. Làm mọi cặp khoảng liên tiếp giao nhau, tổng chi phí nhỏ nhất.
Lời giải. là chi phí khi khoảng có đầu trái ; đặt . Khoảng ở giao khoảng ở khi . Lấy min trên đoạn đó là dời nửa trái của hàm đi , nửa phải đi ; rồi cộng .
Bước 1: dời cả heap bằng một biến. addL, addR là độ dời của hai heap. Giá trị thật là giá trị lưu cộng độ dời; khi đẩy vào thì trừ độ dời để lưu đúng.
addL -= r[i] - l[i];
addR += r[i - 1] - l[i - 1];
ll a = l[i], lt = L.top() + addL, rt = R.top() + addR;
Bước 2: cộng theo ba trường hợp. Nằm trong đoạn cực tiểu: đẩy vào cả hai heap. Lệch trái: chi phí tăng lt - a, top(L) chuyển sang R, đẩy hai lần vào L. Lệch phải đối xứng.
if (a < lt) {
ans += lt - a;
L.pop();
R.push(lt - addR);
L.push(a - addL), L.push(a - addL);
} else if (a > rt) {
ans += a - rt;
R.pop();
L.push(rt - addL);
R.push(a - addR), R.push(a - addR);
} else {
L.push(a - addL), R.push(a - addR);
}
ans là giá trị nhỏ nhất của hàm sau khoảng cuối cùng.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int N = 1e5 + 5;
int n;
ll l[N], r[N];
priority_queue<ll> L;
priority_queue<ll, vector<ll>, greater<ll>> R;
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (int i = 1; i <= n; i++) cin >> l[i] >> r[i];
L.push(l[1]), R.push(l[1]);
ll addL = 0, addR = 0, ans = 0;
for (int i = 2; i <= n; i++) {
addL -= r[i] - l[i];
addR += r[i - 1] - l[i - 1];
ll a = l[i], lt = L.top() + addL, rt = R.top() + addR;
if (a < lt) {
ans += lt - a;
L.pop();
R.push(lt - addR);
L.push(a - addL), L.push(a - addL);
} else if (a > rt) {
ans += a - rt;
R.pop();
L.push(rt - addL);
R.push(a - addR), R.push(a - addR);
} else {
L.push(a - addL), R.push(a - addR);
}
}
cout << ans << '\n';
}
3
1 3
6 8
9 12
4
Độ phức tạp: .
Gộp hàm trên cây
Trên cây, hàm của một đỉnh là tổng hàm của các con: gộp heap điểm gãy, đổ heap nhỏ vào heap lớn (; heap trái lệch cho ). Hàm của một cây con có độ dốc ở xa bên phải đúng bằng số con trực tiếp, nên bỏ đi đúng số điểm gãy lớn nhất cần thiết để biết hình dạng quanh cực tiểu.
Ví dụĐồng Bộ Tín Hiệu
Đề. Cây gốc 1, đỉnh, lá là đầu nhận. Đổi độ dài cạnh thành số nguyên không âm với chi phí bằng mức thay đổi, sao cho mọi lá cách gốc bằng nhau. Chi phí nhỏ nhất.
Lời giải. là chi phí nhỏ nhất để mọi lá trong cây con , tính cả cạnh lên cha, cách cha đúng . Lá: , heap gồm hai bản . Đỉnh trong có con: gộp, bỏ điểm gãy lớn nhất (còn độ dốc 1 bên phải), lấy hai điểm lớn nhất (đoạn phẳng ) và thay bằng , : cạnh dời đoạn phẳng, độ dốc hai bên thành . Ở gốc bỏ điểm lớn nhất; và mỗi điểm gãy còn lại bớt đi , nên đáp án là .
Bước 1: xử lý một đỉnh. Đề cho cha có chỉ số nhỏ hơn con, nên duyệt từ về 2 thì mọi con xong trước cha. Tổng hàm của các con có độ dốc deg[i] ở xa bên phải; bỏ deg[i] - 1 điểm gãy lớn nhất để độ dốc bên phải còn 1.
if (i > n) {
pq[i].push(c[i]), pq[i].push(c[i]);
} else {
for (int j = 1; j < deg[i]; j++) pq[i].pop();
ll a = pq[i].top();
pq[i].pop();
ll b = pq[i].top();
pq[i].pop();
pq[i].push(a + c[i]), pq[i].push(b + c[i]);
}
Bước 2: gộp vào cha. Đổ heap nhỏ vào heap lớn (swap trước để luôn đổ cái nhỏ). Mỗi điểm gãy chuyển lần, mỗi lần .
int p = par[i];
if (pq[p].size() < pq[i].size()) swap(pq[p], pq[i]);
while (!pq[i].empty()) pq[p].push(pq[i].top()), pq[i].pop();
Bước 3: ở gốc. Bỏ deg[1] điểm lớn nhất để độ dốc bên phải về 0; mỗi điểm gãy còn lại làm độ dốc tăng 1 từ .
for (int j = 0; j < deg[1]; j++) pq[1].pop();
while (!pq[1].empty()) sum -= pq[1].top(), pq[1].pop();
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int N = 3e5 + 5;
int n, m, par[N], deg[N];
ll c[N];
priority_queue<ll> pq[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
ll sum = 0;
for (int i = 2; i <= n + m; i++) {
cin >> par[i] >> c[i];
deg[par[i]]++;
sum += c[i];
}
for (int i = n + m; i >= 2; i--) {
if (i > n) {
pq[i].push(c[i]), pq[i].push(c[i]);
} else {
for (int j = 1; j < deg[i]; j++) pq[i].pop();
ll a = pq[i].top();
pq[i].pop();
ll b = pq[i].top();
pq[i].pop();
pq[i].push(a + c[i]), pq[i].push(b + c[i]);
}
int p = par[i];
if (pq[p].size() < pq[i].size()) swap(pq[p], pq[i]);
while (!pq[i].empty()) pq[p].push(pq[i].top()), pq[i].pop();
}
for (int j = 0; j < deg[1]; j++) pq[1].pop();
while (!pq[1].empty()) sum -= pq[1].top(), pq[1].pop();
cout << sum << '\n';
}
1 3
1 1
1 4
1 6
5
Độ phức tạp: .
Hỏi nhanh
Ở một đỉnh trong có 3 con, vì sao bỏ đúng 2 điểm gãy lớn nhất trước khi xử lý cạnh lên cha?
Kéo dài cạnh lên cha một đơn vị tốn 1, nên mọi đoạn có độ dốc lớn hơn 1 đều bị thay bằng độ dốc 1.
Khi đi thi
- DP trên số nguyên lớn với chuyển “cộng ”, “min tiền tố”, “min trên cửa sổ”: kiểm hàm lồi, độ dốc nguyên, rồi viết bằng hai heap.
- Ghi rõ giá trị lưu so với giá trị thật khi có độ dời lười; đẩy vào thì trừ độ dời, lấy ra thì cộng.
- Cần dãy nghiệm (không chỉ chi phí): lưu đỉnh heap sau mỗi bước rồi đi ngược, .
Tóm tắt
- Hàm lồi từng đoạn = giá trị nhỏ nhất + điểm gãy hai phía trong hai heap.
- ; mỗi phần là một lần đẩy và chuyển đỉnh.
- Min tiền tố: bỏ heap phải; dời cửa sổ: hai độ dời lười.
- Hàm của cây là tổng hàm con: gộp heap nhỏ vào lớn, cắt độ dốc thừa.
Bài tập
Đọc thêm
- Slope TrickUSACO Guide
- Slope Trick 优化OI Wiki