淺談區間類動態規划


注:此文章為作者原創,轉載請注明出處!

\(\mathcal{Before\ Writing}\)

為了加深印象,寫下這則學習筆記 .

若文中有錯誤之處還請指出,感激不盡 .

\(\mathcal{PS:}\) 菜雞筆者水平有限,沒寫好不要噴我哦qwq

\(\mathcal{Come\ into\ subject}\)

我們先從一個問題入手。

\(\mathcal{Problem\ Link\ :P1090}\)

題解就是將 \(n\) 堆果子任意兩堆合並最終合並成一堆所需要的最小的體力耗費值。很容易想到貪心的方法,即每次合並耗費最小的兩堆,可以通過維護一個小根堆實現。這里就不給出代碼了,請讀者自己實現。

接下來,考慮如果把題目改為每次只能合並相鄰的兩堆(保證果子都在一條線上,即第1堆和第 \(n\) 堆不算相鄰)那么很明顯貪心的思路是行不通的。因此需要換一種算法。

\(\mathcal{Solution}\)

我們可以這么想,無論怎么合並這些果子堆,最終都是要合並成一堆的。

舉個例子:假設有5堆果子(編號從1到5)需要合並,那么當她們最終合並成一堆時,一定是由 ①--②③④⑤ | ①②--③④⑤ | ①②③--④⑤ | ①②③④--⑤ 這四種合並方法中的某一種合並得到的( \(a\) -- \(b\) 表示 \(a\)\(b\) 合並)。

也就是我們只需要知道最后一堆是由哪兩堆合並而來的,問題也就迎刃而解了。而對於最終解需要最優,也就是合並而來的兩堆也要是最優的,這就符合了動態規划最優子結構的性質,而且兩堆合並的情況也是獨立唯一的,不存在后效性,因此我們可以考慮用動態規划來解決這個問題。

約定\(H(a,b)\) 表示將編號從 \(a\)\(b\)所有堆合並后得到的一個堆

描述狀態

\(dp[i][j]\) 表示將編號從 \(i\)\(j\) 的所有果子堆合並能得到的最小體力耗費值(也就是 \(H(i,j)\) 的最小值)。因為 \(H(i,j)\) 是由 \(H(i,k)\)\(H(k+1,j)\) 合並得到的,即上述例子中
\(H(1,5)=min\begin{cases}H(1,1)+H(2,5)\\H(1,2)+H(3,5)\\H(1,3)+H(4,5)\\H(1,4)+H(5,5)\end{cases}\)

由此我們可以得到狀態轉移方程

\(dp[i][j]=min\{\ dp[i][k]+dp[k+1][j]\ \}+cost(i,j)\) \(\ \ \ \ \ (1\leq i \leq j \leq n,i\leq k \leq j-1)\)

所以我們去枚舉 \(i,j,k\) 就好了,最終的解即為 \(dp[1][n]\) 。也就是:

初始化 dp[i][i]=0;

for(int i=1;i<=N;++i)
  for(int j=i;j<=N;++j){
  	for(int k=i;k<=j-1;++k)
  	  dp[i][j]=min(dp[i][j],dp[i][k]+dp[k+1][j]+cost(i,j));
  	/* dp[i][j]+=cost(i,j);  狀態轉移方程中的 +cost(...) 操作也可以放到循環外面來做,對於此題沒有影響 
  	   cost可以是一個計算花費的函數,也可以是一個數組,具體看題目而定,而在本題中表示 H(i,j) 的值,可以使用
       前綴和,即將函數cost(i,j)用來計算sum[j]-sum[i-1] 
    */
}

Res=dp[1][N];
    

通常 \(dp\) 數組的第一維是階段,第二維是狀態。但如果你對動態規划的概念非常清楚,那么就會發現這么做其實是有問題的,因為這里的 \(i\) 並不能作為階段

還是拿之前的例子來說,對於動態規划的最優子結構性質,我們要求當做到 \(dp[1][5]\) 時,她的子問題 \(dp[1][1],\) \(dp[1][2]\) \(...\) \(dp[4][5]\) 必須都是已經確定且最優的,但放入我們寫的循環里看,做到 \(dp[1][5]\) 時,需要用到的 \(dp[2][3]\) \(,dp[2][4]...\) \(dp[4][5]\) 全都還沒有確定。

你也可以這么想,對於上面的狀態轉移方程的變量范圍 $\ 1\leq i \leq j \leq n\ ,\ i\leq k \leq j-1\ \ \ $
很明顯,結合轉移方程來看,\(dp[k+1][j]\) 這個數組的階段 \(k+1\) 是會大於 \(i\) 這個階段的,也就是階段 \(i\) 的所有狀態還沒確定好,就用到了后面等待確定的階段 \(k+1\) 中的狀態,這顯然是錯誤的。

下面我們就要來考慮到底以什么來作為階段了。

