使用動態規劃完美解決硬幣找零問題(Python)

Mr.鄭先生_發表於2020-10-06

前情提要

在前面幾篇文章中,我們使用了貪心演算法求解硬幣找零的問題,並從中發現了貪心演算法本身的侷限性:

  • 貪心演算法幾乎只考慮了區域性最優,因此無法應對需要考慮整體最優的演算法問題。

針對這一問題,我們重新思考了解決方案,用遞迴的方法來窮舉出所有可能的組合,從這些可能組合中找出最優解。雖然遞迴考慮了整體最優,而且真的可以解決問題,但效率太低

因此,為了解決低效問題,我們又提出了備忘錄的概念。你應該發現了,我們在解決硬幣找零問題時的思路是一以貫之的:發現問題,找解決方案;如果方案有侷限性,那麼就看如何擴充套件視野,找尋更優的方法。

含有備忘錄的遞迴演算法已經與動態規劃思想十分相似了,從效率上說也是如此。事實上,你已經在使用動態規劃的思想解決問題了。但“真正”的動態規劃解法跟備忘錄法又有一些區別。

動態規劃的問題描述

我們曾不止一次提到重疊子問題,其實,重疊子問題是考慮一個問題是否為動態規劃問題的先決條件,除此之外,我還有無後效性。動態規劃問題一定具備以下三個特徵:

  1. 重疊子問題:在窮舉的過程中(比如通過遞迴),存在重複計算的現象;
  2. 無後效性:子問題之間的依賴是單向性的,某階段狀態一旦確定,就不受後續決策的影響;
  3. 最優子結構:子問題之間必須相互獨立,或者說後續的計算可以通過前面的狀態推匯出來。

重疊子問題

斐波那契數列沒有求最值的問題,因此嚴格來說它不是最優解問題,當然也就不是動態規劃問題。但它能幫助你理解什麼是重疊子問題。首先,它的數學形式即遞迴表達是這樣的:
在這裡插入圖片描述
如果要計算原問題 F(10),就需要先計算出子問題 F(9) 和 F(8),如果要計算 F(9),就需要先計算出子問題 F(8) 和 F(7),以此類推。這個遞迴的終止條件是當 F(1)=1 或 F(0)=0 時結束。

在這裡插入圖片描述

看完斐波那契數列的求解樹之後,你發現問題沒有:

  • 用紅色標出的兩個區域中,它們的遞迴計算過程完全相同!

這意味著,第 2 個紅色區域的計算是“完全沒有必要的”,它是重複的計算。因為我們已經在求解 F(7) 的時候把 F(6) 的所有情況計算過了。因此我們把第 2 個紅色區域的計算稱為重疊子問題。

無後效性

有些問題雖然看起來像包含“重疊子問題”的子問題,但是這類子問題可能具有後效性,但我們追求的是無後效性。

所謂無後效性,指的是在通過 A 階段的子問題推導 B 階段的子問題的時候,我們不需要回過頭去再根據 B 階段的子問題重新推導 A 階段的子問題,即子問題之間的依賴是單向性的。

換句話說,如果一個問題可以通過重疊子問題快取進行優化,那麼它肯定都能被畫成一棵樹。

最優子結構

用一個例子來說明一下最優子結構:

互相獨立——滿足最優子結構

假設你在外賣平臺購買 5 斤蘋果和 3 斤香蕉。由於促銷的緣故,這兩種水果都有一個互相獨立的促銷價。如果原問題是讓你以最低的價格購買這些水果,你該怎麼買?

顯然,由於這兩種水果的促銷價格相互獨立、互不影響,你只需直接購買就能享受到最低折扣的價格。

現在我們得到了正確的結果:最低價格就是直接購買這兩種折扣水果。因為這個過程符合最優子結構,打折的蘋果和香蕉這兩個子問題是互相獨立、互不干擾的。

互相干擾——不滿足最優子結構

但是,如果平臺追加一個條件:折扣不能同時享用。即購買了折扣的蘋果就不能享受折扣的香蕉,反之亦然。

這樣的話,你肯定就不能同時以最低的蘋果價格和最低的香蕉價格享受到最低折扣了。按剛才那個思路就會得到錯誤的結果。因為子問題並不獨立,蘋果和香蕉的折扣價格無法同時達到最優,這時最優子結構被破壞。

