動態規劃算法

原文:

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

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

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

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

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