決策單調性DP

all_for_god發表於2024-10-09
  • 決策單調性DP是一個非常重要的DP類別。在決策點隨列舉點增加單調不降時,可以有效地最佳化複雜度。

  • 一般而言,決策點指的是對於一個 \(f[i]\),它的值需要從另一個值j中轉移,而對於所有j,令 \(f[i]\) 最大的j值就是決策點。
    而其單調性體現在對於一個點i,它的決策點一定會大於等於i-1的決策點。如果此單調性成立,那麼一般就會用二分縮小決策點值域或者一些單調的資料結構(一般為單調棧,有時也有單調佇列)來減小複雜度。

  • 決策點的單調性大多數時候只能感性理解或者打表。在考場上證明最基本的需要四邊形不等式。一旦證明卡住乃至錯誤,很有可能就會丟失大部分分數

  • 下面就是處理決策單調性的兩種方法———二分與單調資料結構

二分

Yet Another Minimization Problem

  • 最樸素的DP就是設 \(f_{i,j}\) 代表對於前i個數,將其分為j段的最小代價
    轉移方程也是呼之欲出, \(f_{i,j}=min_{k<i}(f_{k-1,j-1}+cacl(k,i))\),其中 \(cacl(k,i)\) 指k到i的貢獻
  • 但就算可以 \(O(1)\)\(cacl\),時間複雜度也是 \(O(n^{2})\) 的。
    考慮單調性。由於對於 \(cacl(k,i)\) ,其值是由\(cacl(k,i-1)+\sum_{j=k}^{i}[a_{j}=a_{i}]\) 轉移而來的,因此對於點 \(i\) ,對於它之前的兩個點 \(u,v(u<v)\) ,如果 \(i\)\(u\) 轉移過來的值 \(f_{u,j-1}+cacl(u+1,i)\) 比從 \(v\) 轉移過來的值 \(f_{v,j-1}+cacl(v+1,i)\) 大,那麼對於點 \(i+1\) ,其 \(f_{i+1,j}\) 值如果從兩者中轉移,那對於 \(u\) 而言,\(f_{i+1,j}=f_{u,j-1}+cacl(u+1,i+1)\)。之前我們知道,\(cacl(a,b)\) 中隨著 \(b\) 的增大,其值一定單調不降,所以從 \(v\) 轉移有可能比從 \(u\) 轉移更優,因為儘管 \(i\)\(u\) 轉移更優,但 對於\(i+1\) 加的 \(cacl\) 值比 \(v\) 大,因此皆有可能。但對於 \(u\) 之前一個點,如果\(i\) 從其轉移比 \(u\) 劣,那 \(i+1\) 從其轉移一定比 從 \(u\) 轉移劣。因此,\(i+1\) 的決策點一定大於等於 \(i\) 的決策點。
  • 知道有單調性後,考慮到對於每個 \(f_{k,j}\) ,其決策點是獨立的,只與上一層列舉的分為 \(j-1\) 段的 \(f_{1->k,j-1}\) 值有關,與同層之間的 \(f\) 值無關,因此直接考慮列舉將區間分為多少段,再二分每個 \(mid\),求出它的決策點後就知道了它前後點的決策點的範圍,進一步縮小列舉範圍。