我們可以發現,對於 \(H(1,5)\) ,她是由5個堆合並得到的,而她的子問題 \(H(1,2),\) \(H(2,4)\) 等,都是屬於由2~4個的堆合並得到的,即要確定由 \(n\) 個堆合並而成的 \(H(a,a+n-1)\) 時,我們要做的是確定由2至 \(n-1\) 個堆合並而成的 \(H(a,a+k-1)\) 。因為 \(k\) 一定小於 \(n\) ,所以我們可以將合並的堆數作為階段。發現合並的堆數知道了,只要知道合並操作的起始堆的位置,就能夠算出終止堆的位置。

下面給出正確的偽代碼

初始化 dp[i][i]=0; 

for(int L=2;L<=N;++L) //這里合並的堆數從2開始,也就是最少兩個堆合並
  for(int i=1;i+L-1<=N;++i){  //枚舉起點
  	int j=i+L-1;  //由起點計算出終點
  	for(int k=i;k<=j-1;++k)
  	  dp[i][j]=min(dp[i][j],dp[i][k]+dp[k+1][j]+cost(i,j));
}

Res=dp[1][N];

至此,問題已經得到解決。

\(end.\)

上述代碼就是區間類動態規划的思想,基本套路就是1.枚舉區間長度 2.枚舉起點,算出終點 3.枚舉過渡點 4.轉移狀態

但是現在廣大毒瘤出題人當然不會給你太裸的模型來做,所以他們會在一些 描述狀態、轉移狀態、或者 在進行 \(dp\) 前的處理 等方面做文章。其中環形區間動規最為經典,本文就以此為例進行分析。

我們可以對之前的題目再次進行修改:將 \(n\) 堆果子圍成一圈,每次只能合並相鄰的兩堆,其他約定與原題一致,問最少的體力耗費值。

\(\mathcal{Solution}\)

我們來看一張圖

可以看出,環狀分布解法其實就相當於從環上取一條長為 \(n\) 的鏈,而我們做前面題目的解 \(dp[1][N]\) 只是在這個環上取鏈的其中一種情況。

較朴素的做法就是在原先的循環最外層再套一層循環,枚舉鏈的起點,還請讀者自行嘗試。這里主要介紹一種環形區間動規的經典解法。

割環

顯然,既然是環,我們可以考慮在環上“切”一刀,使其變成一條鏈。這樣由環轉鏈,是解決環形區間動規較常用的一種方法,對於圖示中的環,我們就可以將其表示為: 123451234 也就是將長度為 \(n\) 的環變成了長度為 \(2n-1\) 的鏈。那么最優解就在 12345 23451 34512 45123 51234 中。

你會發現,接下來的操作便和之前的寫法完全一樣了

偽代碼如下

初始化 dp[i][i]=0;

for(int i=1;i<=N;++i) a[i]=a[i+N]=read();  由環轉鏈 

for(int L=2;L<=N;++L)
  for(int i=1;i+L-1<=2*N-1;++i){
  	int j=i+L-1;
  	for(int k=i;k<=j-1;++k)
  	  dp[i][j]=min(dp[i][j],dp[i][k]+dp[k+1][j]+cost(...));
}

Res=min{dp[i][i+N-1]}  1<=i<=N

理解了之后,就可以嘗試做這兩道環形區間動規的經典題目。(如果還有例題可以提出來哦)

\(\mathcal{Problem\ Link : P1880}\)

\(\mathcal{Problem\ Link : P1063}\)

\(end.\)

我們再來看一道挺有意思的題目

\(\mathcal{Description:}\)

給定一個具有 \(N\) 個頂點的凸多邊形,將頂點從1至 \(N\) 標號,每個頂點的權值都是一個正整數。將這個凸多邊形划分成 \(N-2\) 個互不相交的三角形,試求這些三角形頂點的權值乘積和至少為多少。

\(\mathcal{Solution}\)

約定\(P(a,b)\) 表示從點 \(a\)\(b\) 順時針連線組成的多邊形划分三角形后能得到權值乘積和

對於題目給出的 \(N\) 多邊形,我們將她的頂點按順時針方向 \(1-N\) 編號。

我們可以這么想,對於最終的最優解 \(P(1,N)\) ,因為此時點之間的分割線已經確定,那么我們如果割去這個 \(N\) 邊形外部的某一塊三角形,剩下的多邊形也必然是符合邊數等於 \(N-1\) 時的最優解的。也就是說,這符合動態規划的最優子結構的性質,並且每一種割法都是獨立唯一的,不具有后效性,這也符合動態規划的原則。因此,我們可以確定了算法框架:動態規划。

\(dp[i][j]\) 表示從點 \(i\)\(j\) 順時針連線組成的多邊形划分三角形后能得到的最小權值乘積和(即 \(P(i,j)\) 的最小值 )。結合上圖看,顯然,我們要求的 \(P(i,j)\) 的值就等於 \(P(i,k)\) \(+\) \(P(k,j)\) \(+\) \(W(i)*W(j)*W(k)\)

由此得到狀態轉移方程

\(dp[i][j]=min\{\ dp[i][k]+dp[k][j]+W(i)*W(j)*W(k)\ \}\) \(\ \ \ \ \ (1\leq i < j \leq n,i< k < j)\)

於是,你會發現這又是一道區間類動態規划,接下來的做法就和前面相似了。下面是偽代碼

初始化 dp[i][i+1]=0

