進一步理解動態規劃

理解動態規劃、BFS和DFS一文中,只是初步講解了一下動態規劃,理解的並不到位,這裏再加深理解一下。html

本文主要參考什麼是動態規劃一文。python

1、前言

1.一、算法問題的求解過程

相似於機器學習的步驟,對同一個問題,能夠用不一樣的模型建模,而後對於肯定的模型,能夠用不一樣的算法求解。程序員

通常的算法問題求解步驟,分爲兩步:算法

  • 一、問題建模:
    對於同一個問題,能夠有不一樣的模型。
  • 二、問題求解:
    對於特定的模型,選出一個合適的算法(時間複雜度和空間複雜度知足要求),求解問題。

對應到動態規劃算法上,具體分爲這兩步:網絡

  • 一、問題建模:[最優子結構][邊界][狀態轉移方程]
  • 二、用動態規劃算法求解問題。

1.二、動態規劃的思想

大事化小,小事化了。把一個複雜的問題分階段進行簡化,逐步化簡成簡單的問題。app

1.三、動態規劃的步驟

1.3.1 問題建模機器學習

  • 一、 根據問題,找到【最優子結構】
    把原問題從大化小的第一步,找到比當前問題要小一號的最好的結果,而通常狀況下當前問題能夠由最優子結構進行表示。
  • 二、肯定問題的【邊界】
    根據上述的最優子結構,一步一步從大化小,最終能夠獲得最小的,能夠一眼看出答案的最優子結構,也就是邊界。
  • 三、經過上述兩步,經過分析最優子結構與最終問題之間的關係,咱們能夠獲得【狀態轉移方程】

1.3.2 問題求解的各個方法(從暴力枚舉 逐步優化到動歸)函數

  • 暴力枚舉:
    下面的樓梯問題,國王與金礦問題,還有最少找零硬幣數問題,均可以經過多層嵌套循環遍歷全部的可能,將符合條件的個數統計起來。只是時間複雜度是指數級的,因此通常 不推薦。性能

  • 遞歸:
    一、既然是從大到小,不斷調用狀態轉移方程,那麼就能夠用遞歸。
    二、遞歸的時間複雜度是由階梯數和最優子結構的個數決定的。不一樣的問題,用遞歸的話可能效果會大不相同。
    三、在階梯問題,最少找零問題中,遞歸的時間複雜度和空間複雜度都比動歸方法的差, 可是在國王與金礦的問題中,遞歸的時間複雜度和空間複雜度都比動歸方法好。這是須要注意的。學習

每一種算法都沒有絕對的好與壞,關鍵看應用場景。、

上面這句話說的很好,不止於遞歸和動歸,通常的算法也是,好比通常的排序算法,在不一樣的場景中,效果也大不相同。

  • 備忘錄算法:
    一、在階梯數N比較多的時候,遞歸算法的缺點就顯露出來了:時間複雜度很高。若是畫出遞歸圖(像二叉樹同樣),會發現有不少不少重複的節點。然而傳統的遞歸算法並不能識別節點是否是重複的,只要不到終止條件,它就會一直遞歸下去。
    二、爲了不上述狀況,使遞歸算法可以不重複遞歸,就把已經獲得的節點都存起來,下次再遇到的時候,直接用存起來的結果就好了。這就是備忘錄算法。
    三、備忘錄算法的時間複雜度和空間複雜度都獲得了簡化。

  • 正經的動歸算法:
    一、上述的備忘錄算法,儘管已經不錯了,可是依然仍是從最大的問題,遍歷獲得全部的最小子問題,空間複雜度是O(N)。
    二、爲了再次縮小空間複雜度,咱們能夠自底向上的構造遞歸問題,經過分析最優子結構與最終問題之間的關係,咱們能夠獲得【狀態轉移方程】
    而後從最小的問題不斷往上迭代,即便一直到最大的原問題,也是隻依賴於前面的幾個最優子結構。這樣,空間複雜度就大大簡化。也就獲得了正經的動歸算法。

下面經過幾個例題,來具體瞭解動歸問題。

2、例題

