斜率優化 (Convex Hull Trick)
斜率優化、又稱凸包優化,英文叫做 Convex Hull Trick, 簡稱 CHT
介紹
斜率優化應該不算太難的 DP 優化,而且出現的頻率也絕對不在少數。
雖然說他的名字叫做斜率優化,但是我個人覺得凸包優化 (英文名字) 比較適合他。理由的話我們就繼續看下去吧。
概述
理論
有一些 1D/1D 的 DP 的轉移式會長成下面這樣:
其中 是一個遞增函數、和 是容易求得的函數,而 、裡面可能會包含 ,也就是 是狀態 的子問題。這樣子的複雜度是 的,而斜率優化就是在優化這種類型的轉移式。因為 是一個容易求得的函數,而真正難算的部分在於後面的 ,所以下面說明後面那坨東西要怎麼優化。
最一開始有提到,DP 優化大多都是優化轉移的過程,斜率優化也不例外,就是要加速轉移的過程。為了加速轉移的過程,我們可以拋棄掉那些不可能成為轉移來源的狀態不去處理,那要怎麼做呢?
對於每個已經算出答案的 (也就是說 和 都已經被算出來了),我們畫一條直線 到平面上。那麼結果可能會長得跟下面這張圖這樣 (考慮 ):

我們會發現所有數字的最大值會形成一個下凸包(紅色部分)。而如果有線完全落在這個下凸包的下面(例如 那條線,也就是藍色直線),那麼這條線就永遠不可能會成為我們的轉移來源。換句話說,我們的轉移來源只會出現在那個下凸包上面。假設我們現在要更新 這個狀態,我們的轉移就相當於是在這個下凸包上找到 這個點是落在下凸包哪一條線段上。把 帶入那個線段所得到的值就會是我們的 。
所以說我們現在把題目變成要做兩件事:
動態維護下凸包
快速找到 這個點是落在哪一個線段上,並求他的值
動態凸包?
我們現在要維護一個支持插入操作跟查詢操作的凸包。事實上真的有動態凸包這個東西。但是身為一個佛系競程玩家,我不想學那種又長又不好寫常數又大的東西。所以我們這邊要使用一個神奇的資料結構:李超線段樹
李超線段樹
這個東西其實應該被放在資料結構裡面,但是因為 iceylemon 很懶,所以他把決定先寫在這邊。
我們用一個問題來引入李超線段樹這個東西:
現在要進行若干次操作,每一次操作會是下列兩種中的一種:
插入一條直線
詢問一條直線 與其他直線相交的點中,最大的 座標
沒錯,這就是一個動態凸包問題 (就是我們要解決的問題)。而李超線段樹就是一個用來解決這類問題的線段樹變形。
李超線段樹的理念如下:對值域開線段樹。而每個節點維護的是讓這個區間中點的值最大的那一條直線。注意,每一個節點只維護一條直線。這看起來像是一個奇怪的定義,別急,我們繼續看下去。
考慮 這個區間(線段樹節點),不妨先假設原先這個節點維護的是 這條直線,而我們現在要插入這條直線 (不妨先考慮的斜率大於)。那麼應該會有兩種情況,帶入 比較大 (如下圖),或是帶入 比較大。

