首先先分析一個叫做“鋼條切割”的問題,這個問題從遞歸開始導入,然后引入帶備忘錄的自頂向下方法,最后得到自底向上的動態規划的解法,發現所有的問題都可以遵循這樣的解決方法。然后分析用遞歸方法和動態規划的方法解這類問題的一般思路。
鋼條切割問題:
問題描述,給定一個數組,表示的是出售長度為i的鋼條的價格。如p = [1, 5, 8, 9, 10, 17, 17, 20, 24, 30] 表示的是長度為1的鋼條為1美元,長度為2的鋼條為5美元,以此類推。 現在有一個鋼條長度為n,那么如何切割鋼條能夠使得收益最高,切割的時候不消耗費用。來源於算法導論15.1。
在下面的分析當中我們來這樣約定:$r_n$表示的是長度為n的鋼條的最高收益,$p_i$表示長度為i的鋼條的售價。對於一般的問題,我們這樣描述$r_n = \max (p_n, r_1+r_{n-1}, r_2 + r_{n-2},...,r_{n-1}+r_1$,若將$p_n$看做切割成為0和n兩段,那么長度為n的鋼條的最高收益為:所有可能長度為i和n-i的鋼條的最高收益和 中的最大值。這樣,把原來的鋼條切割求解最優解的問題轉化為切割成為兩段后求解最優解的子問題,子問題的最優解的和是原問題的最優解,我們說這個問題滿足最優子結構問題。因為動態規划問題往往解決的是最優化問題,所以最優子結構問題很重要。
暴力解法:長度為n的鋼條,一共有$2^{n-1}$種切法,即在每個長度為1的位置上決定切還是不切。如何編程?我發現我不會編寫暴力的解法,雖然我知道如何划分。這需要多少個循環?
遞歸方法:我們從鋼條的左邊切割下來一段長度為i的鋼條,它不再進行切割,收益為$p_i$,對后面的鋼條進行遞歸切割。所以原問題的最優解是,所有可能左邊切割結果的收益和右邊遞歸切割收益和的最大值,所以,最優解為:$r_n = \max \limits_{1 \le i \le n} (p_i,r_{n-i})$
def cut_rod(price, rod_length): """遞歸方法求解""" if rod_length == 0: return 0 profit = float('-inf') for i in range(1, rod_length + 1): profit = max(profit, price[i-1] + cut_rod(price, rod_length - i)) return profit
遞歸過程實際上是嘗試了所有的$s^{n-1}$種可能,它的算法復雜度為$2^{n}$。在分析為什么遞歸過程復雜度如此之高的過程,我們看下面這種圖:
上面這種圖反應的是切割長度為4的鋼條的情況,節點的數字表明鋼條右邊剩余的長度時的切割情況。比如3表示在左邊切1個長度,右邊剩余3個長度。在每一步都會求出來節點切割的最優解,在圖中有很多值重復的節點,而這些節點在計算的過程當中被重復計算,所以復雜度很高,動態規划基本上是保存了中間的這些值,讓復雜度變成多項式級別。
帶備忘錄的自頂向下法
對於上述問題最朴素的解決方法是引入一個記憶數組,保存每次求出來的最優解,這樣再次遇到的時候直接返回,而不是進行重復求解。
def memoized_cut_rod(price, rod_length): memoized_arr = [float('-inf')] * (rod_length+1) # 記憶數組 return memoized_cut_rod_aux(price, rod_length, memoized_arr) def memoized_cut_rod_aux(price, rod_length, memoized_arr): """遞歸求解,但是遇到保存的值直接返回""" if memoized_arr[rod_length] >= 0: return memoized_arr[rod_length] if rod_length == 0: profile = 0 else: profile = float('-inf') for i in range(1, rod_length+1): profile = max(profile, price[i-1] + memoized_cut_rod_aux(price, rod_length - i, memoized_arr)) memoized_arr[rod_length] = profile return profile
自底向上版本:
需要對問題的規模進行界定,當前長度的最優解依賴規模更小的子問題的最優解,求解當前規模最優解的時候,最小子問題的最優解已經求解完畢。
def bottom_up_cut_rod(price, rod_length): # memoized_arr[i]表示長度為i的鋼條的最優收益 memoized_arr = [float('-inf')] * (rod_length + 1) memoized_arr[0] = 0 for i in range(1, rod_length + 1): profile = float('-inf') for j in range(1, i+1): # 長度為i的鋼條的最優解為:(所有長度為j的最優解+長度為i-j鋼條最優解)中的最大值,j=0,1...,i profile = max(profile, price[j - 1] + memoized_arr[i - j]) memoized_arr[i] = profile return memoized_arr[rod_length]
帶備忘錄的自頂向下的方法和自底向上的方法時間復雜度都為$O(n^2)$。對於后者,它的結構如下所示,它將上圖當中所有需要可能重復求解的點合並成為一個點,求解頂層節點需要求出它依賴的底層節點:
重構解:
上面的解決方法給出了最優解,但是並沒有說明是如何划分的,對上面的解法進行稍微的改進可以得到划分的方法,如下:
def bottom_up_cut_rod(price, rod_length): # memoized_arr[i]表示長度為i的鋼條的最優收益 memoized_arr = [float('-inf')] * (rod_length + 1) memoized_arr[0] = 0 # cur_arr[i]表示的是長度為i的鋼條的第一段的切割方案 cut_arr = [0] * (rod_length + 1) for i in range(1, rod_length + 1): profile = float('-inf') for j in range(1, i+1): if profile < price[j-1] + memoized_arr[i-j]: profile = price[j-1] + memoized_arr[i-j] cut_arr[i] = j memoized_arr[i] = profile return memoized_arr, cut_arr def print_cut_rod_solution(price, rod_length): memoized_arr, cut_arr = bottom_up_cut_rod(price, rod_length) print('profile is: ',memoized_arr[rod_length]) print('cut solution is:', end=' ') while rod_length > 0: cut_length = cut_arr[rod_length] print(cut_length, end=' ') rod_length = rod_length - cut_length print() if __name__ == '__main__': p = [1, 5, 8, 9, 10, 17, 17, 20, 24, 30] print_cut_rod_solution(p, 9) # result """ profile is: 25 cut solution is: 3 6 """
帶切割開銷的切割方法
假設每次切割都要划分固定的成本c,那么最后的收益等於鋼條的價格減去切割的成本。
# 多新建一個數組m[0...n]記錄每個長度的鋼條最優解的切割段數, # 當完成長度為i的鋼條最優解時,更新長度為i+1時使m[i+1] = m[j] + 1,其 # 中長度為i+1的鋼條切割成長度為(i+1-j)和j的兩大段,長度為j的鋼條繼續切割。 # copy url: https://blog.csdn.net/chan15/article/details/50603255 def bottom_up_cut_rod(price, rod_length, c): # memoized_arr[i]表示長度為i的鋼條的最優收益 memoized_arr = [float('-inf')] * (rod_length+1) memoized_arr[0] = 0 # 每個長度的鋼條最優解的切割段數 rod_num_arr = [0] * (rod_length+1) for i in range(1, rod_length+1): profile = float('-inf') for j in range(1, i + 1): # 切割段數為x,那么切割次數為x-1, 現在有切割一次,現在切割次數為x,顧乘以rod_num_arr[i-j] previous_profile = price[j-1] + memoized_arr[i-j] - rod_num_arr[i-j]*c if profile < previous_profile: profile = previous_profile rod_num_arr[i] = rod_num_arr[i-j] + 1 memoized_arr[i] = profile return memoized_arr[rod_length]
動態規划問題:
事實上上面分析的過程,首先是用遞歸的方法來求解所有可能情況的暴力解法,發現暴力解法當中存在的重復計算的問題,然后增加一個記憶表,將程序修改成為自上而下的帶記憶表的遞歸過程,最后是修改成為自下而上的我們熟悉的動態規划的過程。的確動態規划的過程可以這樣引出來,
所以實際當中對於動態規划有兩種解決辦法,1:先寫出遞歸的式子,然后將遞歸的式子進行修改得到自上而下的方法,最后得到自下而上的方法。這樣能夠保障思路清晰,而且相對來說容易一些。 2:直接寫動態規划過程,可以說是在第一種方法熟練的基礎上直接進行。
遞歸解法的一般思路:
要構建一個遞歸過程,就需要很好的描述它,當你能夠很好的描述它的時候,這個遞歸問題已經很好寫了。
描述的時候要用到最優子結構:如何將這個問題轉化為相同的子問題。最優子結構是:原問題的最優解可以由相同子問題的最優解來進行構造。 這樣就可以遞歸的求解原來的問題。求解父問題的過程其實是一個選擇過程,從下面的很多問題當中可以看出,其實是在諸多的選擇問題當中選擇一個最優解。
動態規划解法的一般思路:
動態規划可以由遞歸生成,所以,能用動態規划來解的問題,一定可以用遞歸來解。
動態規划解法分為兩步:1.確定狀態,2.根據狀態列出狀態轉移方程。 什么是狀態?當我們把原問題分解為子問題的時候,那些子問題就是狀態,什么是狀態轉移方程,我們如何由子問題構造出來父問題的過程,這個過程就是狀態轉移的過程。這個過程往往是自上而下的,先定義和求解最簡單的子問題,然后一步一步向上轉移和求解。
問題分析:
下面我用一些問題來說明如何用遞歸思路和動態規划的思路來分析問題。每個問題都用遞歸和動態規划兩種思路來解。
鋼條切割
【遞歸思路】在上面鋼條切割問題當中,原問題是求解長度為n的鋼條的最優解,這個問題是在i=1到n當中,長度為i的鋼條價格+長度為n-1的鋼條的價格的最優解,這樣將原來的問題轉化為n個子問題,只需要求解這n個子問題的最大值就可以得到原問題的最優解。讓我再貼一遍代碼:
def cut_rod(price, rod_length): """遞歸方法求解""" if rod_length == 0: return 0 profit = float('-inf') for i in range(1, rod_length + 1): profit = max(profit, price[i-1] + cut_rod(price, rod_length - i)) return profit
【動態規划思路】鋼條切割問題當中的狀態,也就是子問題是:如何求解長度為i的鋼條的最優收益。長度為0的鋼條的最優收益為0,長度為1的鋼條的最優收益為它的價值本身。 狀態轉移方程:假設長度為0到i-1的鋼條的最優收益都已經有了,那么如何來求解長度為i的鋼條的最優收益,這個時候應該考慮所有可能組合的情況,而不僅僅是長度為i-1的鋼條和長度為1的鋼條等等,狀態轉移方程是在這些所有的組合當中求解最大值。所以,再貼一遍代碼:
def bottom_up_cut_rod(price, rod_length): # memoized_arr[i]表示長度為i的鋼條的最優收益 memoized_arr = [float('-inf')] * (rod_length + 1) memoized_arr[0] = 0 for i in range(1, rod_length + 1): profile = float('-inf') for j in range(1, i+1): # 長度為i的鋼條的最優解為:(所有長度為j的最優解+長度為i-j鋼條最優解)中的最大值,j=0,1...,i profile = max(profile, price[j - 1] + memoized_arr[i - j]) memoized_arr[i] = profile return memoized_arr[rod_length]
數字三角形
【問題】給定一個數字三角形,找到從頂部到底部的最小路徑和。每一步可以移動到下面一行的相鄰數字上,如下圖所示,最小的和為2+3+5+1 = 11。
【遞歸思路】父問題:求從第i行第j列開始,到底部的最小和的最優解。這個問題可以由兩個子問題的解來進行構造:1:從第i+1行,第j列(i行j列的左節點)開始到底部的最小和。 2:第i+1行,第j+1列(i行j列的右節點)開始到底部的最小和。原問題的最小和=min(子問題1的最小和,子問題2的最小和)+第i行j列的值。
def solve(arr): return process(arr, len(arr), 0, 0) def process(arr, heigth, i, j): if i == (heigth - 1): return arr[i][j] x = process(arr, heigth, i+1, j) y = process(arr, heigth, i+1, j+1) return arr[i][j] + min(x, y)
【動態規划思路】狀態:第i行,第j列的最小路徑和。對於最下面的一行數來說,它們的最小路徑和就是它們自身的數值。狀態轉移方程:知道了第i行的最小路徑和,那么可以求出來第i-1行的最小路徑和,第i-1行的最小路徑和是第i行的最小路徑和的最小值加上它們數值本身,這里需要用一個數組來存儲中間過程。
def numerical_triangle(arr): if arr is None or len(arr) == 0: return 0 dp = arr.copy() for i in range(len(arr)-2, -1, -1): for j in range(len(dp[i])): dp[i][j] = arr[i][j] + min(dp[i+1][j], dp[i+1][j+1]) return dp[0][0]
背包問題
【問題】:在n個物品,它們的重量是數組w(weight),價值是數組v(value),那么給定背包能房屋能放入物品的最大重量m,問在不超過背包容量m的情況下能夠拿到的物品的最大的價值是多少?
【遞歸思路】原問題,n個物品能裝入背包的最大值,可以由1個子問題來進行構造,即n-1個物品放入背包的最大值,那么對於當前物品,會有兩種策略,放入或者不放入。所以,n個物品放入背包的最大值=max(選擇當前物品放入背包+ 其余n-1個物品放入背包的最大值, 當前物品不放入背包+其余n-1個物品放入背包的最大值),可能會有疑問,上面的max肯定會選擇第一個,其實這兩個子問題不相等,因為它們是在考慮不同整體重量的情況下n-1個物品放入背包的最大值。
def solver(w, v, m): """ Args: w: 物品的重量 v:物品的價值 m:背包的容量 """ return process(w, v, m, 0, 0) def process(w, v, m, i, s): """ Args: arr: 背包 m: 背包能夠裝的最大重量 s: 當前背包裝的重量 i: 當前指向第幾個背包 """ if i == len(w): return 0 if s + w[i] > m: return process(w, v, m, i+1, s) else: return max(process(w, v, m, i+1, s + w[i]) + v[i], process(w, v, m, i+1, s))
【動態規划思路】背包問題應該以什么作為狀態,這在剛開始思考的時候有點難,可以參考 動態規划之01背包問題(最易理解的講解)。
背包問題的描述是這樣的:重量為m的背包,裝入n個物品的最大價值,這里的狀態是f[i,j],即前i件物品放入重量為j的背包的最大價值。 那么現在的狀態轉移方程是:$f[i,j] = \max \{ f[i-1, j-w_i] +v_i \ (j \ge w_i), f[i-1,j]\} $,這個狀態轉移方程可以描述為這樣的:前i件物品放入重量為j的背包的最大值等於 是否選擇將第i件物品放入背包的最大值。
這個狀態可以看做是兩維的,即重量m和前n個物品,因為你發現,缺少任何一維在寫狀態轉移的時候是沒有辦法寫的。
def max_bag_problem(w, v, m): item_number = len(w) # memoriezed 是 item_number * m+1 維的, 初始化后全為0, # memmoriezed[i,j] 表示的是重量為j的背包, 能夠裝前i個物品的最大重量 memorized = [[0 for i in range(m+1)] for i in range(item_number)] # 初始化背包第一行的值,表示重量為m的背包裝入第一個物品的價值,裝不下為0,能裝下為第一個物品的價值 for i in range(m+1): if i >= w[0]: memorized[0][i] = v[0] for item_index in range(1, item_number): for weight in range(1, m+1): if weight > w[item_index]: memorized[item_index][weight] = max( memorized[item_index-1][weight], v[item_index] + memorized[item_index-1][weight-w[item_index]]) else: # 如果當前物品的重量大於背包容量,那么不可能裝入當前物品,總價值和前n-1個物品價值相等 memorized[item_index][weight] = memorized[item_index-1][weight] return memorized[item_number-1][m]
公共子串
【問題】給出兩個字符串,找到最長公共子串,並返回其長度
【遞歸思路】說實話,這個問題有點難,至少對於我來說,最優解是找出兩個字符串當中最長的公共字符串,這里把問題分解為從兩個字符串開始的最長公共字符串,在這里一個問題是這樣的:原問題並沒有分解為子問題,因為子問題和父問題在這里面的描述是不一樣的,父問題是找出兩個字符串當中的最長公共字符串,沒有什么限定,而子問題必須從開始位置找。 比如‘abc’,‘cba’,子問題返回的0,只要第一個不相等那么,它就返回0。但是很顯然,對於父問題它應該返回1,所以,這里並沒有很好的體現出最優子結構。
def common_str(a, b): max_value = 0 for i in range(len(a)): for j in range(len(b)): max_value = max(max_value, helper(a, b, i, j)) return max_value def helper(a, b, i, j): if i == len(a) or j == len(b): return 0 if a[i] == b[j]: return 1 + helper(a, b, i+1, j+1) else: return 0
【動態規划思路】和LCS問題很像,但是當兩個字符串不相等的時候,不會進行后續的操作,只有當連續的字符串出現的時候才會不斷的增加。
def common_str(str1, str2): if len(str1) == 0 or len(str2) == 0: return 0 m = len(str1) n = len(str2) max_length = 0 # memorized[i][j]表示的是第二個字符串到j位置和第一個字符串到第i位置的公共子串 memorized = [[0 for i in range(n+1)] for j in range(m+1)] for i in range(1, m+1): for j in range(1, n+1): if str1[i-1] == str2[j-1]: memorized[i][j] = memorized[i-1][j-1] + 1 max_length = max(max_length, memorized[i][j]) return max_length
公共子序列
【問題】給出兩個字符串,找到最長公共子序列(LCS),返回LCS的長度。
【遞歸思路】相對於公共字符串問題,這個解決方法就可以用遞歸了。 要找兩個字符串的LCS,那么子問題是尋找從0到i位置和從0到j位置兩個字符串的LCS。那么原問題可以分為兩種選擇:如果原來字符串i位置和j位置相等,那么LCS加1,繼續向后尋找。如果不相等,那么比較后面i+1和j以及i和j+1兩種情況的最大值。
def lcs(a, b): return process(a, b, 0, 0) def process(a, b, i, j): if i == len(a) or j == len(b): return 0 if a[i] == b[j]: return 1 + process(a, b, i+1, j+1) else: return max(process(a, b, i+1, j), process(a, b, i, j+1))
【動態規划思路】我們用C[i,j]來表示狀態,表示的是第一個字符串到i位置和第二個字符串到j位置的lcs。那么狀態轉移方程為:$$C[i,j]= \begin{cases} 0, & 當 i=0或j=0 \\ C[i-1,j-1]+1, &當i,j>0 且x_i=y_j \\ MAX(C[i,j-1],C[i-1,j]) &當i,j>0且x_i≠y_j \end{cases} $$
def lcs(str1, str2): if len(str1) == 0 or len(str2) == 0: return 0 m = len(str1) n = len(str2) # memorized[i][j]表示的是第二個字符串到j位置和第一個字符串到第i位置的lcs memorized = [[0 for i in range(n+1)] for j in range(m+1)] for i in range(1, m+1): for j in range(1, n+1): if str1[i-1] == str2[j-1]: memorized[i][j] = memorized[i-1][j-1] + 1 else: memorized[i][j] = max(memorized[i-1][j], memorized[i][j-1]) return memorized[-1][-1]
以’cnblogs’和’belongs‘為例 最長公共子序列和最長公共字符串的memorized值分別為
打劫房屋問題
【問題】假設你是一個專業的竊賊,准備沿着一條街打劫房屋。每個房子都存放着特定金額的錢。你面臨的唯一約束條件是:相鄰的房子裝着相互聯系的防盜系統,且 當相鄰的兩個房子同一天被打劫時,該系統會自動報警。
給定一個非負整數列表,表示每個房子中存放的錢, 算一算,如果今晚去打劫,你最多可以得到多少錢 在不觸動報警裝置的情況下。
【遞歸思路】問題可以描述為,搶劫所有的房屋,使得搶劫的錢最多。 那么,這個問題的最優解,可以由子問題的最優解構造:除了當前房屋以外,搶劫剩余房屋獲得的最多的錢,那么對於當前房屋會有兩種策略,搶劫或者不搶劫。 所以,搶劫所有房屋獲得的做多的錢 = max(搶劫當下房屋的錢+搶劫剩余n-2個房屋獲得的最多的錢, 搶劫剩余n-1個房屋獲得的錢)
遞歸問題可以描述為:從0位置開始到結束位置打劫的最高收益,它等於打劫當前房屋+從下下個位置開始打劫的最高收益 或者 從下個位置開始打劫的最高收益的 兩者的最大值。
class solution(object): def rob(self, nums): return self.search(len(nums) - 1, nums) def search(self, i, nums): if i < 0: return 0 return max(self.search(i - 1, nums), self.search(i - 2, nums) + nums[i])
【動態規划思路】狀態是打劫前n個房屋的最大收益f(n),狀態轉移方程式:f(n) = max{f(n-1), A[n]+f(n-2)}。 即打劫前n個房屋的最大收益是是否選擇打劫當前房屋這個決定產生的兩個結果的最大值。
def rob_house(nums): if nums is None or len(nums) == 0: return 0 house_number = len(nums) memorized = [0] * (house_number) memorized[0] = nums[0] memorized[1] = max(memorized[0], nums[1]) for i in range(2, house_number): memorized[i] = max(memorized[i-1], nums[i]+memorized[i-2]) return memorized[-1]
遞歸和動態規划的總結:
縱觀上面所有的問題,可以發現,在用遞歸問題解決問題的時候,實際上運用了最優子結構,要求原問題的最優解,就要求子問題的最優解,由子問題的最優解構造出來原問題的最優解。在構造的過程當中,其實是在多鍾可能的結果當中選擇最優的那一個。
動態規划問題,建立在遞歸的基礎之上,它解決了子問題重疊的問題,所以相比於遞歸算法,它的好處有1:復雜度明顯降低,它的時間復雜度是多項式級別的,而遞歸的復雜度是指數級別的。在LCS問題當中,假如兩個字符串的長度都大於10,那么遞歸方法可能用上10分鍾左右,而動態規划方法時間是毫秒和微妙級別的。2:遞推算法可以保存中間結果的值,不僅可以得到我們要的值,而且可以分析這些值的組成方式。
動態規划和分治法的異同點
相同點:都是將問題划分為子問題,子問題有最優子結構。
不同點:分治法的子問題獨立,動態規划的子問題重疊。(雖然是這樣描述的,倒不如說是,它們求解的時候讓子問題重疊或者不重疊。分治法可以求解子問題重疊的問題,只不過求解的時候還是對重疊的部分進行了重復計算,只不過子問題重疊的問題一般用動態規划來解,所以才說動態規划的子問題重疊)
參考:
一道題看清動態規划的前世今生 ( 一 ) 搶劫房屋問題, 用三種方法求解
一道題看清動態規划的前世今生 ( 二 ) 背包問題2, 用三種方法求解
【算法】動態規划問題集錦與講解 很多動態規划的問題,都是直接使用動態規划方法來求解,java代碼實現。
動態規划:從新手到專家 主要講解了動態規划的狀態,和狀態轉移方程