动态规划
通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。动态规划常常适用于有重叠子问题和最优子结构性质的问题。
基本思想
若要解一个给定问题,我们需要解其不同部分(即子问题),再合并子问题的解以得出原问题的解。 通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,从而减少计算量: 一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。 这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。
与分治法区别
动态规划算法与分治法类似,都使用了将问题实例归纳为更小的、相似的子问题,并通过求解子问题产生一个全局最优值的思路,但动态规划不是分治法:关键在于分解出来的各个子问题的性质不同。分治法要求各个子问题是独立的(即不包含公共的子问题),因此一旦递归地求出各个子问题的解后,便可自下而上地将子问题的解合并成原问题的解。如果各子问题是不独立的,那么分治法就要做许多不必要的工作,重复地解公共的子问题。动态规划与分治法的不同之处在于动态规划允许这些子问题不独立(即各子问题可包含公共的子问题),它对每个子问题只解一次,并将结果保存起来,避免每次碰到时都要重复计算。
1. 动态规划---斐波那契堆
下面是斐波那契堆的一个递推式和两个初始化条件来定义:
当n>1时,F(n)=F(n-1)+F(n-2),F(0)=1,F(1)=1。
利用递推式直接计算第n个斐波那契堆F(n),可能对该函数的相同值计算好几遍。
计算F(n)这个问题是以计算它的两个更小的交叠字问题F(n-1)和F(n-2)的形式来表达的。因此可以简单地在一张一维表中填入n+1个F(n)的连续值。最初通过初始条件可以填入0和1,然后以运算规则计算出其他所有的元素。显然,该数组的最后一个元素应该包含F(n)。
下面是代码:
int Fib(int n)
{
int i;
int prev,next, result;
if (n <= 1)
return 1;
prev = next = 1;
for (i = 2; i <= n; i++)
{
result = prev + next;
next = prev;
prev = result;
}
return result;
}
2. 爬楼梯问题
一个人每次只能走1层台阶或2层台阶,请问走N层台阶有多少种方法?
分析:这个问题归根结底还是斐波那契堆问题。
我们设走i层台阶需要dp[i]种方法。
因为一个人每次只能走1层或2层台阶,那么走上i层台阶之前,他走到了i-1层台阶或者i-2层台阶。走i-1层台阶和走i-2层台阶的的方法数分别是dp[i-1]和dp[i-2]。因此dp[i] = dp[i-1] + dp[i-2]。
我们再来找出初始条件,走0层台阶显然没有方法即dp[0] = 0,而走一层台阶的方法为dp[1] = 1。因此还是斐波那契堆的问题。
3. 0-1背包问题
给定 n 种物品和一个容量为 C 的背包,物品 i 的重量是 wi,其价值为 vi 。
问:应该如何选择装入背包的物品,使得装入背包中的物品的总价值最大?(面对每个物品,我们只有选择拿取或者不拿两种选择,不能选择装入某物品的一部分,也不能装入同一物品多次。)
解决办法:声明一个大小为 m[n][c] 的二维数组,m[i][j] 表示在面对第 i 件物品,且背包容量为 j 时所能获得的最大价值,那么我们可以很容易分析得出 m[i][j] 的计算方法,
(1)j < w[i] 的情况,这时候背包容量不足以放下第 i 件物品,只能选择不拿m[i][j] = m[i-1][j]
(2)j>=w[i] 的情况,这时背包容量可以放下第 i 件物品,我们就要考虑拿这件物品是否能获取更大的价值。
1、如果拿取,m[i][j]=m[i-1][j-w[i]] + v[i]。 这里的m[i-1][j-w[i]]指的就是考虑了i-1件物品,背包容量为j-w[i]时的最大价值,也是相当于为第i件物品腾出了w[i]的空间。
2、如果不拿,m[i][j] = m[i-1][j] , 同(1)
究竟是拿还是不拿,自然是比较这两种情况那种价值最大。
下面是一个示例:
例:0-1背包问题。在使用动态规划算法求解0-1背包问题时,使用二维数组m[i][j]存储背包剩余容量为j,可选物品为i、i+1、……、n时0-1背包问题的最优值。绘制
价值数组v = {8, 10, 6, 3, 7, 2},
重量数组w = {4, 6, 2, 2, 5, 1},
背包容量C = 12时对应的m[i][j]数组。
#include <iostream>
#include <cstring>
using namespace std;
const int N = 15;
int v[N] = { 0,8,10,6,3,7,2 };
int w[N] = { 0,4,6,2,2,5,1 };
int m[N][N];
void Knapsack()
{
int n = 6, c = 12;
memset(m, 0, sizeof(m));
for (int i = 1; i <= n; i++)
for (int j = 1; j <= c; j++)
{
if (j >= w[i])
m[i][j] = max(m[i - 1][j], m[i - 1][j - w[i]] + v[i]);
else
m[i][j] = m[i - 1][j];
}
for (int i = 1; i <= n; i++)
{
for (int j = 1; j <= c; j++)
cout << m[i][j] << ' ';
cout << endl;
}
}
int main()
{
Knapsack();
system("pause");
return 0;
}
运行结果如下
到这一步,可以确定的是可能获得的最大价值,但是我们并不清楚具体选择哪几样物品能获得最大价值。
另起一个 x[ ] 数组,x[i]=0表示不拿,x[i]=1表示拿。
m[n][c]为最优值,如果m[n][c]=m[n-1][c] ,说明有没有第n件物品都一样,则x[n]=0 ; 否则 x[n]=1。当x[n]=0时,由x[n-1][c]继续构造最优解;当x[n]=1时,则由x[n-1][c-w[i]]继续构造最优解。以此类推,可构造出所有的最优解。
void traceback()
{
for(int i=n;i>1;i--)
{
if(m[i][c]==m[i-1][c])
x[i]=0;
else
{
x[i]=1;
c-=w[i];
}
}
x[1]=(m[1][c]>0)?1:0;
}
4. 矩阵的最小路径和
题目:给定一个矩阵m,从左上角开始每次只能向右或向下走,最后到达右下角的位置,路径上所有的数字累加起来就是路径和。返回所有的路径和中最小的路径和。
举例
给定如下 1 3 5 9
8 1 3 4
5 0 6 1
8 8 4 0
路径1,3,1,0,6,1,0是所有路径和最小的,所以返回12。
1)经典动态规划方法。
假设矩阵m的大小为M*N,行数为M,列数为N。先生成大小和m一样的矩阵dp,dp[i][j]的值表示从左上角(即(0,0))位置走到(i,j)位置的最小路径和。对m的第一行的所有位置来说,即(0,j)(0<=j<N),从(0,0)位置走到(0,j)位置只能向右走,所以(0,0)位置到(0,j)位置的路径和就是m[0][0..j]这些值的累加结果。同理,对于(0,0)到(i,0)也是一样。
除第一行和第一列的其他位置(i,j)外,都有左边位置(i-1,j)和上边位置(i,j-1)。从(0,0)到(i,j)的路径必定经过位置(i-1,j)和(i,j-1),所以,dp[i][j] = min{ dp[i-1][j], dp[i][j-1]} + m[i][j]。
int minPathSum1(vector<vector<int>>map) {
int m = map.size();
int n = map[0].size();
vector<vector<int>>dp(m, vector<int>(n, 0));
dp[0][0] = map[0][0];
for (int i = 1; i < m; i++) {
dp[i][0] = dp[i - 1][0] + map[i][0];
}
for (int j = 1; j < m; j++) {
dp[0][j] = dp[0][j - 1] + map[0][j];
}
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + map[i][j];
}
}
return dp[m - 1][n - 1];
}
5. 换钱的最少货币数
题目:给定数组arr,arr中所有的值都为正数且不重复。每个值代表一种面值的货币,每种面值的货币可以使用任意张,再给定一个整数aim代表要找的钱数,求组成aim的最少货币数。
举例
arr=[5,2,3],aim=20
4张5元可以组成20元。返回4
补充题目:给定数组arr,arr中所有的值都为正数。每个值仅代表一张钱的面值,再给定一个整数aim代表要找的钱数,求组成aim的最少货币数。
举例
arr=[5,2,3],aim=20
5元、2元、3元的钱各有一张,所以无法组成20元,返回-1
arr=[5,2,5,3],aim=10
5元的有两张,返回2
解答:
原问题的经典动态规划解法。如果arr的长度为N,生成行数为N、列数为aim+1的动态规划表dp。dp[i][j]的含义是,在可以任意使用arr[0...i]货币的情况下,组成j所需的最小张数。根据这个定义,dp[i][j]的值按如下方式计算:
1.dp[0...N-1][0]的值表示(即dp矩阵中第一列的值)表示找的钱数为0时需要的最少张数,钱数为0时,完全不需要任何货币,所以全设为0即可。
2.dp[0][0...aim]的值表示(即dp矩阵中第一行的值)表示只能使用arr[0]货币的情况下,找某个钱数的最小张数。比如,arr[0]=2,那么能找开的钱数为2,4,6,8,...所以令dp[0][2]=1,dp[0][4]=2,dp[0][6]=3,...
第一行其他位置所代表的钱数一律找不开,所以一律设置为INT_MAX。
3.剩下的位置依次从左到右,再从上到下计算。假设计算到位置(i,j),dp[i][j]的值可能来自下面的情况
1)完全不使用当前货币arr[i]情况下的最少张数,即dp[i-1][j]的值
2)只使用1张当前货币arr[i]情况下的最少张数,即dp[i-1][j-arr[i]]+1
3)只使用2张当前货币arr[i]情况下的最少张数,即dp[i-1][j-2*arr[i]]+2
4)只使用3张当前货币arr[i]情况下的最少张数,即dp[i-1][j-3*arr[i]]+3
所有的情况中,最终取张数最小的。所以
dp[i][j]=min{dp[i-1][j-k*arr[i]]+k (0<=k)}
=>dp[i][j]=min{dp[i-1][j],min{dp[i-1][j-x*arr[i]]+x (1<=x)}}
=>dp[i][j]=min{dp[i-1][j],min{dp[i-1][j-arr[i]-y*arr[i]]+y+1 (0<=y)}}
又有min{dp[i-1][j-arr[i]-y*arr[i]]+y(0<=y)} => dp[i][j-arr[i]],所以,最终有:dp[i][j]=min{dp[i-1][j],dp[i][j-arr[i]]+1}.如果j-arr[i]<0,即发生越界了,说明arr[i]太大,用一张都会超过钱数j,令dp[i][j]=dp[i-1][j]即可。下面是代码,整个过程的时间复杂度与额外空间复杂度都为O(N*aim),N为arr的长度。
int minCoins1(vector<int>arr, int aim) {
int n = arr.size();
int max = INT_MAX;
vector<vector<int>>dp(n,vector<int>(aim+1));
for (int j = 1; j <= aim; j++) {
dp[0][j] = max;
if (j - arr[0] >= 0 && dp[0][j - arr[0]] != max) {
dp[0][j] = dp[0][j - arr[0]] + 1;
}
}
int left = 0;
for (int i = 1; i < n; i++) {
for (int j = 1; j <= aim; j++) {
left = max;
if (j - arr[i] >= 0 && dp[i][j - arr[i]] != max) {
left = dp[i][j - arr[i]] + 1;
}
dp[i][j] = min(left,dp[i-1][j]);
}
}
return dp[n - 1][aim] != max ? dp[n - 1][aim] : -1;
}
补充问题的经典动态规划解法。如果arr的长度为N,生成行数为N、列数为aim+1的动态规划表的dp。dp[i][j]的含义是,在可以任意使用arr[0...i]货币的情况下(每个值仅代表一张货币),组成j所需的最小张数。根据这个定义,dp[i][j]的值按如下方式计算:
1.dp[0...N-1][0]的值(即dp矩阵中第一列的值)表示找的钱数为0时需要的最少张数,钱数为0时完全不需要任何货币,所以全部设置为0;
2.dp[0][0...aim]的值(即dp矩阵中第一行的值)表示只能使用一张arr[0]货币的情况下,找某个钱数的最小张数。比如arr[0]=2,那么能找开的钱数仅为2,所以令dp[0][2]=1。因为只有一张钱,所以其他位置所代表的钱数一律找不开,一律设为INT_MAX。
3.剩下的位置依次从左到右,再从上到下计算。假设计算到位置(i,j),dp[i][j]的值可能来自下面两种情况。
1)dp[i][j]的值代表在可以任意使用arr[0...i-1]的货币的情况下,组成j所需的最小张数。可以任意使用arr[0...i]货币的情况当然包括不使用这一张面值为arr[i]的货币,而只任意使用arr[0..i-1]货币的情况,所以dp[i][j]的值可能等于dp[i-1][j]。
2)因为arr[i]只有一张不能重复使用,所以我们考虑dp[i-1][j-arr[i]]的值,这个值代表在可以任意使用arr[0...i-1]货币的情况下,组成j-arr[i]所需的最小张数。从钱数为j-arr[i]到钱数j,只用再加上当前的这张arr[i]即可,所以dp[i][j]的值可能等于dp[i-1][j-arr[i]]+1。
4.如果dp[i-1][j-arr[i]]中j-arr[i]<0,也就是位置越界了,说明arr[i]太大,只用一张都会超过钱数j,令dp[i][j]=dp[i-1][j]即可。否则,dp[i][j]=min{dp[i-1][j],dp[i-1][j-arr[i]]+1}。
下面是代码,整个过程的时间复杂度与额外空间复杂度都为O(N*aim),N为arr的长度。
int minCoins3(vector<int>arr, int aim) {
int n = arr.size();
int max = INT_MAX;
vector<vector<int>>dp(n,vector<int>(aim+1));
for (int j = 1; j <= aim; j++) {
dp[0][j] = max;
}
if (arr[0] <= aim)
dp[0][arr[0]] = 1;
int leftup = 0;
for (int i = 1; i < n; i++) {
for (int j = 1; j <= aim; j++) {
leftup = max;
if (j - arr[i] >= 0 && dp[i - 1][j - arr[i]] != max) {
leftup = dp[i - 1][j - arr[i]] + 1;
}
dp[i][j] = min(leftup,dp[i-1][j]);
}
}
return dp[n - 1][aim] != max ? dp[n - 1][aim] : -1;
}
6. 换钱的方法数
题目:给定数组arr,arr中所有的值都为正数且不重复。每个值代表一种面值的货币,每种货币值可以使用任意张,再给定一个整数aim代表要找的钱数,求换钱有多少种方法
举例
arr=[5,10,25,1]aim=15. 共有6种
arr=[5,10,25,1]aim=0,返回1
arr=[3,5],aim=2,返回0
解答
生成行数为N、列数为aim+1的矩阵dp,dp[i][j]的含义是在使用arr[0...i]货币的情况下,组成钱数j有多少种方法。dp[i][j]的值求法如下
1.对于矩阵dp第一列的值dp[..][0],表示组成钱数为0的方法,很明显一种,全部设为1.
2.对于矩阵dp第一行的值dp[0][...],表示只能使用arr[0]这一种货币的情况下,组成钱的方法数。因此dp[0][k*arr[0]]=1(0<=k*arr[0]<=aim)。
3.除了第一行和第一列的其他位置,记为位置(i,j)。dp[i][j]的值是以下几个值的累加。
1)完全不用arr[i]货币,只使用arr[0..i-1]货币时,方法数为dp[i-1][j]。
2)用1张arr[i]货币,剩下的钱用arr[0..i-1]货币组成时,方发数为dp[i-1][j-arr[i]]。
3)用2张arr[i]货币,剩下的钱用arr[0..i-1]货币组成时,方发数为dp[i-1][j-2*arr[i]]。
4)用k张arr[i]货币,剩下的钱用arr[0..i-1]货币组成时,方发数为dp[i-1][j-k*arr[i]]。j-k*arr[i]>=0,k为非负数。
4.最终dp[N-1][aim]的值就是最终结果。
解法
int coins(vector<int>arr, int aim)
{
int n = arr.size();
vector<vector<int>>dp(n, vector<int>(aim + 1, 0));
for (int i = 0; i < n; i++) {
dp[i][0] = 1;
}
for (int j = 1; arr[0] * j <= aim; j++) {
dp[0][arr[0] * j] = 1;
}
int num = 0;
for (int i = 1; i < n; i++) {
for (int j = 1; j <= aim; j++) {
num = 0;
for (int k = 0; j - arr[i] * k >= 0; k++) {
num += dp[i - 1][j - arr[i] * k];
}
dp[i][j] = num;
}
}
return dp[n - 1][aim];
}