深入理解最優子結構

所謂後續的計算可以通過前面的狀態推導,是指:如果你準備購買了 5 斤折扣蘋果,那麼這個價格(即子問題)就被確定了,繼續在購物車追加 3 斤折扣香蕉的訂單,只需要在剛才的價格上追加折扣香蕉的價格,就是最低的總價格(即答案)。

現在,回到硬幣找零的問題上來,它滿足最優子結構嗎?

滿足。假設有兩種面值的硬幣 c[0]=5, c[1]=3,目標兌換金額為 k=11。原問題是求這種情況下求最少兌換的硬幣數。如果你知道湊出 k=6 最少硬幣數為 “2”(注意,這是一個子問題),那麼你只需要再加 “1” 枚面值為 c[0]=5 的硬幣就可以得到原問題的答案,即 2 + 1 = 3。

原問題並沒有限定硬幣數量,你應該可以看出這些子問題之間沒有互相制約的情況,它們之間是互相獨立的。因此,硬幣找零問題滿足最優子結構,可以使用動態規劃思想來進行求解。

使用動態規劃求解硬幣找零問題

當動態規劃最終落到實處,其實就是一個狀態轉移方程,這同樣是一個嚇唬人的名詞。不過沒關係,其實我們已經具備了寫出這個方程的所有工具。現在,就讓我帶你一起看看如何寫出這個狀態轉移方程。

首先,任何窮舉演算法(包括遞迴在內)都需要一個終止條件。那麼對於硬幣找零問題來說,終止條件是什麼呢?當剩餘的金額為 0 時結束窮舉,因為這時不需要任何硬幣就已經湊出目標金額了。在動態規劃中,我們將其稱之為初始化狀態

接著,我們按照上面提到的湊硬幣的思路,找出子問題與原問題之間會發生變化的變數。原問題指定了硬幣的面值,同時沒有限定硬幣的數量,因此它們倆無法作為“變數”。唯獨剩餘需要兌換的金額是變化的,因此在這個題目中,唯一的變數是目標兌換金額 k。在動態規劃中,我們將其稱之為狀態引數

同時,你應該注意到了,這個狀態在不斷逼近初始化狀態。而這個不斷逼近的過程,叫做狀態轉移

接著,既然我們確定了狀態,那什麼操作會改變狀態,並讓它不斷逼近初始化狀態呢?每當我們挑一枚硬幣,用來湊零錢,就會改變狀態。在動態規劃中,我們將其稱之為決策

終於,我們構造了一個能解決問題的思路:

  • 初始化狀態 -> 確定狀態引數 -> 設計決策

現在萬事俱備,只欠東風,讓我們一起來寫這個狀態轉移方程。通常情況下,狀態轉移方程的引數就是狀態轉移過程中的變數,即狀態引數。而函式的返回值就是答案,在這裡是最少兌換的硬幣數。

這裡先描述一下狀態轉移的過程,這跟我們在上面討論的挑硬幣的過程是一致的:
在這裡插入圖片描述

遞迴與動態規劃

帶備忘錄的遞迴演算法與你現在看到的動態規劃解法之間,有著密不可分的關係。它們要解決的核心問題是一樣的,即消除重疊子問題的重複計算。事實上,帶備忘錄的遞迴演算法也是一種動態規劃解法。但是,一般不把這種方法作為動態規劃演算法題的常規解法。

首先,從理論上說,雖然帶備忘錄的遞迴演算法與動態規劃解法的時間複雜度是相同規模的,但在計算機程式設計的世界裡,遞迴是依賴於函式呼叫的,而每一次函式呼叫的代價非常高昂。

遞迴呼叫是需要基於堆疊才能實現的。而對於基於堆疊的函式呼叫來說,在每一次呼叫的時候都會發生環境變數的儲存和還原,因此會帶來比較高的額外時間成本。這是無法通過時間複雜度分析直接表現出來的。

更重要的是,即便我們不考慮函式呼叫帶來的開銷,遞迴本身的處理方式是自頂向下的。所謂自頂向下,是指訪問遞迴樹的順序是自頂向下的,把遞迴處理斐波那契數列的順序畫出來,你就明白了:
在這裡插入圖片描述
如果從解路徑的角度看遞迴的自頂向下處理,那麼它的形式可以用由左至右的連結串列形式表示:
在這裡插入圖片描述
因此每次都需要查詢子問題是否已經被計算過,如果該子問題已經被計算過,則直接返回備忘錄中的記錄。

