DP(一)

xingyu_xuan發表於2024-06-10

前言

習題部落格:link

因為各種原因,這個部落格是趕出來的,所以大機率會有沒講清楚或者講錯了的情況,請大家及時指出。

因為個人不是非常擅長於 DP,可能很難判別一道題的好壞,所以可能存在幾道史題在題單中,請大家諒解。

這篇部落格理論上僅限於講解例題,大部分習題的題解請移步至配套部落格檢視。關於習題:就是我認為大家做完上一道題之後能自己做出來的題。

那麼就讓我們開始吧。

DP 是什麼

DP(Dynamic Programming),動態規劃,通常用於處理最優性問題,也可以用來計數等。其核心思想就是設定狀態,使得這些狀態能夠進行轉移,最後得出結果。

另一種理解方式就是,DP 能夠透過設定狀態轉移的方式來縮小問題規模,就比如我們推出了一個從前 \(i\) 個物品轉移到前 \(i+1\) 個物品的轉移式,我們就成功把問題規模從 \(n\) 變成了 \(1\),因為這裡存在一個從 \(1\)\(n\) 的遞推關係。

DP 優於暴力就是因為,暴力的時候,我們其實會重複處理很多資訊,而 DP 透過設定狀態並儲存狀態資訊來進行一個類似於記憶化的過程,從而省去了很多很多重複的計算。

舉一個非常簡單的例子:

最長公共子序列問題:

給定兩個串 \(S,T\),可以刪除 \(S\) 中的一些元素,將 \(S\) 中剩下元素按原順序拼在一起組成串 \(A\);對 \(T\) 進行一樣的操作得到 \(B\),求最長的 \(A\) 的長度 \(l\) 使得 \(\exist A,B,|A|=|B|=l, A=B\)

我們可以設定狀態 \(f_{i,j}\) 代表這個最長的子序列放到 \(S\) 的原位置中最後位置的下標 \(\leq i\),放到 \(T\) 的原位置中最後位置的下標 \(\leq j\) 時的最長子序列長度。我們發現有轉移:

\[f_{i+1,j+1}=\max(f_{i,j}+[S_{i+1}=T_{j+1}],f_{i+1,j},f_{i,j+1}) \]

這樣我們可以在 \(O(|S||T|)\) 的時間複雜度內解決這個問題。這比暴力的 \(O(2^{|S|}+2^{|T|})\) 好多了。

DP 的前置條件

能用 DP 解決的問題通常需要滿足三個條件,即最優子結構,無後效性,子問題重疊。

  • 最優子結構

這個指子問題的最優解能夠轉移到原問題的最優解。滿足這個條件的問題有些時候也可以用貪心做。

  • 無後效性

前面子問題的解不會受後面決策的影響。

  • 子問題重疊

如果沒有重疊的子問題,那麼 DP 其實和暴力是等複雜度的。

對於大多數題,如果計算的貢獻完整且沒有重複,然後無後效性,這個 DP 大機率就是正確的。

樸素 DP

就是什麼特殊類 DP 都不是的純暴力 DP,放到開頭給大家練練手。

例題:CF1096D Easy Problem

*1800,想必大家都能獨立切掉。

給你一個長為 \(n\) 的字串 \(s\) 以及 \(a_{1..n}\),刪去第 \(i\) 個字元的代價為 \(a_i\),你需要刪去一些字元(如果一開始就符合條件當然可以不刪)使得剩下的串中不含子序列 "hard",求最小代價。

子序列不需要連續。

\(n\leq 10^5,a_i\in[1,998244353]\)

題解

我們設狀態 \(f_{i,j}\) 表示前 \(i\) 個字元中,hard 僅前 \(j\) 個字元已經匹配完成的最小代價。

轉移是簡單的。如果當前位是 h,那麼有:

\[f_{i,1}=\min(f_{i-1,1},f_{i-1,0})\\ f_{i,0}=f_{i-1,0}+a_i \]

第一個式子代表不刪這個 h 時的轉移,第二個式子則代表刪掉這個 h 之後的轉移。

當前位為其他值類似,最後的答案就是 \(\min_if_{n,i}\)。時間複雜度為 \(O(n)\)。空間可以滾動。

程式碼
#include<bits/stdc++.h>
using namespace std;
int n;
char s[100005];
long long f[100005][4];
int a[100005];
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);
	memset(f,0x3f,sizeof f);
	f[0][0]=0;
	cin>>n;
	for(int i=1;i<=n;i++)cin>>s[i];
	for(int i=1;i<=n;i++)cin>>a[i];
	for(int i=1;i<=n;i++){
		for(int j=0;j<=3;j++)f[i][j]=f[i-1][j];
		if(s[i]=='h')f[i][1]=min(f[i][1],f[i-1][0]),f[i][0]=f[i-1][0]+a[i];
		else if(s[i]=='a')f[i][2]=min(f[i][2],f[i-1][1]),f[i][1]=f[i-1][1]+a[i];
		else if(s[i]=='r')f[i][3]=min(f[i][3],f[i-1][2]),f[i][2]=f[i-1][2]+a[i];
		else if(s[i]=='d')f[i][3]=f[i-1][3]+a[i];
	}
	cout<<min({f[n][0],f[n][1],f[n][2],f[n][3]});
	return 0;
}

習題:[ABC301F] Anti-DDoS

例題:P1410 子序列

要想學好 DP,首先要練習如何吃史。

給定一個長度為 \(N\)\(N\) 為偶數)的序列,問能否將其劃分為兩個長度為 \(N / 2\) 的嚴格遞增子序列。

\(N\leq 2000\)

題解

這題的狀態設計非常神秘。

\(f_{i,j}\) 為前 \(i\) 個元素可以拆成兩個遞增子序列,第一個的長度為 \(j\),並且最後一位對應的下標是 \(i\),第二個的最大值的最小值。

因為存的是最小值,我們就能較為方便的轉移。

假設下一個元素 \(i+1\) 的值 \(a_{i+1}>f_{i,j}\),那麼這裡可以直接擴充套件第二個子序列,有 \(f_{i+1,i-j+1}\gets a_i\)

如果 \(a_{i+1}>a_i\),這裡也可以直接擴充套件第一個子序列,有 \(f_{i+1,j+1}\gets f_{i,j}\)

其他情況均不能擴充套件任何子序列。

最後判定 \(f_{n,\frac{n}{2}}\) 是不是 \(+\infty\) 就行了。

最初做這道題的時候,作者認為這道題非常史。

程式碼
#include<bits/stdc++.h>
using namespace std;
int f[2005][2005];
int a[2005];
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);
	int n;
	while(cin>>n){
		for(int i=1;i<=n;i++)cin>>a[i];
		memset(f,0x3f,sizeof f);
		f[1][1]=0;
		for(int i=1;i<n;i++){
			for(int j=1;j<=i;j++){
				if(a[i+1]>f[i][j])f[i+1][i-j+1]=min(f[i+1][i-j+1],a[i]);
				if(a[i+1]>a[i])f[i+1][j+1]=min(f[i+1][j+1],f[i][j]);
			}
		}
		if(f[n][n/2]<=1000000000)cout<<"Yes!\n";
		else cout<<"No!\n";
	}
	return 0;
}

