DP最佳化——wqs二分

Green&White發表於2024-09-05

在看 wqs 二分前建議先去看另一篇部落格——斜率最佳化,對凸包等知識點有所瞭解。

介紹

wqs 二分最初由王欽石在他的 2012 年國家集訓隊論文中提出,也叫"帶權二分",或者"dp凸最佳化",而從 IOI 2016 的 Aliens 題目開始,這種方法開始逐步在競賽圈中有了一定的地位。在國內我們一般稱為「wqs 二分」,而在國外一般稱為「Alien Trick」。

適用題型

wqs 二分的題目一般有以下特點:

  • 題目內容形式為:有 \(n\) 個物品,從中選出 \(m\) 個,每個要求最後的權值最小/最大。
  • 直接 dp 設 \(f[i][j]\) 表示前 \(i\) 個選出 \(j\) 個物品的話,轉移是 \(f[i][j]=min_k(f[k][j-1] + Val(k,i))\),其中\(Val(k,i)\) 表示這次轉移帶來的權值。時間複雜度無論如何都是 \(O(n^2)\) 及以上的。
  • 如果沒有選 \(m\) 這個限制,那可以最佳化到更低複雜度,並且可以算出此時最優方案選的數的個數。
  • 選的個數越多,最終權值越小/越大,即如果設 \(g(x)\),表示選 \(x\) 可以得到的最小/最大圈子,那麼 \(g(x)\) 的影像構成一個凸包。

當然 wqs 二分不止應用於 DP 題,具體看例題。

解法

假設 \(g(x)\) 的影像為上凸包,要求的是最大值,不妨畫一下 \(g(x)\) 的大致影像(當然其實我們是一個點都求不出來的):

假設我們現在用一條直線 \(y=Kx+b\) 去切一個點 \((x,g(x))\),那麼可以得到 \(g(x)=Kx+b\),即這個點的座標也可以表示成 \((x,Kx+b)\)
又因為上凸包有個性質,一條斜率為 \(K\) 的直線在他與這個凸包的切點處截距最大,也就是說如果我們能求出這個最大截距,並知道此時的橫座標,就能知道那個切點的具體座標了。
因為凸包的斜率是單調的,所以隨著 \(K\) 的減小,切到的 \(x\) 也越大,所以可以二分這個 \(K\),我們可以根據切點的座標去調整 \(K\) 直到切到 \((m,g(m))\) 為止,。


現在的問題就是怎麼求最大截距,因為我們壓根不知道這個凸包長什麼樣子。
會發現 \(b = g(x)-Kx\),定義 \(h(x) = g(x)-Kx\),如果我們能以較低的複雜度求出最大的 \(h(x)\) 以及此時的 \(x\),也就求出了我們要的東西。
考慮給 \(h(x)\) 定義一個合理的意義,不難發現他其實就是給每個物品多加了一個 \(-K\) 的權值(所以叫代權二分),選了這個數就要 \(-K\)
而我們要求 \(h(x)\) 的最大值是沒有限制要選多少個的,所以 dp 時直接 \(f_i = max_j(f_j + Val(j,i) - K)\) 即可,比一開始那個少了一維,會更好求,具體的最佳化方法/求法因題目而異,在例題中會講。
注意最後的求 \(g(x)\) 時,要記得把 \(Kx\) 加上。

關於wqs二分的實現細節也在例題中。


例題

忘情

把式子變成 $((\sum_{i=1}^n x_i)+1)^2 $,設 \(S\) 為字首和,那麼樸素的 dp 是:
\(f[i][j]\) 表示前 \(i\) 個數劃分成 \(j\) 段的最小值,轉移為 \(f[i][j]=\min_{0<=k<i}(f[k][j-1] + (S_i - S_j + 1)^2)\)
容易證明 \((a+b+1)^2 > (a+1)^2 + (b+1)^2\),也就是說分的段數越多答案越小,即按照上面的定義 \(g(x)\) 表示分 \(x\) 段的最小值,那麼 \(g(x)\) 的影像應該是一個下凸殼:

二分一個斜率 \(K\),用斜率 \(K\) 的直線去切這個凸包,那麼截距 \(b=h(x)=g(x)-Kx\),因為是下凸包,所以我們要求最小截距,即把一段的權值定義成 \(((\sum xi)+1)^2 - K\),然後去掉段數限制,求最小答案。
考慮對這個新的問題 \(dp\),設 \(dp[i]\) 表示前 \(i\) 個數的最小值,\(dp[i]=\min_{0<=j<i}(dp[j] + (S[i]-S[j]+1)^2 - K)\),因為還要求此時的橫座標 \(x\),所以還要額外記一個dp陣列,轉移也是顯然的。
這是經典的斜率最佳化形式,可以用單調佇列最佳化到 \(O(n)\),不會斜率最佳化的戳這裡
總時間複雜度 \(O(n \log n)\)

wqs 二分一些實現的細節:

  1. 這裡因為是下凸包,所以斜率 \(K\) 是負的,但是為了方便二分時我們把他變成正的,所以 check 裡 dp 變成 \(dp[i]=\min_{0<=j<i}(dp[j] + (S[i]-S[j]+1)^2 + K)\) , 原來二分要把斜率調大的就調小。
  2. 注意最後凸包可能會有一段斜率為 \(0\) 的線段,即可能 \(g(m-1)=g(m)\),那如果我們在 \(check\) 裡的斜率最佳化dp,在 \(g\) 值相同時取的是靠左的點,那麼二分寫的就是: 如果返回的那個 \(x\) <=m,那就更新答案並把斜率調大(這裡還認為斜率是負的,不進行 1. 的轉換) ; 相反,如果我們在 \(check\) 裡的斜率最佳化dp,在 \(g\) 值相同時取的是靠右的點,那麼二分寫的就是: 如果返回的那個 \(x\) >=m,那就更新答案並把斜率調小。看取的是靠左還是靠右只要看斜率最佳化維護凸包時寫的是 >= 還是 >,> 就是取靠左的,>=就是取靠右的。

code

變數名稍有不同。

#include<bits/stdc++.h>
#define int long long 
using namespace std;
const int N=1e5+5,inf=1e18;
inline int read(){
    int w = 1, s = 0;
    char c = getchar();
    for (; c < '0' || c > '9'; w *= (c == '-') ? -1 : 1, c = getchar());
    for (; c >= '0' && c <= '9'; s = 10 * s + (c - '0'), c = getchar());
    return s * w;
}

int n,m,a[N]; 
int dp[N],g[N];  //dp[i]表示前 i 個數分成若干段的最小值,g[i] 表示取到最小值分的段樹 
int dq[N],l,r;
int calc(int j){  //求縱座標 
	return dp[j]+a[j]*a[j]-2ll*a[j];
} 
void check(int mid){
	l=1,r=0;
	dp[0]=0,g[0]=0;   
	dq[++r]=0;  //放 0 不是 1,因為可以自成 1 段。 
	for(int i=1;i<=n;i++){
		while(l<r && ( calc(dq[l+1]) - calc(dq[l]) ) < (2ll * a[i] * (a[dq[l+1]] - a[dq[l]]))) l++;  //把開頭斜率小於當前斜率的線段 pop 掉
		int j=dq[l];
		dp[i]=dp[j]+(a[i]-a[j]+1ll)*(a[i]-a[j]+1ll)+mid;
		g[i]=g[j]+1ll;
		while(l<r && ( calc(i) - calc(dq[r]) ) * ( a[dq[r]] - a[dq[r-1]]) < ( calc(dq[r]) - calc(dq[r-1] ) ) * ( a[i] - a[dq[r]] )) r--;  //維護凸殼
		dq[++r]=i; 
	}
}
signed main(){
	n=read(),m=read();
	for(int i=1;i<=n;i++) a[i]=read(),a[i]+=a[i-1];
	int l=0,r=inf,mid,ans=0;   //實際上斜率是負的,但是移項之後:b=f(x)-kx,所以就乾脆把 k 取成正的,這樣在check裡是每一段+mid,而不是-mid 
	while(l<=r){
		mid=(l+r)>>1;
		check(mid);
		if(g[n]<=m) r=mid-1,ans=mid;
		else l=mid+1; 
	}
	check(ans);
	printf("%lld\n",dp[n]-ans*m);  //這裡要減掉 mid(也就是最後的 ans) 帶來的貢獻 
	return 0;
}