也就是說,在帶備忘錄的遞迴解法中,無論如何都要多處理一個分支邏輯,只不過這個分支的子分支是不需要進行處理的。

這樣的話,我們就可以預想到,如果遇到子問題分支非常多,那麼肉眼可見的額外時間開銷在所難免。我們不希望把時間浪費在遞迴本身帶來的效能損耗上。那麼,我們需要設計一種新的快取方式,並考慮使用迭代來替換遞迴

狀態快取與迴圈

在帶備忘錄的遞迴演算法中,每次都需要查詢子問題是否已經被計算過。針對這一問題,我們可以思考一下,是否有方法可以不去檢查子問題的處理情況呢?在執行 A 問題的時候,確保 A 的所有子問題一定已經計算完畢了。

仔細想一想,這不就是把處理方向倒過來用自底向上嘛!那麼我們具體要怎麼做呢?回顧一下自頂向下的方法,我們的思路是從目標問題開始,不斷將大問題拆解成子問題,然後再繼續不斷拆解子問題,直到子問題不可拆解為止。通過備忘錄就可以知道哪些子問題已經被計算過了,從而提升求解速度。

那麼如果要自底向上,我們可以首先求出所有的子問題,然後通過底層的子問題向上求解更大的問題。還是通過斐波那契數列來畫出自底向上的處理方式:

在這裡插入圖片描述
如果從解路徑的角度看動態規劃的自底向上處理方式,那麼它的形式可以用一個陣列來進行表示,而這個陣列事實上就是實際的備忘錄儲存結構:
在這裡插入圖片描述

當求解大問題的時候,我們已經可以確保該問題依賴的所有子問題都已經計算過了,那麼我們就無需檢查子問題是否已經求解,而是直接從快取中取出子問題的解。

通過自底向上,我們完美地解決掉了遞迴中由於“試探”帶來的效能損耗。有了思路之後,讓我們把上一篇文章中的遞迴程式碼做些修改,變成新的迭代實現:

def getMinCountsLoop(k, values):
    memo = [-1] * (k + 1)
    memo[0] = 0 # 初始化狀態
    for item in range(1, k + 1):
        minCount = k + 1 # 模擬無窮大
        for iter in range(len(values)):
            currentValue = values[iter]
            # 如果當前面值大於硬幣總額,那麼跳過
            if (currentValue > item):
                continue

            # 使用當前面值,得到剩餘硬幣總額
            rest = item - currentValue
            restCount = memo[rest]
           
            # 如果返回-1,說明組合不可信,跳過
            if (restCount == -1):
                continue

            # 保留最小總額
            itemCount = 1 + restCount
            if (itemCount < minCount):
                minCount = itemCount

        # 如果是可用組合,記錄結果
        if (minCount != k + 1):
            memo[item] = minCount

    return memo[k]

def getMinCountsDPSol():
    values = [3, 5] # 硬幣面值
    total = 22 # 總值

    # 求得最小的硬幣數量
    return getMinCountsLoop(total, values) # 輸出答案

def main():
    result = getMinCountsDPSol()
    print(result)

if __name__ == "__main__":
    main()

我們的關注點在 GetMinCountsLoop 函式上,該函式先定義了一個“新款”狀態備忘錄,用陣列 memo 來表示(通常將其稱之為 DP 陣列,DP 是 Dynamic Programming 的縮寫即動態規劃)

這個備忘錄由陣列構成,其定義是:

  • 當目標兌換金額為 item時,至少需要 memo[item] 枚硬幣才能湊出。

有了備忘錄的定義後,我們接下來再依據狀態轉移方程的指導來初始化狀態:

  1. 將 F(0) 初始化成 0,即 memo[0]=0;
  2. 把備忘錄中剩餘的位置初始化成 k + 1。湊成金額 k 的硬幣數至多隻可能等於 k (如果硬幣的最低面值是 1),因此初始化為 k + 1 就相當於將這些位置初始化成正無窮大,便於後續決策時取最小值。