順帶一提,這題有雙倍經驗 P4728。

習題:P2224 [HNOI2001] 產品加工

選做題:P4740 [CERC2017] Embedding Enumeration

作者認為,分討是 DP 的一大重點。

輸入一棵有標號樹,求把這棵樹放入 \(2*n\) 的網格圖中的方案數,對 \(10^9+7\) 取模。

兩種方案相同當且僅當網格圖內標號分佈完全相同。

要求:\(1\) 必須放在 \((1,1)\),有邊連線的節點必須相鄰,兩個節點不能放在同一個格子。

\(n\leq 3\times 10^5\)

Hint

大家可以嘗試直接對某個節點在左上角時的方案數進行分類討論。

題解

作者的分類討論和程式碼都非常複雜,僅供參考!!!

另外,因為這個題調不出來會很難受,所以作者提供資料,內網外網

\(f_i\) 為樹上的點 \(i\) 在左上角時放 \(i\) 的子樹的方案數。

\(4\) 種大情況:

第一種:

注意到這裡的左上角黑點不一定就是 \(i\),也有可能是 \(i\) 的子孫。注意這個子孫到 \(i\) 中間經過的所有點度數為 \(2\),否則會和下面的情況重複一些。

這個時候的貢獻就是紅色的點的 DP 值,我們需要記錄樹上的這種鏈(鏈頂到鏈底的父親都只有一個兒子)的 DP 值和。

第二種:

需要判斷左下鏈的合法性,貢獻為右下的點的 DP 值。

第三種:

下面的子樹可以往左擺或者往右擺。

向左擺是簡單的,但是向右擺的話就要滿足這兩棵子樹中至少有一個是鏈,貢獻就是某個子樹第一次超過另外一個鏈的點的 DP 值,沒有超過也有 \(1\) 的貢獻。

這裡可能需要記錄 DFS 序來實現 \(O(1)\) 轉移。

注意如果下面的子樹大小就只有 \(1\),注意向左擺和向右擺只能算一個,不然會算重。

第四種:

這裡有三個子樹,我們把左下子樹稱為 \(a\) 子樹,右下子樹稱為 \(b\) 子樹,右上子樹稱為 \(c\) 子樹。

顯然 \(a\) 子樹一定是鏈,我們只用保證 \(b,c\) 子樹其中有至少一個是鏈就行了,貢獻和第三種是一致的。

注意如果 \(i\) 的子樹就是鏈,還要特判一條直鏈,一條直鏈最後拐下來一格,還有拐下來後只往左擺。

可能還要特判 \(i\) 有兩個兒子的情況。

作者的實現非常史,程式碼中分了 \(7\) 種情況,其中 Situation 1 對應這裡的第一種情況;Situation 2 對應這裡的第二種情況;Situation 3-5 對應這裡的第三種情況;Situation 6 對應這裡的第四種情況;Situation 7 說的是一條鏈拐下來之後向左擺(擺的長度 \(>1\))的情況。

程式碼
#include<bits/stdc++.h>
using namespace std;
const int mod=1e9+7;
#define int long long
vector<int> son[300005];
int f[300005],fa[300005],sum[300005],len[300005],edid[300005];
int tst[300005],dfn[300005],rk[300005],dfncnt;
vector<int> to[300005];
void init(int now,int f){
	fa[now]=f;
	dfn[now]=++dfncnt;
	rk[dfncnt]=now;
	for(auto v:to[now]){
		if(v^f){
			son[now].push_back(v);
			init(v,now);
		}
	}
	if(son[now].size()==1){
		len[now]=len[son[now][0]]+1;
		edid[now]=edid[son[now][0]];
		if(!edid[now])edid[now]=son[now][0];
		tst[now]=tst[son[now][0]];
	}else if(son[now].size()==2)tst[now]=1,edid[now]=now;
	else if(son[now].size()>2){
		cout<<0;
		exit(0);
	}
	return;
}
int n,x,y;
void dfs(int now){
	for(auto v:son[now])dfs(v);
	int &p=f[now];
	//Situation 1
	if(len[now]>=2)p=(p+sum[son[son[now][0]][0]]);
	//Situation 7
	if(!tst[now]&&len[now]>=3)p=(p+len[now]-2-(len[now]/2)+1)%mod;
	//SP-Straight segment
	if(!tst[now])p=(p+1)%mod;
	//SP-len>=1
	// ------
	//      |
	if(!tst[now]&&len[now]>=1)p=(p+1)%mod;
	//Situation 2-6
	if(tst[now]){
		//get next deg-3 point
		int nxt3=edid[now];
		//SP-now is deg-3 point
		if(nxt3==now){
			//sub1:2 segments
			if(!tst[son[now][0]]&&!tst[son[now][1]]){
				int mx=len[son[now][0]]>len[son[now][1]]?son[now][0]:son[now][1];
				int mi=mx==son[now][0]?son[now][1]:son[now][0];
				p=(p+f[rk[dfn[mx]+len[mi]]])%mod;
				if(len[mx]>len[mi]+1)p=(p+f[rk[dfn[mx]+len[mi]+2]]%mod)%mod;
				else p=(p+1)%mod;
			}//sub2:1 tree,1 segment
			else if(tst[son[now][0]]+tst[son[now][1]]==1){
				int _1=tst[son[now][0]]==1?son[now][0]:son[now][1];
				int _0=tst[son[now][0]]==0?son[now][0]:son[now][1];
				if(len[_1]>=len[_0])p=(p+f[rk[dfn[_1]+len[_0]]])%mod;
				if(len[_1]>=len[_0]+2)p=(p+f[rk[dfn[_1]+len[_0]+2]])%mod;
			}
		}else{
			//Situation 2
			if(dfn[nxt3]-dfn[now]>=2){
				int nxtlen=dfn[nxt3]-dfn[now]-1;
				if(!tst[son[nxt3][0]]&&len[son[nxt3][0]]<nxtlen)p=(p+f[son[nxt3][1]])%mod;
				if(!tst[son[nxt3][1]]&&len[son[nxt3][1]]<nxtlen)p=(p+f[son[nxt3][0]])%mod;
			}
			//Situation 3
			if(!tst[son[nxt3][0]]&&!tst[son[nxt3][1]]){
				//Copy upper code-deal RR situation
				int mx=len[son[nxt3][0]]>len[son[nxt3][1]]?son[nxt3][0]:son[nxt3][1];
				int mi=mx==son[nxt3][0]?son[nxt3][1]:son[nxt3][0];
				p=(p+f[rk[dfn[mx]+len[mi]]])%mod;
				if(len[mx]>len[mi]+1)p=(p+f[rk[dfn[mx]+len[mi]+2]])%mod;
				else p=(p+1)%mod;
				//deal LR situation
				if(len[now]>=len[mi]&&len[mi]!=0)p=(p+f[mx])%mod;
				if(len[now]>=len[mx]&&len[mx]!=0)p=(p+f[mi])%mod;
			}
			//Situation 4-5
			if(tst[son[nxt3][0]]+tst[son[nxt3][1]]==1){
				int mx=tst[son[nxt3][0]]?son[nxt3][0]:son[nxt3][1];
				int mi=mx==son[nxt3][0]?son[nxt3][1]:son[nxt3][0];
				//Situation 4
				if(len[mi]<=len[now]&&len[mi]!=0)p=(p+f[mx])%mod;
				if(len[mx]>=len[mi])p=(p+f[rk[dfn[mx]+len[mi]]]%mod)%mod;
				//Situation 5
				if(len[mx]>=len[mi]+2)p=(p+f[rk[dfn[mx]+len[mi]+2]]%mod)%mod;
			}
			//Situation 6
			//  -------
			//     |
			//  -------
			//shape
			//this is a large situation!!!
			if(max(son[son[nxt3][0]].size(),son[son[nxt3][1]].size())==2&&son[son[nxt3][0]].size()+son[son[nxt3][1]].size()<=3){
				int mx=son[son[nxt3][0]].size()==2?son[nxt3][0]:son[nxt3][1];
				int mi=mx==son[nxt3][0]?son[nxt3][1]:son[nxt3][0];
				int _1=mi,_2=son[mx][0],_3=son[mx][1];
				//sub1: 3 segments
				if(tst[_1]+tst[_2]+tst[_3]==0){
					if(len[now]>len[_2]){
						int __1=len[_1]>len[_3]?_1:_3;
						int __2=len[_1]<len[_3]?_1:_3;
						if(len[__1]==len[__2])p=(p+1)%mod;
						else p=(p+f[rk[dfn[__1]+len[__2]+1]])%mod;
					}
					if(len[now]>len[_3]){
						int __1=len[_1]>len[_2]?_1:_2;
						int __2=len[_1]<len[_2]?_1:_2;
						if(len[__1]==len[__2])p=(p+1)%mod;
						else p=(p+f[rk[dfn[__1]+len[__2]+1]])%mod;
					}
				}
				//sub2: 2 segments 1 tree
				if(tst[_1]+tst[_2]+tst[_3]==1){
					if(tst[_1]==1){
						if(len[_2]<len[now]&&len[_3]<len[_1])p=(p+f[rk[dfn[_1]+len[_3]+1]])%mod;
						if(len[_3]<len[now]&&len[_2]<len[_1])p=(p+f[rk[dfn[_1]+len[_2]+1]])%mod;
					}else{
						if(tst[_2]!=1)swap(_2,_3);
						if(len[_3]<len[now]&&len[_1]<len[_2])p=(p+f[rk[dfn[_2]+len[_1]+1]])%mod;
					}
				}
			}
		}
	}
	sum[now]=f[now];
	if(son[now].size()==1)sum[now]=(sum[now]+sum[son[now][0]])%mod;
	return;
}
signed main(){
	f[0]=1;
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);
	cin>>n;
	for(int i=1;i<n;i++)cin>>x>>y,to[x].push_back(y),to[y].push_back(x);
	init(1,0);
	dfs(1);
	cout<<f[1];
	return 0;
}