void erfen(ll l,ll r,ll lt,ll rt)
{
	ll mid=(l+r)>>1,ml=max(1ll,lt),mr=min(mid,rt),pos;
	//這裡注意mr的取值。我們要求的是mid的f值和其決策點的位置
	//但mid的決策點的位置起碼不應小於自己 
	ll res=inf;
	for(ll i=ml;i<=mr;i++)
	{
		ll tmp=f[i-1][dep-1]+cacl(i,mid);
		if(tmp<res) res=tmp,pos=i;
	}
	f[mid][dep]=res;
	if(l==r) return;
	erfen(l,mid,lt,pos);
	erfen(mid+1,r,pos,rt);
}
  • 但如果要想做到 \(O(knlog_{2}n)\) 的複雜度,就還需要 \(O(1)\)\(cacl\) 的值。考慮到對於上述程式碼中的每次二分的過程,在迴圈中列舉的左端點是連續上升的,而對於其列舉的左右區間,從 \(l,r\) 遞迴到 \(l,mid\),其詢問的 \(cacl\) 的區間最多移動 \(r-l\) 次。所有區間壘起來會成為類似於線段樹分割區間那樣的形式。每遞迴一層 \(l,r\) 都會移動 \(n\) 次,而一共遞迴了 \(log_{2}n\) 層,因此對於每一個列舉的分為的段數, \(l,r\) 總共移動了 \(nlong_{2}n\) 次。因此考慮像莫隊一樣用桶和左右指標相對暴力地維護 \(cacl\) 的值。而二分的複雜度也是 \(O(nlog_{2}n)\) 的,因此總複雜度就為 \(O(knlong_{2}n)\)
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll inf=1e18;
const ll N=1e6+10;
ll n,k,dep=0,a[N],f[N][25],sum=0,cnt[N],L=1,R=0;
ll cacl(ll lt,ll rt)
{
	while(L>lt) sum+=cnt[a[--L]]++;
	while(R<rt) sum+=cnt[a[++R]]++;
	while(L<lt) sum-=--cnt[a[L++]]; 
	while(R>rt) sum-=--cnt[a[R--]];
	return sum;
}
void erfen(ll l,ll r,ll lt,ll rt)
{
	ll mid=(l+r)>>1,ml=max(1ll,lt),mr=min(mid,rt),pos;
	ll res=inf;
	for(ll i=ml;i<=mr;i++)
	{
		ll tmp=f[i-1][dep-1]+cacl(i,mid);
		if(tmp<res) res=tmp,pos=i;
	}
	f[mid][dep]=res;
	if(l==r) return;
	erfen(l,mid,lt,pos);
	erfen(mid+1,r,pos,rt);
}
int main()
{
	ios::sync_with_stdio(false);cin.tie(0),cout.tie(0);
	cin>>n>>k;
	for(int i=1;i<=n;i++) f[i][0]=inf;
	for(int i=1;i<=n;i++) cin>>a[i];
	while(dep<=k)
	{
		dep++;
		erfen(1,n,1,n);
	}
	cout<<f[n][k]<<endl;
	return 0;
}

單調資料結構

P1912 [NOI2009] 詩人小G

  • 先想出最樸素的DP狀態 \(f_{i}\) 表示前 \(i\) 首詩所能湊出的最小代價。
  • \(s_{i}=i+\sum_{j=1}^{i}len_{j}\),即字首長度。加一是因為都要加空格。
    方程 \(f_{i}=min_{j<i}(f_{j}+|s_{i}-s_{j}-1|^{P})\),減一是因為還要減去行末空格
  • 顯然暴力 \(O(n^{2})\),考慮如何最佳化。由於方程裡有 \(P\) 次方項,不好數學方法轉化或者資料結構維護,因此向單調性這方面靠。
  • 可惜我不會證四邊形不等式不如打表。可以發現每個點的決策點所組成的序列可能長成如下的情況:
    112333334456
    這裡的數字對應的是每個點從哪個點轉移過來最優,我們可以發現兩條相當顯然的性質:
  1. 每個點的決策點都一定小於自己
  2. 每個點做出決策後決策點一定不動,其 \(f\) 值一定也是確定了的(其實就是無後效性)
  • 那我們就想到假設某個點的最優決策點已經找到,那可不可以在列舉到它的時候將其後面的點的最優決策點是它的點也一起處理了呢?
  • 但這樣的想法有一個問題,由前面的點更新後面的點很有可能後面的點又再次被更新,比如列舉到3的時候:
    122|3333333
    但列舉到4的時候:
    1223|344444
  • 這樣我們就需要維護一個支援修改的單調的序列。同時,上面的過程中,豎線前的數我們已經處理了,需要被去掉。那麼這個資料結構已經近在眼前了——單調佇列。
  • 這個單調佇列不可能真的存下一整個真的完整序列,也沒必要。我們考慮去存每一段連續的相同的區間的左右端點。設處理到一個點 \(i\),其決策點為 \(q_{head}\),就將決策點右端點小於 \(i\) 的區間刪掉,然後在其右邊的區間裡二分從哪個點開始轉移更優。比如說對於上面列舉到4的這個例子,我們要找的就是其左邊這個點的決策點不是4而其本身決策點是4的加粗的這個點
    1223|344444
    由於這個點右邊的所有點從4轉移都比3更優,因此就可以二分。
  • 不過二分有幾個需要注意的點:
  1. 可能有某些決策點的整個區間都被覆蓋完
    如:
    12233|33345
    122333|6666
    這時,就需要先判斷單調佇列末尾的區間的左端點從原來決策點轉移與從現在這個點轉移的優劣。
    如果從原來轉移優,那就在這個區間裡二分(之前說的斷點一定在這個區間裡)
    否則,這個區間一定會被現在列舉的這個點完全覆蓋,因此直接刪除即可(因為除非極劣,否則新加入的這個區間右端點一定是n)
  2. 可能這個決策點很劣,完全覆蓋不了
    這種情況就在做上一步之前特判一下最後一個點從它原來的決策點轉移好還是從當前列舉的點轉移好就行了。
  • 輸出並不是主要要講的,但還是有些坑點,這裡提一下。
    1.要用long double,因為中間並不是最優的決策點的答案超過 \(1e18\),因此需要用 long double捨棄一些精度來提高儲存量(long double會自動幫你用科學計數法捨棄一些精度來儲存資料)
    2.在二分的時候需要對每個區間儲存一下每個點對應的決策點位置,稱為 \(lst_{i}\),再用其更新 \(nxt_{i}\) 來表示在哪一個數的位置換行,依次輸出,注意 \(nxt_{i}\) 是反著更新的
  • 時間複雜度的話,對於列舉的每個點,其最多入隊、出隊一次,對於列舉的每個點最差情況下都要二分,因此會算 \(nlong_{2}n\)\(|s_{i}-s_{j}-1|^{P}\),而這個東西還需要快速冪 \(log_{2}n\) 去算,因此總複雜度為\(O(Tnlog_{2}^{2}n)\)
