数据结构与算法、讲解、动态规划一脸懵?看完之后轻松掌握!
來源 | 昊天碼字
責編 | Carol
封圖?| CSDN 付費下載于視覺中國
碰到動態規劃問題摸不著頭腦?總結不出動態規劃的類型?有多少人曾經歷過這種迷茫與無助?看完本文,讓你一腳邁進動態規劃的大門。
我們在用遞歸求解問題的過程中,可能會存在將遞歸的某一環節計算多次的現象,因為之前的遞歸結果無法有效存儲,下次碰到一樣的值還需要重新計算一次,下面引出一個例子:
相信大家都知道斐波那契數列,那我們由斐波那契數列遞歸求解過程中帶來的問題來引出動態規劃問題。
斐波那契數列
斐波那契數列的規律為:當n > 2時,fib(n)=fib(n-1)+fib(n-2),當n為1或2時,fib(n)=1。假如我們讓電腦代入這個遞歸式來求fib(5),則求解過程如下圖:
求fib(5)
我們可以看到,如果我們按照如上的遞歸式來求一個斐波那契數,那么當n很大時,這棵樹會非常龐大,且從中我們也可看出,電腦會非常傻的把fib(3)算了兩遍,這種問題叫做重疊子問題,所以這個算法的時間復雜度會達到O(2^n),但實際上我們沒有必要把fib(3)算兩遍,我們在算第一遍時把它保存起來,在下次需要時直接調用就可以了。
因此我們在計算fib(5)時,完全可以從fib(1)開始計算,從1一直算到5,并不斷保存,在必要時調用之前保存的值,而不需要重新運算,這樣算法的時間復雜度瞬間就降到O(n)了。
所以我們在做動態規劃時,首先要用到的就是這個思想,可以看出,動態規劃的特點之一就是開辟內存保存數值,那么我們可以根據開辟內存的方式分為一維動態規劃問題和二維動態規劃問題,下面我們來具體介紹。
第一類問題(一維)
1.求薪水問題
我們來看下面的圖,從上到下依次為8種工作,橫軸代表工作所占的時間段,紅色字體代表每份工作的薪水,兩份同一時間的工作不能同時做,我們求的問題是一個員工怎樣在0到11的時間段內獲取最大的薪水。
我們首先思考這道題的本質,就是選不重合的工作,求能最多得到的薪水,我們先建立一個數組workvalue來保存每份工作的薪水。
我們先按照開辟內存的思想來做這道題,之后我會說明為什么要開辟內存。由上文的動態規劃規律可知,我們可以依次從只做前一個工作能得到的最大薪水、只做前二個工作能得到的最大薪水、只做前三個工作能得到的最大薪水來思考,并建立一個value數組來保存它們,假如我們想求前6個工作中選哪些工作才能賺的最多,那么我們可以有兩個選擇:做或者不做這份工作,第一種,假如我們選擇第6份工作,我們先找到最近的一個與第6份工作時間不重合的工作(第2份工作),那最大薪水為做第6份工作得到的錢加上做前2個工作能得到的最大的薪水(本題就是第6份工作的薪水3+value[2]);第二種,我們可以選擇不做第6份工作,那么最大的薪水就是做前5個工作得到的最大的薪水。因此:
value[6]=max(3+value[2],value[5]);
我們可以看出,如果我們不開辟數組,用遞歸的思想接著求value[2],那么不可避免地計算了重復子問題,因此我們選擇開辟數組,從前往后用動態規劃的思想做。此外,我們還應設立pre數組來存每個工作的前一個與之不重合的工作,例如pre[6]=2。因此我們可以寫出如下代碼來解決上述問題:
#include <iostream> using namespace std; int value[10]; int workvalue[8] = {5, 1, 8, 4, 6, 3, 2, 4}; int pre[8] = {0, 0, 0, 1, 0, 2, 3, 5}; int main() {value[0] = 0;value[1] = 5;value[2] = 5;for (int i = 3; i <= 8; i++) {value[i] = max(value[i - 1], value[pre[i - 1]] + workvalue[i - 1]);}cout << value[8];return 0; }求出結果等于13,即最多的薪水。那么這道問題解決,鑒于如果此題換個衣服大家可能會不認識的現象,我們接下來再搞一道同類型不同描述的問題。
2.求最大數
我們可以在如下arr數組中的數字中可以任意選數字,但是不能選相鄰的數字,輸出我們選的數字中和最大的值。
arr數組
我們可以一眼看出,此題同樣是個選擇與否的問題,不能選相鄰的數就如求薪水問題中的不能同時做兩份工作,我們先來試試遞歸,用遞歸從后往前來做,我們設opt(n)是前n個數中選擇的數相加最大的值,那么當我們考慮opt(n)時,我們有兩種選擇:選或者不選數組n位置所在的數,如果選擇n,那它的值就為arr[n]+opt[n-2],如果不選擇n,那它的值就為opt[n-1]。而opt(n)就是兩種選擇中的最大值。假如我們求opt(6),那么就如下圖所示:
遞歸思路
那遞歸出口就是opt(1)=arr[1],opt(2)=max(arr[2],opt(1)),因此我們可以得出如下代碼(在此代碼中由于數組從0開始,因此在相應的地方-1):
#include <iostream> using namespace std; int arr[7] = {1, 2, 4, 1, 7, 8, 3};int rec(int arr[], int i) {if (i == 0) return arr[0];else if (i == 1) return max(arr[0], arr[1]);else {int A = rec(arr, i - 2) + arr[i];int B = rec(arr, i - 1);return max(A, B);} } int main() {cout << rec(arr, 6); }毫無疑問我們也計算了重疊子問題,由上面的動態規劃可知,我們可以通過創建數組來保存相應的值,以此減少不必要的計算,因此,動態規劃的代碼如下:
#include <iostream> using namespace std; int arr[7] = {1, 2, 4, 1, 7, 8, 3}; int opt[7];int rec(int arr[], int j, int opt[]) {opt[0] = arr[0];opt[1] = max(arr[0], arr[1]);for (int i = 2; i < 7; i++) {int A = opt[i - 2] + arr[i];int B = opt[i - 1];opt[i] = max(A, B);}return opt[j]; } int main() {cout << rec(arr, 6, opt); }好,我們來思考,開辟一維內存的原因是其中只能找到一個變量,如前n個工作的最大薪水、前n個數的最大值,那當我們存在多個變量時又該如何求解呢?所以我們引出二維動態規劃。
第二類問題(二維)
1.選取數字
給定一個數,我們是否可以在如下數組中找到任意個數,使選中的所有數值之和等于給定的數,如果可以找到,則返回true,如果不可以找到,則返回false。
arr數組
首先我們先用遞歸的思想來看這道題,假如給定一個數9,我們怎樣寫出一個算法來讓計算機判斷數組中是否有任意個數之和為9呢?還是老套路,我們創建一個遞歸函數,傳給它數組、數組的最后一個下標(此題為5)、目標值。我們從數組中第六個元素開始看,我們有兩種選擇:第一種,我們選擇這個元素,如果選擇的話,那么我們接下來調用遞歸函數,它的實參就是數組、此下標數減1、目標數減此下標的值(因為我們選了這個下標上的值,所以應該把值減去);第二種,我們不選擇這個數,那么我們接下來調用遞歸函數,它的實參就是數組、此下標數減1、目標數。如果這兩種選擇有一種能夠成功找出結果,那么就返回true,如果兩種選擇都不滿足,那么則返回false即可。
接下來我們要思考遞歸的中止條件:
1.當目標值被減為0時,直接返回true
2.當下標數為0時,如果下標0對應的數組值不等于此時的目標值,則返回false
3.當選擇此下標值時,如果此下標對應的數組值大于目標值,則不能做此選擇
好,既然確定了終止條件,那么我們來看代碼:
#include <iostream> using namespace std;int arr[6] = {3, 34, 4, 12, 5, 2};int rec_subset(int arr[], int i, int s) {if (s == 0)return true;else if (i == 0)return arr[0] == s;else if (arr[i] > s)return rec_subset(arr, i - 1, s);else {bool A = rec_subset(arr, i - 1, s - arr[i]);bool B = rec_subset(arr, i - 1, s);return A or B;} } int main() {cout << rec_subset(arr, 5, 13); }由于此遞歸重復了大量重復子問題,接下來我們要思考的是,怎樣用動態規劃的方式從前往后做這道題?
首先我們判斷此動態規劃為二維問題,因為有兩個東西可以作為二維數組的兩個下標:目標值、arr數組中的元素的下標。因為我們從剛才的遞歸可以看出目標值從后往前是不斷遞減的,因此我們就可以從0開始往后遞增,arr數組中的元素的個數也可以依次增加,所以作為二維數組的另一個下標。因為我們返回的是布爾值,所以我們需要建立二維布爾數組如下:
初始的二維數組
這就是我們要保存每個數據的二維數組,第一行代表的是目標值從1到9,列代表的是下標數,每個格子代表用當前的元素可否能找出數據使其和等于目標值,那怎么樣填此二維數組呢?
現在我們來思考一下我們上面所想到的終止條件:
1.當目標值被減為0時,直接返回true,所以第一列都為true
2.當下標數為0時,如果下標0對應的數組值不等于此時的目標值,則返回false,因此第一行除了3等于第0個元素對應的數組數據為true外,其余都為false
因此我們可以把此表改成如下:
改過后的二維數組
我們用T代表true,用F代表false,其中第0行第0列是T或者F都可以。
接下來我們就可以給二維數組里面的其他元素賦值了,比如第i行j列,我們先判斷i下標對應的數組元素值是否大于j(目標元素)。
如果不大于,那么就可以分為兩種情況:第一種,其值等于行為i-1,列為j-arr[i]的值(代表選了此下標對應的數);第二種行為i-1,列為j(代表沒有選此下標對應的數)。當兩種情況有一種以上為T時,第i行j列的值就為T,否則就為F。
如果大于,意為我們不能選擇此時對應的元素值,因為如果選擇的話,目標值就變為負數了,所以我們僅有一種選擇,那么第i行j列的值就等于i-1行j列的值。
以此類推,填滿此二維數組,最終的值為二維數組最右下角的值。我們來看代碼:
#include <iostream> using namespace std;int arr[6] = {3, 34, 4, 12, 5, 2};bool dpsubset(int arr[], int s) {bool subset[6][s + 1];for (auto j : subset) {j[0] = true;}for (auto &j : subset[0]) {j = false;}subset[0][arr[0]] = true;for (int h = 1; h < 6; h++) {for (int k = 1; k < s + 1; k++) {if (arr[h] > k)subset[h][k] = subset[h - 1][k];else {bool A = subset[h - 1][k];bool B = subset[h - 1][k - arr[h]];subset[h][k] = A or B;}}}return subset[5][s]; }int main() {cout << dpsubset(arr, 13); }由此問題引出另外一個問題,我們怎樣求得我們選擇的是哪些數呢?別急,看了下面的題目,我們就會解決了。
2.背包回溯
現在有四個物品,背包的總容量為8,背包最多能裝入價值為多少的物品?都裝了哪些物品?
問題描述
毫無疑問,這是二維動態規劃,怎么判斷的呢?因為它有兩個東西可以作為二維數組的兩個下標,一個是物品編號,一個是背包容量,所以我們創建二維數組如下:
二維數組
其中第一行代表背包容量,第一列代表物品個數,每個格子為在當前背包容量的情況下考慮當前的物品所能裝下的最大價值是多少。
現在我們來填格子,首先第二行全部為0,因為此時沒有物品;其次第二列為0,因為此時背包容量為0。現在我們來填其余表格,在填之前,我們先考慮當前格子對應的物品是否能裝入背包。
如果能裝下當前物品,那么分兩種情況:第一種為裝入背包,獲得此物品的價值,然后減去它所占的空間,找對應剩余空間容量、物品數減一對應的最大價值(就是找之前的格子),然后兩個價值相加;第二種為不裝,那么當前最大價值等于對應空間容量不變、物品數量減一的最大價值(就是此格子頭頂的格子)。最后此格子的值等于兩種情況中最大的值。
如果不能裝入背包,那么當前最大價值等于對應空間容量不變、物品數量減一的最大價值(就是此格子頭頂的格子),最后此格子的值等于此格子頭頂上的格子的值。
在我們填滿二維數組后,來考慮一下新的問題:回溯,如何找到背包放的物品編號?
我們先從二維數組最右下角的格子找,如果它的值等于它頭頂上的格子,則證明它沒裝此時對應的物品,否則就是裝了此物品,這個格子的值等于這個格子的兩個下標分別減一和此時放入背包的物品的所占空間的格子對應的值加上這個格子對應的物品價值,開辟一個數組,這樣回溯,如果找到放入的物品,則在此數組對應的位置上設為1,沒放入此物品則在數組對應的位置上設為0,最后輸出數組即可,大家看代碼:
/* i物品編號 1 2 3 4 w體積 2 3 4 5 v價值 3 4 5 6 */ #include <iostream> using namespace std; int weight[5] = {0, 2, 3, 4, 5}; int value[5] = {0, 3, 4, 5, 6}; int dp[5][9]; int object[5];void Dynamic() {for (int i = 1; i < 5; i++) {for (int j = 1; j < 9; j++) {if (weight[i] > j)dp[i][j] = dp[i - 1][j];elsedp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);}} }void Find(int i, int j) {if (i == 0) {for (int i = 0; i < 5; i++)cout << object[i] << " ";return;}if (dp[i][j] == dp[i - 1][j]) {object[i] = 0;Find(i - 1, j);} else if (dp[i][j] == dp[i - 1][j - weight[i]] + value[i]) {object[i] = 1;Find(i - 1, j - weight[i]);} } int main() {Dynamic();cout << dp[4][8];Find(4, 8); }延伸
如果你看到這里,并且認真看完上述文字后,恭喜你初步掌握了動態規劃的常見問題,最好把上述代碼反復敲幾遍,第一遍照著敲,第二表憑借理解單獨敲。馬化騰曾說過一句話,給我感觸很大,“用最笨的方式去領悟編程,用抄代碼的方式來培養感覺”。
此外大家就可以不斷在各個平臺上刷題來提升自己的感覺,一起加油!
?
推薦閱讀
手把手教你配置VS Code 遠程開發工具,工作效率提升N倍
用大白話徹底搞懂 HBase RowKey 詳細設計
后端程序員必備:書寫高質量SQL的30條建議
Go 遠超 Python,機器學習人才極度稀缺,全球 16,655 位程序員告訴你這些真相!
任正非談“狼文化”:華為沒有 996,更沒有 007
區塊鏈必讀“上鏈”哲學:“胖鏈下”與“瘦鏈上”
在商業中,如何與人工智能建立共生關系?
真香,朕在看了!
總結
以上是生活随笔為你收集整理的数据结构与算法、讲解、动态规划一脸懵?看完之后轻松掌握!的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 对话阿里云叔同:释放云价值,让容器成为“
- 下一篇: Facebook陷入史上最大危机;华为5