這裡有一道據說類似的題,如果感興趣可以做做。

CF613E Puzzle Lover

區間 DP

區間 DP,就是指狀態中含有一個區間的 DP,使用時通常需要滿足可以快速加入元素或可以快速合併,大多數題都是從小區間轉移到大區間。

因為合併區間時會列舉決策點,這個時候可以使用一些有關決策的 DP 最佳化,但是這目前不在我們的討論範圍之內。

如果用區間 DP 計數的時候,注意去重。

由於作者認為這個 DP 比較重要,所以塞了比較多題。

接下來就是例題時間了。

P7914 [CSP-S 2021] 括號序列

link

具體而言,小 w 定義“超級括號序列”是由字元 ()* 組成的字串,並且對於某個給定的常數 \(k\),給出了“符合規範的超級括號序列”的定義如下:

  1. ()(S) 均是符合規範的超級括號序列,其中 S 表示任意一個僅由不超過 \(\bf{k}\) 字元 * 組成的非空字串(以下兩條規則中的 S 均為此含義);
  2. 如果字串 AB 均為符合規範的超級括號序列,那麼字串 ABASB 均為符合規範的超級括號序列,其中 AB 表示把字串 A 和字串 B 拼接在一起形成的字串;
  3. 如果字串 A 為符合規範的超級括號序列,那麼字串 (A)(SA)(AS) 均為符合規範的超級括號序列。
  4. 所有符合規範的超級括號序列均可透過上述 3 條規則得到。

例如,若 \(k = 3\),則字串 ((**()*(*))*)(***) 是符合規範的超級括號序列,但字串 *()(*()*)((**))*)(****(*)) 均不是。特別地,空字串也不被視為符合規範的超級括號序列。

現在給出一個長度為 \(n\) 的超級括號序列,其中有一些位置的字元已經確定,另外一些位置的字元尚未確定(用 ? 表示)。小 w 希望能計算出:有多少種將所有尚未確定的字元一一確定的方法,使得得到的字串是一個符合規範的超級括號序列?

\(1\leq k\leq n\leq 500\)

Hint

如果不好去重,就多定義幾個狀態出來輔助 DP 來去掉去重這一步。

題解

這裡講一種不需要去重的 DP 方式。

首先我們可以預處理出那些區間是可以變成 S 的。

\(f_{i,j}\) 為該區間字串是超級括號序列的方案數,\(g_{i,j}\) 代表該區間字串為 SAA 是超級括號序列)的方案數,\(h_{i,j}\) 代表該區間字串為 AS 的方案數,\(w_{i,j}\) 代表該區間字串是 ()(S)(AS)(SA) 的方案數。

首先看 \(g\) 怎麼轉移。我們肯定是列舉 S 的長度,然後再後邊嘗試接上一個 A,不難發現其實是不會有算重的情況的,\(h\) 也是類似。

然後考慮怎麼轉移 \(w\)。首先可以特判 ()(S) 的情況,然後 (AS)(SA) 其實都可以直接透過 \(g\)\(h\) 轉移過來。

最後就是最難的 \(f\) 了。最簡單的轉移是 \(f_{i,j}\gets w_{i,j}\),然後我們考慮 ASB,發現其實可以直接 \(f_{i,j}\gets f_{i,k}\times g_{k+1,j}\),這樣是不會重複的,因為 S 的起點在每次轉移的時候都不同。最後我們考慮 AB,我們發現如果直接 \(f_{i,j}\gets f_{i,k}\times f_{k+1,j}\) 是不可行的,因為顯然此時假如說這個區間存在一種由 \(k\)(...)AB 方式組合起來的方案,那麼這樣計算這種方案就被計算了 \(k-1\) 次。考慮每種方案只有一個最左邊的 (...),所以 \(f_{i,j}\gets w_{i,k}\times f_{k+1,j}\) 就是正確的了(這裡程式碼中寫的是 \(f_{i,j}\gets f_{i,k}\times w_{k+1,j}\))。

