通過對換錢類題目的學(xué)習(xí),我們將了解到
- 暴力遞歸及優(yōu)化方法
- 記憶搜索(優(yōu)化一)
- 動態(tài)規(guī)劃的基本實(shí)現(xiàn)方法(優(yōu)化二)
- 動態(tài)規(guī)劃的空間優(yōu)化(優(yōu)化三)
1. 換錢的最少貨幣數(shù),貨幣可重復(fù)使用
給定數(shù)組arr,arr中所有的值都為正數(shù)且不重復(fù)。每個值代表一種面值的貨幣,每種貨幣都可以使用任意張,再給定一個整數(shù)aim代表要找的錢數(shù),求組成aim的最少貨幣數(shù)。
例如
arr=[5, 2, 3], aim=20, 返回4
arr=[5, 2, 3], aim=0, 返回0
arr=[3, 5], aim=2, 返回-1
動態(tài)規(guī)劃方法的關(guān)鍵是構(gòu)造動態(tài)規(guī)劃表dp。
動態(tài)規(guī)劃的實(shí)質(zhì)就是在計(jì)算過程中,根據(jù)動態(tài)規(guī)劃表計(jì)算下一個目標(biāo)值,并更新動態(tài)規(guī)劃表
在本題中,構(gòu)造這樣二維動態(tài)規(guī)劃表,dp[i][j],其中,i j的含義如下:
- i: 代表可以使用的貨幣種類為 arr[0..i]
- j: 代表需要兌換的面值數(shù),其取值范圍為[0..aim],因此實(shí)現(xiàn)時,二維數(shù)組的列數(shù)應(yīng)該為aim + 1
構(gòu)造過程:
- 構(gòu)造邊界值。邊界值是更新規(guī)劃表的起始值,也是很容易犯錯的地方,需要謹(jǐn)慎設(shè)置起始邊界值。
- dp[0..N-1][0]:規(guī)劃表的第一列值,表示當(dāng)需要兌換0元時,需要的貨幣數(shù),顯然貨幣數(shù)為0,直接設(shè)置為0.
- dp[0][0..aim]:規(guī)劃表的第一行值,表示只使用arr[0]一種貨幣兌換[0..aim]時,需要的貨幣數(shù),因此,只要tmp_aim可以被arr[0]整除,返回整除后的數(shù),即為對應(yīng)的值。在下面的實(shí)現(xiàn)中,我們使用了更為通用的方法來得到規(guī)劃表的第一行的值,可以根據(jù)下面的算法,理解一下實(shí)現(xiàn)中的計(jì)算方法。
- 更新動態(tài)規(guī)劃表
- 更新方向:逐行更新,每行從左到右更新。
- 更新值:對于dp[i][j],更新的依據(jù)有兩個值,一個是“不使用當(dāng)前種類的貨幣時,組成總數(shù)的j的最小方法,即dp[i-1][j]”,另外一個是“使用并且僅使用一張當(dāng)前種類的貨幣時,組成總數(shù)的j的最小方法,即 dp[i][j-arr[i]] + 1”,取這兩個值中的最小值即為dp[i][j]的值。
關(guān)于依據(jù)中的第二個值,可以通過公式推導(dǎo)得到??梢圆榭次哪┑膮⒖假Y料詳細(xì)了解。
實(shí)現(xiàn)
class Solution
{
public:
int ExchangeMoney(std::vector<uint32_t>& arr, uint32_t aim);
};
int Solution::ExchangeMoney(std::vector<uint32_t>& arr, uint32_t aim)
{
// fix: precheck
if (arr.size() == 0 || aim == 0)
{
return 0;
}
int arrSize = arr.size();
int dp[arrSize][aim + 1];
for (uint32_t lineIndex = 0; lineIndex < arrSize; ++lineIndex)
{
dp[lineIndex][0] = 0;
}
for (uint32_t rowIndex = 1; rowIndex < aim + 1; ++rowIndex)
{
// 動態(tài)初始化邊界值的方法
if (int(rowIndex - arr[0]) >= 0 && dp[0][rowIndex - arr[0]] != UINT32_MAX)
{
dp[0][rowIndex] = dp[0][rowIndex - arr[0]] + 1;
}
else
{
dp[0][rowIndex] = UINT32_MAX;
}
}
for (uint32_t lineIndex = 1; lineIndex < arrSize; ++lineIndex)
{
for (uint32_t rowIndex = 1; rowIndex < aim + 1; ++rowIndex)
{
int subCurArrValue = rowIndex - arr[lineIndex];
uint32_t upValue = dp[lineIndex - 1][rowIndex];
uint32_t leftValue = UINT32_MAX;
if (subCurArrValue >= 0 && dp[lineIndex][subCurArrValue] != UINT32_MAX)
{
leftValue = dp[lineIndex][subCurArrValue] + 1;
}
dp[lineIndex][rowIndex] = leftValue < upValue ? leftValue : upValue;
}
}
return dp[arrSize - 1][aim] == UINT32_MAX ? -1 : dp[arrSize - 1][aim];
}
2. 動態(tài)規(guī)劃的空間優(yōu)化
上面的實(shí)現(xiàn),需要的空間復(fù)雜度是O(N * aim),下面的方法,可以將空間復(fù)雜度優(yōu)化到O(aim)。
優(yōu)化方法:
- 生成一個一維動態(tài)規(guī)劃數(shù)組dp[aim + 1]
- 構(gòu)造初始值:參考上面的方法,初始值表示只使用arr[0]一種貨幣時,兌換[0..aim]的最少貨幣數(shù)。
- 更新動態(tài)規(guī)劃表:更新方向,從左到右更新。對于dp[j],更新的依據(jù)有兩個,一個是 dp[j](old),換算成二維數(shù)組,即為不使用arr[j]貨幣時的最少貨幣數(shù),即為dp[line-1][j]。另外一個是dp[j-arr[j]] + 1,即為“使用并且僅使用一張當(dāng)前種類的貨幣時的最少貨幣數(shù)”,換算成二維數(shù)組,即為dp[line][j-arr[j]] + 1.
實(shí)現(xiàn)
int Solution::ExchangeMoney(std::vector<uint32_t>& arr, uint32_t aim)
{
if (arr.size() == 0 || aim == 0)
{
return 0;
}
int arrSize = arr.size();
int dp[aim + 1]; // updated by line
dp[0] = 0; // first element is always 0
for (uint32_t rowIndex = 1; rowIndex < aim + 1; ++rowIndex)
{
if (int(rowIndex - arr[0]) >= 0 && dp[rowIndex - arr[0]] != UINT32_MAX)
{
dp[rowIndex] = dp[rowIndex - arr[0]] + 1;
}
else
{
dp[rowIndex] = UINT32_MAX;
}
}
for (uint32_t lineIndex = 1; lineIndex < arrSize; ++lineIndex)
{
for (uint32_t rowIndex = 1; rowIndex < aim + 1; ++rowIndex)
{
int subCurArrValue = rowIndex - arr[lineIndex];
uint32_t upValue = dp[rowIndex];
uint32_t leftValue = UINT32_MAX;
if (subCurArrValue >= 0 && dp[subCurArrValue] != UINT32_MAX)
{
leftValue = dp[subCurArrValue] + 1;
}
dp[rowIndex] = leftValue < upValue ? leftValue : upValue;
}
}
return dp[aim] == UINT32_MAX ? -1 : dp[aim];
}
從上面的實(shí)現(xiàn),我們可以看到,我們將二維動態(tài)規(guī)劃表壓縮成一維動態(tài)規(guī)劃表,依據(jù)是:
- 對于表中的每個元素,我們只會在更新下一個值時,使用一次,之后便不再使用。
- 更新下一個值,依賴“向左跳一次”和“向上跳一位”,而這兩個數(shù)可以在一維動態(tài)規(guī)劃表中保存。如果依賴多個“向上跳”的值,則無法使用一維動態(tài)規(guī)劃表實(shí)現(xiàn)空間優(yōu)化。
3. 還錢的最少貨幣數(shù),貨幣不可重復(fù)使用
給定數(shù)組arr,arr中所有的值都為正數(shù)。每個值僅代表一張錢的面值,再給定一個數(shù)aim代表要找的錢數(shù),求組成aim的最少貨幣數(shù)。
例如
arr = [5, 2, 3], aim = 20,無法組成,返回-1
arr = [5, 2, 5, 3], aim = 10,返回2
arr = [5, 2 ,5 ,3], aim = 15,返回4
arr = [5, 2, 5, 3], aim = 0,返回0
構(gòu)造二維動態(tài)規(guī)劃表dp[i][j],i表示可以使用貨幣arr[0..i],j表示組成的貨幣總數(shù)。
構(gòu)造過程
- 構(gòu)造邊界值
- dp[0..N-1][0]:規(guī)劃表的第一行值,表示當(dāng)前需要兌換0元時需要的貨幣數(shù),顯然貨幣數(shù)為0,直接設(shè)置為0
- dp[0][0..aim]:規(guī)劃表的第一列,表示僅使用arr[0]一種貨幣兌換[0..aim]時,是否可以兌換,如果arr[0]就等于j,則dp[0][j] = 1,否則等于UINT32_MAX,表示無法兌換。
- 更新動態(tài)規(guī)劃表
- 更新方向:逐行更新,每行從左到右更新。
- 更新值:對于dp[i][j],更新的依據(jù)有兩個值,一個是“不適用當(dāng)前面值的貨幣時,組成總數(shù)j的最少方法,即dp[i-1][j]“,另一個是”使用當(dāng)前面值的貨幣時,組成總數(shù)j的最少方法,即dp[i-1][j-arr[j]] + 1“,取這兩個值中的最小值即為dp[i][j]的值。
這里同樣可以用一維動態(tài)規(guī)劃表對空間優(yōu)化到O(aim)。
實(shí)現(xiàn)的思路基本和上面兩例一樣。
4 換錢的方法數(shù),可重復(fù)使用
給定數(shù)組arr,arr中所有的值都為正數(shù),且不重復(fù)。每個值代表一種面值的貨幣,每種面值的貨幣可以使用任意張,再給定一個整數(shù)aim代表要找的錢數(shù),求換錢有多少種方法。
例如
arr = [5, 10, 25, 1], aim = 0,返回1
arr = [5, 10, 25, 1], aim = 15,返回6
arr = [3, 5],aim = 2,返回0
暴力遞歸法
遞歸終止條件:aim == 0
循環(huán)條件:使用當(dāng)前面值貨幣0..k張,其中 k * arr[curIndex] <= aim
遞歸:對每個面值的貨幣,返回組成余下aim的方法數(shù)
int Solution::ExchangeMoney(std::vector<uint32_t>& arr, uint32_t aim)
{
if (aim == 0)
{
return 1;
}
if (arr.size() == 0)
{
return 0;
}
return process(arr, 0, aim);
}
int Solution::process(std::vector<uint32_t>& arr, uint32_t index, uint32_t aim)
{
int ret = 0;
if (index == arr.size())
{
ret = 0 == aim ? 1 : 0;
}
else
{
for (uint32_t k = 0; k * arr[index] <= aim; ++k)
{
ret += process(arr, index + 1, aim - k * arr[index]);
}
}
return ret;
}
記憶搜索法
上面的暴力遞歸法,存在著大量的重復(fù)計(jì)算。
優(yōu)化方法是將中間結(jié)果保存下來,在下一次計(jì)算的時候,查看表中是否已經(jīng)有結(jié)果。
int Solution::ExchangeMoney(std::vector<uint32_t>& arr, uint32_t aim)
{
if (aim == 0)
{
return 1;
}
if (arr.size() == 0)
{
return 0;
}
std::map<std::pair<uint32_t,uint32_t>, uint32_t> resultMap;
return process(arr, 0, aim, resultMap);
}
int Solution::process(std::vector<uint32_t>& arr, uint32_t index, uint32_t aim,
std::map<std::pair<uint32_t, uint32_t>, uint32_t>& resultMap)
{
int ret = 0;
if (index == arr.size())
{
ret = 0 == aim ? 1 : 0;
}
else
{
for (uint32_t k = 0; k * arr[index] <= aim; ++k)
{
uint32_t nextIndex = index + 1;
uint32_t nextAim = aim - k * arr[index];
std::map<std::pair<uint32_t, uint32_t>, uint32_t>::iterator it = resultMap.find(std::make_pair(nextIndex, nextAim));
if (it != resultMap.end())
{
ret += it->second;
}
else
{
ret += process(arr, nextIndex, nextAim, resultMap);
}
}
}
return ret;
}
動態(tài)規(guī)劃
構(gòu)造二維動態(tài)規(guī)劃表dp[i][j]
- 構(gòu)造邊界值
- dp[0][0..j],動態(tài)規(guī)劃表第一行,表示只使用arr[0]一個種類的貨幣時,可以兌換貨幣總數(shù)j的方法數(shù),無法兌換時,則設(shè)置為0。
- dp[0..N-1][0],動態(tài)規(guī)劃表第一列,表示兌換貨幣總數(shù)0的方法數(shù),設(shè)置為1.
- 更新動態(tài)規(guī)劃表
- dp[i][j]的取值依據(jù)有兩個,第一個是,"不使用當(dāng)前面值的貨幣時,組成貨幣總數(shù)的方法數(shù),dp[i-1][j]",第二個是,”分別使用1..k張當(dāng)前面值的貨幣時,組成貨幣總數(shù)的方法數(shù)之和,由公式可以推導(dǎo)出,該值為dp[i][j-arr[i]]“,dp[i][j] = dp[i-1][j] + dp[i][j - arr[i]]。
代碼實(shí)現(xiàn)請參考上面的幾例,稍加需改即可得出。
參考資料
《程序員代碼面試指南:IT名企算法與數(shù)據(jù)結(jié)構(gòu)題目與最優(yōu)解》