#include<bits/stdc++.h>
using namespace std;
typedef long long ld;
typedef long long ll;
//#define int ll
const ld inf=1e18*1.0;
const ll N=2e5+10;
ll n,L,P,len[N],sum[N],tail,head,l[N],r[N],q[N];
ll lst[N],nxt[N];
ld f[N];

string s[N];
ld ksm(ld x)
{
	ld res=1;ll k=P;
	while(k)
	{
		if(k&1) res=res*x;
		x*=x,k>>=1;
	}
	return res;
}
#define getf(x,y) ((ld)((ld)f[y]+ksm((ld)abs(sum[x]-sum[y]-L-1))))
void shuru()
{
	cin>>n>>L>>P;
	for(ll i=1;i<=n;i++)
	{
		cin>>s[i];
		len[i]=(ll)s[i].length()+1;
		sum[i]=sum[i-1]+len[i];
	}
}

void erfen(ll x)
{
	ll now=q[tail],lt=l[now],rr=n;
	while(lt<rr)
	{
		ll mid=(lt+rr)>>1;
		if(getf(mid,now)>getf(mid,x)) rr=mid;
		else lt=mid+1;
	}
	r[now]=lt-1,q[++tail]=x;l[x]=lt,r[x]=n;//更新左右區間範圍 
}

void cacl()
{
	head=tail=0;
	q[0]=0,l[0]=1,r[0]=n;
	for(ll i=1;i<=n;i++)
	{
		while(r[q[head]]<i) head++;
		ll now=q[head];
		f[i]=getf(i,now);lst[i]=now;
		if(getf(n,q[tail])<getf(n,i)) continue;
		while(getf(l[q[tail]],q[tail])>getf(l[q[tail]],i)) tail--;
		erfen(i);
	}
}
int T;
void write()
{
	if(f[n]>inf) cout<<"Too hard to arrange"<<'\n';
	else
	{
		cout<<(ll)(f[n]+0.5)<<'\n';
		for(ll i=n;i;i=lst[i]) nxt[lst[i]]=i;//注意i是倒著跳lst的 
		ll now=0;
		for(ll i=1;i<=n;i++)
		{
			now=nxt[now];
			for(ll j=i;j<now;j++) cout<<s[j]<<' ';
			cout<<s[now]<<'\n';
			i=now;  //i不用再加1了,for會幫你加 
		}
	}
//	puts("--------------------");
	if(!T)
	cout<<"--------------------";
	else cout<<"--------------------"<<'\n';
	
}

signed main()
{
	ios::sync_with_stdio(false);cin.tie(0),cout.tie(0);
	cin>>T;
	while(T--)
	{
		shuru();
		cacl();
		write();
	}
}

相關文章