程式碼
#include<bits/stdc++.h>
using namespace std;
const int mod=1e9+7;
#define int long long
int n,k;
int S[502][502];
char s[505];
int f[505][505],g[505][505],h[505][505],whol[505][505];
signed main(){
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);
	cin>>n>>k;
	for(int i=1;i<=n;i++)cin>>s[i];
	for(int i=1;i<=n;i++){
		for(int j=i;j<=min(n,i+k-1);j++){
			//[i,j]
			if(s[j]=='*'||s[j]=='?')S[i][j]=1;
			else break;
		}
	}
	for(int i=1;i<n;i++){
		if((s[i]=='?'||s[i]=='(')&&(s[i+1]=='?'||s[i+1]==')'))whol[i][i+1]=1;
		for(int j=i+2;j<=min(n,i+k+1);j++){
			if((s[i]=='?'||s[i]=='(')&&(s[j]=='?'||s[j]==')')&&S[i+1][j-1])whol[i][j]=1;
		}
	}
	for(int i=2;i<=n;i++){
		for(int j=1;j<=n-i+1;j++){
			for(int p=j;p<j+i-1;p++){
				f[j][j+i-1]=(f[j][j+i-1]+whol[j][p]*(f[p+1][j+i-1]+g[p+1][j+i-1])%mod)%mod;
				g[j][j+i-1]=(g[j][j+i-1]+S[j][p]*f[p+1][j+i-1])%mod;
				h[j][j+i-1]=(h[j][j+i-1]+S[p+1][j+i-1]*f[j][p])%mod;
			}
			if((s[j]=='?'||s[j]=='(')&&(s[j+i-1]=='?'||s[j+i-1]==')'))whol[j][j+i-1]=(whol[j][j+i-1]+f[j+1][j+i-2]+g[j+1][j+i-2]+h[j+1][j+i-2])%mod;
			f[j][j+i-1]=(f[j][j+i-1]+whol[j][j+i-1])%mod;
		}
	}
	cout<<f[1][n];
	return 0;
}

UVA1630 串摺疊 Folding

link

摺疊由大寫字母組成的長度為 \(n\)\(1\leqslant n\leqslant100\))的一個字串,使得其成為一個儘量短的字串,例如 AAAAAA 變成 6(A)
這個摺疊是可以巢狀的,例如 NEEEEERYESYESYESNEEEEERYESYESYES 會變成 2(N5(E)R3(YES))
多解時可以輸出任意解。

\(n\leq 100\)

輸出方案的題是逃避不了的/xk。

如果大家寫掛了,可以先到這道題來檢驗 DP 的正確性。

題解

這題我直接寫從最短的迴圈節轉移就過了,然後我和 zfy 討論能不能從最短的迴圈節轉移過來,最後 zfy 說自己證出來可以,但是我不太知道原理。

這題其實比較簡單。

\(f_{i,j}\) 為區間 \([i,j]\) 的答案,可以從列舉迴圈節轉移,也可以兩區間合併。

暴力列舉迴圈節複雜度是 \(O(n+\sum_{p|n}\frac{n}{p})\leq O(n\ln n)\),所以總複雜度是 \(O(n^3\ln n)\)

方案的話,記錄這個是從迴圈節還是區間轉移過來的,記錄決策點,最後 DFS 還原即可。

程式碼
#include<bits/stdc++.h>
using namespace std;
int dp[101][101],g[101][101];
char s[101],*ss=s+1;
const int SIG=1e5;
int _10[101];
string getans(int l,int r){
	// cerr<<l<<" "<<r<<"\n";
	if(!g[l][r]){
		string tmp;
		tmp.clear();
		for(int i=l;i<=r;i++)tmp+=s[i];
		return tmp;
	}
	if(g[l][r]>SIG){
		string tmp;
		tmp.clear();
		tmp+=to_string((r-l+1)/(g[l][r]-SIG-l+1));
		tmp+='(';
		tmp+=getans(l,g[l][r]-SIG);
		tmp+=')';
		return tmp;
	}
	return getans(l,g[l][r])+getans(g[l][r]+1,r);
}
#define ull unsigned long long
ull hsh[105],_b[105];
const ull base=179;
ull gethsh(int l,int r){
	return hsh[r]-hsh[l-1]*_b[r-l+1];
}
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);
	for(int i=1;i<=9;i++)_10[i]=1;
	for(int i=10;i<=100;i++)_10[i]=_10[i/10]+1;
	_b[0]=1;
	for(int i=1;i<=100;i++)_b[i]=_b[i-1]*base;
	while(cin>>ss){
		memset(g,0,sizeof g);
		int n=strlen(ss);
		for(int i=1;i<=n;i++)hsh[i]=hsh[i-1]*base+s[i]-'A'+1;
		for(int i=1;i<=n;i++)for(int j=i;j<=n;j++)dp[i][j]=j-i+1;
		for(int i=2;i<=n;i++){
			for(int j=1;j<=n-i+1;j++){
				for(int k=j;k<j+i-1;k++){
					if(dp[j][j+i-1]>dp[j][k]+dp[k+1][j+i-1]){
						g[j][j+i-1]=k;
						dp[j][j+i-1]=dp[j][k]+dp[k+1][j+i-1];
					}
					if(i%(k-j+1))continue;
					//[j,k]
					bool flag=1;
					for(int o=j;o<=j+i-1&&flag;o+=k-j+1){
						flag&=gethsh(j,k)==gethsh(o,o+k-j);
					}
					if(flag){
						if(dp[j][j+i-1]>_10[i/(k-j+1)]+2+dp[j][k]){
							g[j][j+i-1]=SIG+k;
							dp[j][j+i-1]=_10[i/(k-j+1)]+2+dp[j][k];
						}
					}
				}
			}
		}
		// cout<<dp[1][n]<<"\n";
		cout<<getans(1,n)<<"\n";
	}
	return 0;
}

習題:P2470 [SCOI2007] 壓縮

有興趣的可以再來做一道字串壓縮的題,這道題感覺比較有意思:[AGC020E] Encoding Subsets

P4766 [CERC2014] Outer space invaders

link

來自外太空的外星人(最終)入侵了地球。保衛自己,或者解體,被他們同化,或者成為食物。迄今為止,我們無法確定。

外星人遵循已知的攻擊模式。有 \(N\) 個外星人進攻,第 \(i\) 個進攻的外星人會在時間 \(a_i\) 出現,距離你的距離為 \(d_i\),它必須在時間 \(b_i\) 前被消滅,否則被消滅的會是你。

你的武器是一個區域衝擊波器,可以設定任何給定的功率。如果被設定了功率 \(R\),它會瞬間摧毀與你的距離在 \(R\) 以內的所有外星人(可以等於),同時它也會消耗 \(R\) 單位的燃料電池。

求摧毀所有外星人的最低成本(消耗多少燃料電池),同時保證自己的生命安全。

\(1\leq n\leq 300\)

Hint

如果發現不好想轉移的話,可以想想什麼是這個區間一定會執行的操作。

題解

首先不難發現這個時間是可以離散化的,這樣我們就可以設 \(f_{i,j}\) 是消滅生存區間的兩個端點都在 \([i,j]\) 內的最低成本。

