動態規划法
動態規划法將待求解問題分解成若干個相互重疊的子問題,每個子問題對應決策過程的一個階段,一般來說,子問題的重疊關系表現在對給定問題求解的遞推關系稱為動態規划函數中,將子問題的解求解一次並填入表中,當需要再次求解此子問題時,可以通過查表獲得該子問題的解,從而避免了大量重復計算。具體的動態規划法多種多樣,但都具有相同的填表形式。一般來說,動態規划法的求解過程由以下三個階段組成:
- 划分子問題:將原問題分解為若干個子問題,每個子問題對應一個決策階段,並且子問題之間具有重疊關系。
- 確定動態規划函數:根據子問題之間的重疊關系找到子問題滿足的遞推關系式即動態規划函數,這是動態規划法的關鍵。
- 填寫表格:設計表格,以自底向上的方式計算各個子問題的解並填表,實現動態規划過程。
上述動態規划過程可以求得問題的最優值即目標函數的極值,如果要求出具體的最優解,通常在動態規划過程中記錄必要的信息,再根據最優決策序列構造最優解。
找零錢問題
在面值為 (v1,v2,…,vn) 的 n 種貨幣中, 需要支付 y 值的貨幣,應如何支付才能使貨幣支付的張數最少?例如給定 v1=1,v2=5,v3=6,v4=11,y=20,在使用 4 種貨幣換取出 20 的同時保證使用的張數最小。
問題分析
要用 4 種面值的貨幣換取 20,總共有 4 種可能,分別是先換取 19 再加上一張面值 1 的貨幣、先換取 15 再加上一張面值 5 的貨幣、先換取 14 再加上一張面值 6 的貨幣、先換取 9 再加上一張面值 11 的貨幣。設 Cost(n) 為換取 n 需要的最少貨幣數,則使用數學語言的描述如下:
把情況從特殊推廣到一般情況,設貨幣面值集合 V 中有表示不同面值貨幣的元素 v1,v2,v3…,vi,獲得狀態轉換方程為:
最優子結構證明
設需要換取的總金額為 y,貨幣的面值分別為 v1,v2,v3…,vi,滿足換取 y 的最少貨幣數對應到每種面值貨幣的張數為 n1,n2,n3…,ni。此時換取的總張數為 n1+n2+n3+…ni,換取的公式為:
假設從這些貨幣中拿掉一張面值為 v1 的貨幣,此時換取的總張數為 n1+n2+n3+…ni-1,換算公式為:
假設此時換取 y-v1 的總張數 n1+n2+n3+…ni-1 不是最少張數,則必然存在另一種換算方式為每種面值貨幣的張數對應 m1,m2,m3…,mi,也就是總張數為 m1+m2+m3+…mi 張。進而推出換算 y 的最少張數為 m1+m2+m3+…mi+1 張。然而已知總張數為 n1+n2+n3+…ni 為換取 y 的最少張數,不可能存在換取方式的總張數比這種方法還要少,產生了矛盾,因此找零錢問題滿足最優子結構。
問題求解
想要求出總金額 y 的最少貨幣張數,就需要先算出 1—(y-1) 的最少貨幣張數,可以申請一個一維數組 conversion_table[y+1] 來存儲。假設我們要算金額 11 的最少貨幣張數,我們就先要算出金額 1—10 的最小貨幣張數。
根據狀態轉移方程,換取金額 11 的最少張數為:
如果想要知道具體是如何換算的,還需要一個一維數組 add_table[MAXV] 進行輔助,該數組用於存儲該金額相對於前驅狀態添加的貨幣面值。要獲取總金額 y 的前驅狀態添加的貨幣金額,需要訪問數組元素 add_table[y-add_table[y]]。
總結一下解決問題的方式,可以得到算法的偽代碼如下:
程序編寫
#include <iostream>
using namespace std;
#define MAXV 51
#define type 5
int main()
{
int conversion_table[MAXV]; //金額對應的最小貨幣數
int monetary_value[type] = {}; //存儲貨幣不同的面值
int add_table[MAXV] = {}; //添加貨幣表
int total; //總共需要的金額數
int types; //貨幣的種類數
int num; //當前所需貨幣數
int pre;
cout << "總共需要的金額:";
cin >> total;
conversion_table[0] = 0;
//初始化每種總金額的最小貨幣數
for(int i = 1; i <= total; i++)
{
conversion_table[i] = 9999;
}
cout << "貨幣種數:";
cin >> types;
//初始化 types 種貨幣
for(int i = 0; i < types; i++)
{
cout << "第" << i + 1 << "種貨幣面值為:";
cin >> monetary_value[i];
}
//計算低於總金額的每一種金額的最優換取方式
for(int i = 1; i <= total; i++)
{
//依次分析加上某種面值貨幣的前置狀態
for(int j = 0; j < types; j++)
{
//若總金額達不到某種貨幣的面值,就不用分析
if(i - monetary_value[j] >= 0)
{
//貨幣張數 = 減去這種貨幣面值需要的張數 + 一張該面值的貨幣
num = conversion_table[i - monetary_value[j]] + 1;
//如果這種換取方式張數更少,更新狀態
if(num <= conversion_table[i])
{
conversion_table[i] = num;
add_table[i] = monetary_value[j]; //添加的貨幣面值
}
}
}
}
//輸出所有金額的換取方式
for(int i = 1; i <= total; i++)
{
cout << "換取" << i << "元所需的最少貨幣數為:" << conversion_table[i] << ",換取方式為:" << add_table[i];
pre = i - add_table[i];
while(conversion_table[pre])
{
cout << "+" << add_table[pre];
pre = pre - add_table[pre];
}
cout << endl;
}
return 0;
}
測試樣例
時間復雜度
算法的時間復雜度主要由兩部分組成:第一部分是依次計算從金額 1 到 y 的各個狀態的貨幣最少張數,由兩層嵌套的循環組成,外層循環執行 n-1 次,內層循環分別對 i 種貨幣面值進行計算,並且在所有循環中,每種面值只計算一次。假定總金額數為 m,則時間性能是 O(m)。第二部分是輸出最少張數的換取方式,設換取張數為 k,其時間性能是 O(k)。綜上所述,時間復雜度為 O(m+k)。
參考資料
《算法設計與分析(第二版)》——王紅梅,胡明 編著,清華大學出版社