接著,我們從 1 開始遍歷,求解 F(1) 的結果,直到求解 F(k) 的結果為止。迴圈結束後我們想要的結果就儲存在 memo[k] 中,也就是 F(k) 的解。

在這個基於原來遞迴程式碼上改進得到的程式碼中,我們來看一下每次迴圈中做了什麼。每一次迴圈都包含一個小迴圈,這個小迴圈會遍歷所有的面值。

  1. 先看當前面額總值是否小於當前硬幣面額。如果是,說明組合不存在,直接進入下一輪迴圈。
  2. 否則,我們就可以認為已經使用了這一枚硬幣,那麼就求得使用完硬幣後的餘額 rest,並從備忘錄中獲取 F(rest) 的結果:
  • 如果 F(rest) 為 -1,說明 F(rest) 組合不存在,子問題不成立那麼當前問題也就無解,直接進入下一輪迴圈;
  • 如果返回的值不是 -1,說明組合存在,那麼求 F(rest) + 1,並和當前最小硬幣總數比較,取最小值。
  1. 內部迴圈結束後,我們看一下 minCount 的值:
  • 如果是 -1,說明 F(item) 不存在,那麼不做任何處理,保留 F(item)=-1 即可;
  • 否則將最小值存入 memo[item],表示已經求得 f(item) 的值,準備為後續的問題使用。

這樣我們就通過這種自下而上的方法將遞迴轉換成了迴圈。但是,這段程式碼還是跟我們常見的動態規劃程式碼有些出入,不過沒有關係,經過簡單的調整就可以把它變漂亮:

def getMinCounts(k, values):
    memo = [-1] * (k + 1)
    memo[0] = 0 # 初始化狀態
    for item in range(1, k + 1):
       memo[item] = k + 1
   
    for item in range(1, k + 1):
        for coin in values:
            if (item - coin < 0):
                continue
            memo[item] = min(memo[item], memo[item - coin] + 1) # 作出決策

    return memo[k]

def getMinCountsDPSol():
    values = [3, 5] # 硬幣面值
    total = 22 # 總值

    # 求得最小的硬幣數量
    return getMinCounts(total, values) # 輸出答案

def main():
    result = getMinCountsDPSol()
    print(result)

if __name__ == "__main__":
    main()

現在我們看一下,每一次迴圈中是如何做決策的。每一次迴圈都包含一個小迴圈,這個小迴圈會遍歷所有的面值:

  1. 跟之前一樣,我們先看當前面額總值是否小於當前硬幣面額。如果是,則說明組合不存在,直接進入下一輪迴圈。
  2. 否則,就可以認為已經使用了這一枚硬幣,這時我們要作出決策:
  • 如果採納了這枚硬幣,則湊的硬幣數量需要 +1,這時“狀態 A”是 memo[item - coin] + 1;
  • 如果不採納這枚硬幣,則湊的硬幣數量不變,這時“狀態 B”是 memo[item];

顯然,硬幣找零問題是求最值問題(即最少需要幾枚硬幣湊出總額 k)。因此,我們在這裡作出決策,在狀態 A 與狀態 B 中誰的硬幣數量更少,即取最小值 min(狀態 A, 狀態 B)。

  1. 當迴圈結束後,我們看一下備忘錄中位置為 k 的值是多少,即 memo[k]:
  • 如果是 k + 1,就意味著在初始化狀態時的值沒有被更新過,是“正無窮大”。這時按照題目要求,返回 -1;
  • 否則,我們就找到了最少湊出硬幣的數量,返回它,就是我們的答案。

這樣一來,藉助於自底向上的方法,我們成功的將遞迴轉換成了迭代。這段程式碼的時間複雜度是非常標準的 O(m*n)。它不會有任何額外的效能開銷,我們通過動態規劃完美地解決了硬幣找零問題。

通用的動態規劃

動態規劃問題的核心是寫出正確的狀態轉移方程,為了寫出它,我們要先確定以下幾點:

  1. 初始化狀態:由於動態規劃是根據已經計算好的子問題推廣到更大問題上去的,因此我們需要一個“原點”作為計算的開端。在硬幣找零問題中,這個初始化狀態是 memo[0]=0;
  2. 狀態:找出子問題與原問題之間會發生變化的變數。在硬幣找零問題中,這個狀態只有一個,就是剩餘的目標兌換金額 k;
  3. 決策:改變狀態,讓狀態不斷逼近初始化狀態的行為。在硬幣找零問題中,挑一枚硬幣,用來湊零錢,就會改變狀態。

