動態規劃算法

原文:

常用的算法設計思想主要有動態規劃、貪婪法、隨機化算法、回溯法等等,這些思想有重疊的部分,當面對一個問題的時候,從這幾個思路入手往往都能得到一個還不錯的答案。

本來想把動態規劃單獨拿出來寫三篇文章呢,后來發現自己學疏才淺,實在是只能講一些皮毛,更深入的東西嘗試構思了幾次,也沒有什么進展,打算每種設計思想就寫一篇吧。

動態規劃(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)

本小節就暫時到這里了,其實我們很容易能體會到,動態規劃的核心就是找到那個狀態轉移方程,所以遇到問題的時候,首先想一想其有沒有最優子結構,很可能幫助我們省下大把的思考時間。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,797評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,179評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,628評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,642評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,444評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,948評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,040評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,185評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,717評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,794評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,418評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,414評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,750評論 2 370

推薦閱讀更多精彩內容

  • 動態規劃 動態規劃算法, Dynamic Programming簡稱DP,通常基于一個遞推公式及一個或多個初始狀態...
    御風逍遙閱讀 5,294評論 0 7
  • 回溯算法 回溯法:也稱為試探法,它并不考慮問題規模的大小,而是從問題的最明顯的最小規模開始逐步求解出可能的答案,并...
    fredal閱讀 13,703評論 0 89
  • 動態規劃學習-無資料 理論解釋http://www.cnblogs.com/steven_oyj/archive/...
    RavenX閱讀 1,025評論 0 2
  • 基本概念 動態規劃過程是:每次決策依賴于當前狀態,又隨即引起狀態的轉移。一個決策序列就是在變化的狀態中產生出來的,...
    羽恒閱讀 318評論 0 1
  • Western Blot實驗(MEC2的蛋白,及部分患者單個核細胞主要是淋巴細胞的蛋白) 核酸電泳(MEC2細胞制...
    詩芬兒閱讀 202評論 0 0