直接想轉移可能比較困難。考慮什麼東西是這個區間的答案一定含有的。

不難發現想要消滅這個區間內的所有外星人,最優情況下我們一定有一次選擇了 \(R=\max_i d_i\) 的功率進行消滅。我們可以在 \(O(n)\) 的時間內找到這個外星人。

因為我們一定會在這個外星人的生存區間中的一個點發動 \(R=\max_id_i\) 的攻擊,所以我們可以列舉在這個生存區間的哪個位置發動這個攻擊。因為這是整個區間的最大功率,所以它一定也會消滅所有生存區間跨過這個位置的外星人,這個時候就只剩下左右兩個區間內的外星人了。我們可以列出方程式(設 \(id\) 是距離最遠的那個外星人的編號):

\[f_{l,r}=d_{id}+\max_{k\in[l_{id},r_{id}]}(f_{l,k-1}+f_{k+1,r}) \]

時間複雜度 \(O(n^3)\)

程式碼
#include<bits/stdc++.h>
using namespace std;
int T,n,dis[505],l[505],r[505],maxid[605][605],dp[605][605];
int num[605],kcnt;
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);
	cin>>T;
	while(T--){
		cin>>n;
		kcnt=0;
		memset(maxid,0,sizeof maxid);
		for(int i=1;i<=n;i++){
			cin>>l[i]>>r[i]>>dis[i];
			num[++kcnt]=l[i];
			num[++kcnt]=r[i];
		}
		sort(num+1,num+kcnt+1);
		kcnt=unique(num+1,num+kcnt+1)-num-1;
		for(int i=1;i<=n;i++){
			l[i]=lower_bound(num+1,num+kcnt+1,l[i])-num;
			r[i]=lower_bound(num+1,num+kcnt+1,r[i])-num;
		}
		for(int i=1;i<=kcnt;i++){
			for(int j=i+1;j<=kcnt;j++){
				for(int p=1;p<=n;p++){
					if(l[p]>=i&&r[p]<=j)if(dis[p]>dis[maxid[i][j]])maxid[i][j]=p;
				}
			}
		}
		for(int i=1;i<=kcnt;i++)for(int j=i+1;j<=kcnt;j++)dp[i][j]=1e9;
		for(int i=2;i<=kcnt;i++){
			for(int j=1;j<=kcnt-i+1;j++){
				//[j,j+i-1]
				int id=maxid[j][j+i-1];
				if(!id){dp[j][j+i-1]=0;continue;}
				for(int k=l[id];k<=r[id];k++){
					dp[j][j+i-1]=min(dp[j][j+i-1],dp[j][k-1]+dp[k+1][j+i-1]+dis[id]);
				}
			}
		}
		cout<<dp[1][kcnt]<<"\n";
	}
	return 0;
}

選做習題:P3592 [POI2015] MYJ

P2339 [USACO04OPEN] Turning in Homework G

link

貝茜有 $ C $ ( $ 1 \leq C \leq 1000 $ ) 門科目的作業要上交,之後她要去坐巴士和奶牛同學回家。

每門科目的老師所在的教室排列在一條長為 $ H $ ( $ 1 \leq H \leq 1000 $ ) 的走廊上,他們只在課後接收作業,交作業不需要時間。貝茜現在在位置 0,她會告訴你每個教室所在的位置,以及走廊出口的位置。她每走 1 個單位的路程,就要用 1 秒。她希望你計算最快多久以後她能交完作業併到達出口。

Hint

這道題不難發現先交一個區間的作業不能作為狀態,因為有可能在等區間內的老師下課的時候可以跑到區間外交作業。那有沒有可能先不交一個區間的作業作為狀態呢?

題解

\(f_{l,r,0/1}\) 為區間 \([l,r]\) 的作業還沒交,當前在 \(l\) 位置還是 \(r\) 位置的最小時間。

非常反直覺的狀態設計對吧?如何證明其包含最優解呢?考慮把最優解倒過來看,這樣我們可以把下課變成上課,即需要在上課之前交作業。這個時候的 DP 就是列舉交了哪個區間的作業,現在在該區間的左端點還是右端點,求最小時間。把這個 DP 再倒過來就變成上面的狀態了,兩個都是最小時間沒有問題,因為這兩個最小時間加起來就等於最優解時間。

另一種理解方式就是,如果 \(l+1\) 位置的作業之前沒交,你移動到 \(r\) 之後要走回來交;如果 \(l+1\) 位置的作業之前可以交,那麼完全可以先移動到 \(l+1\) 之後再移動。所以轉移是完整的。

剩下的轉移也非常簡單了,從大區間轉移到小區間,列舉上次移動是從左邊還是右邊移動過來即可。注意沒有下課的時候需要等待,在倒過來的 DP 中這一步就代表這個地方已經上課了,需要增加最優解時間來使得其延後上課。

時間複雜度 \(O(n^2)\)

如果實在理解不了,你也可以在外面套個二分,因為答案具有單調性。然後倒著 DP 檢驗即可,時間複雜度 \(O(n^2\log V)\)

程式碼
#include<bits/stdc++.h>
using namespace std;
int n,h,b;
int x[1005],t[1005];
int id[1005];
int dp[1005][1005][2];
int main(){
	ios::sync_with_stdio(0);
	cin>>n>>h>>b;
	h++;
	b++;
	int acth=0;
	for(int i=1;i<=n;i++){
		cin>>x[i]>>t[i];
		x[i]++;
		acth=max(x[i],acth);
		if(t[id[x[i]]]<t[i])id[x[i]]=i;
	}
	h=acth;
	memset(dp,0x3f,sizeof dp);
	dp[1][h][0]=0;
	dp[1][h][1]=h-1;
	for(int i=h-1;i>=1;i--){
		for(int j=1;j<=h-i+1;j++){
			//[j,j+i-1]
			dp[j][j+i-1][1]=min(max(dp[j][j+i][1],t[id[j+i]])+1,max(dp[j-1][j+i-1][0],t[id[j-1]])+i);
			dp[j][j+i-1][0]=min(max(dp[j-1][j+i-1][0],t[id[j-1]])+1,max(dp[j][j+i][1],t[id[j+i]])+i);
		}
	}
	int ans=1e9;
	for(int i=1;i<=h;i++){
		ans=min(ans,max(dp[i][i][0],t[id[i]])+abs(b-i));
	}
	cout<<ans;
	return 0;
}

P9746 「KDOI-06-S」合併序列

注意:該題為選做題。

link

給定一個長度為 \(n\) 的序列 \(a_1,a_2,\ldots a_n\)

你可以對這個序列進行若干(可能為 \(0\))次操作。在每次操作中,你將會:

  • 選擇三個正整數 \(i<j<k\),滿足 \(a_i\oplus a_j\oplus a_k=0\)\(k\) 的值不超過此時序列的長度。記 \(s=a_i\oplus a_{i+1}\oplus \cdots\oplus a_k\)

  • 然後,刪除 \(a_i\sim a_k\),並在原來這 \(k-i+1\) 個數所在的位置插入 \(s\)。注意,此時序列 \(a\) 的長度將會減少 \((k-i)\)

