Chương 7·Bài 79

Slope Trick

Nhiều DP có trạng thái là một số nguyên tới 10910^9, nhưng hàm chi phí theo trạng thái lại lồi, từng đoạn thẳng, độ dốc nguyên. Slope trick không lưu giá trị từng điểm mà lưu các điểm gãy: mỗi điểm gãy là nơi độ dốc tăng 1. 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 ∣x−a∣|x - a|, lấy min tiền tố hay tịnh tiến hàm trong O(log⁡N)O(\log N).

Hai heap và điểm gãy

Hàm f(x)=∑i∣x−ai∣+Bf(x) = \sum_i |x - a_i| + B lồi; độ dốc tăng 2 tại mỗi aia_i. Coi ∣x−a∣=(a−x)++(x−a)+|x - a| = (a - x)^+ + (x - a)^+: mỗi số hạng góp một điểm gãy. Lưu:

  • heap trái LL (lấy lớn nhất): các điểm gãy bên trái đoạn cực tiểu, độ dốc −∣L∣-|L| ở xa bên trái;
  • heap phải RR (lấy nhỏ nhất): các điểm gãy bên phải, độ dốc +∣R∣+|R| ở xa bên phải;
  • mnmn: giá trị nhỏ nhất. Đoạn cực tiểu là [top(L),top(R)][\text{top}(L), \text{top}(R)].

Cộng (x−a)+(x - a)^+: nếu a<top(L)a < \text{top}(L) thì giá trị nhỏ nhất tăng top(L)−a\text{top}(L) - a; đẩy aa vào LL rồi chuyển phần tử lớn nhất của LL sang RR. Cộng (a−x)+(a - x)^+ thì làm đối xứng. Hai phép này giữ đúng bất biến “mọi phần tử LL không lớn hơn mọi phần tử RR”.

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 ở L (xanh dương), nửa lớn ở R (xanh lá), đoạn phẳng ở giữa.

Hỏi nhanh

Với f(x)=∣x−2∣+∣x−5∣+∣x−9∣f(x) = |x - 2| + |x - 5| + |x - 9|, số xx làm ff nhỏ nhất và giá trị nhỏ nhất là gì?

Ví dụĐiểm Cân Bằng

Đề. ff ban đầu bằng 0. Q≤2⋅105Q \le 2 \cdot 10^5 truy vấn: cộng ∣x−a∣+b|x - a| + b, hoặc in số nguyên xx nhỏ nhất làm ff nhỏ nhất cùng f(x)f(x).

Lời giải. ff 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. bb cộng thẳng vào mn. Lính canh −∞-\infty, +∞+\infty để khỏi kiểm heap rỗng.

Bước 1: cộng ∣x−a∣|x - a| là cộng hai hàm. Khối đầu là (x−a)+(x - a)^+: nếu aa nằm bên trái top(L) thì giá trị nhỏ nhất tăng top(L) - a; đẩy aa 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 (a−x)+(a - x)^+.

C++
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à [top(L),top(R)][\text{top}(L), \text{top}(R)] 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.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using 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

Mỗi truy vấn O(log⁡Q)O(\log Q).

Min tiền tố: dãy không giảm

DP chọn dãy BB không giảm gần AA nhất có dạng

dpi(x)=min⁡y≤xdpi−1(y)+∣x−ai∣.dp_i(x) = \min_{y \le x} dp_{i-1}(y) + |x - a_i|.

Phép min⁡y≤x\min_{y \le x} 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 ∣x−a∣|x - a| khi không có heap phải: đẩy aa vào LL; nếu top(L)>a\text{top}(L) > a thì chi phí tăng top(L)−a\text{top}(L) - a, bỏ đỉnh và đẩy aa thêm lần nữa (một bản của aa thay cho điểm gãy vừa bị đẩy sang phải rồi bị xóa).

Heap trái L (đỉnh tô xanh). Mỗi số nhỏ hơn đỉnh làm tốn top − a và thay đỉnh bằng a.

Dãy tăng nghiêm ngặt đổi về không giảm bằng ai−ia_i - i: Bi<Bi+1B_i < B_{i+1} nguyên tương đương Bi−i≤Bi+1−(i+1)B_i - i \le B_{i+1} - (i + 1).

Hỏi nhanh

Vì sao sau mỗi bước chỉ cần heap trái?

Ví dụBậc Thang Nguyên

Đề. N≤106N \le 10^6, 0≤Ai≤2⋅1090 \le A_i \le 2 \cdot 10^9. Chọn BB nguyên tăng nghiêm ngặt, ∑∣Ai−Bi∣\sum |A_i - B_i| nhỏ nhất.

Lời giải. Trừ ii khỏi AiA_i để đổi dãy tăng nghiêm ngặt thành không giảm, như đã giải thích. dpi(x)dp_i(x) là chi phí nhỏ nhất khi Bi=xB_i = x; mỗi bước lấy min tiền tố rồi cộng ∣x−a∣|x - a|. 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 aa trước, rồi nếu đỉnh heap vẫn lớn hơn aa thì cộng chi phí, bỏ đỉnh và đẩy thêm một bản aa. Hai bản aa thay cho điểm gãy cũ: một bản của ∣x−a∣|x - 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.

C++
a -= i;
L.push(a);
if (L.top() > a) {
    ans += L.top() - a;
    L.pop();
    L.push(a);
}

Chi phí tới 106⋅2⋅10910^6 \cdot 2 \cdot 10^9, dùng long long.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using 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

O(Nlog⁡N)O(N \log N).

Heap có 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á pp; nếu giá mua rẻ nhất còn trong heap là q<pq < p thì bán, lãi p−qp - q, rồi đẩy pp vào heap hai lần. Một bản là “hoàn tác”: nếu sau này gặp giá p′>pp' > p, lấy bản đó ra nghĩa là bán ở p′p' thay vì pp, lãi thêm p′−pp' - p. Bản còn lại là ngày pp làm điểm mua mới. Không bán thì chỉ đẩy pp 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á

Đề. N≤3⋅105N \le 3 \cdot 10^5 ngày, giá pi≤106p_i \le 10^6. 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á pp 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á pp lớn hơn top: lãi p - top, bỏ giá đó, rồi đẩy pp một lần làm bản hoàn tác. Sau đó (ngoài khối if) đẩy pp thêm một lần làm điểm mua của chính ngày đó.

C++
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 p′>pp' > p về sau nghĩa là bán ở p′p' thay vì pp: lãi thêm đúng p′−pp' - p, 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.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using 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

O(Nlog⁡N)O(N \log N).

Tịnh tiến hai nửa

Phép g(x)=min⁡y∈[x−b,x+a]f(y)g(x) = \min_{y \in [x - b, x + a]} f(y) kéo nửa trái sang trái aa và nửa phải sang phải bb, đoạn cực tiểu dài thêm a+ba + b. Không sửa từng phần tử: giữ hai độ dời addLaddL, addRaddR; 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 ∣x−a∣|x - a| sau đó chia ba trường hợp: aa nằm trong đoạn cực tiểu thì đẩy vào cả hai heap; aa lệch trái thì giá trị nhỏ nhất tăng top(L)−a\text{top}(L) - a, chuyển top(L)\text{top}(L) sang RR và đẩy aa hai lần vào LL; lệch phải làm đối xứng.

Ví dụDải Hình Nối Liền

Đề. N≤105N \le 10^5 khoảng [Li,Ri][L_i, R_i], 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. dpi(x)dp_i(x) là chi phí khi khoảng ii có đầu trái xx; đặt wi=Ri−Liw_i = R_i - L_i. Khoảng i−1i - 1 ở yy giao khoảng ii ở xx khi y∈[x−wi−1,x+wi]y \in [x - w_{i-1}, x + w_i]. Lấy min trên đoạn đó là dời nửa trái của hàm đi −wi-w_i, nửa phải đi +wi−1+w_{i-1}; rồi cộng ∣x−Li∣|x - L_i|.

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.

C++
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 ∣x−Li∣|x - L_i| 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 aa hai lần vào L. Lệch phải đối xứng.

C++
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.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using 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

O(Nlog⁡N)O(N \log N).

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 (O(Nlog⁡2N)O(N \log^2 N); heap trái lệch cho O(Nlog⁡N)O(N \log N)). 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, N+M≤3⋅105N + M \le 3 \cdot 10^5 đỉnh, MM 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. fv(x)f_v(x) là chi phí nhỏ nhất để mọi lá trong cây con vv, tính cả cạnh lên cha, cách cha đúng xx. Lá: ∣x−c∣|x - c|, heap gồm hai bản cc. Đỉnh trong có kk con: gộp, bỏ k−1k - 1 đ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 a≥ba \ge b (đoạn phẳng [b,a][b, a]) và thay bằng a+ca + c, b+cb + c: cạnh cc dời đoạn phẳng, độ dốc hai bên thành ±1\pm 1. Ở gốc bỏ kk điểm lớn nhất; f(0)=∑cf(0) = \sum c và mỗi điểm gãy pp còn lại bớt đi pp, nên đáp án là ∑c−∑p\sum c - \sum p.