例1:Climbing Stairs

leetcode原題:你正在爬一個有n個臺階的樓梯,每次只能上 1個 或者 2個臺階,那麼到達頂端共有多少種不一樣的方法?

1.一、 創建模型

  • 最終問題F(N):
    假設從0到達第N個臺階的方法共有F(N)個。
  • 最優子結構F(N-1),F(N-2):
    到達N個臺階,有兩種可能,第一種多是從第 N-1 個臺階上1個臺階到達終點,第二種多是從第 N-2 個臺階上2個臺階到達終點。
  • 最優子結構與最終問題之間的關係:
    按照上述表達,那麼能夠概括出F(N) = F(N-1) + F(N-2) (n>=3)

結束條件爲F(1) = 1,F(2) = 2

1.二、 問題求解

1.2.一、 解法1:遞歸

先用比較容易理解的遞歸求解(結束條件已知,遞歸公式已知,能夠直接寫代碼了)

class Solution:
    def climbStairs(self, n):
        """
        :type n: int
        :rtype: int
        """
        if n == 1:
            return 1
        elif n == 2:
            return 2
        else:
            return self.climbStairs(n-1) + self.climbStairs(n-2)

回想前面所說,遞歸的時間複雜度是由階梯數和最優子結構的個數決定的。這裏的階梯數是 N ,最優子結構個數是 2 。若是想象成一個二叉樹,那麼就能夠認爲是一個高度爲N-1,節點個數接近 2 的 N-1 次方的樹,所以此方法的時間複雜度能夠近似的看做是O(2N) 。

1.2.二、 解法2:備忘錄算法

參考什麼是動態規劃中遞歸的圖,發現有不少相同的參數被重複計算,重複的太多了。

因此這裏咱們想到了把重複的參數存儲起來,下次遞歸遇到時就直接返回該參數的結果,也就是備忘錄算法了,這裏須要用到一個哈希表,解決方法就是對類用init進行初始化。

class Solution:
    def __init__(self):
        self.map = {}
        
    def climbStairs(self, n):
        """
        :type n: int
        :rtype: int
        """
        
        if n == 1:
            return 1
        if n == 2:
            return 2
        if n in self.map:
            return self.map[n]
        else:
            value  =  self.climbStairs(n-1) + self.climbStairs(n-2)
            self.map[n] = value
            return value

這裏哈希表裏存了 N-2 個結果,時間複雜度和空間複雜度都是O(N)。程序性能獲得了明顯優化。

1.2.三、 解法3:動態規劃

以前都是自頂向下的求解,考慮一下自底向上的求解過程。從F(1)和F(2)邊界條件求,可知F(3) = F(1)+F(2)。不斷向上,可知F(N)只依賴於前兩個狀態F(N-1)和F(N-2)。因而咱們只須要保留前兩個狀態,就能夠求得F(N)。相比於備忘錄算法,咱們再一次簡化了空間複雜度。

這就是動態規劃了。(具體的細節看漫畫比較好理解。)

具體代碼實現中,能夠令F(N-2)=a,F(N-1)=b,則temp等於a+b,而後把a向前挪一步等於b,b向前挪一步等於temp。那麼下一次迭代時,temp就依然等於a+b。

代碼以下:

class Solution:
    def climbStairs(self, n):
        """
        :type n: int
        :rtype: int
        """
        if n == 1:
            return 1
        if n == 2:
            return 2
        a = 1
        b = 2
        for i in range(3,n+1):
            temp = a + b
            a = b
            b = temp
        return temp

例2: Making change using the fewest coins.

參考Dynamic Programming中,用最少的硬幣數目找零錢的一個例子。

問題描述:
假設你是一家自動售貨機制造商的程序員。你的公司正設法在每一筆交易 找零時都能提供最少數目的硬幣以便工做能更加簡單。已知硬幣有四種(1美分,5美分,10美分,25美分)。假設一個顧客投了1美圓來購買37美分的物品 ,你用來找零的硬幣的最小數量是多少?
(這個問題用貪心算法也能解,具體細節看參考文獻)

2.一、 創建模型