請你判斷是否能夠使得序列 \(a\) 僅剩一個數,也就是說,在所有操作結束後 \(a\) 的長度為 \(1\)。若可以,你還需要給出一種操作方案。

\(n\leq 500\)\(a_i<512\)

Hint

大家可以嘗試多建幾個陣列來在 DP 的時候分步合併轉移。

題解

以下令 \(V=O(n)\)

轉換一下題意,設 \(f_{l,r}\) 為區間 \([l,r]\) 是否可以被消成一個數,如果需要讓 \(f_{l,r}=1\),那麼我們需要滿足存在 \(l\leq a<b\leq c<d\leq r\),使得 \(f_{l,a}=f_{b,c}=f_{d,r}=1\)\(\bigoplus_{i\in[l,a]\cup[b,c]\cup[d,r]}a_i=0\)

首先肯定可以先 \(O(n^2)\) 預處理出所有區間的異或和,然後直接暴力列舉 \(a,b,c,d\),時間複雜度 \(O(n^6)\)

我們的目標是 \(O(n^3)\),所以我們要把這 \(4\) 個維度的列舉變成 \(1\) 個維度的列舉,想到分步轉移。假設最後列舉 \(d\),我們可以設 \(g_{l,p}\) 代表滿足 \(\bigoplus_{i\in[l,a]\cup[b,c]}=p\) 的最小的 \(c\)。這個時候還需要列舉 \(2\) 個維度,於是繼續壓。因為求的已經是最小的 \(c\) 了,為了方便就只能列舉 \(a\)。於是我們再設 \(h_{l,p}\) 表示滿足 \(\bigoplus_{i\in[l+1,c]}=p\) 的最小的 \(c\),這樣我們就可以 \(O(n)\) 轉移了。

\[\forall l,r,s=\bigoplus_{i=l}^ra_{i}\\ h_{i-1,s}\gets r(i\leq l)\\ g_{l,p}\gets h_{r,s\oplus p}\\ f_{l,r}\gets [g_{l,\oplus_{i=k}^ra_{i}}<k] \]

輸出方案的時候,轉移 \(f\) 的時候記錄 \(d\),轉移 \(g\) 的時候記錄 \(a\),轉移 \(h\) 的時候記錄 \(b\) 即可。時間複雜度 \(O(n^3)\)

因為這題是可以透過 \(>l\) 的資訊轉移到 \(l\) 上,所以轉移順序應該是倒序列舉 \(l\) 再列舉 \(r\),根據第二個轉移,我們需要順序列舉 \(r\),因為 \(>r\) 的右端點一定不會貢獻 \(\leq r\) 的值。

交一發之後發現 T 在了 hack 資料,我們需要減小常數。

我們發現第一個轉移 \(i\leq l-1\) 的部分可以變成最後在轉移完 \(l\) 的時候進行 \(h_{l-1,p}\gets h_{l,p}\)。這樣能減少 \(\frac{1}{3}\) 左右的常數,可以透過。

程式碼
#include<bits/stdc++.h>
using namespace std;
int T,n;
int a[505];
int xors[505][505];
int h[505][515],g[505][515];
bool f[505][505];
int posh[505][515],posg[505][515],posf[505][505];
const int M=512;
struct op{
	int i,j,k;
};
vector<op> ans;
void getans(int l,int r){
	// cerr<<l<<" "<<r<<" "<<f[l][r]<<"\n";
	if(l==r)return;
	int d=posf[l][r];
	int a=posg[l][xors[d][r]];
	int b=posh[a][xors[d][r]^xors[l][a]];
	int c=g[l][xors[d][r]];
	getans(d,r);
	getans(b,c);
	getans(l,a);
	ans.push_back((op){l,b-(a-l),d-(a-l)-(c-b)});
	return;
}
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);
	cin>>T;
	int n;
	while(T--){
		memset(h,0x3f,sizeof h);
		memset(g,0x3f,sizeof g);
		memset(f,0,sizeof f);
		cin>>n;
		for(int i=1;i<=n;i++)cin>>a[i];
		for(int i=1;i<=n;i++){
			int tmp=0;
			for(int j=i;j<=n;j++){
				tmp^=a[j];
				xors[i][j]=tmp;
			}
		}
		for(int i=1;i<=n;i++)f[i][i]=1;
		for(int i=n;i>=1;i--){
			int l=i;
			for(int j=i;j<=n;j++){
				int r=j;
				int tmp=0;
				for(int k=r;k>l;k--){
					tmp^=a[k];
					if(g[l][tmp]<k&&f[k][r]){
						f[l][r]=1;
						posf[l][r]=k;
						break;
					}
				}
				if(f[l][r]){
					if(h[l-1][xors[l][r]]>r)h[l-1][xors[l][r]]=r,posh[l-1][xors[l][r]]=l;
					for(int i=0;i<M;i++){
						if(g[l][i]>h[r][i^xors[l][r]])g[l][i]=h[r][i^xors[l][r]],posg[l][i]=r;
					}
				}
			}
			for(int i=0;i<M;i++)if(h[l-1][i]>h[l][i])h[l-1][i]=h[l][i],posh[l-1][i]=posh[l][i];
		}
		if(!f[1][n])cout<<"Shuiniao\n";
		else{
			// cerr<<f[2][4]<<"\n";
			cout<<"Huoyu\n";
			getans(1,n);
			cout<<ans.size()<<"\n";
			for(auto p:ans)cout<<p.i<<" "<<p.j<<" "<<p.k<<"\n";
			ans.clear();
		}
	}
	return 0;
}

P3607 [USACO17JAN] Subsequence Reversal P

link

給你一個長度為 \(n\) 的序列 \(a\),求翻轉一個子序列之後最長的不下降子序列長度。

\(n\leq 50,a_i\leq 50\)

Hint

嘗試轉化(或拆)一下翻轉子序列這個操作呢?

題解

把翻轉子序列這個操作拆成交換幾個元素對,這些元素對滿足兩兩均有包含關係。根據這個 包含關係,我們知道這道題是顯然可以區間 DP 的。

所以我們設 \(f_{l,r,L,R}\) 為區間 \([l,r]\) 中的所有元素值在 \([L,R]\) 的最長子序列長度。列舉翻轉和擴充套件的所有情況,轉移較為容易,這裡就不展開敘述。注意幾個點就行了:

  • 列舉翻轉的時候不止可以轉移兩邊都能擴充套件的子序列。
  • 注意在 \([L,R]\) ,所以這裡要取一個類似於二維字首 \(\max\) 的東西。
