【以下內容僅為本人在學習中的所感所想,本人水平有限目前尚處學習階段,如有錯誤及不妥之處還請各位大佬指正,請諒解,謝謝!】
引言
通過目前參與的各類比賽和網友的面經,不難發現動態規劃一直是各類競賽和麵試中的高頻和難點,但其最優化的思想在生產生活中的各大領域都具有許多作用。撰寫這篇隨筆的目的既是為了自我學習的總結,也是為了能得到更多的幫助與見解,從而提高自己。在此,我將以自己的想法敘述我解決動規的過程。
動規簡述
動態規劃本質是記憶化搜尋,即記錄之前行為所產生的結果,這也是其比純搜尋演算法更快的原因。
舉個例子:計算斐波那契數列(f[n] = f[n – 1] + f[n – 2]),當我們要計算f[8]時,會使用之前已經算過的f[6]與f[7]直接相加得到答案,而不是再從f[0]開始從頭計算一遍每個值,這樣當資料量很龐大時就可以節省很多時間。
動規特徵
1. 重疊子問題:每一步,在操作上都具有明顯的相同相似性。
2. 最優子結構:最終問題的最優解基於每一步的最優解而得出。
解體步驟
- 大化小;將完整的問題劃分到每一步
- 分情況定變數,將每一步可做的選擇列出;一般用陣列值代表最終問題的答案,陣列每個索引表示影響到最終答案的變數,類似於自變數與因變數。
- 推方程;在每一個選擇的基礎上推出當前一步與上一步(或前幾步)間的關係
- 定邊界;預處理邊界,給定初始值
步驟一:大化小
官方稱法為劃分子問題。一般地,我們將每一次操作視為一個子問題,既然滿足重疊子結構,那麼針對每一次操作,處理方式理應是相同或相似的;在最優子結構的性質下,只要得出每一次操作的最優解,就能遞推出最終問題的最優解。
如:
(1)01揹包
題意:有N件物品,一個容量為V的揹包,其中第i件物品價值為w[i],所佔空間為v[i],如何選擇物品使得其不超過容量的同時價值最大。
劃分:最終問題是如何選擇,最終選擇的結果來源於每一次選擇,其關鍵在於每次選擇哪一個物品,那麼就把每次選擇視為一個子問題。並且每次的選擇總是以最小空間換最大價值的最優方式進行,即針對每次選擇,處理方式相同。
(2)買賣股票
題意:在每一天,你可以決定是否購買或出售股票。你在任何時候最多隻能持有一股股票。你也可以當天購買,然後在當天出售,求最大利潤。
劃分:最終問題是如何讓利潤最大,最終利潤來源於每天的利潤,其關鍵在於如何在每一天選擇(買、賣、不買不賣)讓利潤最大,那麼就把每天的操作視為一個子問題。並且每次選擇總以最大利潤為基礎進行操作,每次選擇處理方式相同。
來源:力扣(LeetCode)
連結:https://leetcode-cn.com/problems/best-time-to-buy-and-sell-stock-ii/
(3)最長迴文子串
題意:給定一個字串s,找到s中最長的迴文子串
劃分:最終問題是如何讓迴文子串最長,最終的迴文串最長基於迴文串的字迴文串最長,問題的關鍵在於如何判斷某子串是不是迴文串,那麼就把每次判斷視為一個子問題。並且每次判斷總是記錄最大回文子串,每次處理方式相同。
來源:力扣(LeetCode)
連結:https://leetcode-cn.com/problems/longest-palindromic-substring/
(4)編輯距離
題意:給定兩個單詞word1和word2,請返回將word1轉換成word2所使用的最少運算元。可以對一個單詞進行如下三種操作:插入、刪除、替換一個字元。
劃分:最終問題為求最少運算元,最終的運算元基於每次字元的運算元,其關鍵在於兩個字串中當前所選定的兩個字元是否相同,那麼就把每一次判斷視為一個子問題。並且每次判斷總是選擇最少的操作次數進行執行,每次處理方式相同。
來源:力扣(LeetCode)
連結:https://leetcode-cn.com/problems/edit-distance
步驟二:分情況,定變數
針對劃分出的結果,對每一次操作(選擇或判斷等操作)列出所有可能的情況。
如:
(1)01揹包
劃分:最終問題是如何選擇,最終選擇的結果來源於每一次選擇,其關鍵在於每次選擇哪一個物品,那麼就把每次選擇視為一個子問題。並且每次的選擇總是以最小空間換最大價值的最優方式進行,即針對每次選擇,處理方式相同。
定變數:我們需要記錄所選擇的物品數,以及當前所用的空間,存在兩個變數故需要一個二維陣列,陣列值表最終問題的答案即最大價值,每個維度的索引代表一個變數,用f[i][v]表示,選擇了前i件物品,且在所佔空間為v(不超過v)的情況下所產生的最大價值。
(2)買賣股票
劃分:最終問題是如何讓利潤最大,最終利潤來源於每天的利潤,其關鍵在於如何在每一天選擇(買、賣、不買不賣)讓利潤最大,那麼就把每天的操作視為一個子問題。並且每次選擇總以最大利潤為基礎進行操作,每次選擇處理方式相同。
定變數:由於每天最多隻能持有一股,所以我們需要記錄當前持有股票的狀態即持股或不持股,以及當前所過的天數,陣列值代表最終問題的答案即最大利潤,每個維度的索引代表一個變數,用f[i][flag]表示,從0到i天所獲得的最大利潤。由於持股狀態需要繼續分情況,所以會有兩個陣列f[i][0],f[i][1]分別表示從0到i天,當前持股/不持股時,所獲得的最大利潤。
(3)最長迴文子串
劃分:最終問題是如何讓迴文子串最長,最終的迴文串最長基於迴文串的字迴文串最長,問題的關鍵在於如何判斷某子串是不是迴文串,那麼就把每次判斷視為一個子問題。並且每次判斷總是記錄最大回文子串,每次處理方式相同。
定變數:對於一個子串而言,如果它是迴文串,並且長度大於2,那麼將它首尾的兩個字母去除之後,它仍然是個迴文串。所以兩個變數首字元與尾字元會影響結果。我們已經知道了某字串是否為迴文串,那麼只需要記錄首尾字元是否相同,利用拼接法(將首尾字元拼接到上面提到的某字串上)進行判斷,用f[i][j]表示從i到j的字串是否為迴文串,判斷首位i與末尾j是否相同即可。
(4)編輯距離
劃分:最終問題為求最少運算元,最終的運算元基於每次字元的運算元,其關鍵在於兩個字串中當前所選定的兩個字元是否相同,那麼就把每一次判斷視為一個子問題。並且每次判斷總是選擇最少的操作次數進行執行,每次處理方式相同。
定變數:判斷當前的操作方式,首先需要判斷當前兩個字元是否相同,相同則不操作,不同則選擇操作次數最少的一種方式。所以兩個變數i與j會影響結果,我們需要記錄從word1的0到i位轉化為從word2的0到j位所需要的最少操作次數,判斷當前兩個位置上的字元是否相同即可。用f[i][j]表示從word1的0到i位變為word2的0到j位所需要額最少操作次數。
步驟三:推方程
列出所有情況後,針對每種情況,推匯出相應的狀態轉移方程,即如何得出在本次操作執行完後的當前最優解。
(1)01揹包
劃分:最終問題是如何選擇,最終選擇的結果來源於每一次選擇,其關鍵在於每次選擇哪一個物品,那麼就把每次選擇視為一個子問題。並且每次的選擇總是以最小空間換最大價值的最優方式進行,即針對每次選擇,處理方式相同。
(2)買賣股票
劃分:最終問題是如何讓利潤最大,最終利潤來源於每天的利潤,其關鍵在於如何在每一天選擇(買、賣、不買不賣)讓利潤最大,那麼就把每天的操作視為一個子問題。並且每次選擇總以最大利潤為基礎進行操作,每次選擇處理方式相同。
(3)最長迴文子串
劃分:最終問題是如何讓迴文子串最長,最終的迴文串最長基於迴文串的字迴文串最長,問題的關鍵在於如何判斷某子串是不是迴文串,那麼就把每次判斷視為一個子問題。並且每次判斷總是記錄最大回文子串,每次處理方式相同。
(4)編輯距離
劃分:最終問題為求最少運算元,最終的運算元基於每次字元的運算元,其關鍵在於兩個字串中當前所選定的兩個字元是否相同,那麼就把每一次判斷視為一個子問題。並且每次判斷總是選擇最少的操作次數進行執行,每次處理方式相同。
步驟四:定邊界
陣列索引從0開始,在上述遞推式中發現如果索引變數從0開始,會出現負索引的情況,所以我們需要對無法計算到的值進行預處理,直接賦上相應的結果。
如:
(1)01揹包
注意到迴圈遍歷從1開始,則需要對索引為0時的結果進行預處理f[0][v] = 0;
(2) 買賣股票
同理:f[0][0] = 0,f[0][1] = -prices[0];
(3) 最長迴文子串
某些特殊情況下可能涉及到初始化一個序列,本題中單個字元一定是迴文串,所以將所有的單個字元的結果全部初始化f[i][i] = true,f[j][j]= true;
(4)編輯長度
本題中當索引為0時,無法進行訪問計算,所以需要對索引為0的所有情況進行初始化f[i][0] = i,f[0][j] = j;
一般地,初始化邊界時可能只需要對某幾個量進行初始化賦值,也可能對某一序列進行初始化賦值,視情況而定。
總結
個人認為,動態規劃的關鍵在於對每一步進行情況劃分(即步驟二)。有些時候我們覺得動態規劃難,並不是因為推導狀態轉移方程難,更多的是我們沒能將每一種情況劃分清楚,不知道每一步有多少種可能的選擇,從而導致了我們無法準確推匯出方程。動態規劃確實比較難掌握,需要我們一步一步去積累,學習是艱苦的也是快樂的,腳踏實地相信我們終能成為理性中的我們,讓我們一起努力!
【感謝您可以抽出時間閱讀到這裡,第一篇部落格可能會有許多不妥之處;受限於水平,許多地方可能存在錯誤,還請各位大佬留言指正,請見諒,謝謝!】
#附文中所提到的4個題目的程式碼
(1)01揹包
#include<bits/stdc++.h> using namespace std; int f[1024][1024], v[1024],w[1024],n,k; int main() { cin>>n>>k; for(int i=1;i<=n;i++) cin>>v[i]>>w[i]; for(int i=1;i<=n;i++) { for(int j=0;j<=k;j++) { f[i][j]=f[i-1][j]; if(j>=v[i]) f[i][j]=max(f[i][j],f[i-1][j-v[i]]+w[i]); } } int ans=-1; for(int i=0;i<=k;i++) ans=max(ans,f[n][i]); cout<<ans; return 0; }
(2)買賣股票
public class Solution { public int MaxProfit(int[] prices) { int[,] profit = new int[prices.Length, 2]; profit[0, 1] = -prices[0]; profit[0, 0] = 0; for(int i = 1; i < prices.Length; i++) { profit[i, 1] = Math.Max(profit[i - 1, 1], profit[i - 1, 0] - prices[i]); profit[i, 0] = Math.Max(profit[i - 1, 0], profit[i - 1, 1] + prices[i]); } return profit[prices.Length - 1, 0]; } }
(3)最長迴文子串
public class Solution { public string LongestPalindrome(string s) { int len = s.Length, begin = 0, maxLen = 1; if(len < 2) return s; bool[,] dp = new bool[len, len];//[i, j]是否為迴文串 for(int i = 0; i < len; i++) dp[i, i] = true; for(int L = 2; L <= len; L++) { for(int i = 0; i < len; i++)//L = j - i + 1; { int j = L + i - 1; if(j >= len) break; if(s[i] != s[j]) dp[i, j] = false; else { if(j - i < 3) dp[i, j] = true; else dp[i, j] = dp[i + 1, j - 1]; } if(dp[i, j] && j - i + 1 > maxLen) { maxLen = j - i + 1; begin = i; } } } return s.Substring(begin, maxLen); } }
(4)編輯距離
public class Solution { public int MinDistance(string word1, string word2) { int n = word1.Length, m = word2.Length; int[,] cost = new int[n + 1, m + 1]; for(int i = 0; i <= n; i++) cost[i, 0] = i; for(int j = 0; j <= m; j++) cost[0, j] = j; for(int i = 1; i <= n; i++) for(int j = 1; j <= m; j++) { if(word1[i - 1] == word2[j - 1]) cost[i, j] = cost[i - 1, j - 1]; else cost[i, j] = Math.Min(cost[i - 1, j - 1], Math.Min(cost[i, j - 1], cost[i - 1, j])) + 1; } return cost[n, m]; } }