一般來說,狀態轉移方程的核心引數就是狀態。

接著,我們需要自底向上地使用備忘錄來消除重疊子問題,構造一個備忘錄(在硬幣找零問題中,它叫 memo。為了通用,我們以後都將其稱之為 DP table)。

最後,我們需要實現決策。在硬幣找零問題中,決策是指挑出需要硬幣最少的那個結果。通過這樣幾個簡單步驟,我們就能寫出狀態轉移方程:
在這裡插入圖片描述
由於是經驗,因此它在 90% 以上的情況下都是有效的,而且易於理解。至於嚴格的數學推導和狀態轉移方程框架,我會在後續的課程中給出。

從貪心演算法到動態規劃

首先,貪心演算法是根據當前階段得到區域性最優解,然後再看下一個階段,逐個求解。這樣導致的問題就是,我們可能永遠無法得到真正的最優解:整體最優解。

為了解決這個問題,我們在貪心演算法中加入了回溯的過程。如果無法求解的時候,就會返回,然後重新嘗試當前階段的“區域性次優方案”,重新計算這種情況下的解。

這樣一來,我們至少保證了所有問題都能求得一個解。但是如果遇到一些區域性最優解前提條件不一定滿足全域性最優解的情況,這種方法也不一定能讓我們找到整體最優解,因為貪心演算法裡我們找到一個解就結束了,如果約束不足,那麼返回可能不一定是整體最優解。

為了解決貪心演算法的問題,真正求得整體最優解,我們就必須得到問題解的所有可能組合。這個時候我們就要利用遞迴來解決問題。遞迴就是自頂向下求得滿足問題條件的所有組合,並計算這些組合的解,最後從這些組合的解中取出最優解,這樣暴力計算出來的結果必定是整體最優解。

但是這樣就又出現了效率問題,暴力遞迴在計算量巨大的情況下,時間複雜度實在太高了,幾乎會呈現指數爆炸形式。那麼我們就得考慮是否有些問題可以進行剪枝優化。

在遞迴求解過程中我們會把一個大問題分解成多個子問題,那些在求解計算分支中可能被反覆求解的子問題就是所謂的重疊子問題。如果這些重疊子問題無後效性,那麼我們就可以利用快取的方法,在求得每個子問題的解之後將求解結果存入快取陣列中。

如果在後續的計算分支中遇到相同的子問題,就直接從備忘錄中取出我們已經計算過的結果。這樣一來,我們就不需要浪費時間重複求解已經求解的問題,在這種情況下可以將時間複雜度約束在多項式級別。但是遞迴求解最後還是會有效能損耗問題,因此這時我正式引入了動態規劃。

在經歷了這些討論與探索後,你現在應該能夠理解動態規劃與貪心、回溯、遞迴的關係了。

總結與昇華

帶備忘錄的遞迴解法從傳統意義上說已經是動態規劃思想的範疇了,但它使用的是自頂向下的處理方式來解題,它離我們日常看到的動態規劃還有差距。

這個差距不僅僅體現在程式碼的形式上,更重要的是它仍然還不夠好:

  • 遞迴本身的性質導致了演算法執行時額外的儲存開銷。

為此,我們正式引入自底向上的一種處理方式,並用迭代代替了遞迴,實現了較為簡潔的硬幣找零動歸解法。在多項式級別的演算法時間複雜度內,我們用最快的速度得到了我們想要的結果。

另外,動態規劃的關鍵是狀態轉移方程,為了寫出它,我們需要按照套路找出以下專案:

  • 初始化狀態:由於動態規劃是根據已經計算好的子問題推廣到更大問題上去的,因此我們需要一個“原點”作為計算的開端;
  • 狀態:找出子問題與原問題之間會發生變化的變數,這個變數就是狀態轉移方程中的引數;
  • 決策:改變狀態,讓狀態不斷逼近初始化狀態的操作。這個決策,就是狀態轉移方程狀態轉移的方向。

最後,我們要將以上資訊組裝起來,一般來說就可以得到動態規劃的狀態轉移方程了。

相關文章