DP專題-學習筆記:Slope Trick
1. 前言
Slope Trick,是一種優化 DP 的方式,這個方式目前好像並不盛行,但是以前好像還挺流行的(?),網上講 Slope Trick 的部落格好像也不多(?)。
- (?) 表示筆者持懷疑態度,也就是說這句話可能是錯的。
現在筆者得知的能夠使用 Slope Trick 的題目並不多,這裡主要講 CF 的一道題,好像是已知的 Slope Trick 最早出現的時間(2016 年)(?)。
2. 詳解
先放例題:CF713C Sonya and Problem Wihtout a Legend。
簡要題意:給定 \(n\) 個正整數 \(\{a_i\}\)
當然我們需要加強到 \(n \leq 10^5\),否則這個能用 \(O(n^2)\) DP 暴力解決。
首先發現嚴格遞增這個東西我們有一個套路的方式就是令 \(a_i \leftarrow a_i-i\),於是這樣就變成了嚴格不降。
然後我們有一個簡略的 DP 方程:
設 \(f_{i,j}\) 表示將 \(a_i\) 變成 \(j\),使得 \([1,i]\) 的數列不降所需要的最小操作次數,那麼有:
\[f_{i,j}=\min_{1 \leq k \leq j}(f_{i-1,k})+|a_i-j| \]這個 DP 方程還是比較簡單的吧~
這樣,我們就能夠在 \(O(n^2)\) 的複雜度內賽時解決這道題,當然在 \(n \leq 10^5\) 的情況下是過不了的。
此時 Slope Trick 就可以派上用場了。
首先我們需要知道 Slope Trick 能夠優化什麼問題:通常情況下,Slope Trick 能夠解決的是一類凸函式問題。
啥意思呢?
比如說這道題的 DP 方程,我們首先轉變一下式子:記 \(F_i(x)\) 為 \(f_{i,x}\),定義 \(G_i(x)=\min_{1 \leq k \leq x}f_{i,k}\)。
那麼式子就是 \(F_i(x)=G_{i-1}(x)+|a_i-x|\)。
然後仔細研究這個函式,我們會發現這個函式有下凸性質,畫出來大概是這個樣子:
我們記 \(h(i)\) 表示當 \(x=h(i)\) 時 \(F_{i}(x)=G_{i}(x)\),也就是函式 \(F_{i}(x)\) 取到最小值的地方。
然後我們重新看一下這個方程:\(F_i(x)=G_{i-1}(x)+|a_i-x|\)。
我們發現這個式子很有意思:我們只需要快速維護 \(G_i(x)\) 就可以了。
具體怎麼維護呢?
其實對於這種分段函式且各函式都是一次函式的情況下,有一種快速的方法維護最小值:維護所有分段點以及最右端一次函式即可。
比如說還是上圖,可以發現我們只需要維護下圖的紅點和紅線即可:
特別需要注意的是如果兩個相鄰段的斜率之差大於 1,那麼這個關鍵點是要存兩遍的。
那麼現在我們來討論如何通過所有已知的 \(F_{i-1}(x)\) 和 \(G_{i-1}(x)\) 來快速維護 \(F_{i}(x)\)。
有幾個關鍵點:
- 我們畫出 \(F_i(x)\) 的大致影象後,結合狀態轉移方程,發現在 \(h(i)\) 前的所有函式斜率遞減,在 \(h(i)\) 之後的所有函式斜率遞增。
- 其實 \(F_i(x)\) 是在 \(F_{i-1}(x)\) 上的疊加。
- \(h(i)\) 處這個點左右兩個函式的斜率一個是正的,一個是負的。
- 實際上所有斜率為正的點在計算的時候是無用的,因為我們的轉移是從 \(F_{i-1}(x)\) 轉移到 \(F_i(y),x \leq y\),所以我們需要 \(F_i(y)\) 資料的時候完全可以直接從 \(F_{i-1}(x)\) 推過來。
根據上面的第四點,我們只需要維護所有左邊的函式斜率小於 0 的關鍵點即可,也就是下面這些藍色的點:
然後我們討論一下 \(h(i-1)\) 和 \(a_i\) 的關係:
- \(h(i-1) \leq a_i\)
這個情況下你會發現,我們在函式疊加的時候,所有 \(x < h(i)\) 的點其縱座標之差加一,用式子表達就是所有 \(F_i(x)-F_i(x-1)\) 都降低了 1,從斜率角度來看就是斜率全部遞減 1。
從貪心的角度來理解,此時此刻你沒必要將 \(a_i\) 減小到 \(h(i-1)\),因為這個時候 \(h(i)=a_i\),肯定是當前最優的解。
至於如果後面的 \(a_{i+1}\) 遠小於 \(a_i\),那就是另外一個情況要討論的事情了。
於是我們只需要將 \(a_i\) 這個點丟進我們的關鍵點裡面,然後這個點就做完了。
- \(h(i-1)>a_i\)
這個時候我們發現我們需要將 \(a_i\) 提升到 \(h(i-1)\) 才能夠做到序列單調不降,因此這一塊的貢獻是 \(h(i-1)-a_i\)。
然後我們發現對於所有 \(x<a_i\),所有 \(F_i(x)-F_i(x-1)\) 都降低了 1,而對於所有 \(h(i-1) \geq x>a_i\),,所有 \(F_i(x)-F_i(x-1)\) 都升高了 1,這個同樣也能從斜率角度來理解。
此時我們發現在 \(a_i\) 這個關鍵點出現了問題,因為這個時候整個函式已經被改變了,由於 \(h(i-1)>a_i\),此時 \(a_i\) 這個地方左右斜率相差大於 1 了,因此我們需要將 \(a_i\) 兩次丟進我們的關鍵點裡面。
最後將 \(h(i-1)\) 丟出佇列,因為這個時候在 \(h(i-1)\) 這個地方因為 \(a_i\) 的影響使得最後的位置斜率升高了,這個點不再是我們的最小值點了(\(h(i) \neq h(i-1)\)),所以我們需要將 \(h(i-1)\) 丟出佇列。
有人可能會問:會不會存在在一個同樣的值 \(x\) 出現 4 個或者更多的關鍵點都是 \(x\) 呢?
這個情況嗎……emm……確實是存在的,但是我們需要知道這道題的一個顯然結論:
- 將 \(a_{i-1}\) 改到 \(a_i\) 和將 \(a_i\) 改到 \(a_{i-1}\) 的貢獻是一樣的。
據此,實際上我們會發現將 \(a_i,a_{i-1}\) 改成兩者較小一定是更優的,因為這樣後面的一些小數就可以花費較少的花費達到嚴格不降得目的。
↑上述問題其實也是我在學習的時候遇到的一個問題。
現在討論完了兩種情況,我們發現實際上我們只需要維護一個優先佇列就可以完成快速維護關鍵點的工作。
而快速維護關鍵點實質上就是快速維護 \(F_i(x)\),也就是快速得到 \(G_i(x)\)。
那麼最後答案當然就是 \(F_n(h(n))\) 啦~
實際操作的時候我們不需要另外開一個 f[]
來存下所有的 \(G_i(\max(a_i,h(i-1))\)(對的,是這玩意),只需要一個 ans
算貢獻就好,因為我們的全部過程只需要利用到 \(F_{i-1}\) 的相關資訊。
程式碼如下:
/*
========= Plozia =========
Author:Plozia
Problem:CF713C Sonya and Problem Wihtout a Legend
Date:2021/9/22
========= Plozia =========
*/
#include <bits/stdc++.h>
using std::priority_queue;
typedef long long LL;
const int MAXN = 3000 + 5;
int n, a[MAXN];
LL ans = 0;
priority_queue <LL> q;
int Read()
{
int sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum * 10) + (ch ^ 48);
return sum * fh;
}
int Max(int fir, int sec) { return (fir > sec) ? fir : sec; }
int Min(int fir, int sec) { return (fir < sec) ? fir : sec; }
int main()
{
n = Read();
for (int i = 1; i <= n; ++i) a[i] = Read() - i;
for (int i = 1; i <= n; ++i)
{
q.push(a[i]);
if (q.top() > a[i])
{
ans += q.top() - a[i];
q.pop(); q.push(a[i]);
}
}
printf("%lld\n", ans);
return 0;
}
3. 總結
Slope Trick 通常解決的是這樣一類問題:
- DP 中維護的 \(f\) 資料具有凸性。
- 可以通過維護關鍵點和最右端的一次函式來快速處理最大/最小值。
推薦練習題:P3642 [APIO2016]煙火表演。