題目描述
給定不同面額的硬幣 coins 和一個總金額 amount。編寫一個函數來計算可以湊成總金額所需的最少的硬幣個數。如果沒有任何一種硬幣組合能組成總金額,返回 -1。
示例 1:
輸入: coins = [1, 2, 5], amount = 11
輸出: 3
解釋: 11 = 5 + 5 + 1
示例 2:
輸入: coins = [2], amount = 3
輸出: -1
解題思路
動態規划問題的一般形式就是求最值。動態規划其實是運籌學的一種最優化方法,只不過在計算機問題上應用比較多,比如說讓你求最長遞增子序列呀,最小編輯距離呀等等。
既然是要求最值,核心問題是什么呢?求解動態規划的核心問題是窮舉。因為要求最值,肯定要把所有可行的答案窮舉出來,然后在其中找最值唄。
動態規划就這么簡單,就是窮舉就完事了?我看到的動態規划問題都很難啊!
首先,動態規划的窮舉有點特別,因為這類問題存在「重疊子問題」,如果暴力窮舉的話效率會極其低下,所以需要「備忘錄」或者「DP table」來優化窮舉過程,避免不必要的計算。
而且,動態規划問題一定會具備「最優子結構」,才能通過子問題的最值得到原問題的最值。
另外,雖然動態規划的核心思想就是窮舉求最值,但是問題可以千變萬化,窮舉所有可行解其實並不是一件容易的事,只有列出正確的「狀態轉移方程」才能正確地窮舉。
以上提到的重疊子問題、最優子結構、狀態轉移方程就是動態規划三要素。具體什么意思等會會舉例詳解,但是在實際的算法問題中,寫出狀態轉移方程是最困難的,這也就是為什么很多朋友覺得動態規划問題困難的原因,我來提供我研究出來的一個思維框架,輔助你思考狀態轉移方程:
明確「狀態」 -> 定義 dp 數組/函數的含義 -> 明確「選擇」-> 明確 base case。
下面通過斐波那契數列問題和湊零錢問題來詳解動態規划的基本原理。前者主要是讓你明白什么是重疊子問題(斐波那契數列嚴格來說不是動態規划問題),后者主要舉集中於如何列出狀態轉移方程。
請讀者不要嫌棄這個例子簡單,只有簡單的例子才能讓你把精力充分集中在算法背后的通用思想和技巧上,而不會被那些隱晦的細節問題搞的莫名其妙。
一、斐波那契數列
1、暴力遞歸
斐波那契數列的數學形式就是遞歸的,寫成代碼就是這樣:
int fib(int N) { if (N == 1 || N == 2) return 1; return fib(N - 1) + fib(N - 2); }
這個不用多說了,學校老師講遞歸的時候似乎都是拿這個舉例。我們也知道這樣寫代碼雖然簡潔易懂,但是十分低效,低效在哪里?假設 n = 20,請畫出遞歸樹。
PS:但凡遇到需要遞歸的問題,最好都畫出遞歸樹,這對你分析算法的復雜度,尋找算法低效的原因都有巨大幫助。
這個遞歸樹怎么理解?就是說想要計算原問題 f(20),我就得先計算出子問題 f(19) 和 f(18),然后要計算 f(19),我就要先算出子問題 f(18) 和 f(17),以此類推。最后遇到 f(1) 或者 f(2) 的時候,結果已知,就能直接返回結果,遞歸樹不再向下生長了。
遞歸算法的時間復雜度怎么計算?子問題個數乘以解決一個子問題需要的時間。
子問題個數,即遞歸樹中節點的總數。顯然二叉樹節點總數為指數級別,所以子問題個數為 O(2^n)。
解決一個子問題的時間,在本算法中,沒有循環,只有 f(n - 1) + f(n - 2) 一個加法操作,時間為 O(1)。
所以,這個算法的時間復雜度為 O(2^n),指數級別,爆炸。
觀察遞歸樹,很明顯發現了算法低效的原因:存在大量重復計算,比如 f(18) 被計算了兩次,而且你可以看到,以 f(18) 為根的這個遞歸樹體量巨大,多算一遍,會耗費巨大的時間。更何況,還不止 f(18) 這一個節點被重復計算,所以這個算法及其低效。
這就是動態規划問題的第一個性質:重疊子問題。下面,我們想辦法解決這個問題。
2、帶備忘錄的遞歸解法
明確了問題,其實就已經把問題解決了一半。即然耗時的原因是重復計算,那么我們可以造一個「備忘錄」,每次算出某個子問題的答案后別急着返回,先記到「備忘錄」里再返回;每次遇到一個子問題先去「備忘錄」里查一查,如果發現之前已經解決過這個問題了,直接把答案拿出來用,不要再耗時去計算了。
一般使用一個數組充當這個「備忘錄」,當然你也可以使用哈希表(字典),思想都是一樣的。
int fib(int N) { if (N < 1) return 0; // 備忘錄全初始化為 0 vector<int> memo(N + 1, 0); // 初始化最簡情況 return helper(memo, N); } int helper(vector<int>& memo, int n) { // base case if (n == 1 || n == 2) return 1; // 已經計算過 if (memo[n] != 0) return memo[n]; memo[n] = helper(memo, n - 1) + helper(memo, n - 2); return memo[n]; }
現在,畫出遞歸樹,你就知道「備忘錄」到底做了什么。
實際上,帶「備忘錄」的遞歸算法,把一棵存在巨量冗余的遞歸樹通過「剪枝」,改造成了一幅不存在冗余的遞歸圖,極大減少了子問題(即遞歸圖中節點)的個數。
遞歸算法的時間復雜度怎么算?子問題個數乘以解決一個子問題需要的時間。
子問題個數,即圖中節點的總數,由於本算法不存在冗余計算,子問題就是 f(1), f(2), f(3) ... f(20),數量和輸入規模 n = 20 成正比,所以子問題個數為 O(n)。
解決一個子問題的時間,同上,沒有什么循環,時間為 O(1)。
所以,本算法的時間復雜度是 O(n)。比起暴力算法,是降維打擊。
至此,帶備忘錄的遞歸解法的效率已經和迭代的動態規划解法一樣了。實際上,這種解法和迭代的動態規划已經差不多了,只不過這種方法叫做「自頂向下」,動態規划叫做「自底向上」。
啥叫「自頂向下」?注意我們剛才畫的遞歸樹(或者說圖),是從上向下延伸,都是從一個規模較大的原問題比如說 f(20),向下逐漸分解規模,直到 f(1) 和 f(2) 觸底,然后逐層返回答案,這就叫「自頂向下」。
啥叫「自底向上」?反過來,我們直接從最底下,最簡單,問題規模最小的 f(1) 和 f(2) 開始往上推,直到推到我們想要的答案 f(20),這就是動態規划的思路,這也是為什么動態規划一般都脫離了遞歸,而是由循環迭代完成計算。
3、dp 數組的迭代解法
有了上一步「備忘錄」的啟發,我們可以把這個「備忘錄」獨立出來成為一張表,就叫做 DP table 吧,在這張表上完成「自底向上」的推算豈不美哉!
int fib(int N) { vector<int> dp(N + 1, 0); // base case dp[1] = dp[2] = 1; for (int i = 3; i <= N; i++) dp[i] = dp[i - 1] + dp[i - 2]; return dp[N]; }
畫個圖就很好理解了,而且你發現這個 DP table 特別像之前那個「剪枝」后的結果,只是反過來算而已。實際上,帶備忘錄的遞歸解法中的「備忘錄」,最終完成后就是這個 DP table,所以說這兩種解法其實是差不多的,大部分情況下,效率也基本相同。
這里,引出「狀態轉移方程」這個名詞,實際上就是描述問題結構的數學形式:
為啥叫「狀態轉移方程」?為了聽起來高端。你把 f(n) 想做一個狀態 n,這個狀態 n 是由狀態 n - 1 和狀態 n - 2 相加轉移而來,這就叫狀態轉移,僅此而已。
你會發現,上面的幾種解法中的所有操作,例如 return f(n - 1) + f(n - 2),dp[i] = dp[i - 1] + dp[i - 2],以及對備忘錄或 DP table 的初始化操作,都是圍繞這個方程式的不同表現形式。可見列出「狀態轉移方程」的重要性,它是解決問題的核心。很容易發現,其實狀態轉移方程直接代表着暴力解法。
千萬不要看不起暴力解,動態規划問題最困難的就是寫出狀態轉移方程,即這個暴力解。優化方法無非是用備忘錄或者 DP table,再無奧妙可言。
這個例子的最后,講一個細節優化。細心的讀者會發現,根據斐波那契數列的狀態轉移方程,當前狀態只和之前的兩個狀態有關,其實並不需要那么長的一個 DP table 來存儲所有的狀態,只要想辦法存儲之前的兩個狀態就行了。所以,可以進一步優化,把空間復雜度降為 O(1):
int fib(int n) { if (n == 2 || n == 1) return 1; int prev = 1, curr = 1; for (int i = 3; i <= n; i++) { int sum = prev + curr; prev = curr; curr = sum; } return curr; }
有人會問,動態規划的另一個重要特性「最優子結構」,怎么沒有涉及?下面會涉及。斐波那契數列的例子嚴格來說不算動態規划,因為沒有涉及求最值,以上旨在演示算法設計螺旋上升的過程。下面,看第二個例子,湊零錢問題。
二、湊零錢問題
先看下題目:給你 k 種面值的硬幣,面值分別為 c1, c2 ... ck,每種硬幣的數量無限,再給一個總金額 amount,問你最少需要幾枚硬幣湊出這個金額,如果不可能湊出,算法返回 -1 。算法的函數簽名如下:
// coins 中是可選硬幣面值,amount 是目標金額
int coinChange(int[] coins, int amount);
比如說 k = 3,面值分別為 1,2,5,總金額 amount = 11。那么最少需要 3 枚硬幣湊出,即 11 = 5 + 5 + 1。
你認為計算機應該如何解決這個問題?顯然,就是把所有肯能的湊硬幣方法都窮舉出來,然后找找看最少需要多少枚硬幣。
1、暴力遞歸
首先,這個問題是動態規划問題,因為它具有「最優子結構」的。要符合「最優子結構」,子問題間必須互相獨立。啥叫相互獨立?你肯定不想看數學證明,我用一個直觀的例子來講解。
比如說,你的原問題是考出最高的總成績,那么你的子問題就是要把語文考到最高,數學考到最高…… 為了每門課考到最高,你要把每門課相應的選擇題分數拿到最高,填空題分數拿到最高…… 當然,最終就是你每門課都是滿分,這就是最高的總成績。
得到了正確的結果:最高的總成績就是總分。因為這個過程符合最優子結構,“每門科目考到最高”這些子問題是互相獨立,互不干擾的。
但是,如果加一個條件:你的語文成績和數學成績會互相制約,此消彼長。這樣的話,顯然你能考到的最高總成績就達不到總分了,按剛才那個思路就會得到錯誤的結果。因為子問題並不獨立,語文數學成績無法同時最優,所以最優子結構被破壞。
回到湊零錢問題,為什么說它符合最優子結構呢?比如你想求 amount = 11 時的最少硬幣數(原問題),如果你知道湊出 amount = 10 的最少硬幣數(子問題),你只需要把子問題的答案加一(再選一枚面值為 1 的硬幣)就是原問題的答案,因為硬幣的數量是沒有限制的,子問題之間沒有相互制,是互相獨立的。
那么,既然知道了這是個動態規划問題,就要思考如何列出正確的狀態轉移方程?
先確定「狀態」,也就是原問題和子問題中變化的變量。由於硬幣數量無限,所以唯一的狀態就是目標金額 amount。
然后確定 dp 函數的定義:當前的目標金額是 n,至少需要 dp(n) 個硬幣湊出該金額。
然后確定「選擇」並擇優,也就是對於每個狀態,可以做出什么選擇改變當前狀態。具體到這個問題,無論當的目標金額是多少,選擇就是從面額列表 coins 中選擇一個硬幣,然后目標金額就會減少:
# 偽碼框架 def coinChange(coins: List[int], amount: int): # 定義:要湊出金額 n,至少要 dp(n) 個硬幣 def dp(n): # 做選擇,選擇需要硬幣最少的那個結果 for coin in coins: res = min(res, 1 + dp(n - coin)) return res # 我們要求的問題是 dp(amount) return dp(amount)
最后明確 base case,顯然目標金額為 0 時,所需硬幣數量為 0;當目標金額小於 0 時,無解,返回 -1:
def coinChange(coins: List[int], amount: int): def dp(n): # base case if n == 0: return 0 if n < 0: return -1 # 求最小值,所以初始化為正無窮 res = float('INF') for coin in coins: subproblem = dp(n - coin) # 子問題無解,跳過 if subproblem == -1: continue res = min(res, 1 + subproblem) return res if res != float('INF') else -1 return dp(amount)
至此,狀態轉移方程其實已經完成了,以上算法已經是暴力解法了,以上代碼的數學形式就是狀態轉移方程:
至此,這個問題其實就解決了,只不過需要消除一下重疊子問題,比如 amount = 11, coins = {1,2,5} 時畫出遞歸樹看看:
時間復雜度分析:子問題總數 x 每個子問題的時間。
子問題總數為遞歸樹節點個數,這個比較難看出來,是 O(n^k),總之是指數級別的。每個子問題中含有一個 for 循環,復雜度為 O(k)。所以總時間復雜度為 O(k * n^k),指數級別。
2、帶備忘錄的遞歸
只需要稍加修改,就可以通過備忘錄消除子問題:
def coinChange(coins: List[int], amount: int): # 備忘錄 memo = dict() def dp(n): # 查備忘錄,避免重復計算 if n in memo: return memo[n] if n == 0: return 0 if n < 0: return -1 res = float('INF') for coin in coins: subproblem = dp(n - coin) if subproblem == -1: continue res = min(res, 1 + subproblem) # 記入備忘錄 memo[n] = res if res != float('INF') else -1 return memo[n] return dp(amount)
不畫圖了,很顯然「備忘錄」大大減小了子問題數目,完全消除了子問題的冗余,所以子問題總數不會超過金額數 n,即子問題數目為 O(n)。處理一個子問題的時間不變,仍是 O(k),所以總的時間復雜度是 O(kn)。
3、dp 數組的迭代解法
當然,我們也可以自底向上使用 dp table 來消除重疊子問題,dp 數組的定義和剛才 dp 函數類似,定義也是一樣的:
dp[i] = x 表示,當目標金額為 i 時,至少需要 x 枚硬幣。
int coinChange(vector<int>& coins, int amount) { // 數組大小為 amount + 1,初始值也為 amount + 1 vector<int> dp(amount + 1, amount + 1); // base case dp[0] = 0; for (int i = 0; i < dp.size(); i++) { // 內層 for 在求所有子問題 + 1 的最小值 for (int coin : coins) { // 子問題無解,跳過 if (i - coin < 0) continue; dp[i] = min(dp[i], 1 + dp[i - coin]); } } return (dp[amount] == amount + 1) ? -1 : dp[amount]; }
PS:為啥 dp 數組初始化為 amount + 1 呢,因為湊成 amount 金額的硬幣數最多只可能等於 amount(全用 1 元面值的硬幣),所以初始化為 amount + 1 就相當於初始化為正無窮,便於后續取最小值。
三、最后總結
第一個斐波那契數列的問題,解釋了如何通過「備忘錄」或者「dp table」的方法來優化遞歸樹,並且明確了這兩種方法本質上是一樣的,只是自頂向下和自底向上的不同而已。
第二個湊零錢的問題,展示了如何流程化確定「狀態轉移方程」,只要通過狀態轉移方程寫出暴力遞歸解,剩下的也就是優化遞歸樹,消除重疊子問題而已。
如果你不太了解動態規划,還能看到這里,真得給你鼓掌,相信你已經掌握了這個算法的設計技巧。
計算機解決問題其實沒有任何奇技淫巧,它唯一的解決辦法就是窮舉,窮舉所有可能性。算法設計無非就是先思考“如何窮舉”,然后再追求“如何聰明地窮舉”。
列出動態轉移方程,就是在解決“如何窮舉”的問題。之所以說它難,一是因為很多窮舉需要遞歸實現,二是因為有的問題本身的解空間復雜,不那么容易窮舉完整。
備忘錄、DP table 就是在追求“如何聰明地窮舉”。用空間換時間的思路,是降低時間復雜度的不二法門,除此之外,試問,還能玩出啥花活?
作者:labuladong
鏈接:https://leetcode-cn.com/problems/coin-change/solution/dong-tai-gui-hua-tao-lu-xiang-jie-by-wei-lai-bu-ke/
代碼如下
public int coinChange(int[] coins, int amount) { if(coins.length == 0) return -1; //聲明一個amount+1長度的數組dp,代表各個價值的錢包,第0個錢包可以容納的總價值為0,其它全部初始化為無窮大 //dp[j]代表當錢包的總價值為j時,所需要的最少硬幣的個數 int[] dp = new int[amount+1]; Arrays.fill(dp,1,dp.length,Integer.MAX_VALUE); //i代表可以使用的硬幣索引,i=2代表只在第0個,第1個,第2個這三個硬幣中選擇硬幣 for (int i = 0; i < coins.length; i++) { /** * 當外層循環執行一次以后,說明在只使用前i-1個硬幣的情況下,各個錢包的最少硬幣個數已經得到, * 有些錢包的值還是無窮大,說明在僅使用前i-1個硬幣的情況下,不能湊出錢包的價值 * 現在開始再放入第i個硬幣,要想放如w[i],錢包的價值必須滿足j>=w[i],所以在開始放入第i個硬幣時,j從w[i]開始 */ for (int j = coins[i]; j <= amount; j++) { /** * 如果錢包當前的價值j僅能允許放入一個w[i],那么就要進行權衡,以獲得更少的硬幣數 * 如果放入0個:此時錢包里面硬幣的個數保持不變: v0=dp[j] * 如果放入1個:此時錢包里面硬幣的個數為: v1=dp[j-coins[i]]+1 * 【前提是dp[j-coins[i]]必須有值,如果dp[j-coins[i]]是無窮大,說明無法湊出j-coins[i]價值的錢包, * 那么把w[i]放進去以后,自然也湊不出dp[j]的錢包】 * 所以,此時當錢包價值為j時,里面的硬幣數目為 dp[j]=min{v0,v1} * 如果錢包當前價值j能夠放入2個w[i],就要再進行一次權衡 * 如果不放人第2個w[i],此時錢包里面硬幣數目為,v1=dp[j]=min{v0,v1} * 如果放入第2個w[i], 此時錢包里面硬幣數目為,v2=dp[j-coins[i]]+1 * 所以,當錢包的價值為j時,里面的硬幣數目為dp[j]=min{v1,v2}=min{v0,v1,v2} * 錢包價值j能允許放入3個,4個.........w[i],不斷更新dp[j],最后得到在僅使用前i個硬幣的時候,每個錢包里的最少硬幣數目 */ if(dp[j-coins[i]] != Integer.MAX_VALUE) { dp[j] = Math.min(dp[j], dp[j-coins[i]]+1); } } } if(dp[amount] != Integer.MAX_VALUE) return dp[amount]; return -1; }