錢幣找零問題
先從一個題目引出動態規劃。
有陣列 penny,penny 中所有的值都為正數且不重複。每個值代表一種面值的貨幣,每種面值的貨幣可以使用任意張,給定一個整數 N 表示貨幣總數,再給定一個整數 aim (小於等於 1000 )代表要找的錢數,求換錢有多少種方法。
給定陣列penny及它的大小(小於等於 50 ),同時給定一個整數aim,請返回有多少種方法可以湊成aim。
測試樣例:
[1,2,4],3,3
返回:2
本題非常經典,經典之處在於本題可以體現:暴力搜尋方法,記憶搜尋方法,動態規劃方法之間的關係,並可以在動態規劃的基礎上進行再一次的優化從而能夠幫助大家瞭解什麼是動態規劃。
暴力搜尋方法
假設:penny = {5, 10, 25, 1}, aim = 1000。
暴力搜尋的過程如下:
1、用 0 張 5 元的貨幣,讓 [10, 25, 1] 組成剩下的 1000,最終方法數記為 res1
2、用 1 張 5 元的貨幣,讓 [10, 25, 1] 組成剩下的 995,最終方法數記為 res2
3、用 2 張 5 元的貨幣,讓 [10, 25, 1] 組成剩下的 990,最終方法數記為 res3
…
201、用 200 張 5 元的貨幣,讓 [10, 25, 1] 組成剩下的 0,最終方法數記為 res201
所以最終總的方法數為 res1 + res2 + res3 +…+ res201。
據此定義遞迴函式:int p1(arr, index, aim),它的含義是用 arr[index..N - 1]這些面值的錢組成 aim,返回總的方法數。
程式碼如下:
public int coins1(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0;
}
return process1(arr, 0, aim);
}
public int process1(int[] arr, int index, int aim) {
int res = 0;
if (index == arr.length) {
res = aim == 0 ? 1 : 0;
} else {
for (int i = 0; i * arr[index] <= aim; i++) {
res += process1(arr, index + 1, aim - i * arr[index]);
}
}
return res;
}
暴力搜尋之所以暴力是因為存在大量的重複計算,比如:
- 如果已經使用了 0 張 5 元和 1 張 10 元的情況下,後續將繼續求:process1(arr, 2, 990)
- 如果已經使用了 2 張 5 元和 0 張 10 元的情況下,後續同樣會繼續求:process1(arr, 2, 990)
類似這樣的情況在暴力遞迴的過程中大量的發生,所以暴力遞迴的時間複雜度就非常的高。
所以就有了下面的這種記憶搜尋的方法:
記憶搜尋方法
重複計算之所以會大量發生實際上是因為每一個遞迴過程的結果並沒有記錄下來,所以下次還要重複去求,所以可以事先準備好一個雜湊表,每計算完一個遞迴結果後,都講結果放入 map 中,下次進行遞迴過程之前,先在這個 map 中查詢這個遞迴過程是否已經計算過了,如果已經計算過了,則直接取值,如果不存在,才進行遞迴計算。
因為本題的遞迴過程可以由兩個變數來表示,所以 map 是一張二維表,map[i][j] 代表 p(arr, i, j) 的返回結果。
1. 每計算完一個 p(index, aim), 都將結果放入 map 中,index 和 aim 組成共同的 key,返回結果為 value。
2. 要進入一個遞迴過程 p(index, aim),先以 index 和 aim 註冊的 key 在 map 中查詢是否已經存在的 value,如果存在,則直接取值,如果不存在,才進行遞迴計算。
程式碼如下:
public int countWays(int[] penny, int n, int aim) {
if (penny == null || penny.length == 0 || aim < 0) {
return 0;
}
int[][] map = new int[penny.length + 1][aim + 1];
return process1(penny, 0, aim, map);
}
public int process1(int[] arr, int index, int aim, int[][] map) {
int res = 0;
if (index == arr.length) {
res = aim == 0 ? 1 : 0;
} else {
for (int i = 0; i * arr[index] <= aim; i++) {
int mapValue = 0;
if (mapValue != 0) {
res += mapValue == -1 ? 0 : mapValue;
} else {
res += process1(arr, index + 1, aim - i * arr[index], map);
}
}
}
map[index][aim] = res == 0 ? -1 : res;
return res;
}
動態規劃方法
如果陣列長度為 N,生成行數為 N,列數為 aim + 1(因為組成的錢數可能為 0,所以要多加一列) 的矩陣 dp。dp[i][j]的含義是:在使用 arr[0..i] 貨幣的情況下,組成錢數 j 有多少種方法。
解法如下:
1、對於組成矩陣第一列的值表示組成錢數為 0 的方法數,那麼很明顯就是 1 種(不適用任何的貨幣),所以 dp 第一列的值統一設定為 1。
2、對於 dp 矩陣第一行的值,表示只使用 arr[0] 這一種貨幣的情況下組成的方法數,只有 arr[0] 的整數倍的位置才能被 arr[0] 組成,其他錢數統統不行,所以就將相應位置設定為 1,其他位置設定為 0。
3、第一行第一列以外的 dp[i][j] 的值是以下情況的累加:
* 如果完全不使用 arr[i] 貨幣,只使用 arr[0..i-1] 貨幣時,方法數為 dp[i-1][j]
* 如果只使用一張 arr[i] 貨幣,剩下的錢用 arr[0..i-1] 貨幣組成時,方法數為 dp[i-1][j-1*arr[i]]
* 如果用兩張 arr[i] 貨幣,剩下的錢用 arr[0..i-1] 貨幣組成時,方法數為 dp[i-1][j-2*arr[i]]
* 如果用三張 arr[i] 貨幣,剩下的錢用 arr[0..i-1] 貨幣組成時,方法數為 dp[i-1][j-3*arr[i]]
…
dp[i][j] 的求法:從左到右依次求出 dp 矩陣中每一行的值,然後再計算下一行的值,最終最右下角的值,也就是 dp[N-1][aim] 的值就是最終的結果,返回即可。
注:在求每一個位置的值時都要列舉這個位置上一排左邊所有的值,時間複雜度為 O(aim)。dp 中一共有 N * aim 個位置,所以總體的時間複雜度為 O( N * aim * aim )。
程式碼如下:
public int countWays(int[] penny, int n, int aim) {
if (penny == null || penny.length == 0 || aim < 0) {
return 0;
}
int[][] dp = new int[n][aim + 1];
// 初始化第一行的值
for (int i = 0; i < aim + 1; i++) {
if (i % penny[0] == 0) {
dp[0][i] = 1;
}
}
// 初始化第一列的值
for (int i = 0; i < n; i++) {
dp[i][0] = 1;
}
for (int i = 1; i < n; i++) {
for (int j = 1; j < aim + 1; j++) {
for (int k = 0; j - k * penny[i] >= 0; k++) {
dp[i][j] += dp[i - 1][j - k * penny[i]];
}
}
}
return dp[n - 1][aim];
}
記憶搜尋方法與動態規劃方法的聯絡
- 記憶化搜尋方法就是某種形態的動態規劃方法
- 記憶化搜尋的方法不關心到達某一個遞迴過程的路徑,只是單純地對計算過的遞迴過程進行記錄,避免重複的遞迴計算。
- 動態規劃的方法則是規定好每一個遞迴過程的計算順序,依次進行計算,後面的計算過程嚴格依賴前面的計算過程。
- 兩者都是空間換時間的方法,也都有列舉的過程,區別就在於動態規劃規定計算順序,而記憶搜尋不用規定。
到底什麼是動態規劃方法
其本質是利用申請的空間來記錄每一個暴力搜尋的計算過程,下次要用結果的時候直接使用,而不再進行重複的遞迴過程。
動態規劃規定每一種遞迴狀態的計算順序,依次進行計算。
動態規劃方法和記憶搜尋方法本質上是相同的,但是動態規劃方法把狀態的計算順序給規定了,從而讓狀態的進一步化簡成為可能。
通過剛才對問題的分析,我們知道 (i,j) 位置的 dp 值需要上面很多位置的值的累加:
dp[i][j] = dp[i-1][j] + dp[i-1][j - penny[i]] + dp[i-1][j - 2 * penny[i]] +…
可以發現紅框圈出來的部分中的黑色方塊的值就是 dp[i][j - penny[i]] 的值,所以dp[i][j] 的值就可以化簡為:dp[i][j] = dp[i - 1][j] + dp[i][j - penny[i]],時間複雜度就從 O(n * aim * aim) 降低到了 O(n * aim),從而進一步得到了優化,程式碼如下:
public int countWays(int[] penny, int n, int aim) {
if (penny == null || penny.length == 0 || aim < 0) {
return 0;
}
int[][] dp = new int[n][aim + 1];
// 初始化第一行的值
for (int i = 0; i < aim + 1; i++) {
if (i % penny[0] == 0) {
dp[0][i] = 1;
}
}
// 初始化第一列的值
for (int i = 0; i < n; i++) {
dp[i][0] = 1;
}
for (int i = 1; i < n; i++) {
for (int j = 1; j < aim + 1; j++) {
if (j - penny[i] >= 0) {
dp[i][j] += dp[i][j - penny[i]];
}
dp[i][j] += dp[i - 1][j];
}
}
return dp[n - 1][aim];
}
到這裡其實我們發現只用一個一維的陣列就可以記錄已有的狀態,空間上更優的程式碼如下:
public int countWays(int[] penny, int n, int aim) {
if (penny == null || penny.length == 0 || aim < 0) {
return 0;
}
int[] dp = new int[aim + 1];
// 初始化第一行的值
for (int i = 0; i < aim + 1; i++) {
if (i % penny[0] == 0) {
dp[i] = 1;
}
}
for (int i = 1; i < n; i++) {
for (int j = 1; j < aim + 1; j++) {
if (j - penny[i] >= 0) {
dp[j] += dp[j - penny[i]];
}
}
}
return dp[aim];
}
面試中遇到的暴力遞迴題目可以優化成動態規劃的方法的大體過程:
- 實現暴力遞迴方法。
- 在暴力遞迴方法的函式中看看哪些引數可以代表遞迴過程,找到那些引數,把這些引數整體當做 key,把這個遞迴過程的計算結果當做 value 存入 map 中,每個遞迴過程計算完成後,都把結果放入 map 中,下次再碰到同樣的狀態要進行計算的時候,就可以直接從 map 中取出來用了。
- 有了記憶化搜尋方法,要想得到動態規劃的方法,下一步就要去整理各個狀態之間的依賴關係,簡單的可以直接得到的狀態先計算(如果是一個二維的,一般就是第一行和第一列了),依賴簡單狀態的計算結果的複雜的狀態後計算。
- 然後再看看每個狀態能不能再簡化,得到更簡單的狀態方程,可以就相當於得到了更好的動態規劃方法。
現在就可以解釋一下為什麼很多人會覺得動態規劃方法很難了,比如動態規劃的經典問題:求最長遞增子序列問題,0-1 揹包問題,硬幣找零問題… 其實是因為很多人對這種經典動態規劃問題最開始的暴力搜尋問題不瞭解,在課本接觸到動態規劃問題時,教學者又省掉了最初的暴力搜尋過程,而是把優化後的動態規劃方法的整套方法論直接進行講述,所以不瞭解整套優化過程的人當然會覺得動態規劃方法理解起來非常的困難。
所以動態規劃方法其實就是那些我們不瞭解的先賢們在當初他們面對暴力搜尋的時候發現了有一些常規的優化方法,並且加以總結所形成的用空間換時間的一整套方法的集合。
看到這裡,在把課本上那一套關於動態規劃方法的原理搬出來理解理解:
動態規劃方法的關鍵點:
- 最優化原理,也就是最優子結構性質。這指的是一個最優化的策略具有這樣的性質:不論過去狀態和決策如何,對前面的決策所形成的狀態而言,餘下的諸決策必須構成最優策略,簡單來說就是一個最優化策略的子策略總是最優的,如果一個問題滿足最優化原理,就稱其具有最優子結構性質。
- 無後效性,指的是某狀態下決策的收益,至於狀態和決策相關,與到達該狀態的方式無關。
- 子問題的重疊性,動態規劃將原來具有指數級時間複雜度的暴力搜尋演算法改進成了具有多項式時間複雜度的演算法。其中的關鍵在於解決冗餘,這是動態規劃演算法的根本目的。