for(register int L=2;L<=N-1;++L)
	  for(register int i=1;i+L<=N;++i){
	  	int j=i+L;
	  	for(register int k=i+1;k<=j-1;++k)
	  	  dp[i][j]=min(dp[i][j],dp[i][k]+dp[k][j]+a[i]*a[j]*a[k]);
	}
    
Res=dp[1][N]

另外,此題我跑了好幾個oj好像都沒有找到,所以我造了數據放在私人題庫里了,有興趣寫一寫的可以來這里qwq由於原題數據原因需要用到高精度,順手練練自己的高精度好啦。

這里放上代碼

#include <bits/stdc++.h>
#define ll long long
#define qomzi(n,i) for(register int i=1;i<=(n);++i)
#define INF 1e9
const int Maxn=160;
using namespace std;
inline int icin(){
	char c=getchar();int s=0;bool sign=0;
	while(!isdigit(c)&&c^'-')c=getchar();
	if(c=='-')c=getchar(),sign=1;
	while(isdigit(c))s=(s<<1)+(s<<3)+c-'0',c=getchar();
	return sign?-s:s;
}

int N;
struct Node{
    int len,num[Maxn<<1];
    Node(){len=1;memset(num,0,sizeof(num));}
    
    Node operator * (const Node &A)const{
    	Node ret; 
    	ret.len=A.len+len-1;
    	for(register int i=1;i<=A.len;++i)
    	  for(register int j=1;j<=len;++j)
    	    ret.num[i+j-1]+=A.num[i]*num[j],ret.num[i+j]+=ret.num[i+j-1]/10,ret.num[i+j-1]%=10;
	    while(ret.num[ret.len+1]>0)ret.len++;
	    return ret;
	}
	Node operator + (const Node &A)const{
		Node ret;
		ret.len=max(A.len,len);
		for(register int i=1;i<=ret.len;++i)
		  ret.num[i]+=A.num[i]+num[i],ret.num[i+1]+=ret.num[i]/10,ret.num[i]%=10;
		while(ret.num[ret.len+1]>0) ret.len++;
		return ret;
	}
	inline void print(){
		int f=len;
		while(!num[f]&&f>1) f--;
		for(register int i=f;i>=1;--i) printf("%d",num[i]);
	}
}dp[Maxn][Maxn],a[Maxn];
Node High(int a){
    Node ret;//cout<<a<<endl;
    while(a>0){
    	ret.num[ret.len++]=a%10;
    	a/=10;
	}
	//ret.print();cout<<'\n';
	return ret;
}
Node Min(Node x,Node y){
	int lenx=x.len,leny=y.len;
	if(lenx<leny) return x;
	if(lenx>leny) return y;
	for(register int i=lenx;i>=1;--i){
	    if(x.num[i]<y.num[i]) return x;
	      else if(x.num[i]>y.num[i]) return y;	
	}
	return x;
}

inline void init(){
	N=icin();
	for(register int i=0;i<=N;++i)
	  for(register int j=0;j<=N;++j)
	    dp[i][j].len=250,dp[i][j].num[250]=9;
	qomzi(N,i) a[i]=High(icin()),dp[i][i+1].len=dp[i][i+1].num[250]=0;
}
int main(){
	init();
	for(register int L=2;L<=N-1;++L)
	  for(register int i=1;i+L<=N;++i){
	  	int j=i+L;
	  	for(register int k=i+1;k<=j-1;++k)
	  	  dp[i][j]=Min(dp[i][j],dp[i][k]+dp[k][j]+a[i]*a[j]*a[k]);
	}
	dp[1][N].print();
    return 0;
}

\(end.\)

\(\mathcal{After\ Writing}\)

經過分析了幾道區間類動態規划的題目,相信你一定有所領悟了吧,盡管此類dp或許會有大致的框架,但在解題時還是不能死板盲目地想當然放上模板做法,因為針對不同的題目代碼內容還是會有微小的變化的。所以在做題目時,我們要將所給的問題一層一層地剝開,最后找到問題的實質,這樣,面對動態規划的題目就可以得心應手啦。

這里放上網上的一位博主對區間類型dp的理解

"區間動態規划是線性動歸的拓展,在划分階段時,往往是以區間的長度從小到大為階段,逐步求解到到長度為N的區間的最優值,在枚舉每一個區間的最優值時,由於當前區間內又有很多種合並方式並到到當前區間,那么就需要枚舉這些合並方式中產生的值維護最優值,合並的不同,可以看作是區間划分的不同,划分時需要枚舉划分的位置,即分割點。 那么對於區間類動態規划問題,往往可以將問題分解成為兩兩合並的形式。其解決方法是對整個問題設最優解,枚舉分割點,維護最優值。

現在再看這段話就一定會覺得很有感觸了吧qwq

最后,如果你認真看了這篇文章,或多或少一定會有點收獲叭(dalao請忽略qwq)。如果你還有什么問題請發在討論區或者私信我,我會不定時解疑的。

另外筆者文化課水平欠佳,有些抽象的意思實在不能解釋的很清楚,還請你們自行畫圖列表幫助理解哦。

\({End.}\)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM