LeetCode - Medium - 322. Coin Change

巨輪發表於2020-12-29

Topic

  • Dynamic Programming

Description

https://leetcode.com/problems/coin-change/

You are given coins of different denominations and a total amount of money amount. Write a function to compute the fewest number of coins that you need to make up that amount. If that amount of money cannot be made up by any combination of the coins, return -1.

Example 1:

Input: coins = [1, 2, 5], amount = 11
Output: 3
Explanation: 11 = 5 + 5 + 1

Example 2:

Input: coins = [2], amount = 3
Output: -1

Note:
You may assume that you have an infinite number of each kind of coin.

Analysis

解說來源

解決動態規劃問題的4步驟

  1. 確定問題狀態
    • 提煉最後一步
    • 子問題轉化
  2. 轉移方程,把問題方程化
  3. 按照實際邏輯設定初始條件和邊界情況
  4. 確定計算順序並求解

本文中的“Write a function to compute the fewest number of coins that you need to make up that amount.”一句中的關鍵詞“the fewest number of coins”,最值問題自然聯想到,“求最值問題,動態規劃”。


假設 Input: coins = [2, 5, 7], amount = 27,用最少的硬幣組合

正常人第一反應思路:

最少硬幣組合?

優先使用大面值硬幣——7+7+7+5=26 額?可求解目標是27啊……

改演算法——7+7+7+2+2+2=27,總共用了6枚硬幣正好27元.

實際正確答案:7+5+5+5+5=27,才用了5枚硬幣。

所以這裡貪心演算法是不正確的


開始使用動態規劃4步驟解決本問題

確定問題狀態

動態規劃問題求解需要先開一個陣列,並確定陣列的每個元素f[i]代表什麼,就是確定這個問題的狀態。類似於解數學題中,設定X,Y,Z代表什麼。

確定狀態首先提取【最後一步】

最優策略必定是K枚硬幣a1, a2,…, aK 面值加起來是27。

找出不影響最優策略的最後一個獨立角色,這道問題中,那枚最後的硬幣“aK”就是最後一步。

把aK提取出來,硬幣aK之前的所有硬幣面值加總是27- aK

因為總體求最硬幣數量最小策略,所以拼出27- aK的硬幣數也一定最少(重要設定)。

在這裡插入圖片描述

轉化子問題

最後一步aK提出來之後,我們只要求出“最少用多少枚硬幣可以拼出27- aK”就可以了。

這種與原問題核心一致,但是規模變小的問題,叫做子問題。

為簡化定義,我們設狀態f(X)=最少用多少枚硬幣拼出總面值X。

我們目前還不知道最後的硬幣aK面額多少,但它的面額一定只可能是2、5、7之一。

  1. 如果aK是2,f(27)應該是f(27-2) + 1 (加上最後這一枚面值2的硬幣)
  2. 如果aK是5,f(27)應該是f(27-5) + 1 (加上最後這一枚面值5的硬幣)
  3. 如果aK是7,f(27)應該是f(27-7) + 1 (加上最後這一枚面值7的硬幣)

除此以外,沒有其他的可能了。

至此,通過找到原問題最後一步,並將其轉化為子問題。
為求面值總額27的最小的硬幣組合數的狀態就形成了,用以下函式表示:

f(27) = min{f(27-2)+1, f(27-5)+1, f(27-7)+1}

在這裡插入圖片描述

轉移方程,把問題方程化

//(動態規劃都是要開陣列,所以這裡改用方括號表示)
f[X] = min{f[X-2]+1, f[X-5]+1, f[X-7]+1}

實際面試中求解動態規劃類問題,正確列出轉移方程正確基本上就解決一半了。

但是請問:這與遞迴有什麼不同??

遞迴的解法如下:

// f(X)返回最少用多少枚硬幣拼出X
int f(int X) {
	// 0元錢只要0枚硬幣
	if (X == 0) return 0;
	// 初始化用無窮大
	int res = MAX_VALUE;
	// 最後一枚硬幣是2元
	if (X >= 2) {
		res = Math.min(f(X – 2) + 1, res);
	}
	// 最後一枚硬幣是5元
	if (X >= 5) {
		res = Math.min(f(X – 5) + 1, res);
	}
	// 最後一枚硬幣是7元
	if (X >= 7) {
		res = Math.min(f(X – 7) + 1, res);
	}
	return res;
}

