DP
序言
動態規劃(DP)是一種透過把原問題分解為相對簡單的子問題的方式求解複雜問題的方法。
運用DP必須滿足兩個條件:
- 最優子結構:即當前子狀態是最優的,不會出現更優情況。
- 無後效性:即當前狀態的改變不會對後續狀態產生影響。
其實第一個性質是大部分題目都滿足的,而無後效性可能就需要選手們自己分析要求,改變思路來使其無後效性(其實這種題也不多)
一般來說,DP解題順序為:
- 分析題目主要性質
- 根據性質特點列出DP狀態
- 分析DP轉移方程
- 解剖細節
- 敲程式碼
其實最主要的就是列出DP狀態與轉移方程,這也是DP為什麼會成為大部分初學者的噩夢的原因,因為通常DP題的題面會給人很強的誤導性,可能會讓大家想到搜尋、退火等演算法。
但從個人角度來說,DP最主要的就是思維的敏捷性,比拼的是能否快速地分析題幹要求,去除不必要的性質。
本文將會從各大DP中總結規律以及舉例題來幫助理解這一演算法。
ex:本文是一篇個人記述學習總結的文章,且更偏向省選級別選手的難度,但還是隻會從基本開始,不過思路可能會非常簡潔(時間不夠)
線性DP
最最最基本的DP,這種一般會有很強的子結構特性。一般來說都是能很輕易地分析的。這裡就放一道例題的最佳化吧
B3637 最長上升子序列
典中典的題,相信也是所有DP初學者的必做題目。
考慮定義 \(f_{i}\) 表示前 \(i\) 個元素構成的最長上升子序列的長度。
顯然能發現 \(f_i\) 中必定單調不減,這使我們聯想到了單調棧。考慮當前元素 \(a_i\) 在 \(f\) 中的位置,可以使用二分或者樹狀陣列實現。然後判斷當前位置的序號與當前最長上升子序列長度的大小,若大,意味著當前元素必定可以使最長上升子序列長度增加,故答案加一即可。
#include<bits/stdc++.h>
#define int long long
using namespace std;
template<typename P>
inline void read(P &x){
P res=0,f=1;
char ch=getchar();
while(ch<'0' || ch>'9'){
if(ch=='-') f=-1;
ch=getchar();
}
while(ch>='0' && ch<='9'){
res=res*10+ch-'0';
ch=getchar();
}
x=res*f;
}
int n;
int a[5020];
int dp[5020];
signed main(){
read(n);
for(int i=1;i<=n;++i) read(a[i]);
dp[1]=a[1];
int ans=1;
for(int i=2;i<=n;++i){
int l=1,r=ans,mid;
while(l<=r){
mid=(l+r)>>1;
if(a[i]<=dp[mid]) r=mid-1;
else l=mid+1;
}
dp[l]=a[i];
if(l>ans) ++ans;
}
cout<<ans<<endl;
return 0;
}
由於線性DP過於板,這裡不再詳述。
區間DP
其實也算是線性DP的一種,不過還是單獨拿出來吧。
通常解題思路是考慮某一段區間的DP狀態,列舉區間斷點進行兩邊的合併更新答案。
P1880 [NOI1995] 石子合併
還是板子題,設 \(f_{i,j}\) 表示 \(i\) 到 \(j\) 之間的答案,那麼只需要列舉一個 \(k,i\le k < j\),然後就有 \(f_{i,j}=f_{i,k}+f_{k+1,j}\),這道題只需要維護兩個即可。
#include<bits/stdc++.h>
using namespace std;
int n;
int a[300];
int dpma[300][300];
int dpmi[300][300];
int s[300];
inline int d(int i,int j){
return s[j]-s[i-1];
}
int main()
{
scanf("%d",&n);
int ans=0;
for(int i=1;i<=n;++i){
scanf("%d",&a[i]);
a[i+n]=a[i];
}
for(int i=1;i<=2*n;++i){
dpma[i][i]=dpmi[i][i]=0;
s[i]=s[i-1]+a[i];
}
for(int len=1;len<=n;++len){
for(int i=1,j=i+len;(j<2*n) && (i<=2*n);++i,j=i+len){
dpmi[i][j]=0x3f3f3f3f;
for(int k=i;k<j;++k)
{
dpma[i][j]=max(dpma[i][j],dpma[i][k]+dpma[k+1][j]+d(i,j));
dpmi[i][j]=min(dpmi[i][j],dpmi[i][k]+dpmi[k+1][j]+d(i,j));
}
}
}
int minl=0x3f3f3f3f;
int maxx=-1;
for(int i=1;i<=n;++i)
{
maxx=max(maxx,dpma[i][i+n-1]);
minl=min(minl,dpmi[i][i+n-1]);
}
cout<<minl<<endl<<maxx;
return 0;
}
揹包DP
其實這個我都覺得沒有太大必要,網上已經很詳盡了,這裡給一點思路即可。
0-1 揹包:
設 \(f_{i,j}\) 表示前 \(i\) 個選了重量為 \(j\) 的物品的價值。那麼當前物品可選可不選,不選時答案不變,即 \(f_{i,j}=f_{i-1,j}\)。
要選時答案即為前一個物品沒算當前物品重量的價值加上當前價值,即 \(f_{i,j}=f_{i-1,j-w_i}+v_i\),二者合起來求最大就ok了
完全揹包:
其實就是將 0-1 揹包程式碼中倒著列舉的 \(j\) 正著列舉就ok了,讀者們可以想一想為什麼。
多重揹包:
多重揹包也是 0-1 揹包的一個變式。與 0-1 揹包的區別在於每種物品有 \(k_i\) 個,而非一個。
樸素的想法是每一個物品都處理一次。
也可以二進位制分組。
混合揹包:
前三種組合在一起。
有的只能取一次,有的能取無限次,有的只能取 \(k\) 次。
分討一下,或者全用二進位制分組。
二維費用揹包:
選一個物品會消耗兩種重量(如經費,時間)。
不就是再開一維嘛
分組揹包:
每組中的物品只能選一個。
每組內做一次01。見程式碼。
#include<bits/stdc++.h>
using namespace std;
int v,n,t;
int x;
int g[205][205];
int i,j,k;
int w[10001],z[10001];
int b[10001];
int dp[10001];
int main(){
cin>>v>>n;
for(i=1;i<=n;i++){
cin>>w[i]>>z[i]>>x;
t=max(t,x);
b[x]++;
g[x][b[x]]=i;
}
for(i=1;i<=t;i++){
for(j=v;j>=0;j--){
for(k=1;k<=b[i];k++){
if(j>=w[g[i][k]]){
dp[j]=max(dp[j],dp[j-w[g[i][k]]]+z[g[i][k]]);
}
}
}
}
cout<<dp[v];
return 0;
}
狀壓DP
將一種狀態壓縮為一個數進行DP。通常來說資料規模極小,不超過 \(20\)。
P1896 [SCOI2005] 互不侵犯
也是板子,用二進位制表示上一行國王在哪兒,列舉下一行狀態轉移。
#include<bits/stdc++.h>
#define int long long
template<typename P>
inline void read(P &x){
P res=0,f=1;
char ch=getchar();
while(ch<'0' || ch>'9'){
if(ch=='-') f=-1;
ch=getchar();
}
while(ch>='0' && ch<='9'){
res=res*10+ch-'0';
ch=getchar();
}
x=res*f;
}
using namespace std;
int T=1;
int n,K;
int bitcount(unsigned int n){
int count=0;
while(n){
count++;
n&=(n-1);
}
return count;
}
int dp[10][82][(1<<10)];
bool pd[(1<<9)+5],pd2[(1<<9)+5][(1<<9)+5];
void Init(){
for(int i=0;i<(1<<9);++i){
int p=i,las=-1;
bool flag=1;
while(p>0){
int now=p%2;
p/=2;
if(now==las && now==1){
pd[i]=0;
flag=0;
break;
}
else las=now;
}
pd[i]=flag;
if(pd[i]){
for(int j=0;j<(1<<9);++j){
bool o=1;
for(int w=0,ww=1;w<9;ww<<=1,++w){
bool i1=i&ww;
bool j1=j&ww;
bool i2=i&(ww<<1);
bool j2=j&(ww<<1);
if(i1+i2+j1+j2>1){
o=0;
break;
}
}
pd2[i][j]=o;
}
}
}
}
signed main(){
read(n),read(K);
Init();
for(int i=0;i<(1<<n);++i) dp[1][bitcount(i)][i]=1;
for(int i=2;i<=n;++i){
for(int k=0;k<=K;++k){
for(int S=0;S<(1<<n);++S){
if(pd[S] && bitcount(S)<=k)
for(int Z=0;Z<(1<<n);++Z){
if(pd2[S][Z])dp[i][k][S]+=dp[i-1][k-bitcount(S)][Z];
}
}
}
}
int ans=0;
for(int S=0;S<(1<<n);++S) ans+=dp[n][K][S];
cout<<ans<<endl;
return 0;
}
P2704 [NOI2001] 炮兵陣地
一個炮兵會影響兩行,壓兩行就行。
ex:插頭DP,後面講
樹形DP
就是把DP的操作搞到樹上。基本思路無異。
P1352 沒有上司的舞會
要麼父親來兒子必定不來,要麼父親不來兒子可來可不來。3種情況。
#include<bits/stdc++.h>
using namespace std;
#define MAXN 6005
int h[MAXN];
int v[MAXN];
vector<int> son[MAXN];
int f[MAXN][2];
void dp(int x){
f[x][0]=0;
f[x][1]=h[x];
for(int i=0;i<son[x].size();i++){
int y=son[x][i];
dp(y);
f[x][0]+=max(f[y][0],f[y][1]);
f[x][1]+=f[y][0];
}
}
int main(){
int n;
cin>>n;
for(int i=1;i<=n;i++) cin>>h[i];
for(int i=1;i<=n-1;i++){
int x,y;
cin>>x>>y;
son[y].push_back(x);
v[x]=1;
}
int root;
for(int i=1;i<=n;i++)if(!v[i]) {root=i;break;}
dp(root);
cout<<max(f[root][0],f[root][1])<<endl;
return 0;
}
P2014 [CTSC1997] 選課
樹上揹包板子,先列舉子樹內情況進行揹包,然後進行揹包合併到父親,向上遞迴。
#include<bits/stdc++.h>
#define int long long
using namespace std;
template<typename P>
inline void read(P &x){
P res=0,f=1;
char ch=getchar();
while(ch<'0' || ch>'9'){
if(ch=='-') f=-1;
ch=getchar();
}
while(ch>='0' && ch<='9'){
res=res*10+ch-'0';
ch=getchar();
}
x=res*f;
}
int T=1;
const int Max=320;
int n,m;
struct edge{
int to,nxt;
}e[Max<<1];
int head[Max],cnt=0;
void add(int u,int v){
e[++cnt].nxt=head[u];
e[cnt].to=v;
head[u]=cnt;
}
int dp[Max][Max];
void work(int u){
for(int i=head[u];i;i=e[i].nxt) work(e[i].to);
for(int i=head[u];i;i=e[i].nxt){
int to=e[i].to;
for(int j=m;j>0;--j){
for(int k=0;k<j;++k){
dp[u][j]=max(dp[u][j],dp[u][j-k]+dp[to][k]);
}
}
}
}
signed main(){
read(n),read(m);
++m;
for(int i=1;i<=n;++i){
int fa;
read(fa),read(dp[i][1]);
add(fa,i);
}
work(0);
cout<<dp[0][m]<<endl;
return 0;
}
換根DP
樹上的DP,每次會將樹根 \(root\) 進行改變,其實只需要分析改變根會造成的影響,可能會有分討。
P3478 [POI2008] STA-Station
其實每次換根使 \(root \rightarrow u\),其實會使答案 \(-size_u+size_1-size_u\),還是很顯然的,程式碼也很好寫。
#include<bits/stdc++.h>
#define int long long
using namespace std;
template<typename P>
inline void read(P &x){
P res=0,f=1;
char ch=getchar();
while(ch<'0' || ch>'9'){
if(ch=='-') f=-1;
ch=getchar();
}
while(ch>='0' && ch<='9'){
res=res*10+ch-'0';
ch=getchar();
}
x=res*f;
}
const int Max=1e6+10;
int n;
struct edge{
int to,nxt;
}e[Max<<1];
int head[Max],cnt=0;
void add(int u,int v){
e[++cnt].nxt=head[u];
e[cnt].to=v;
head[u]=cnt;
}
int siz[Max];
int dp[Max];
void dfs1(int u,int fa,int dep){
siz[u]=1;
for(int i=head[u];i;i=e[i].nxt){
int to=e[i].to;
if(to==fa) continue;
dfs1(to,u,dep+1);
siz[u]+=siz[to];
}
dp[1]+=dep;
}
void dfs2(int u,int fa){
for(int i=head[u];i;i=e[i].nxt){
int to=e[i].to;
if(to==fa) continue;
dp[to]=max(dp[to],dp[u]-siz[to]*2+siz[1]);
dfs2(to,u);
}
}
signed main(){
read(n);
for(int i=1;i<n;++i){
int u,v;
read(u),read(v);
add(u,v);
add(v,u);
}
dfs1(1,0,0);
dfs2(1,0);
int maxx=*max_element(dp+1,dp+n+1);
for(int i=1;i<=n;++i) if(dp[i]==maxx) {cout<<i<<endl;break;}
return 0;
}
數位DP
這個是我比較喜歡的DP了,代表性很強,而且很有意思。
一般是討論 \([l,r]\) 區間內每個數字出現次數、所有數字之和...總之就是跟數字每一位有關。
一般會轉換為 \([1,r]-[1,l-1]\)
P2602 [ZJOI2010] 數字計數
就是去DP每個數位能填的數字,分別記錄答案。注意要判斷是否達上界和前導 \(0\) 即可。
#include<bits/stdc++.h>
#define int long long
using namespace std;
template<typename P>
inline void read(P &x){
P res=0,f=1;
char ch=getchar();
while(ch<'0' || ch>'9'){
if(ch=='-') f=-1;
ch=getchar();
}
while(ch>='0' && ch<='9'){
res=res*10+ch-'0';
ch=getchar();
}
x=res*f;
}
int a,b;
int f[15][2][15][2];
int num[15];
int dfs(int now,int x,int sum,bool op0,bool lim){
int res=0;
if(now==0) return sum;
if(f[now][lim][sum][op0]!=-1) return f[now][lim][sum][op0];
for(int i=0;i<=9;++i){
if(lim && i>num[now]) break;
res+=dfs(now-1,x,sum+((!op0 || i) && (i==x)),(op0) && (i==0),!((!lim) || (i<num[now])));
}
f[now][lim][sum][op0]=res;
return res;
}
int work(int sum,int x){
int len=0;
while(sum){
num[++len]=sum%10;
sum/=10;
}
memset(f,-1,sizeof(f));
return dfs(len,x,0,1,1);
}
signed main(){
read(a),read(b);
for(int i=0;i<=9;++i){
cout<<work(b,i)-work(a-1,i)<<' ';
}
return 0;
}
P4999 煩人的數學作業
跟上一題幾乎一模一樣,但這個不需要列舉每一個數字,其他地方稍微改改就行。
#include<bits/stdc++.h>
#define int long long
using namespace std;
template<typename P>
inline void read(P &x){
P res=0,f=1;
char ch=getchar();
while(ch<'0' || ch>'9'){
if(ch=='-') f=-1;
ch=getchar();
}
while(ch>='0' && ch<='9'){
res=res*10+ch-'0';
ch=getchar();
}
x=res*f;
}
int T=1;
int f[200][200];
int num[1000010];
int a,b;
const int mod=1e9+7;
int dfs(int now,int sum,bool lim){
if(now==0) return sum;
int res=0;
if(f[now][sum]>=0 && !lim) return f[now][sum]%mod;
int upp=lim?num[now]:9;
for(int i=0;i<=upp;++i){
res+=dfs(now-1,sum+i,lim && i==num[now]);
res%=mod;
}
if(!lim) f[now][sum]=res%mod;
return res%mod;
}
int work(int sum){
int len=0;
while(sum){
num[++len]=sum%10;
sum/=10;
}
memset(f,-1,sizeof(f));
return dfs(len,0,1)%mod;
}
signed main(){
auto solve=[&](){
read(a),read(b);
int ans=0;
ans+=work(b)-work(a-1)+mod;
ans%=mod;
cout<<ans<<endl;
};
read(T);
while(T--) solve();
return 0;
}
計數DP
顧名思義,就是統計方案數的DP,DP的過程沒有本質區別。,其實市面上很多求方案數的題都跟這個掛鉤,讀者們可以注意一下。
P2327 [SCOI2005] 掃雷
典典典,這道題其實只需要根據第一個格子的數字再往後判斷是否合法即可,不難,所以我們改一下題目:
有一些格子不知道是多少。
這樣呢,就發現前面的會影響後面的。
但其實只會影響周圍倆,所以還是可以做的。
其實只需要設 \(f_{i,0/1,0/1}\) 表示前 \(i\) 個位置,第 \(i\) 個位置有沒有雷,第 \(i-1\) 個格子有沒有雷。對應狀態轉移即可。沒寫程式碼。
P2051 [AHOI2009] 中國象棋
令 \(f_{i,j,k}\) 為考慮到第 \(i\) 行,有 \(j\) 列填了1個棋子,\(k\) 列填了2個棋子的方案數。
然後列舉行,處理每一列的情況。有6種,一個都不放有一種,放一個有兩種放的位置(一個上面已經放了一個,一個上面還是空的),放兩個有三种放的位置,都放上面空的、一個空一個有一個的、兩個都放頭上有一個的。
#include<bits/stdc++.h>
#define int long long
using namespace std;
template<typename P>
inline void read(P &x){
P res=0,f=1;
char ch=getchar();
while(ch<'0' || ch>'9'){
if(ch=='-') f=-1;
ch=getchar();
}
while(ch>='0' && ch<='9'){
res=res*10+ch-'0';
ch=getchar();
}
x=res*f;
}
const int mod=9999973;
int dp[120][120][120];
int n,m;
int bal(int x){
return (x*(x-1)/2)%mod;
}
signed main(){
read(n),read(m);
int ans=0;
dp[0][0][0]=1;
for(int i=1;i<=n;++i){
for(int j=0;j<=m;++j){
for(int k=0;k<=m-j;++k){
dp[i][j][k]=dp[i-1][j][k];
if(k>=1) dp[i][j][k]=(dp[i][j][k]+dp[i-1][j+1][k-1]*(j+1))%mod;
if(j>=1) dp[i][j][k]=(dp[i][j][k]+dp[i-1][j-1][k]*(m-j+1-k))%mod;
if(k>=1) dp[i][j][k]=(dp[i][j][k]+dp[i-1][j][k-1]*j*(m-j-k+1))%mod;
if(j>=2) dp[i][j][k]=(dp[i][j][k]+dp[i-1][j-2][k]*bal(m-j+2-k));
if(k>=2) dp[i][j][k]=(dp[i][j][k]+dp[i-1][j+2][k-2]*bal(j+2));
dp[i][j][k]%=mod;
}
}
}
for(int i=0;i<=m;++i) for(int j=0;j<=m;++j) ans+=dp[n][i][j],ans%=mod;
cout<<ans<<endl;
return 0;
}
CF559C Gerald and Giant Chess
接考慮不能走黑色,資料範圍太大不好做。
考慮容斥一下,不走黑色的方案數=總的方案數-經過黑點的方案數
不考慮限制從點 \(i\) 走到點 \(j\) 的方案數為:\(C_{x_i+y_i-x_j-y_j}^{x_i-x_j}\)
先把黑點按照座標排序。
定義 \(f_i\) 表示不經過黑點走到第 \(i\) 個點的方案數。
那麼:\(f[i]=C_{x_i+y_i-2}^{x_i-1}-\sum_{j=1}^{i-1}f[j]*C_{x_i+y_i-x_j-y_j}^{x_i-x_j}\)
表示隨便走到第 \(i\) 個點的方案數減去經過前面一個黑點後剩下隨便走的方案數。
令 \((h,w)\) 為第 \(n+1\) 個黑點,最終答案就是 \(f_{n+1}\)。
程式碼還沒改對,之後上傳。
期望DP
倒數第二不會的來了。
實際就是DP套個期望/機率。
- 期望的一些性質
$ E(X+Y)=E(X)+E(Y)$
\(E(XY)=E(X)E(Y)(X,Y相互獨立)\)
\(E(aX+b)=aE(X)+b\)
\(E(c)=c\)
CF148D Bag of mice
設 \(f(i,j)\) 表示有 \(i\) 只白鼠,\(j\) 只黑鼠時A先手勝的機率
初始狀態
全白時,顯然先手必勝
有一隻黑鼠時,先手若抽到黑鼠則後手必勝,所以先手首回合必須抽到白鼠
\(f(i,0)=1,f(i,1)=\frac{i}{i+1}\)
轉移方程 \(f(i,j)\)
先手抽到白鼠,勝:\(\frac{i}{i+j}\)
先手抽到黑鼠,後手抽到白鼠,敗: \(0\)
先手抽到黑鼠,後手抽到黑鼠,跑一隻白鼠:\(\frac{j}{i+j}\times \frac{j-1}{i+j-1}\times \frac{i}{i+j-2}\times f(i-1,j-2)\)
先手抽到黑鼠,後手抽到黑鼠,跑一隻黑鼠:\(\frac{j}{i+j}\times \frac{j-1}{i+j-1}\times \frac{j-2}{i+j-2}\times f(i,j-3)\)
\(f(i,j)=\frac{i}{i+j}+\frac{j}{i+j}\times \frac{j-1}{i+j-1}\times \frac{i}{i+j-2}\times f(i-1,j-2)+\frac{j}{i+j}\times \frac{j-1}{i+j-1}\times \frac{j-2}{i+j-2}\times f(i,j-3)\)
\(O(wb)\)
#include<bits/stdc++.h>
using namespace std;
double f[1010][1010];
int w,b;
double dfs(int nw,int nb){
if(nw==0) return 0.0;
if(nb==0) return 1.0;
if(f[nw][nb]>0) return f[nw][nb];
double ans=0;
ans+=1.0*nw/(nw+nb);
if(nb==2)
ans+=1.0*nb/(nw+nb)*(nb-1)/(nw+nb-1)*dfs(nw-1,nb-2);
else if(nb>=3)
ans+=1.0*nb/(nw+nb)*(nb-1)/(nw+nb-1)*(1.0*nw/(nw+nb-2)*dfs(nw-1,nb-2)+1.0*(nb-2)/(nw+nb-2)*dfs(nw,nb-3));
return f[nw][nb]=ans;
}
signed main(){
cin>>w>>b;
printf("%.9lf",dfs(w,b));
return 0;
}
插頭DP
最不會的。
還沒懂,懂了補上。