閆氏DP分析法
眾所周知,OI界有一種神奇(ex)的演算法,它幾乎可以應用在任何題上,它的分析讓許多OIer感到頭皮發麻。它就是:DP
閆氏DP分析法
核心思想:從集合的角度考慮DP
\(\color {red}{“所有的DP問題,本質上都是有限集中的最值問題” — yxc}\)
階段
動態規劃有兩個要點:狀態與狀態轉移
那麼階段自然也應該有兩個:狀態表示和狀態計算
狀態表示:化零為整
把幾個具有相同點的元素合在一起考慮,成為一個狀態
對於一個狀態 \(F(i)\) ,考慮兩個角度:
- 1.集合 :\(F(i)\) 表示什麼集合
由於 \(F(i)\) 表示的是一堆東西(這也是DP優於列舉的核心),我們要考慮這一堆東西的共同特徵,如:所有滿足某個條件的元素集合
這一點請仔細考慮,到底是大於等於,大於,小於,小於等於,等於......這些的不同會導致狀態計算方式的不同
- 2.屬性:\(F(i)\) 存的數與集合的關係:如 \(max,min,count,sum\) 等
很明顯,\(F(i)\) 大多數時候是一個數,代表這個集合的某一個屬性,多是最大值、最小值、數量、總和等。題目問什麼,屬性一般就是什麼
狀態計算:化整為零
先看 \(F(i)\) 表示的集合:
將其劃分為若干個子集,要求不重(針對涉及加和型別的屬性)和不漏
劃分的依據:找最後一個不同點(這個待會會講)
劃分過後,求 \(F(i)\) 就可根據子集來求
如:當屬性為 \(max\)
當屬性為 \(count\) 時,\(F(i)=\sum_ (\text{子集的count})\)
具體例子:
0-1揹包問題
有 \(N\) 件物品以及一個容量為 \(V\) 的揹包,每個物品只能使用一次。放入第 \(i\) 件物體的代價是 \(C_i\) ,得到的價值是 \(W_i\)。求在不超過容量的情況下獲得的最大價值。
輸入格式:
第1行兩個正整數,分別表示 \(N\),\(V\)。
接下來N行分別有 \(2\) 個正整數 \(C_i\) ,\(W_i\)。
輸出格式:
一行一個正整數,為最大價值總和。
樣例
#in: 4 20 8 5 9 6 5 7 2 3 #out: 16
資料範圍: \(1≤N≤100,1≤V≤10^6,1≤C_i≤10000,1≤W_i≤10000\) 。
解析:
根據乘法原理,總共的方案為 \(2^n\) 。在所有的方案數中選擇一個價值最大的方案,屬於有限集的最優問題,可以用試著DP來解。
狀態表示
對於\(F(i,j)\):
集合:所有隻考慮前\(i\)個物品,且總體積不超過的\(j\)的方案
屬性:題目要求我們求最大價值,則其屬性就是\(max\)。
狀態計算
首先看一下\(F(i,j)\)集合:
我們試著把這個集合分成若干個子集
找最後一個不同點
對於這個題,最後一個不同點就是最後一個物品選或不選
所以對於 \(F(i,j)\) 對應的集合可以如下劃分
顯然,這種劃分方案不重不漏。
現在要分別求出左邊和右邊的最大值。
-
對於左邊的集合,由於它在 \(F(i,j)\) 內,且不包含 \(i\) ,那它其實相當於
\(F(i-1,j)\)。 -
對於右邊的集合,我們再次細分,將 \(i\) 單獨拿出來,得到\(F(i-1,j-V_i)\)。
也就是說求右邊的最大值:\(max(F(i-1,j-V_i)+W_i)\)。
但是,右邊這個集合不一定存在,所以要特判:\(j≥V_i\)。
於是我們可以得到狀態轉移方程:
\[F(i,j)=max(\ F(i-1,j)\ ,\ F(i-1,j-V_i)+W_i\ ) \]
這就事樸素DP的分析過程了,至於壓維等時空優化從狀態轉移方程出發
\(\color{red}{“DP的所有優化,都是對程式碼的恆等變形” — yxc}\)
對於01揹包,方程中 \(F(i,j)\) 只與 \(F(i-1,x)\) 有關,且 \(x\leq j\)
所以第 \(i\) 維可以使用滾動陣列滾掉。
還是放一下程式碼:
#include<bits/stdc++.h>
using namespace std;
int ci[4000],wi[4000];
int f[138800]//下標要>=c;
int main()
{
int n,c;
cin>>n>>c;
int i,j;
for(i=1;i<=n;i++)
{
cin>>ci[i]>>wi[i];
}
for(i=1;i<=n;i++)
{
for(j=c;j>=ci[i];j--)
{
f[j]=max(f[j],f[j-ci[i]]+wi[i]);//左子集就是f[j]=f[j],省略沒寫
}
}
cout<<f[c];
return 0;
}
完全揹包問題
有 \(N\) 種物品以及一個容量為 \(V\) 的揹包,每種物品有無限個可用。放入第 \(i\) 種物體的代價是 \(C_i\) ,得到的價值是 \(W_i\)。求在不超過容量的情況下獲得的最大價值。
輸入格式:
第1行兩個正整數,分別表示 \(N\),\(V\)。
接下來N行分別有 \(2\) 個正整數 \(C_i\) ,\(W_i\)。
輸出格式:
一行一個正整數,為最大價值總和。
解析
仍然從兩個角度考慮:
設狀態 \(F(i,j)\)
狀態表示:
對於 \(F(i,j)\):
集合:所有隻從前\(i\)個物品中選,總體積不超過\(j\)的所有方案。
屬性:\(max\)。
原因和01揹包相似,畢竟都是揹包問題
狀態計算:
對於 \(F(i,j)\) 的集合:
劃分子集
這裡,最後一個物品可以選若干個,所以要把集合劃分成若干個,分別代表不選第 \(i\) 種,選\(1\)個\(i\),選兩個\(i\)......
不重不漏性顯然。
考慮每個子集怎麼求。
第一個子集:不選第\(i\)種物品:顯然,就是 \(F(i-1,j)\)
剩下的子集,我們可以試著考慮一般情況:選\(k\)個第\(i\)種物品。
那麼每個方案可以再次細分為兩個部分:\(k\)個第\(i\)種物品和前面 \(i-1\) 種物品總體積 \(j-kV_i\) 的方案
所以最大值就是:\(F(i,j)=max(\ F(i-1,j-kV-i)+kW_i\)。
於是易得狀態轉移方程:
\[F(i,j)=max(\ F(i-1,j)\ ,\ F(i-1,j-V_i)+W_i\ ,\ F(i-1,j-2V_i)+2W_i\ ,\ \dots) \]
但是這個東西項數太多,我們平時寫的只有兩項啊?
那想想辦法把它轉換成兩項
由上面的狀態轉移方程我們可以得到:
\[F(i,j-V_i)=max(\ F(i-1,j-V_i)\ ,\ F(i-1,j-2V_i)+W_i\ ,\ F(i-1,j-3V_i)+2W_i\ ,\ \dots) \]
觀察得到,上面的每一項,都是下面的每一項加上一個 \(W_i\) 。類比,上面的最大值就是下面的最大值加上一個 \(W_i\)
於是我們可以得到我們最常用的狀態轉移方程:
\[F(i,j)=max(\ F(i-1,j)\ ,\ F(i,j-V_i)+W_i\ ) \]
得到了狀態轉移方程,我們就要來想想能否有時空優化了。
對比一下01揹包和完全揹包的狀態轉移方程
01:\(F(i,j)=max(\ F(i-1,j)\ ,\ F(i-1,j-V_i)+W_i\ )\)
完全:\(F(i,j)=max(\ F(i-1,j)\ ,\ F(i,j-V_i)+W_i\ )\)
只有一個\(i-1\)和\(i\)的不同,但就是這一個不同,致使在滾動陣列時一個是從大到小列舉一個是從小到大列舉。
放一下參考code:
#include <bits/stdc++.h>
using namespace std;
int ci[10010],wi[10010];
int f[100010];
int main()
{
int t,n;
scanf("%d%d",&t,&n);
for(int i=1;i<=n;i++)
scanf("%d%d",&ci[i],&wi[i]);
for(int i=1;i<=n;i++)
{
for(int j=ci[i];j<=t;j++)
{
f[j]=max(f[j],f[j-ci[i]]+wi[i]);
}
}
cout<<f[t];
return 0;
}
石子合併
設有\(N\)堆石子排成一排,其編號為 \(1,2,3,\dots ,N\)。
每堆石子都有一定的質量,可以用一個整數來描述,現在要把這 \(N\) 堆石子合併成一堆。
每次只能合併相鄰的兩堆,合併的代價是這兩堆石子的質量之和,合併後與這兩堆石子相鄰的石子將要和新的堆相鄰。
顯然,合併時的順序不同會導致合併的總代價不同。
找出一種合理的方法,使得將所有石子合併成一堆的代價最小,輸出最小代價。
輸入格式:
第一行一個整數\(N\)表示石子的堆數。
接下來的一行\(N\)個整數,第\(i\)個整數表示第&i&堆石子的質量。
輸出格式:
一個整數,表示代價。
資料範圍:\(1\leq N\leq 100\)
樣例:
#in:
4
1 3 5 2
#out
22
解析
滿足有限集最優化請讀者自證
仍然從兩個角度考慮:
狀態表示
對於\(F(i,j)\)
集合:所有將區間\([i,j]\)合併成一堆的方案集合
屬性:題目求的是最小值,所以\(min\)。
狀態計算
老套路,來看這個\(F(i,j)\)表示的集合:
仍然是考慮如何劃分這個集合
考慮最後一個不同點
最後一次, 也就是合併到\([i,j]\)時,一定是由兩個區間\([i,k]\)和\([k,j]\)合併而來的。顯然,\(k\in [i,j]\)
所以我們考慮以這個分界點 \(k\) 為劃分依據,分成 \(j-i\) 類。
再來看一下合併的區間:
兩個區間互不干擾,所以兩邊取\(min\),兩邊恰好是 \(F(i,k)\) 和 \(F(k+1,j)\)。
但是這只是兩個子區間的最小代價,求 \(F(i,j)\) 還要加上這部分石子的總質量
於是\(F(i,j) = min(\ F(i,k+1) + F(k+1,j)\ ) + S_j-S_{i-1}\) ,\(S\)是石子重量的字首和。
放程式碼:
#include <bits/stdc++.h>
using namespace std;
const int N=310;
int n;
int s[N];
int dp[N][N];
int main()
{
cin>>n;
for(int i=1;i<=n;i++)
{
cin>>s[i];
s[i]+=s[i-1];
}
if(n==1)//n=1的情況特判
{
cout<<0;
return 0;
}
for(int l=2;l<=n;l++)//列舉區間長度
{
for(int i=1;i+l-1<=n;i++)
{
int j=i+l-1;
dp[i][j]=0x3f3f3f3f;
for(int k=i;k<j;k++)
{
dp[i][j]=min(dp[i][j],dp[i][k]+dp[k+1][j]+s[j]-s[i-1]);
}
}
}
cout<<dp[1][n];
return 0;
}
練習
留一道題練練手
如果找不到感覺,可以再看看上面的過程,yxc老師的視訊裡也有講這道題。
最長公共子序列
給定兩個長度分別為 \(N\) 和 \(M\) 的字串 \(A\) 和 \(B\),求\(A,B\)最長公共子序列長度。
輸入格式
第一行兩個整數\(N,M\)。
第二行為一個長度為\(N\)的字串,表示字串 \(A\) 。
第三行為一個長度為\(M\)的字串,表示字串 \(B\) 。
字串均由小寫字母構成
輸出格式
一個整數,表示最大長度。
樣例
#in
4 5
acbd
abedc
#out
3
總結
DP是一個經驗性的問題,閆氏DP分析法只是給出了一個分析角度,幫助人更好的分析考慮DP的有關問題,裡面的狀態表示設計仍然需要做題經驗的積累才能順利完成。道阻且長,但是經驗多了自然水到渠成。