程式碼
#include<bits/stdc++.h>
using namespace std;
int f[55][55][55][55];
int n;
const int m=50;
int a[55];
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);
	cin>>n;
	for(int i=1;i<=n;i++)cin>>a[i];
	for(int i=1;i<=n;i++){
		for(int l=1;l<=m;l++){
			for(int r=1;r<=m;r++){
				if(l<=a[i]&&r>=a[i])f[i][i][l][r]=1;
			}
		}
	}
	for(int i=2;i<=n;i++){
		for(int j=1;j<=n-i+1;j++){
			//[j,j+i-1]
			if(a[j]>a[j+i-1]){
				//swap j j+i-1
				f[j][j+i-1][a[j+i-1]][a[j]]=2+f[j+1][j+i-2][a[j+i-1]][a[j]];
			}
			for(int p=max(a[j],a[j+i-1])+1;p<=m;p++)f[j][j+i-1][a[j+i-1]][p]=1+f[j+1][j+i-2][a[j+i-1]][p];
			for(int p=min(a[j],a[j+i-1])-1;p>=1;p--)f[j][j+i-1][p][a[j]]=1+f[j+1][j+i-2][p][a[j]];
			for(int p=1;p<=m;p++){
				for(int l=1;l<=m-p+1;l++){
					int r=l+p-1;
					//[j,j+i-1],[l,r]
					f[j][j+i-1][l][r]=max(f[j][j+i-1][l][r],f[j+1][j+i-1][l][r]);
					if(l<=a[j]&&a[j]<=r)f[j][j+i-1][l][r]=max(f[j][j+i-1][l][r],f[j+1][j+i-1][a[j]][r]+1);
					// if(j==1&&j+i-1==2)cerr<<j<<" "<<j+i-1<<" "<<l<<" "<<r<<" "<<f[j][j+i-1][l][r]<<" "<<a[j]<<"\n";
					f[j][j+i-1][l][r]=max(f[j][j+i-1][l][r],f[j][j+i-2][l][r]);
					if(r>=a[j+i-1]&&l<=a[j+i-1])f[j][j+i-1][l][r]=max(f[j][j+i-1][l][r],f[j][j+i-2][l][a[j+i-1]]+1);
					f[j][j+i-1][l][r]=max(f[j][j+i-1][l][r],f[j][j+i-1][l][r-1]);
					f[j][j+i-1][l][r]=max(f[j][j+i-1][l][r],f[j][j+i-1][l+1][r]);
				}
			}
		}
	}
	cout<<f[1][n][1][m];
	return 0;
}

P10041 [CCPC 2023 北京市賽] 史萊姆工廠

link

\(n\) 個史萊姆排成一行,其中第 \(i\) 個的顏色為 \(c_i\),質量為 \(m_i\)

你可以執行任意次把一個史萊姆的質量增加 \(1\) 的操作,需要花費 \(w\) 的價錢。

但是一旦史萊姆的質量達到 \(k\) 或以上,就會變得不穩定而必須在下一次操作之前被賣掉。你只能賣出質量大於等於 \(k\) 的史萊姆。根據市場價,賣掉一個質量為 \(i\) 的史萊姆可以得到 \(p_i\) 的收入。保證 \(p_i-p_{i-1}<w\)。但不保證 \(p_i\) 單調不降。

賣掉一個史萊姆之後,它兩邊的史萊姆會被擠壓繼而靠在一起。如果這兩個史萊姆顏色相同,那麼就會互相融合成一個史萊姆,其質量是二者的質量之和。這個新的史萊姆也有可能需要被賣掉從而接著進行這個過程。

你想知道賣掉所有史萊姆最多可以淨賺多少。

\(n\leq 150\)\(k\leq 10\)

區間 DP 魔王。(僅憑洛谷評級來看)

Hint

對於一個區間,想想最後一個操作會發生在哪裡,然後嘗試把這個操作欽定發生在一個區間內的特殊的位置。

題解

參考了部分 Hanghang 的題解,但是 Hanghang 的題解中有些錯誤,怎麼回事捏。

考慮對於一個區間,它的消除一定是透過幾個不同的區間一起消除,或者是最後左右端點合併進行消除。所以我們一定能重排操作序列,使得這個區間的最後一次操作在左右端點處,這樣方便於我們設計狀態。因為這道題左右端點其實是差不多等價的,所以我們就定義 \(f_{l,r}\) 為消完區間 \([l,r]\) 之後的最大利潤, \(fl_{l,r,p}\) 表示區間 \([l,r]\) 中消完最後剩下一個顏色為 \(c_{l}\),重量為 \(p\) 的史萊姆的最大利潤,需要保證中間的過程中,最左側的史萊姆沒有被消掉。

最後一次的消除有兩種情況,第一種是花錢加重量後消除,第二種是兩個同顏色的撞在一起之後可以直接消除。我們先來看第一種。

我們有一個比較顯然的轉移:

\[f_{l,r}\gets f_{l,k}+fl_{k+1,r,q}+p_{m}-(m-q)w \]

\(fl\) 的轉移也較容易。首先有 \(fl_{l,r,m_l}\gets f_{l+1,r}\)。然後因為最左側史萊姆沒有消掉,所以有 \(fl_{l,r,p}\gets f_{l+1,k}+fl_{k+1,r,p-m_l}(c_{k+1}=c_l)\)。(為什麼沒有 \(fl_{l,k,p}+f_{k+1,r}\):上面那個式子顯然已經包含了這個東西)注意上述兩個轉移不能有邊界上的合併顏色,下面的轉移同樣需要注意,後面就不贅述這一部分了。

接下來我們來轉移第二種情況。採用分步轉移的思想(因為再定義一個 \(fr\),然後列舉兩個顏色相等點轉移的複雜度至少有一個 \(O(n^4)\),無法透過),設 \(g_{l,r,p}\) 為合併完 \([l,r)\) 之後,剩下的物品顏色為 \(c_r\),重量為 \(p\)

所以我們顯然有:\(f_{l,r}\gets g_{l,k,u}+fl_{k,r,q}+p_{u+q}\)。接下來轉移 \(g\) 就大功告成了。而 \(g\) 其實和 \(fl\) 差不多:

\[g_{l,r,p}\gets f_{l,k}+fl_{k+1,r-1,p}(c_{k+1}=c_r) \]

總時間複雜度 \(O(n^3k^2)\)

