動態規划學習心得
說實話吧,動態規划(DP)確實是一個比較難的知識點,對於初學者來說,是一個難過的坎(筆者的臉呢?開玩笑。)。動態規划就是我從初學開始遇到的最神奇的解法,它不同於暴力搜索,也不同於一般的貪心,能夠以出乎人意料的時間復雜度(近似於O(n^2))解決一些難題,算法遠遠優於一般的深搜(O(2^n))。不過,動態規划的思維性比較強,必須會設好狀態,正確寫出狀態轉移方程,並且能夠准確判斷有無最優子結構。
其實有點像貪心,但是它有局部最優解推導向整體最優解的過程,形象一點說,動態規划的“眼光”比貪心更長遠,有一個更新最優解的過程,發現問題了可以“反悔”。它還有一點分治的味道,通過對問題划分各個階段,對各個階段分別求解,最后推向整體的過程。與分治法不同的是,適合於用動態規划求解的問題,經分解得到子問題往往不是互相獨立的。若用分治法來解這類問題,則分解得到的子問題數目太多,有些子問題被重復計算了很多次。如果我們能夠保存已解決的子問題的答案,而在需要時再找出已求得的答案,這樣就可以避免大量的重復計算,節省時間。我們可以用一個表來記錄所有已解的子問題的答案。不管該子問題以后是否被用到,只要它被計算過,就將其結果填入表中。這就是動態規划法的基本思路。具體的動態規划算法多種多樣,但它們具有相同的填表格式。
學習動態規划是一個比較漫長的過程,需要慢慢領悟,去體會動態規划的奧義。顯然,多做題,多思考是必需的,堅持下去,慢慢就能學會了。
下面詳細地描述一下:
一.動態規划的表示方法:
一般地,動態規划有兩種表示方法,分別是:1.遞推 2.記憶化搜索。
這兩種方法各有優缺點,遞推的效率更高,可以降維節省空間,能使用滾動數組,但思維性強,難度高。而記憶化搜索更好寫,更便於理解,不容易出錯,但容易超空間。有時候狀態數目多,記憶化搜索就不行了,會超空間。但是遞推是絕對沒有問題的,只要會滾動數組或者降維。所以,我比較推薦遞推的方法,更能夠鍛煉我們的算法能力。所以我們一般用遞推的方法解決動態規划的問題。
例如:下面的代碼就是一種遞推:
f[i][j]=max(f[i+1][j],f[i+1][j+1])+a[i][j];
至於記憶化搜索,也上一波代碼:
int dfs(int k,int j)
{
if(f[k][j]) return f[k][j];
if(k == n) return a[k][j];
temp1 = dfs(k+1 , j);
temp2 = dfs(k+1 , j+1);
f[k][j] = max(temp1 , temp2)+a[k][j];
}
(以上代碼以題目數字三角形為例)。
二.動態規划的條件:
動態規划有兩個必要條件:
1.無后效性.
2.最優子結構.
無后效性:
標准定義是這樣的:將各階段按照一定的次序排列好之后,對於某個給定的階段狀態,它以前各階段的狀態無法直接影響它未來的決策,而只能通過當前的這個狀態。換句話說,每個狀態都是過去歷史的一個完整總結。這就是無后向性,又稱為無后效性。
最優子結構:
標准定義是這樣的:一個最優化策略具有這樣的性質,不論過去狀態和決策如何,對前面的決策所形成的狀態而言,余下的諸決策必須構成最優策略。簡而言之,一個最優化策略的子策略總是最優的。一個問題滿足最優化原理又稱其具有最優子結構性質。
三.動態規划的分類及解決過程:
分類:
動態規划分為:
1.基本線性動規: 比較基礎的DP入門
2.背包動規 背包問題,見某大佬著作《背包九講》
3.區間動規 區間型的動態規划
4.雙進程動規 分為兩個進程,一般只需要增加一個維度表示狀態就可以了。
5.樹狀動規 在樹上做動態規划,較為高級。
6.各種優化...............等等
解決過程:
第一步:讀懂題意,看看題目是否可以滿足動態規划的條件(及是否可以用動態規划解決)。
第二步:根據題目所給的條件划分階段,可以是題目給定的順序,或者是貪心的順序,或者是特殊的順序。
第三步:根據階段設置狀態,一般用f數組表示,最基本規則:求什么設什么,必須滿足無后效性 。當感覺是dp,但是當前狀態不滿足必要條件的時候,狀態+維。
第四步:推出狀態轉移方程式,能夠表示當前最優值和前面最優值的關系。
第五步:代碼實現,檢查前面的步驟是否正確。
四.動態規划經典例題詳解:
1.基本線性動規:
hloj#402護衛隊
見鏈接:https://www.cnblogs.com/smilke/p/10502784.html(本蒟蒻的一篇博客題解,如有不當之處歡迎指出)
2.背包動規:
【背包】采葯
題目描述
寧智賢是個天資聰穎的孩子,他的夢想是成為世界上最偉大的醫師。為此,他想拜附近最有威望的醫師為師。
醫師為了判斷他的資質,給他出了一個難題。
醫師把他帶到一個到處都是草葯的山洞里對他說:“孩子,這個山洞里有一些不同的草葯,采每一株都需要一些時間,每一株也有它自身的價值。我會給你一段時間,在這段時間里,你可以采到一些草葯。
如果你是一個聰明的孩子,你應該可以讓采到的草葯的總價值最大。”
如果你是寧智賢,你能完成這個任務嗎?
輸入格式
輸入的第一行有兩個整數T(1 <= T <= 1000)和M(1 <= M <= 100),用一個空格隔開,T代表總共能夠用來采葯的時間,M代表山洞里的草葯的數目。接下來的M行每行包括兩個在1到100之間(包括1和100)的整數,分別表示采摘某株草葯的時間和這株草葯的價值。
輸出格式
輸出包括一行,這一行只包含一個整數,表示在規定的時間內,可以采到的草葯的最大總價值。
樣例數據
input
70 3
71 100
69 1
1 2
output
3
數據規模與約定
時間限制:1s
空間限制:256MB
----------------------------------------我是美美的分割線-------------------------------------------------------
這道題就是最基本的01背包,對於每件物品,我們有取和不取兩種選擇.
首先定義狀態f[i][j]以j為容量為放入前i個物品(按i從小到大的順序)的最大價值,那么i=1的時候,放入的是物品1,這時候肯定是最優的.
由此,我們推出狀態轉移方程:f[i][j] = max(f[i-1][j-w[i]])+v[i],f[i-1][j]);
其實,這道題還有一個滾動數組優化,可以優化第一維的空間。
優化后的狀態轉移方程:f[j]=max(f[j-w[i]]+v[i],f[j]);
下面是代碼:
#include<bits/stdc++.h>
using namespace std; int t,m; int w[100010],v[100010]; int f[100010]; int main() { freopen("input.in","r",stdin); freopen("output.out","w",stdout);
cin>>m>>t; for(int i=1;i<=t;i++) cin>>w[i]>>v[i]; for(int i=1;i<=m;i++) for(int j=m;j>=w[i];j--) { f[j]=max(f[j],f[j-w[i]]+v[i]); } cout<<f[m]; return 0; }
3.區間動規:
【區間動規】石子合並
題目描述
在操場上沿一直線排列着n堆石子。現要將石子有次序地合並成一堆。
規定每次只能選相鄰的兩堆石子合並成新的一堆,並將新的一堆石子數計為該次合並的得分。
我們希望這n−1 次合並后得到的得分總和最小。
輸入格式
第一行有一個正整數n(n<=300),表示石子的堆數; 第二行有n個正整數,表示每一堆石子的石子數,每兩個數之間用一個空格隔開。它們都不大於10000。
輸出格式
一行,一個整數,表示答案。
樣例數據
input
3
1 2 9
output
15
數據規模與約定
區間dp第一題
時間限制:1s
空間限制:256MB
--------------------------------------------我是華麗的分割線-----------------------------------------------------
這樣我們可以定義狀態f[i][j],表示i到j合並后的最大得分。其中1<=i<=j<=N。
既然這樣,我們就需要將這一圈石子分割。很顯然,我們需要枚舉一個k,來作為這一圈石子的分割線。
這樣我們就能得到狀態轉移方程:
f[i][j] = max(f[i][k] + f[k+1][j] + d(i,j));
其中,1<=i<=<=k<j<=N。d(i,j)表示從i到j石子個數的和。
下面是代碼:
#include<bits/stdc++.h>
#define din(a) (scanf("%d",&a));
#define dout(a) (printf("%d\n",a));
#define ll long long
using namespace std; int m,k; int n; int a[101000]; int f[1001][1001]; int sumn[1001]; int cost[1001][1001]; void work_cost()//計算合並的代價/得分.
{ for(int i=1;i<=n;i++) for(int j=i;j<=n;j++) cost[i][j]=sumn[j]-sumn[i-1]; } void init() { din(n);//初始化
memset(f,0,sizeof(f)); memset(sumn,0,sizeof(sumn)); sumn[0]=0;//計算石子總數,方便累加得分.
for(int i=1;i<=n;i++){ din(a[i]); sumn[i]=sumn[i-1]+a[i]; } work_cost(); } void work() //區間動規
{ for(int p=1;p<=n;p++) for(int i=1;i<=n;i++){ int j=i+p-1; if(j>n) break; for(int k=i;k<j;k++) if((f[i][j]>f[i][k]+f[k+1][j]+cost[i][j]||(f[i][j]==0))) f[i][j]=f[i][k]+f[k+1][j]+cost[i][j]; } } int main() { freopen("Stone.in","r",stdin); freopen("Stone.out","w",stdout); init(); work(); dout(f[1][n]); return 0; }
4.雙進程DP
構建雙塔
題目描述
2001年9月11日,一場突發的災難將紐約世界貿易中心大廈夷為平地,Mr. F曾親眼目睹了這次災難。為了紀念“911”事件,Mr. F決定自己用水晶來搭建一座雙塔。
Mr. F有N 塊水晶,每塊水晶有一個高度,他想用這N塊水晶搭建兩座有同樣高度的塔,使他們成為一座雙塔,Mr. F可以從這N塊水晶中任取M(1≤M≤N)塊來搭建。但是他不知道能否使兩座塔有同樣的高度,也不知道如果能搭建成一座雙塔,這座雙塔的最大高度是多少。所以他來請你幫忙。
給定水晶的數量NN(1≤N≤100)和每塊水晶的高度Hi(N塊水晶高度的總和不超過2000),你的任務是判斷Mr. F能否用這些水晶搭建成一座雙塔(兩座塔有同樣的高度),如果能,則輸出所能搭建的雙塔的最大高度,否則輸出“ImpossibleImpossible”。
輸入格式
輸入的第一行為一個數N,表示水晶的數量。
第二行為N個數,第i個數表示第i個水晶的高度。
輸出格式
輸出僅包含一行,如果能搭成一座雙塔,則輸出雙塔的最大高度,否則輸出一個字符串“Impossible”。
樣例數據
input
5
1 3 4 5 2
output
7
數據規模與約定
時間限制:1s
空間限制:256MB
-------------------我還是華麗的分割線-------------------------------
水晶放置在任意一座塔上都會對另一座塔產生影響,故屬於雙進程問題。
f[i][j]表示取前i塊水晶、兩塔差為j時較高塔的最大高度。
注意,這里的f[i][j]都是從上一階段推得的。我們在面對第i塊水晶時,它可能是從以下四種決策得來的:
f[i][j]=max(f[i−1][j])f[i][j]=max(f[i−1][j]) . 這塊水晶被丟掉了。
f[i][j]=max(f[i−1][j+h[i]])f[i][j]=max(f[i−1][j+h[i]]) . 這塊水晶被給了上一個狀態中較低的那座塔,且它未超過較高的塔,由圖可知較高塔的最大高度是不變的。
f[i[][j]=max(f[i−1][j−h[i]]+h[i])f[i[][j]=max(f[i−1][j−h[i]]+h[i]) .這塊水晶被給了上一個狀態中較高的塔,由圖可知,較高塔的值增加了h[i]h[i]。
當然,此時我們要保證j>h[i]j>h[i]。f[i][j]=max(f[i−1][h[i]−j]+j)f[i][j]=max(f[i−1][h[i]−j]+j) .這塊水晶被給了上一階段較低的塔,且它超過了較高塔。由圖可知,較高塔的值增加了jj。
(感謝hh大佬提供思路)。
以下為代碼:
#include<bits/stdc++.h>
using namespace std; int h[10010]; int f[1010][1010]; int n,sum=0; int main() { freopen("input.in","r",stdin); freopen("output.out","w",stdout); memset(f,-10,sizeof(f)); f[0][0]=0; scanf("%d",&n); for(int i=1;i<=n;i++) { scanf("%d",&h[i]); sum+=h[i]; } for(int i=1;i<=n;i++) for(int j=0;j<=sum;j++)//f[i][j]表示前i個水晶選擇完后,落差為j時的最優值
{ f[i][j]=max(f[i-1][j],f[i-1][j+h[i]]);//不要水晶和要水晶的最優。
if(j>=h[i]) f[i][j]=max(f[i-1][j-h[i]]+h[i],f[i][j]); else f[i][j]=max(f[i-1][h[i]-j]+j,f[i][j]);//狀態轉移
} if(f[n][0]) printf("%d\n",f[n][0]); else printf("Impossible"); return 0; }
5.樹形動規
樹形DP例題1
題目描述
給定一棵n個點的無權樹,問樹中每個節點的深度和每個子樹的大小? (以1號點為根節且深度為0)
輸入格式
第1行:n。
第2~n行:每行兩個數x,y,表示x,y之間有一條邊。
輸出格式
n行,每行輸出格式為:#節點編號 deep:深度 count:子樹節點數(詳見樣例)
樣例數據
input
7
1 2
2 3
1 4
3 5
1 6
3 7
output
#1 deep:0 count:7
#2 deep:1 count:4
#3 deep:2 count:3
#4 deep:1 count:1
#5 deep:3 count:1
#6 deep:1 count:1
#7 deep:3 count:1
數據規模與約定
15% n<=10;
40% n<=1000;
100% n<=100000;
------------------------------我又是美美的分割線-------------------------------
基本的樹形,建立一個領接表就OK了。
話不多說,直接上代碼:
#include<bits/stdc++.h>
using namespace std; int n,p; int head[1001000],size[1001000],dep[1001000]; int cnt=0; int x,y; struct node { int to,next; }e[1001000]; void add(int x,int y) { cnt++; e[cnt].to=y; e[cnt].next=head[x]; head[x]=cnt; } void dfs(int x,int fa,int depth) { size[x]=1; dep[x]=depth; for(int i=head[x];i;i=e[i].next) { int v=e[i].to; if(v==fa) continue; dfs(v,x,depth+1); size[x]+=size[v]; } } int main() { freopen("tree.in","r",stdin); freopen("tree.out","w",stdout); memset(dep,0,sizeof(dep)); scanf("%d",&n); for(int i=1;i<=n-1;i++) { scanf("%d%d",&x,&y); add(x,y); add(y,x); } dfs(1,0,0); for(int i=1;i<=n;i++) { printf("#%d deep:%d count:%d\n",i,dep[i],size[i]); } return 0; }
五.動態規划的意義:
動態規划程序設計是對解最優化問題的一種途徑、一種方法,而不是一種特殊算法。不像搜索或數值計算那樣,具有一個標准的數學表達式和明確清晰的解題方法。動態規划程序設計往往是針對一種最優化問題,由於各種問題的性質不同,確定最優解的條件也互不相同,因而動態規划的設計方法對不同的問題,有各具特色的解題方法,而不存在一種萬能的動態規划算法,可以解決各類最優化問題。因此讀者在學習時,除了要對基本概念和方法正確理解外,必須具體問題具體分析處理,以豐富的想象力去建立模型,用創造性的技巧去求解。我們也可以通過對若干有代表性的問題的動態規划算法進行分析、討論,逐漸學會並掌握這一設計方法。
好好理解動態規划吧!