动态规划法
动态规划法将待求解问题分解成若干个相互重叠的子问题,每个子问题对应决策过程的一个阶段,一般来说,子问题的重叠关系表现在对给定问题求解的递推关系称为动态规划函数中,将子问题的解求解一次并填入表中,当需要再次求解此子问题时,可以通过查表获得该子问题的解,从而避免了大量重复计算。具体的动态规划法多种多样,但都具有相同的填表形式。一般来说,动态规划法的求解过程由以下三个阶段组成:
- 划分子问题:将原问题分解为若干个子问题,每个子问题对应一个决策阶段,并且子问题之间具有重叠关系。
- 确定动态规划函数:根据子问题之间的重叠关系找到子问题满足的递推关系式即动态规划函数,这是动态规划法的关键。
- 填写表格:设计表格,以自底向上的方式计算各个子问题的解并填表,实现动态规划过程。
上述动态规划过程可以求得问题的最优值即目标函数的极值,如果要求出具体的最优解,通常在动态规划过程中记录必要的信息,再根据最优决策序列构造最优解。
找零钱问题
在面值为 (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)。
参考资料
《算法设计与分析(第二版)》——王红梅,胡明 编著,清华大学出版社