程式碼
#include<bits/stdc++.h>
using namespace std;
int n,k;
#define ll long long
ll p[21],w;
ll f[155][155],fl[155][155][11],fr[155][155][11],g[155][155][11];
void renew(ll &x,ll y){
	if(x<y)x=y;
	return;
}
int c[155],m[155];
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);
	cin>>n>>k>>w;
	for(int i=1;i<=n;i++)cin>>c[i];
	for(int j=1;j<=n;j++)cin>>m[j];
	for(int i=k;i<=2*k-2;i++)cin>>p[i];
	for(int i=1;i<=n;i++){
		for(int j=i;j<=n;j++){
			f[i][j]=-1e18;
			for(int o=1;o<=k;o++)fl[i][j][o]=fr[i][j][o]=g[i][j][o]=-1e18;
		}
	}
	for(int i=1;i<=n;i++)f[i][i]=p[k]-w*(k-m[i]),fl[i][i][m[i]]=fr[i][i][m[i]]=0;
	for(int i=2;i<=n;i++){
		for(int l=1;l<=n-i+1;l++){
			int r=l+i-1;
			fl[l][r][m[l]]=f[l+1][r];
			fr[l][r][m[r]]=f[l][r-1];
			for(int t=l+1;t<=r;t++)if(c[t]==c[l])for(int o=m[l]+1;o<k;o++)renew(fl[l][r][o],f[l+1][t-1]+fl[t][r][o-m[l]]);
			// for(int t=l;t<=r;t++)if(c[t]==c[r])for(int o=m[r]+1;o<k;o++)renew(fr[l][r][o],fr[l][t][o-m[r]]+f[t+1][r-1]);
			for(int t=l;t<=r;t++)if(c[l-1]!=c[t]&&c[t]!=c[r+1])for(int o=1;o<k;o++)renew(f[l][r],f[l][t-1]+fl[t][r][o]+p[k]-w*(k-o));
			for(int t=l;t<=r;t++)if(c[t]==c[r+1])for(int o=1;o<k;o++)renew(g[l][r][o],fl[t][r][o]+f[l][t-1]);
			for(int t=l+1;t<=r;t++)if(c[t]!=c[l-1]&&c[t]!=c[r+1])
				for(int o1=1;o1<k;o1++)for(int o2=1;o2<k;o2++)if(o1+o2>=k)renew(f[l][r],g[l][t-1][o1]+fl[t][r][o2]+p[o1+o2]);
		}
	}
	cout<<f[1][n];
	return 0;
}

揹包 DP

揹包 DP,顧名思義,即使用 DP 的思想解決把物品放到揹包裡的物品。想必大家對 01 揹包,完全揹包,多重揹包,分組揹包都掌握了。所以我們板題就不放了。

依賴性揹包大多是樹形揹包,DAG 上的依賴揹包作者不會,如果讀者會的話可以私信作者。

有一些板題,我看大家好像沒怎麼做,這裡還是淺淺說說吧。

一些板題

板題:P1064 [NOIP2006 提高組] 金明的預算方案

link

金明今天很開心,家裡購置的新房就要領鑰匙了,新房裡有一間金明自己專用的很寬敞的房間。更讓他高興的是,媽媽昨天對他說:“你的房間需要購買哪些物品,怎麼佈置,你說了算,只要不超過 \(n\) 元錢就行”。今天一早,金明就開始做預算了,他把想買的物品分為兩類:主件與附件,附件是從屬於某個主件的,下表就是一些主件與附件的例子:

主件 附件
電腦 印表機,掃描器
書櫃 圖書
書桌 檯燈,文具
工作椅

如果要買歸類為附件的物品,必須先買該附件所屬的主件。每個主件可以有 \(0\) 個、\(1\) 個或 \(2\) 個附件。每個附件對應一個主件,附件不再有從屬於自己的附件。金明想買的東西很多,肯定會超過媽媽限定的 \(n\) 元。於是,他把每件物品規定了一個重要度,分為 \(5\) 等:用整數 \(1 \sim 5\) 表示,第 \(5\) 等最重要。他還從因特網上查到了每件物品的價格(都是 \(10\) 元的整數倍)。他希望在不超過 \(n\) 元的前提下,使每件物品的價格與重要度的乘積的總和最大。

設第 \(j\) 件物品的價格為 \(v_j\),重要度為 \(w_j\),共選中了 \(k\) 件物品,編號依次為 \(j_1,j_2,\dots,j_k\),則所求的總和為:

\(v_{j_1} \times w_{j_1}+v_{j_2} \times w_{j_2}+ \dots +v_{j_k} \times w_{j_k}\)

總物品個數為 \(m\),請你幫助金明設計一個滿足要求的購物單。

對於全部的測試點,保證 \(1 \leq n \leq 3.2 \times 10^4\)\(1 \leq m \leq 60\)\(0 \leq v_i \leq 10^4\)\(1 \leq p_i \leq 5\)\(0 \leq q_i \leq m\),答案不超過 \(2 \times 10^5\)

題解

這題看樣子像一個依賴性揹包問題,但是因為只可能有兩個附件,而且附件沒有其他附件,所以其實可以對每個主件討論選不選附件,選多少附件,情況數較少。

把一個主件的所有情況分為一組,對全域性做一個分組揹包即可。

時間複雜度為 \(O(nm)\)

對於大家來說過於簡單,就不貼程式碼了。

習題,板題:P1941 [NOIP2014 提高組] 飛揚的小鳥

較為板子的題:P1417 烹調方案

link

一共有 \(n\) 件食材,每件食材有三個屬性,\(a_i\)\(b_i\)\(c_i\),如果在 \(t\) 時刻完成第 \(i\) 樣食材則得到 \(a_i-t\times b_i\) 的美味指數,用第 \(i\) 件食材做飯要花去 \(c_i\) 的時間。

眾所周知,gw 的廚藝不怎麼樣,所以他需要你設計烹調方案使得在總花費時間小於等於 \(T\) 時美味指數最大。

\(n\leq 50\),其他數字均小於 \(10^5\)

據說是泛化物品的一道題。隨機大法好。

題解

這個題如果沒有那個 \(a_{i}-t\times b_{i}\) 的美味指數限制,那就是一個顯然的 01 揹包問題。但是因為結束時間改變會導致權值改變,所以顯然不能把所有物品看做等價。

因為從時間上列舉會導致物品算重,所以還是隻能使用類似於揹包的方法加入物品。我們嘗試對於兩個物品在最優解中的順序進行貪心。

使用 exchange arguments 思想,考慮我們把最優順序的兩個物品交換順序,我們發現只會有這兩個物品受到影響,設這兩個物品為 \(1\) 號和 \(2\) 號,\(1\) 號原來在前面,下面我們列出不等式:

\[\begin{aligned} a_1-tb_1+a_2-(t+c_1)b_2&\geq a_2-tb_2+a_1-(t+c_2)b_1\\ -c_1b_2&\geq -c_2b_1\\ c_1b_2&\leq c_2b_1\\ \frac{c_1}{b_1}&\leq\frac{c_2}{b_2} \end{aligned} \]

我們發現,這個東西可以變成一個每個元素可以算出來的值的比較,所以我們可以直接對這個值進行排序以得到最優的順序,其他的就交給 01 揹包就行了。

程式碼
#include<bits/stdc++.h>
using namespace std;
int T,n,a[51],b[51],c[51];
long long dp[100005];
int id[51];
bool cmp(const int &x,const int &y){
	return 1ll*c[x]*b[y]<1ll*b[x]*c[y];
}
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);
	cin>>T>>n;
	for(int i=1;i<=n;i++)cin>>a[i],id[i]=i;
	for(int i=1;i<=n;i++)cin>>b[i];
	for(int i=1;i<=n;i++)cin>>c[i];
	sort(id+1,id+n+1,cmp);
	for(int i=1;i<=n;i++){
		for(int j=T;j>=c[id[i]];j--){
			dp[j]=max(dp[j],dp[j-c[id[i]]]+a[id[i]]-1ll*j*b[id[i]]);
		}
	}
	long long ans=0;
	for(int i=1;i<=T;i++)ans=max(ans,dp[i]);
	cout<<ans;
	return 0;
}