動態規劃(介紹閆氏dp分析法及相關例題分析)

二次方程的老巢發表於2020-12-21

基本思路介紹:

 首先是在看問題時,知道這道題要用dp去做,但是怎麼去實現呢?空想是很難去構造式子的,閆氏dp方法的核心就是用集合的方法去分析問題,從集合的角度入手去解決問題。
 新手入門一般就是求有限集合中的最值,比如大家常見的求一堆符合要求的方案中的最優值,這裡就要分為兩個階段:化零為整,化整為零。第一是找出符合要求的方案,二是找出這些方案中的最優值(但有些題目也可能要讓你找最優解、第k解,不定)。
    閆氏dp分析法流程圖:
在這裡插入圖片描述

1)狀態表示 (對應化零為整的過程):我們每次去處理問題的時候,不是一個元素一個元素去列舉,而是每次列舉一類東西,比如去列舉一個子集,就是將一些有相似點的元素化成一個子集,然後用某一個狀態來表示。
狀態表示分為集合和屬性:
 集合:所有滿足xx條件的集合,這就是 dp 陣列所表示的集合。
 屬性(一般是最大值、最小值、個數、存在性)。

2)狀態計算(對應化整為零的過程):我們要了解每個狀態如何去算出來。這些子集一般要滿足兩個劃分原則:不重複、不遺漏。先搞清楚 dp [ i ] 所表示的狀態是什麼,然後將其劃分為幾個部分,然後每個部分分別去求。相當於將 dp [ i ] 劃分為若干個子集。比方說我們dp [ i ] 求最大值,那麼我們先去求一下每一個子集的最大值,再對它們取一個max 就行了,這就相當於把一個整體問題轉化成某些子集的問題去處理。

ps:劃分原則也不是絕對的,比如“不重複”,在一些情況下是允許重複的,前提是不影響結果,比如求最大值。
集合劃分的依據(常用套路):尋找最後一個不同點。
 dp問題有很多種不同的模型,我們應先把各種dp模型整理出來,比如選擇問題(揹包模型),序列問題(最長上升子序列),狀態壓縮,狀態積,區間dp,樹形dp,單調積的優化,斜率優化,每個問題都要去整理一遍,遇到題目才會有經驗去做,這是dp與其他章節不一樣的地方。

用閆氏dp法進行例題分析:

01揹包問題

題目大意:

有 N 件物品和一個容量是 V 的揹包。每件物品只能使用一次。
第 i 件物品的體積是 v [ i ] ,價值是 w [ i ]。
求解將哪些物品裝入揹包,可使這些物品的總體積不超過揹包容量,且總價值最大。
輸出最大價值。

輸入格式:

第一行兩個整數,N,V,用空格隔開,分別表示物品數量和揹包容積。
接下來有 N 行,每行兩個整數 vi,wi,用空格隔開,分別表示第 i 件物品的體積和價值。

輸出格式

輸出一個整數,表示能取到的最大價值。

資料範圍:

0< N,V ≤1000
0< vi , wi ≤1000

輸入樣例
4 5
1 2
2 4
3 4
4 5
輸出樣例
8

用閆氏分析法
分析01揹包問題:
1、分析:
有限集合求最值,物品數量是有限的,是 n 個,總共的容量也是有限的,最大容量是 v 。我們要選一些物品裝進揹包,保證總體積不超過 v ,每個物品要麼選要麼不選兩種情況,所以總共選法的數量最多就是2 ^ n 。我們從 2 ^ n 種情況裡面找到能使總價值最大的方案。

狀態表示:用 dp [ i ] [ j ] 表示從前 i 個物體中選取且總體積不超過 j 的選法。
狀態計算:狀態劃分為以下兩種情況:
①不選第 i 個物品,此時 dp [ i ] [ j ] = dp [ i - 1 ] [ j ]
②選擇第 i 個物品,那麼就要選取 dp [ i - 1 ] [ j - v [ i ] ] 的情況再加上 w [ i ] ,此時 dp [ i ] [ j ] = dp [ i - 1 ] [ j - v [ [ i ] ] + w[ i ]
對這兩種可能取一個最大值,總狀態轉移方程為:
dp [ i ] [ j ] = max ( dp [ i - 1 ] [ j ] , dp [ i - 1 ] [ j - v [ i ] ] + w [ i ] )
③注意:只有 j >= w [ i ] 時才有第二種情況,需加上判斷。

2、用閆氏分析法做出流程圖:
在這裡插入圖片描述
 如圖做一個 dp [ i , j ] 集合,假設是一個橢圓,dp [ i , j ] 子集劃分:通過找最後一個不同點,即選最後一個物品的狀態不同,選第 i 個物品和不選第 i 個物品是不一樣的。以這一點來劃分整個集合。相當於把 dp [ i , j ] 劃分為兩個子集,左邊為所有不選擇第 i 個物品的方案 : dp [ i , j ] = dp [ i - 1 , j ] 。右邊是所有選擇第 i 個物品的方案 :dp [ i - 1 , j - v[ i ] ] + w [ i ] ]兩邊取 max 則是 dp [ i , j ]的最大值 :
dp [ i , j ] = max( dp [ i - 1 ] [ j ] , dp [ i - 1 ] [ j - v [ i ] ] + w[ i ] )。


樸素程式碼如下(後面有優化程式碼):

#include <stdio.h>
#include <iostream>
#include <string.h>
#define max(a, b) (a) > (b) ? (a) : (b);
const int maxn = 1010;
using namespace std;
int dp[maxn][maxn];
int w[maxn], v[maxn];//存物品的價值、體積

int main()
{
	memset(dp, 0 ,sizeof(dp));
	int n, m;//n為物品數量的,m為揹包質量
	scanf("%d %d",&n, &m);
	for(int i = 1; i <= n; i++)
		scanf("%d %d",&v[i], &w[i]);
	for(int i = 1; i <= n; i++)
	{
		for(int j = 0; j <= m; j++)
		{
			dp[i][j] = dp[i-1][j];//左半邊子集
			if(j >= v[i])//右半邊子集不一定存在,需要進行判斷
				dp[i][j] = max(dp[i][j], dp[i-1][j-v[i]] + w[i]);
		}
	}
	printf("%d\n",dp[n][m]);

	return 0;
}

完全揹包

 由經驗可得,一般第一維是考慮前i個物品,後幾維則是各種限制條件。

 完全揹包與01揹包的區別在於每種物品都有無限件,而01揹包問題每個物品最多隻能用一次。
例題:

題目大意:
有 N 種物品和一個容量是 V 的揹包,每種物品都有無限件可用。第 i 種物品的體積是 vi,價值是 wi。
求解哪些物品裝入揹包,可使這些物品的總體積不超過揹包容量,且總價值最大。
輸出最大價值。

輸入格式:

第一行有兩個整數,N,V用空格隔開,分別表示物品總數和揹包容積。
接下來有N行,每行兩個整數vi,wi,用空格隔開,分別表示第 i 種物品的體積和價值。

輸出格式:

輸出一個整數,表示最大值。

資料範圍:

0 <= N, V <= 1000
0 <= vi, wi <=1000

輸入樣例:

4 5
1 2
2 4
3 4
4 5

輸出樣例:

10

再用閆氏dp分析法分析一波:
在這裡插入圖片描述

 狀態計算:因為每種物品都有無限多個,所以不能像01揹包那樣將集合單純分為兩個子集(選或不選)。應將集合劃分成若干個子集,第1個子集是選0個第 i 個物品,第2個子集是選1個第 i 個物品,在下一個子集選2個第 i 個物品,以此類推……選到不能選為止(當體積超出範圍時)。題目求最大值,即在每一個子集中先求最大值,再對這些子集的最大值取個max即可,完美(bushi做到不重不漏。
 先看第一個子集,從1 ~ i 中選擇0個第 i 個物品且總體積小於等於 j 的所有方案,相當於從1 ~ i - 1 中選且總體積小於等於 j 的所有方案,得 dp [ i ] [ j ] = dp [ i - 1 ] [ j ] 。
 其他子集情況都是類似的,比如第 k + 1個子集,包含了有 k 個第 i 個物品的的所有方案,這些方案有相同的部分就是含有 k 個 i ,剩下的部分為不同的那一部分(這樣就將子集獨立地分為兩個部分)。我們要求這些方案裡的最大值,只需要對這個不同的部分進行討論,而不需要對它們相同的部分進行考慮。
 那不同的部分,就是從 1 ~ i - 1 中選且總體積小於等於 j - k * v [ i ] 的所有方案的集合,這個集合裡方案的最大值就是dp [ i - 1 ] [ j - k * v [ i ] ]。
 綜上所述,最終 dp [ i ] [ j ] 就是對所有子集取一個 max 。推匯出dp [ i ] [ j ] = max ( dp [ i - 1 ] [ j ] , dp [ i - 1] [ j - 1 * v [ i ] ] + 1 * w [ i ] , dp [ i - 1 ] [ j - 2 * v [ i ] ] + 2 * w [ i ] , dp [ i - 1 ] [ j - 3 * v [ i ] ] +3 * w [ i ] , ……);

 構思程式碼時可以發現完全揹包樸素迴圈比01揹包多了一重迴圈,在資料優化後很容易造成超時,所以我們考慮對它進行優化。對以上式子進行推導:將 j 換成 j - v [ i ] , 得 dp [ i ] [ j - v[ i ] ] = max ( dp [ i - 1 ] [ j - v [ i ] ] , dp [ i- 1 ] [ j - 2 * v[ i ] ] + 1 * w [ i ], dp [ i - 1] [ j - 3 * v [ i ] ] + 2 * w [ i ] , ……) 。對比兩個式子可以發現,有很多相似項(每項相差一個 w[ i ] ) 。所以我們求第一個式子的最大值就相當於求第二個式子 + w [ i ] 的最大值,推匯出 dp [ i ] [ j ] = max ( dp [ i - 1] [ j ] , dp [ i ] [ j - v [ i ] ] + w [ i ] ) 。(注意這裡是 i,不是 i - 1)

在這裡插入圖片描述

樸素程式碼(後面有優化程式碼):

#include <stdio.h>
#include <iostream>
#include <string.h>
using namespace std;
#define max(a,b) (a) > (b) ? (a) : (b);
const int maxn = 1010;
int dp[maxn][maxn], v[maxn], w[maxn];

int main()
{
	memset(dp,0,sizeof(dp));
	int n, m;
	scanf("%d %d", &n, &m);
	for(int i = 1; i <= n; i++)
		scanf("%d %d", &v[i], &w[i]);
	/*
	for(int i = 1; i <= n; i++)
	{
		for(int j = 1; j <= m; j++)
		{
			dp[i][j] = dp[i-1][j];
			if(j >= v[i])
				dp[i][j] = max(dp[i-1][j], dp[i][j-v[i]] + w[i]);
		}
	}
	*/
	for(int i = 1; i <= n; i++)
	{
		for(int j = v[i]; j <= m; j++)
		{
			dp[i][j] = max(dp[i-1][j], dp[i][j-v[i]] + w[i]);
		}
	}
	printf("%d\n",dp[n][m]);
	
	return 0;
}

區間dp

例題:石子合併

題目大意:

設有N堆石子排成一排,其編號為1,2,3,…,N。
每堆石子有一定的質量,可以用一個整數來描述,現在要將這N堆石子合併成為一堆。
每次只能合併相鄰的兩堆,合併的代價為這兩堆石子的質量之和,合併後與這兩堆石子相鄰的石子將和新堆相鄰,合併時由於選擇的順序不同,合併的總代價也不相同。
例如有4堆石子分別為 1 3 5 2, 我們可以先合併1、2堆,代價為4,得到 4 5 2, 又合併 1,2堆,代價為9,得到 9 2 ,再合併得到11,總代價為4+9+11=24;
如果第二步是先合併2,3堆,則代價為7,得到 4 7,最後一次合併代價為11,總代價為4+7+11=22。
問題是:找出一種合理的方法,使總的代價最小,輸出最小代價。

輸入格式:

第一行一個數N表示石子的堆數N。
第二行N個數,表示每堆石子的質量(均不超過1000)。

輸出格式:

輸出一個整數,表示最小代價。

資料範圍:

1 ≤ N ≤ 300

輸入樣例:

4
1 3 5 2

輸出樣例:

22

分析:
 每次只能合併相鄰兩個區間,所以如果想把這些區間合併成一堆的話,最後一步一定是把某兩大堆合併成一堆,對於這兩堆,左邊一大堆就是左邊連續的一部分,右邊一堆就是右邊連續的部分。
分析:所有合併方案裡的最小代價。
在這裡插入圖片描述
狀態計算:把dp [ i ] [ j ] 的所有方案分成若干個子集,這些子集的最後一步一定是把左邊的某一段和右邊的某一段進行合併,我們把左半邊的最後一堆設為分界點 k ,在每種方案中都一樣,先把①左邊從 i 到 k 合併,再把②從 k +1 到 j 合併,(注意兩部分是完全獨立的,可以分開計算),要讓左邊代價最小,右邊的代價也最小,在各個子集中求出最小值,再對它們取個 min 就是整個集合的最小值。對於計算①(左邊部分)這一部分的代價就是 dp [ i ] [ k ] ,對於②(右邊部分)這一部分的代價就是 dp [ k +1 ] [ j ] ,得 dp [ i ] [ j ] = dp [ i ] [ k ] + dp [ k + 1] [ j ] + s[ j ] - s [ i - 1 ](最後一次合併的代價是從 i 到 j 的和)。所以我們就列舉 k ,k 取值從 i 到 j - 1,對結果取 min 。在這裡插入圖片描述
AC程式碼:

#include <stdio.h>
#include <iostream>
#include <string.h>
using namespace std;
#define min(a,b) (a) < (b) ? (a) : (b);
const int maxn = 310;
int dp[maxn][maxn];
int s[maxn];//存字首和 

int main()
{
	memset(dp,0,sizeof(dp));
	int n;
	scanf("%d",&n);
	for(int i = 1; i <= n; i++){
		scanf("%d",&s[i]);
		s[i] += s[i-1];
	}
	//先列舉長度,再列舉左端點
	for(int len = 2; len <= n; len++)//給出區間長度,合併至少是兩堆 
	{
		for(int i = 1; i + len - 1 <= n; i++)//列舉 i
		{
			int j = i + len - 1;
			dp[i][j] = 1e8;
			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]);
				//注意這裡是減去s[i-1]而不是s[i] 
		}  
	}
	printf("%d\n",dp[1][n]);
	
	return 0;
}

線性dp

例題:最長公共子序列

題目大意:

給定兩個長度分別為N和M的字串A和B,求既是A的子序列又是B的子序列的字串長度最長是多少。

輸入格式:

第一行包含兩個整數N和M。
第二行包含一個長度為N的字串,表示字串A。
第三行包含一個長度為M的字串,表示字串B。
字串均由小寫字母構成。

輸出格式:

輸出一個整數,表示最大長度。

資料範圍:

1 ≤ N , M ≤ 1000

輸入樣例:

4 5
acbd
abedc

輸出樣例:

3

題目求:A和B的所有公共子序列裡的最長的序列的長度。
在這裡插入圖片描述
狀態計算:對最後一個不同點進行劃分:A的最後一個字元A [ i ] 以及B的最後一個字元B [ j ] 包不包含在這個子序列裡,對於A [ i ] 有兩種情況,對於B [ j ] 也有兩種情況進行討論,一共是四種選擇,四種選擇分別用00,01,10,11表示。,求每一類的最大值。
 當情況為11時(公共子序列包含A [ i ] 也包含 B [ j ] )其必滿足A [ i ] = B [ j ](字元相等) ,將這一子集根據變與不變劃分為兩個部分:①前面 A 的 i - 1個字元與 B 的 j - 1個字元的情況(變的部分) +, ②A [ i ] = b [ j ](不變的部分),則 ① 根據定義就等於 dp[ i - 1] [ j - 1] ,②就是1,最終式子dp [ i ] [ j ] = dp [ i - 1] [ j - 1] + 1。
 當情況是00時,可得dp [ i ] [ j ] = dp [ i - 1 ] [ j - 1 ] 。
 當情況是01時,公共子序列不含A [ i ] 但必包含B [ j ],dp [ i ] [ j ] = dp [ i - 1 ] [ j ] (但這個式子與00情況有重複,因為dp [ i - 1] [ j ]根據狀態表示裡的集合概念來看,B [ j ] 不一定在公共子序列中 。這種情況其實是與dp [ i - 1 ] [ j ] 且含 B [ j ] 這種情況等價。)(但本題重複對於求最大值沒有影響)
 當情況是10時,dp [ i ] [ j ] = dp [ i ] [ j - 1 ] 。
 由上可以看出,01子集已包含00情況下的 dp [ i - 1] [ j - 1] 式子,所以我們在程式碼構造中只需要計算 01、10、11三個部分即可。

#include <stdio.h>
#include <iostream>
#include <string.h>
using namespace std;
#define max(a,b) (a) > (b) ? (a) : (b);
const int maxn = 1010;
char a[maxn], b[maxn];
int dp[maxn][maxn];
int n, m;

int main()
{
	memset(dp,0,sizeof(dp));
	scanf("%d %d",&n, &m);
	cin>>a+1;
	cin>>b+1;
	for(int i = 1; i <= n; i++)
	{
		for(int j = 1; j <= m; j++)
		{
			dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
			if(a[i] == b[j])
				dp[i][j] = max(dp[i][j], dp[i-1][j-1] + 1);
		}
	}
	printf("%d\n",dp[n][m]);

	return 0;
}

dp優化

01揹包

 dp優化和分析一般是分開的,在樸素方法步驟基礎上再考慮能否優化空間或時間(優化核心)。例,在01揹包中,我們可以觀察到第一維所有 dp [ i ] 在算的時候只會用到 dp [ i - 1 ] 這一層,so 我們可以採用滾動陣列去求,只保留前一層資料即可,之前資料不需要再用到。利用滾動陣列進行優化,將dp陣列由dp [ maxn ] [ maxn ] 變為 dp [ 2 ] [ maxn ] 。
 因此01揹包的狀態轉移方程如下:
  dp [ 0 ] [ 0 ] = 0 ;  dp [ 1 ] [ 0 ] = 0;
  dp [ 0] [ r ] = 0;
  當 r < v [ i ] 不能夠選擇第 i 個物品時:dp [ c ] [ r ] = dp [ 1 - c ] [ r ] ;
  當 r >= v [ i ] 時,在放入與不放入物品 i 間選擇最優解:
    dp [ c ] [ r ] = max ( dp [ 1- c ] [ r ] , dp [ 1 - c ] r - v [ i ] ] + w [ i ] ;


採用滾動陣列優化後程式碼:
其實就是多一個 int c = 0;
在for迴圈中每一次利用 c = 1 - c 進行滾動,達到只保留上一層資料的效果。

#include <stdio.h>
#include <iostream>
#include <string.h>
using namespace std;
#define max(a,b) (a) > (b) ? (a) : (b)
const int maxn = 1010;
int dp[2][maxn], v[maxn], w[maxn];

int main()
{
	memset(dp,0,sizeof(dp));
	int n, m, c = 0;
	scanf("%d %d",&n, &m);
	for(int i = 1; i <= n; i++)
		scanf("%d %d",&v[i], &w[i]);
	for(int i = 1; i <= n; i++)
	{
		c= 1 - c;
		for(int j = 0; j <= m; j++)
		{
			if(j < v[i])//不滿足條件,等於它上一層的值 
				dp[c][j] = dp[1-c][j];
			else//滿足條件再在放入與不放入兩種情況下取max 
				dp[c][j] = max(dp[1-c][j], dp[1-c][j-v[i]]+w[i]);
		}
	}
	printf("%d\n",max(dp[1][m], dp[0][m]));
	
	return 0;
}


我們也可以直接使用一維陣列:
但是這裡要著重理解為什麼第二層迴圈 j 要從大到小迴圈:保證上一層(前面)的值還沒被修改
  觀察得 dp 第二維要麼用到 j ,要麼用到 j - v [ i ] , 總的來說就是要麼用自己,要麼用比自己前面的數,那我們就可以把它優化成一個陣列,讓它從大到小迴圈即可。當我們把式子中一維去掉時, 剩下的式子是 dp [ j ] = max ( dp [ j ] , dp [ j - v [ i ] ] + w [ i ] );當我們迴圈第 i 層的時候 dp [ j - v [ i ] ] 還沒有被更新過, 還是上一層的 dp [ i - 1 ] [ j - v[ i ] ],就保證該優化後方程等價於前面樸素二維方程做法。orz

一維陣列優化後程式碼:

#include <stdio.h>
#include <iostream>
#include <string.h>
using namespace std;
#define max(a,b) (a) > (b) ? (a) : (b);
const int maxn = 1010;
int dp[maxn] , v[maxn], w[maxn];

int main()
{
	memset(dp,0,sizeof(dp));
	int n, m;
	scanf("%d %d",&n,&m);
	for(int i = 1; i <= n; i++)
		scanf("%d %d",&v[i], &w[i]);
	for(int i = 1; i <= n; i++)
	{
		for(int j = m; j >= v[i]; j--)
		{
			dp[j] = max(dp[j], dp[j-v[i]] + w[i]);
		}
	}
	printf("%d\n",dp[m]);
	
	return 0;
}

完全揹包

使用一維陣列進行優化(分析與01揹包差不多):

#include <stdio.h>
#include <iostream>
#include <string.h>
using namespace std;
#define max(a,b) (a) > (b) ? (a) : (b);
const int maxn = 1010;
int dp[maxn], v[maxn], w[maxn];

int main()
{
	memset(dp,0,sizeof(dp));
	int n, m;
	scanf("%d %d", &n, &m);
	for(int i = 1; i <= n; i++)
		scanf("%d %d", &v[i], &w[i]);
	for(int i = 1; i <= n; i++)
	{
		for(int j = v[i]; j <= m; j++)
		{
			dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
		}
	}
	printf("%d\n",dp[m]);
	
	return 0;
}

持續更新,跑路

相關文章