Bước 1: xử lý một đỉnh. Đề cho cha có chỉ số nhỏ hơn con, nên duyệt ii từ n+mn + m 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.

C++
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 O(log⁡n)O(\log n) lần, mỗi lần O(log⁡n)O(\log n).

C++
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ừ f(0)=∑cf(0) = \sum c.

C++
for (int j = 0; j < deg[1]; j++) pq[1].pop();
while (!pq[1].empty()) sum -= pq[1].top(), pq[1].pop();
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using 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

O((N+M)log⁡2(N+M))O((N + M) \log^2 (N + M)).

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?

Khi đi thi

  • DP một chiều trên số nguyên lớn với chuyển “cộng ∣x−a∣|x - a|”, “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, Bi=min⁡(Bi+1,đỉnhi)B_i = \min(B_{i+1}, \text{đỉnh}_i).

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.
  • ∣x−a∣=(a−x)++(x−a)+|x - a| = (a - x)^+ + (x - a)^+; 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

1
Dấu Mốc Trên Đườngcanuc80k7903Vị trí không giảm theo thời gian nên như dãy không giảm; các quan sát cùng thời điểm cộng hết vào trước rồi mới lấy min tiền tố.
LQDOJ
2
Dãy Tăng Gần Nhấtcanuc80k7905Như dãy tăng nghiêm ngặt nhưng in dãy: lưu đỉnh heap sau mỗi bước, đi ngược lấy Bi=min⁡(Bi+1,đỉnhi)B_i = \min(B_{i+1}, \text{đỉnh}_i) rồi cộng lại ii.
LQDOJ
3
Vùng Di Chuyểncanuc80k7906Ban đầu chỉ x=0x = 0 được phép (đẩy nhiều bản 0 vào hai heap); mỗi khoảng tt giây dời trái −t-t, phải +t+t; phạt một phía là cộng (X−x)+(X - x)^+ hay (x−X)+(x - X)^+.
LQDOJ
4
Phân Rã Đơn Điệucanuc80k7907Biến là BiB_i; CC không tăng buộc Bi−Bi−1≥max⁡(0,Ai−Ai−1)B_i - B_{i-1} \ge \max(0, A_i - A_{i-1}): dời rồi lấy min tiền tố, cộng ∣x∣+∣x−Ai∣|x| + |x - A_i|.
LQDOJ
5
Phân Bón Dọc Luốngcanuc80k7908Gọi SiS_i là tổng tiền tố của a−ba - b; chọn TT không giảm trong [0,SN][0, S_N] để ∑∣Si−Ti∣\sum |S_i - T_i| nhỏ nhất; chặn hai đầu bằng các điểm gãy ở 00 và SNS_N.
LQDOJ
6
Khoảng Cách Giữa Các Mốccanuc80k7909Sắp cc tăng, đặt ci−idc_i - i d; cần dãy không giảm và không âm, như dãy không giảm có thêm chặn dưới 00.
LQDOJ
7
Cây Bậc Giảmcanuc80k7910Gộp heap của các con, dời một đơn vị cho điều kiện lớn hơn nghiêm ngặt, lấy min tiền tố rồi cộng ∣x−Cv∣|x - C_v|.
LQDOJ
8
Mùa Quả Trên Câycanuc80k7911DP theo ngày cắt; hàm theo ngày không giảm, lưu hiệu trong map và gộp nhỏ vào lớn; quả ngày dd giá ww cộng ww tại dd rồi xóa bớt các hiệu phía sau.
LQDOJ
9
Cân Bằng Đấtcanuc80k7912Mỗi đơn vị đất thừa hay thiếu là một sự kiện; hai heap giá ghép (dời Z⋅iZ \cdot i), ghép với bên kia hoặc mua, bỏ với giá XX, YY, rồi đẩy lại giá ngược để có thể hoàn tác.
LQDOJ
10
Chuẩn Bị Và Incanuc80k7914Như bài mua bán có hoàn tác, với đúng KK bài: phạt λ\lambda mỗi bài (bài 78) và ghép chuẩn bị với in bằng heap.
LQDOJ

Đọc thêm

  • Slope TrickUSACO GuideĐiểm gãy, hai heap và các bài luyện.
  • Slope Trick 优化OI WikiCác phép biến đổi hàm lồi và bài mẫu.