常用的算法設計思想主要有動態規劃、貪婪法、隨機化算法、回溯法等等,這些思想有重疊的部分,當面對一個問題的時候,從這幾個思路入手往往都能得到一個還不錯的答案。
本來想把動態規劃單獨拿出來寫三篇文章呢,后來發現自己學疏才淺,實在是只能講一些皮毛,更深入的東西嘗試構思了幾次,也沒有什么進展,打算每種設計思想就寫一篇吧。
動態規劃(Dynamic Programming)是一種非常有用的用來解決復雜問題的算法,它通過把復雜問題分解為簡單的子問題的方式來獲得最優解。
一、自頂向下和自底向上
總體上來說,我們可以把動態規劃的解法分為自頂向下和自底向上兩種方式。
一個問題如果可以使用動態規劃來解決,那么它必須具有“最優子結構”,簡單來說就是,如果該問題可以被分解為多個子問題,并且這些子問題有最優解,那這個問題才可以使用動態規劃。
- 自頂向下(Top-Down)
自頂向下的方式其實就是使用遞歸來求解子問題,最終解只需要調用遞歸式,子問題逐步往下層遞歸的求解。我們可以使用緩存把每次求解出來的子問題緩存起來,下次調用的時候就不必再遞歸計算了。
舉例著名的斐波那契數列的計算:
#!/usr/bin/env python
# coding:utf-8
def fib(number):
if number == 0 or number == 1:
return 1
else:
return fib(number - 1) + fib(number - 2)
if __name__ == '__main__':
print fib(35)
有一點開發經驗的人就能看出,fib(number-1)和fib(number-2)會導致我們產生大量的重復計算,以上程序執行了14s才出結果,現在,我們把每次計算出來的結果保存下來,下一次需要計算的時候直接取緩存,看看結果:
#!/usr/bin/env python
# coding:utf-8
cache = {}
def fib(number):
if number in cache:
return cache[number]
if number == 0 or number == 1:
return 1
else:
cache[number] = fib(number - 1) + fib(number - 2)
return cache[number]
if __name__ == '__main__':
print fib(35)
耗費時間為 0m0.053s 效果提升非常明顯。
- 自底向上(Bottom-Up)
自底向上是另一種求解動態規劃問題的方法,它不使用遞歸式,而是直接使用循環來計算所有可能的結果,往上層逐漸累加子問題的解。
我們在求解子問題的最優解的同時,也相當于是在求解整個問題的最優解。其中最難的部分是找到求解最終問題的遞歸關系式,或者說狀態轉移方程。
這里舉一個01背包問題的例子:
你現在想買一大堆算法書,需要很多錢,所以你打算去搶一個商店,這個商店一共有n個商品。問題在于,你只能最多拿 W kg 的東西。Wi和Vi
分別表示第i個商品的重量和價值。我們的目標就是在能拿的下的情況下,獲得最大價值,求解哪些物品可以放進背包。對于每一個商品你有兩個選擇:拿或者不拿。
首先我們要做的就是要找到“子問題”是什么,我們發現,每次背包新裝進一個物品,就可以把剩余的承重能力作為一個新的背包來求解,一直遞推到承重為0的背包問題:
作為一個聰明的賊,你用 m[i,w]
表示偷到商品的總價值,其中i表示一共多少個商品,w表示總重量,所以求解m[i,w]就是我們的子問題,那么你看到某一個商品i的時候,如何決定是不是要裝進背包,有以下幾點考慮:
該物品的重量大于背包的總重量,不考慮,換下一個商品;
該商品的重量小于背包的總重量,那么我們嘗試把它裝進去,如果裝不下就把其他東西換出來,看看裝進去后的總價值是不是更高了,否則還是按照之前的裝法;
極端情況,所有的物品都裝不下或者背包的承重能力為0,那么總價值都是0;
由以上的分析,我們可以得出m[i,w]
的狀態轉移方程為:
有了狀態轉移方程,那么寫起代碼來就非常簡單了,首先看一下自頂向下的遞歸方式,比較容易理解:
#!/usr/bin/env python
# coding:utf-8
cache = {}
items = range(0,9)
weights = [10,1,5,9,10,7,3,12,5]
values = [10,20,30,15,40,6,9,12,18]
# 最大承重能力
W = 4
def m(i,w):
if str(i)+','+str(w) in cache:
return cache[str(i)+','+str(w)]
result = 0
# 特殊情況
if i == 0 or w == 0:
return 0
# w < w[i]
if w < weights[i]:
result = m(i-1,w)
# w >= w[i]
if w >= weights[i]:
# 把第i個物品放入背包后的總價值
take_it = m(i-1,w - weights[i]) + values[i]
# 不把第i個物品放入背包的總價值
ignore_it = m(i-1,w)
# 哪個策略總價值高用哪個
result = max(take_it,ignore_it)
if take_it > ignore_it:
print 'take ',i
else:
print 'did not take',i
cache[str(i)+','+str(w)] = result
return result
if __name__ == '__main__':
# 背包把所有東西都能裝進去做假設開始
print m(len(items)-1,W)
改造成非遞歸,即循環的方式,從底向上求解:
#!/usr/bin/env python
# coding:utf-8
cache = {}
items = range(1,9)
weights = [10,1,5,9,10,7,3,12,5]
values = [10,20,30,15,40,6,9,12,18]
# 最大承重能力
W = 4
def knapsack():
for w in range(W+1):
cache[get_key(0,w)] = 0
for i in items:
cache[get_key(i,0)] = 0
for w in range(W+1):
if w >= weights[i]:
if cache[get_key(i-1,w-weights[i])] + values[i] > cache[get_key(i-1,w)]:
cache[get_key(i,w)] = values[i] + cache[get_key(i-1,w-weights[i])]
else:
cache[get_key(i,w)] = cache[get_key(i-1,w)]
else:
cache[get_key(i,w)] = cache[get_key(i-1,w)]
return cache[get_key(8,W)]
def get_key(i,w):
return str(i)+','+str(w)
if __name__ == '__main__':
# 背包把所有東西都能裝進去做假設開始
print knapsack()
從這里可以看出,其實很多動態規劃問題都可以使用循環替代遞歸求解,他們的區別在于,循環方式會窮舉出所有可能用到的數據,而遞歸只需要計算那些對最終解有幫助的子問題的解,但是遞歸本身是很耗費性能的,所以具體實踐中怎么用要看具體問題具體分析。
最長公共子序列(LCS)
解決了01背包問題之后,我們對“子問題”和“狀態轉移方程”有了一點點理解,現在趁熱打鐵,來試試解決LCS問題:
字符串一“ABCDABCD”和字符串二”BDCFG”的公共子序列(不是公共子串,不需要連續)是BDC,現在給出兩個確定長度的字符串X和Y,求他們的最大公共子序列的長度。
首先,我們還是找最優子結構,即把問題分解為子問題,X和Y的最大公共子序列可以分解為X的子串Xi和Y的子串Yj的最大公共子序列問題。
其次,我們需要考慮Xi和Yj的最大公共子序列C[i,j]需要符合什么條件:如果兩個串的長度都為0,則公共子序列的長度也為0;
如果兩個串的長度都大于0且最后面一位的字符相同,則公共子序列的長度是C[i?1,j?1]的長度加一;
如果兩個子串的長度都大于0,且最后面一位的字符不同,則最大公共子序列的長度是C[i?1,j]和C[i,j?1]的最大值;
最后,根據條件獲得狀態轉移函數:
由此轉移函數,很容易寫出遞歸代碼:
#!/usr/bin/env python
# coding:utf-8
cache = {}
# 為了下面表示方便更容易理解,數組從1開始編號
# 即當i,j為0的時候,公共子序列為0,屬于極端情況
A = [0,'A','B','C','B','D','A','B','E','F']
B = [0,'B','D','C','A','B','A','F']
def C(i,j):
if get_key(i,j) in cache:
return cache[get_key(i,j)]
result = 0
if i > 0 and j > 0:
if A[i] == B[j]:
result = C(i-1,j-1)+1
else:
result = max(C(i,j-1),C(i-1,j))
cache[get_key(i,j)] = result
return result
def get_key(i,j):
return str(i)+','+str(j)
if __name__ == '__main__':
print C(len(A)-1,len(B)-1)
上面程序的輸出結果為5,我們也可以像背包問題一樣,把上面代碼改造成自底向上的求解方式,這里就省略了。
但是實際應用中,我們可能更需要求最大公共子序列的序列,而不只是序列的長度,所以我們下面額外考慮一下如何輸出這個結果。
其實輸出LCS字符串也是使用動態規劃的方法,我們假設LCS[i,j]表示長度為i的字符串和長度為j的字符串的最大公共子序列,那么我們有以下狀態轉移函數:
其中C[i,j]是我們之前求得的最大子序列長度的緩存,根據上面的狀態轉移函數寫出遞歸代碼并不麻煩:
#!/usr/bin/python
# coding:utf-8
"""Dynamic Programming"""
CACHE = {}
# 為了下面表示方便,數組從1開始編號
# 即當i,j為0的時候,公共子序列為0,屬于極端情況
A = [0, 'A', 'B', 'C', 'B', 'D', 'A', 'B', 'E', 'F']
B = [0, 'B', 'D', 'C', 'A', 'B', 'A', 'F']
def lcs_length(i, j):
"""Calculate max sequence length"""
if get_key(i, j) in CACHE:
return CACHE[get_key(i, j)]
result = 0
if i > 0 and j > 0:
if A[i] == B[j]:
result = lcs_length(i-1, j-1)+1
else:
result = max(lcs_length(i, j-1), lcs_length(i-1, j))
CACHE[get_key(i, j)] = result
return result
def lcs(i, j):
"""backtrack lcs"""
if i == 0 or j == 0 :
return ""
if A[i] == B[j]:
return lcs(i-1, j-1) + A[i]
else:
if CACHE[get_key(i-1, j)] > CACHE[get_key(i, j-1)]:
return lcs(i-1, j)
else:
return lcs(i, j-1)
def get_key(i, j):
"""build cache keys"""
return str(i) + ',' + str(j)
if __name__ == '__main__':
print lcs_length(len(A)-1, len(B)-1)
print lcs(len(A)-1, len(B)-1)
本小節就暫時到這里了,其實我們很容易能體會到,動態規劃的核心就是找到那個狀態轉移方程,所以遇到問題的時候,首先想一想其有沒有最優子結構,很可能幫助我們省下大把的思考時間。