就以動歸做爲解題的算法來創建模型吧。

  • 邊界:當須要找零的面額正好等於上述的四種整額硬幣時,返回1便可
  • 最優子結構:回想找到最優子結構的方法,就是日後退一步,可以獲得的最好的結果。這裏有四個選擇,1 + mincoins(63-1),1 + mincoins(63-5),1 + mincoins(63-10) 或者 1 + mincoins(63-25),這四個選擇能夠認爲是63的最優子結構。
  • 狀態轉移方程:按照 上述的最優子結構,mincoins(63)也就等於上述四個最優子結構的最小值。因而,方程能夠表示爲:

2.二、 問題求解

模型已經獲得,接下來就運用算法進行求解。
這裏依然能夠按照例1的解法,由模型,很天然的想到用遞歸求解。

2.2.一、解法1,遞歸

邊界條件已知,模型已知,能夠直接寫代碼了。

def recMC(coinValueList,change):
    minCoins = change
    if change in coinValueList:
        return 1
    else:
        for i in [c for c in coinValueList if c <= change]:
            numCoins = 1 + recMC(coinValueList,change-i)
        if numCoins < minCoins:
            minCoins = numCoins
    return minCoins
print(recMC([1,5,10,25],63)

可是,對於每個大於25的數目,都有四個最優子結構,而後對於每一個最優子結構,還有大量相同重複的參數(具體細節看參考)。因此這個解法並不合適。

2.2.二、解法2,動態規劃

首先要有自底向上的思想,從change等於1時,開始往上迭代,參考最優子結構,記錄下來最少硬幣數。一直迭代到63。

1==>1
2==>min(2-1) + 1 = 2
3==>min(3-1) + 1 = 3
4==>min(4-1) + 1 = 4
5==>min(min(5-1) + 1 = 5, min(5-5) + 1 = 1)= 1
6==>min(min(6-1) + 1 = 2, min(6-5) + 1 = 2)= 2
7==>min(min(7-1) + 1 = 3, min(7-5) + 1 = 3)= 3

由此能夠推下去,每個change對應的最少硬幣數,均可以由前面的若干個最優子結構(有幾個最優子結構,由change是多少決定,change大於5就有兩個子結構,大於10就有三個。。)獲得。這樣一直迭代到63,那麼就能夠獲得63的最少硬幣數。

所以,須要一個循環來從頭至尾遍歷。
須要必定須要一個map來記錄部分結果。
每個change,咱們能夠根據上面的式子遍歷最優子結構,並將每一個子結構的結果都添加到一個list中,在遍歷完最有子結構之後,選擇最小的那一個,添加到map中去。

求解一個新的 i 的最優解的過程是很方便的,從最優子結構中挑選最小的值而後加1便可。
最優子結構的值,能夠用minCoin[i-j]獲得。其中j爲有效硬幣面額。

實現代碼:

def dpMakeChange(coinValueList,change):
    minCoins = { }

    for cents in range(change+1):
        #cents小於等於1時,coinCount會爲空,無法執行min。
        #所以這裏先填上
        if cents <= 1:
            minCoins[cents] = cents
            continue
        #遍歷cents的每一個最優子結構而且添加到list中,等待篩選
        coinCount = [ ]
        for j in coinValueList:
            if cents >= j:
                coinCount.append(minCoins[cents - j] + 1)
        minCoins[cents] = min(coinCount)
    return minCoins[change]

result = dpMakeChange([1,5,10,25],63)
print(result)

固然這個函數是有瑕疵的,由於這個函數只告訴咱們最少的硬幣數,並不能告訴咱們應該找零的面額。因此咱們能夠擴展一下函數,跟蹤記錄咱們使用的硬幣便可。具體細節能夠看參考。

例3: 國王與金礦問題

只講一下大體的思路。
問題中須要注意的地方:

  • 國王與金礦的問題中,由於每一個金礦須要的人不一樣,所含金礦數量也不一樣。爲了簡化問題,這裏第 i 個金礦所含的金礦數量和所須要的工人都是 特定不變的。
  • 在實現自底向上的遞推時,由於問題的參數有兩個,那麼存在兩個輸入維度。爲此,能夠畫一個表格來作分析。
  • 在實現自底向上的遞推時,爲了比較快的找到規律,最好把從邊界不斷地往上迭代,結合最優子結構和存儲的結果,慢慢的找到規律。

3.一、問題建模

這裏着重講解一下最後一點,也就是動態規劃最重要的地方。

最優子結構:對於5個金礦,10個工人的狀況,日後退一步存在兩種狀況。(第五個金礦的金礦數量爲350,所需工人爲3人)

  • 狀況1:國王選擇不挖第五個金礦,那麼此時最大化的金礦數量就是在有4個金礦,10個工人的狀況下,可以挖到的最多金礦數量。
  • 狀況2:國王選擇挖第五個金礦,那麼此時用3個工人挖得350的金礦數量是已知的,還剩4個金礦與7個工人。
    那麼最優解至關於在4個金礦與7個工人的狀況下可以挖得的最多金礦數量 + 350。

最優子結構與最終問題之間的關係:5個金礦10個工人的最優選擇,就是上述兩個最優子結構的最大值。

因而咱們能夠獲得狀態轉移方程:

最重要的狀態轉移方程已經獲得,至於剩下的邊界條件,現實中會遇到的各類特殊狀況,這裏就不贅述了。細節參考漫畫。

3.二、問題求解

3.2.1 解法一、遞歸

程序 :把狀態轉移方程翻譯成遞歸程序,遞歸的結束條件就是方程式中的邊界便可。
複雜度:由於每一個狀態有兩個最優子結構,因此遞歸的執行流程相似於一個高度爲N的二叉樹。因此方法的時間複雜度是O(2N)。

3.2.2 解法二、備忘錄算法

程序:在簡單遞歸的基礎上,增長一個HashMap備忘錄,用來存儲中間的結果,HashMap的Key是一個包含金礦數N和工人數W的對象,Value是最優選擇得到的黃金數。
複雜度:時間複雜度和空間複雜度相同,都等於被網絡中不一樣Key的數量。

3.2.3 解法三、動態規劃

爲了實現自底向上的迭代,對於參數有兩個的問題,咱們能夠先畫要一個表格來作分析。根據狀態轉移方程,咱們能夠方便的畫出表格。注意,必定是要根據狀態轉移方程來求的。

因爲咱們在求解每一個格子的數值時,結合狀態轉移方程,發現除了第一行之外,每個格子均可以由前一行的格子中的一個或者兩個格子推導而來。
從總體上來講,每一行的值均可以由前一行來求得。

因而,咱們在寫代碼的時候,也能夠像畫表格同樣,從左至右,從上到下一個一個的推出最終結果。反映到程序上就是:

for i in range(金礦數):
    for j in range(工人數目):
         狀態轉移方程

另外,由上可知,咱們並不須要存儲整個表格,只須要存儲前一行的結果便可推出新的一行。

代碼這裏就不寫了。

注意:

  • 這裏動態規劃的時間複雜度是O(n*w),空間複雜度是O(w)。在n=5,w=1000是,顯然要計算5000次,開闢1000單位的空間。
  • 可是若是用簡單遞歸算法的話,時間複雜度是O(2N),須要計算32次 ,開闢5單位(遞歸深度)的空間。
  • 這是因爲動態規劃方法的時間和空間都和w成正比,而簡單遞歸卻和w無關,因此當工人數量不少的時候,動態規劃反而不如遞歸。

因此說,每一種算法沒有絕對的好與壞,關鍵要看應用場景。

總結:

我的以爲, 動態規劃算法最重要的有兩點

  • 建模:必定要找對最優子結構,而後分析最優子結構與最終問題的關係,從而獲得狀態轉移方程。
  • 問題求解:先手動的自底向上的,運用狀態轉移方程迭代一下,一直到最終問題,從而肯定程序的主體部分。

至於,模型中的邊界問題,特殊狀況等,就是須要多敲代碼來慢慢考慮的了。

相關文章
相關標籤/搜索