上圖顯示 帶入 的值比較大。根據李超線段樹的定義, 這個節點應該要保留這條直線,但是我們會發現直線仍然是有可能會被選到的,不能直接把他丟掉。我們看著上面這張圖可以發現,這條直線接下來只有可能會在的左邊會被選到。還記得李超線段樹是線段樹吧?我們可以直接往左邊遞迴下去做就可以處理到直線的情形了。同理如果 帶入的 值比較大,我們也可以用類似的方法處理。
詳細細節我們可以看下面的程式碼:
李超線段樹插入程式碼
struct Line {
int m, k;
int val(int x) {
return m * x + k;
}
} seg[maxn * 4];
// seg 李超線段樹用到的陣列
void insert(Line line, int l, int r, int idx) {
// line = 要插入的直線
// 目前處理的區間 [l,r]
// 對應到線段樹哪一個編號的節點
if(l == r) {
// 如果 line 的值到底了還比原本的大,那就直接取代掉
if(line.val(l) > seg[idx].val(l)) {
seg[idx] = line;
return;
}
}
int mid = (l + r) >> 1;
// 預設要插入的直線斜率比較大
if(seg[idx].m > line.m) swap(seg[idx], line);
// 看要往哪邊遞迴
if(seg[idx].val(mid) < line.val(mid)) {
swap(line, seg[idx]);
// 這邊使用 swap 是因為上面有可能 swap 過了
// 我們不知道現在手上拿的是誰,反正比較弱的就丟下去就對了
insert(line, l, md, idx << 1);
}
else insert(line, md + 1, r, idx << 1 | 1);
}
那麼查詢呢?你可能會想要直接查詢最下面那個節點的直線帶進去的值,但很不幸的那是錯的。
回憶一下上面的過程,你可能會發現我們在處理區間的時候只有一直往下處理 值比較小的那條直線可能會被選到的區間。也就是說另外半邊的區間我們是沒有處理的。所以說查詢的過程應該是要由上往下經過的每一個節點都把帶進那條直線並取所有值的 才是對的。
李超線段樹詢問
int query(int x, int l, int r, int idx) {
// x 是要查詢的點
if(l == r) return seg[idx].val(x);
int mid = (l + r) >> 1, ret = seg[idx].val(x);
if(x <= mid) ret = max(ret, query(x, l, mid, idx << 1));
else ret = max(ret, query(x, mid + 1, r, idx << 1 | 1);
return ret;
}
我們可以發現全部的操作都跟線段樹一樣,所以總複雜度是
於是我們就可以使用李超線段樹解決上面的問題了。至此斜率優化就這樣被解決了。
特例
雖然我們已經把斜率優化解決掉了,但是斜率優化還有一些很有趣的特例。
上面的那個作法是最通用的作法,而如果題目有給定一些特別的性質的話,會有一些更好的做法。
斜率單調
如同這一小節的標題,如果我們的斜率單調遞增(也就是 會隨著 變大而增加),那麼會有一個不用寫線段樹的做法。
由於下凸包這個東西如果 座標越大,那麼斜率就會越大。如果我們插入的直線斜率是遞增的話,這不就代表說這條直線只會差在這個下凸包的最右邊嗎?也就是說我們可以直接用一個單調 Stack 來維護我們的凸包。
至於查詢的部分,一個最暴力的作法當然是遍歷整個 Stack,找到 在哪一個區間。但是因為區間這個東西是單調遞增的,所以其實我們可以直接對他做二分搜。至此我們獲得了一個 但常數比較小的辦法了。
但老實說,我認為如果題目沒有特別卡的話,寫線段樹也不失為一個不錯的選擇,畢竟寫線段樹就不太需要思考 (x。(我好像有點毒?)
查詢單調
上面那個做法只是常數比較小而已,那如果我現在再加入查詢單調這件事會變成怎樣呢?
上面的作法需要使用二分搜來進行查詢,而既然現在給定查詢遞增,就代表我們查詢的區間的 座標只會增加不會減少。也就是說我的查詢只會把值域遍歷一遍而已。所以查詢的複雜度也會是 。於是總複雜度就是 。
有沒有發現這個做法好像似曾相似?其實單調隊列優化就是斜率優化的特例喔!所以說我們只需要維護一個單調隊列就可以把這題解決掉了。
決策單調性
由於斜率以及查詢同時遞增的這個情形就相當於單調隊列優化,所以他也當然滿足決策單調性。
一些你要知道的事
斜率優化有一個重要的先決條件:取 中 左界必須固定,否則你就可能要支援一個可以動態插入跟刪除的凸包,就算用動態凸包也沒那麼神。但這有一些特例,因為有點複雜就不講了,但是一個最簡單的例子就是單調隊列優化。
上面都只有提到如果題目要問怎麼辦?有一個可愛的東西叫做負號,用他就可以了。
雖然他是 DP 優化的一種,但即使不是 DP 也可以利用他的轉移方式。
斜率優化這個東西除了動態凸包跟李超線段樹的做法之外,貌似也有 CDQ 分治的作法,但複雜度好像會變差
例題
下面這題是一道過於經典的題目,建議大家先不要看題解自己先做。
APIO 2010 Commando
給你一個長度為 的正整數列 以及一個二次方程 。你可以將這個數列分成連續的很多塊,而該分法的價值就是每塊的總和帶入 之後的函數值的總和。求可以獲得的最大價值為何。
題解
容易知道轉移式是 ,令 代表 。那麼就可以變成:
然後我們就發現斜率優化已經成形了!
那麼我們就可以套李超線段樹輕鬆解決這題了!但這題還可以做得更好!
斜率單調:遞增(因為都是正整數),而且 。
查詢單調:遞增
所以我們就可以套單調隊列優化來解掉這題了。
接下來這題是一個很有趣的題目,大家可以自己想一下。
CF 932F Escape Through Leaf
給你一個個節點的有根樹,第個節點有兩個值。你可以從一個節點跳到他的子樹的任意一個節點 ,並且花費的代價。對於每個節點,請找出經過若干次跳躍後到達任意一個葉節點的所花費的代價總和最小值。
題解
容易想到一個暴力的 dp 做法,令 代表所求。那麼轉移就會是
,其中代表以為根的子樹。他雖然長得一臉就是斜率優化的樣子,但是他是在樹上欸?這也就代表對於每一個節點我都要把他的子樹全部遍歷一遍,然後把這些東西加到李超線段樹裡面維護。這樣顯然是不對的,但是真的需要那麼麻煩嗎?
這時候我們如果把焦點放在跟他的小孩身上,就會發現如果他的每一個小孩都建好了一棵李超線段樹,那麼就只需要把他們全部合併就好了。嗯?合併?所以我們就可以合理的想到樹上啟發式合併啦!
但是李超線段樹要怎麼做樹上啟發式呢。總不能開棵李超線段樹吧?等等,我不行嗎?別忘了李超線段樹也是線段樹!動態開點!至此我們就可以把這題用樹上啟發式+斜率優化+動態開點解掉啦~
但是因為作者太菜了,已經 年沒有寫過動態開點這種東西(其實我根本沒有想到)。所以我就往其他的方向去想。因為一次插入只會影響到個節點,那我要刪除的時候就把那些被影響到的節點清空就可以了。因為插入多少節點就會清空多少節點,所以複雜度是不會影響的!那麼就可以不用再寫動態開點了。
程式碼如下
#include "bits/stdc++.h"
#define int long long
#define endl '\n'
#define pb push_back
#define pii pair<int, int>
#define FOR(i, n) for(int i = 1; i <= n; i ++)
#define loli ios_base::sync_with_stdio(false), cin.tie(0);
using namespace std;
const int maxn = 1e5 + 50;
int n, m, q, k, ans, inf = 1e15;
vector<int> vc[maxn];
int dp[maxn], a[maxn], b[maxn];
int sz[maxn], mxson[maxn];
struct segment {
int m = 0, k = -inf;
int val(int x) {
return m * x + k;
}
} tr[maxn * 8];
struct LiChao {
vector<int> vc;
#define ls (idx << 1)
#define rs (idx << 1 | 1)
void clear() {
for(int i : vc) tr[i] = {0, -inf};
vc.clear();
}
void add(segment seg, int l = 0, int r = 2e5, int idx = 1) {
vc.pb(idx);
if(l == r) {
if(seg.val(l) > tr[idx].val(l)) tr[idx] = seg;
return;
}
int md = (l + r) / 2;
if(seg.m < tr[idx].m) swap(seg, tr[idx]);
if(seg.val(md - 1e5) > tr[idx].val(md - 1e5)) {
swap(seg, tr[idx]);
add(seg, l, md, ls);
}
else add(seg, md + 1, r, rs);
}
int qry(int v, int l = 0, int r = 2e5, int idx = 1) {
if(l == r) return tr[idx].val(v);
int md = (l + r) / 2;
int ret = tr[idx].val(v);
if(v <= (md - 1e5) ) ret = max(ret, qry(v, l, md, ls));
else ret = max(ret, qry(v, md + 1, r, rs));
return ret;
}
} LCT;
void init(int x, int fa = -1) {
int tmp = 0;
sz[x] = 1;
for(int i : vc[x]) {
if(i == fa) continue;
init(i, x);
sz[x] += sz[i];
if(tmp < sz[i]) mxson[x] = i, tmp = sz[i];
}
}
inline int A(int x) {
return -b[x];
}
inline int B(int x) {
return -dp[x];
}
void upd(int x, int fa) {
segment tmp = {A(x), B(x)};
LCT.add(tmp);
for(int i : vc[x]) {
if(i == fa) continue;
upd(i, x);
}
}
void dfs(int x, int fa = -1) {
for(int i : vc[x]) {
if(i == fa or i == mxson[x]) continue;
dfs(i, x);
LCT.clear();
}
if(mxson[x]) dfs(mxson[x], x);
for(int i : vc[x]) {
if(i == fa or i == mxson[x]) continue;
upd(i, x);
}
if(sz[x] != 1) dp[x] = -LCT.qry(a[x]);
segment tmp = {A(x), B(x)};
LCT.add(tmp);
}
signed main() {
loli;
cin >> n;
FOR(i, n) cin >> a[i], a[i] = -a[i];
FOR(i, n) cin >> b[i], b[i] = -b[i];
FOR(i, n - 1) {
int a, b;
cin >> a >> b;
vc[a].pb(b);
vc[b].pb(a);
}
init(1);
dfs(1);
FOR(i, n) cout << dp[i] << ' '; cout << endl;
}
題目
TIOJ 1921 這題就只能用動態開點了喔OUO(如果用LiChao的話)
Last updated
Was this helpful?