1. 程式人生 > 實用技巧 >閆氏DP分析法

閆氏DP分析法

yxc老師bilibili原視訊地址

眾所周知,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\)

時,\(F(i)=max( \text{子集的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的有關問題,裡面的狀態表示設計仍然需要做題經驗的積累才能順利完成。道阻且長,但是經驗多了自然水到渠成。