執行圖如下:

在這裡插入圖片描述

要算f(27),就要遞迴f(25)、f(22)、f(20),然後下邊依次遞迴……(三角形表示)

問題明顯——重複遞迴太多。

這是求f(27),還可以勉強遞迴。如果求f(100)呢?簡直是天文數字。最終結果就是遞迴超時

所以,求總體最值,一定優先考慮動態規劃,謹慎使用遞迴

按照實際邏輯設定邊界情況和初始條件

f[X] = min{f[X-2]+1, f[X-5]+1, f[X-7]+1}的 邊 界 情 況 是 [x-2]、[x-5]、[x-7] 不能小於0(硬幣面值為正),也不能高於27。

故對邊界情況設定如下

如果硬幣面值不能組合出Y,就定義f[Y]=正無窮

例如f[-1]=f[-2]=…=正無窮;f[1] =min{f[-1]+1, f[-4]+1,f[-6]+1}=正無窮,

特殊情況:本題的F[0]對應的情況為F[-2]、F[-5]、F[-7],按照上文的邊界情況設定結果是正無窮。

但是實際上F[0]的結果是存在的(即使用0個硬幣的情況下),F[0]=0。

可是按照我們剛剛的設定,F[0] = F[0-2]+1 = F[-2]+1 = 正無窮。

豈不是矛盾?

這種用轉移方程無法計算,但是又實際存在的情況,就必須通過手動定義

這裡手動強制定義初始條件為:F[0]=0。

而從0之後的數值是沒矛盾的,比如F[1]= F[1-2]+1= F[-1]+1=正無窮(正無窮加任何數結果還是正無窮);F[2] = F[2-2]+1= F[0]+1 = 1 ……

確定計算順序並計算求解

那麼開始計算時,是從F[1]、F[2]開始呢?還是從F[27]、F[26]開始呢?
判斷計算順序正確與否的原則是:

當我們要計算F[X](等式左邊,如F[10])的時候,等式右邊(f[X-2], f[X-5], f[X-7]等)都是已經得到結果的狀態,這個計算順序就是OK的。

實際就是從小到大的計算方式(偶有例外的情況我們後邊再講)。

例如我們算到F[12]的時候,發現F[11]、F[10]、F[9]都已經算過了,這種演算法就是對的;
而開始算F[27]的時候,發現F[26]還沒有算,這樣的順序就是錯的。

很顯然這樣的情況下寫一個for迴圈就夠了。

回到這道題,採用動態規劃的演算法,每一步只嘗試三種硬幣,一共進行了27步。演算法時間複雜度(即需要進行的步數)為27 * 3。(coins.;ength * amount)

與遞迴相比,沒有任何重複計算

Submissions

import java.util.Arrays;

public class CoinChange {
	public int coinChange(int[] coins, int amount) {
		int[] f = new int[amount + 1];
		
		Arrays.fill(f, Integer.MAX_VALUE);
		f[0] = 0;

		for (int i = 1; i <= amount; i++) {
			for (int j = 0; j < coins.length; j++) {
				
				if (i >= coins[j] && f[i - coins[j]] != Integer.MAX_VALUE) {
					f[i] = Math.min(f[i - coins[j]] + 1, f[i]);
				}
			}
		}

		if (f[amount] == Integer.MAX_VALUE) {
			return -1;
		}
		return f[amount];
	}
}

Test

import static org.junit.Assert.*;
import org.junit.Test;

public class CoinChangeTest {

	@Test
	public void test() {
		CoinChange obj = new CoinChange();

		assertEquals(3, obj.coinChange(new int[] {1, 2, 5}, 11));
		assertEquals(-1, obj.coinChange(new int[] {2}, 3));
		assertEquals(5, obj.coinChange(new int[] {2, 5, 7}, 27));
	}
}

相關文章