为了引出动态规划,先看看回溯法是如何解决经典问题的。
用回溯法解决0-1背包问题
假设有5个不同的物品,每个物品的重量分别是2,2,4,6,3,背包的最大承载量是9,目的是求在最大承载量下能够装入的物品的最大重量。如果我们把这个例子的回溯求解过程,用递归树画出来就是下面这个样子。
f(i,cw)表示考虑第i个物品是否装入,如果装入,则cw为该物品的重量,如果不装入,则此时加入总重量的数值为0。 设置初始状态为f(0,0),考虑第一件物品是否装入,如果装入,则状态为f(1,0);如果不装入,则状态为f(1,2)(其中2是第一个物品的重量)。同理,第二件物品的装入也有两个状态。
如果将全部状态画出,则第5层有16种状态,第6层有32种状态。即一共有32种可能的装配可能。如果理解为装入时状态为1,不装入时状态为0,则5个物品一共有252^525,即32种可能。
实现代码如下:
// 回溯算法实现0-1背包问题
let maxW; // 最后结果背包允许的最大重量
let weight = [2,2,4,6,3];
let n = 5; // 物品的个数
let w = 9; // 背包承受的最大重量
function recall(i,cw) {
if (cw == w || i == n) {// cw== w表示装满了,i==n表示物品都考察完了
if (cw > maxW) maxW = cw;
return; // 达到最终状态则结束
}
recall(i+1,cw); // 选择不装第i个物品
if (cw + weight[i] <= w) {
recall(i+1,cw+weight[i]); // 选择装第i个物品
}
}
recall(0,0); // 初始状态
看前面的图会发现,有f(3,2),f(3,6)两种状态被重复计算了,我们可以借助备忘录的方式,将计算的结果先存储起来,当需要重复计算时,直接拿存好的数据即可,这样就可以避免冗余计算。
实现代码如下:
// 回溯算法+备忘录实现0-1背包问题
let maxW = 0; // 最后结果背包允许的最大重量
let weight = [2,2,4,6,3];
let n = 5; // 物品的个数
let w = 9; // 背包承受的最大重量
let memo;
function initmemo() {
memo = new Array(5);
for (let i = 0; i < 5; ++i) {
memo[i] = new Array(10);
for (let j = 0;j < 10; ++j) {
memo[i][j] = false;
}
}
}
function recall( i, cw) {
if (cw == w || i == n) { // cw== w表示装满了,i==n表示物品都考察完了
if (cw > maxW) maxW = cw;
return;
}
if (memo[i][cw]) return; // 重复状态
memo[i][cw] = true; // 记录(i,cw)这个状态
recall(i+1,cw); // 选择不装第i个物品
if (cw + weight[i] <= w) {
recall(i+1,cw+weight[i]); // 选择装第i个物品
}
}
initmemo();
recall(0,0);
console.log(maxW); // 9
用动态规划解决0-1背包问题
从实际问题出发,学习动态规划思想之后再加上理论知识。
动态规划的解题思路就是将问题分解成多个阶段,每个阶段对应一种决策。我们将记录中每个阶段可达状态的集合(去掉重复状态)计算下一个状态集合,不断地动态计算。
用动态规划思想来解决0-1背包问题如下:
用一个二维数组dp[n][w+1]来记录每层可到达的状态。
第0个物品(下标从0开始编号)的重量为2,要么装入背包,要么不装入背包,决策完之后会对应背包的两种状态,背包中的物品的重量要么是0要么是2,用dp[0][0]=true和dp[0][2]=true表示这两种状态。
第一个物品的重量也是2,装入背包后对应三种状态(注意,前面那个图中第3层的4个状态合并为3个状态),装入之后,背包中的物品的重量要么是0或2或4。用dp[1][0]=true、dp[1][2]=true、dp[1][4]=true表示。
依次计算,那么计算到最后dp[4][9]就是当第4个也就是最后一个物品装入后,背包中重量为9的状态的时候。需要注意的是,这道题的全部输入刚好满足最大重量等于限制重量,也就是说我们最终的目标是在装入第4个物品之后达到背包中的最大数量,即dp[4][m]==true中m的最大值。
结合代码如下:
/*
weight:物品重量 array
n:物品数量
w:背包可承载重量
*/
let weight = [2,2,4,6,3];
let n = 5;
let w = 9;
function knapsack(weight,n,w) {
let dp = new Array(n);
for (let i = 0; i < n; ++i) { // 初始化dp
dp[i] = new Array(w+1);
for (let j = 0; j < w+1; ++j) {
dp[i][j] = false;
}
}
dp[0][0] = true; // 这里的数据要特殊处理,可以利用哨兵优化
dp[0][weight[0]] = true;
for (let i = 1;i < n;++i) { // 动态规划状态转移
for (let j = 0; j <= w; ++j) { // 不要把第i个物品放入背包
if (dp[i-1][j] == true)
dp[i][j] = dp[i-1][j];
}
for (let j = 0; j <= w-weight[i]; ++j) { // 把第i个物品放入背包
if (dp[i-1][j] == true)
dp[i][j+weight[i]] = true;
}
}
for (let i = w;i >=0 ;--i) {
if (dp[n-1][i] == true) return i;
}
return 0;
}
console.log(knapsack(weight,n,w));
0-1背包问题升级
前面讲到的背包问题只涉及背包重量和物品重量,现在引入物品价值这一变量,要求在不超出背包重量的前提下,使得装入背包的物品的价值最大。
用回溯算法(不加备忘录)解决如下:
let maxV = 0; // 最后结果背包允许的最大重量
let weight = [2,2,4,6,3];
let value = [3,4,8,9,6]; // 物品的价值
let n = 5; // 物品的个数
let w = 9; // 背包承受的最大重量
function recall( i, cw, cv) {
if (cw == w || i == n) { // cw== w表示装满了,i==n表示物品都考察完了
if (cv > maxV) maxV = cv;
return;
}
recall(i+1,cw,cv); // 选择不装第i个物品
if (cw + weight[i] <= w) {
recall(i+1,cw+weight[i],cv+value[i]); // 选择装第i个物品
}
}
recall(0,0,0);
console.log(maxV); // 18
和前面的递归树一样,只不过现在每个节点有3个状态(i,cw,cv),i表示决策第i个物品是否装入,cw表示当前背包的总重量,cv表示当前背包中的总价值。如图所示:
同理,我们可以看到有重复的状态,这里说的重复的状态是指i、cw相同而cv不一定相同的情况,这时候我们需要选择cv值较大的那个状态,舍弃其他状态。
用动态规划解决该问题,我们只需要在决策第i个物品的时候保留cv值最大的那个状态即可。
代码如下:
/*
weight:物品重量
value:物品的价值
n:物品数量
w:背包可承载重量
*/
let weight = [2,2,4,6,3];
let value = [3,4,8,9,6];
let n = 5;
let w = 9;
function knapsack(weight,value,n,w) {
let dp = new Array(n);
for (let i = 0; i < n; ++i) { // 初始化dp
dp[i] = new Array(w+1);
for (let j = 0; j < w+1; ++j) {
dp[i][j] = -1;
}
}
dp[0][0] = 0; // 这里的数据要特殊处理,可以利用哨兵优化
dp[0][weight[0]] = value[0];
for (let i = 1;i < n;++i) { // 动态规划状态转移
for (let j = 0; j <= w; ++j) { // 不要把第i个物品放入背包
if (dp[i-1][j] >= 0)
dp[i][j] = dp[i-1][j];
}
for (let j = 0; j <= w-weight[i]; ++j) { // 把第i个物品放入背包
if (dp[i-1][j] >= 0) {
let v = dp[i-1][j] + value[i];
if (v > dp[i][j+weight[i]])
dp[i][j+weight[i]] = v;
}
}
}
// 找出最大值
let maxvalue = -1;
for (let j= 0;j <= w; ++j) {
if (dp[n-1][j] > maxvalue) maxvalue = dp[n-1][j];
}
return maxvalue;
}
console.log(knapsack(weight,value,n,w)); // 18
总的来说,回溯法和动态规划没有很大的差别,可以理解为动态规划就是回溯法舍弃多余状态(即备忘录形式)避免重复计算的非递归写法。后面会讲到什么时候用动态规划,什么时候用回溯法。一开始可以画出递归树,帮助理解问题的解状态。
如何得到背包重量达到最大值时装入的物品?
动态规划问题达到最优解,则子结构也可以达到最优解,那么我们可以通过这种思路得到装入了那些物品。代码如下:
console.log("待装物品为:");
let N = n-1,ww = w;
while (N)
{
if (dp[N][ww] == (dp[N-1][ww-weight[N]]+value[N])) {
console.log(N+1,'; weight:',weight[N]);
ww -= weight[N];
}
N--;
}
使用动态规划薅羊毛
生活中的满减活动也可以使用动态规划的思想来解决,比如满200减50,大多数情况下我们都希望消费金额刚好超过200一点,而且假设消费金额超过1000,此次薅羊毛行为就不划算了,代码如下:
/*
items:商品价格
n:商品个数
w:满减条件,比如200
*/
function double11advance(items,n,w) {
let dp = new Array(n);
for (let i = 0; i < n; ++i) {
dp[i] = new Array(3*w+1); // 假设超过3倍就没有薅羊毛的价值
}
dp[0][0] = true; // 这里的数据要特殊处理,可以利用哨兵优化
dp[0][items[0]] = true;
for (let i = 1;i < n;++i) { // 动态规划状态转移
for (let j = 0; j <= 3*w; ++j) { // 不要把第i个物品放入背包
if (dp[i-1][j] == true)
dp[i][j] = dp[i-1][j];
}
for (let j = 0; j <= 3*w-items[i]; ++j) { // 把第i个物品放入背包
if (dp[i-1][j] == true)
dp[i][j+items[i]] = true;
}
}
let j;
for (j = w; j < 3*w+1;++j) {
if (dp[n-1][j] == true) break; // 输出结果大于等于w的最小值
}
if (j == -1) return ; // 没有可行解
for (let i = n - 1;i >= 1; --i) { // 有可行解,j为超过200的最小数
if (j-items[i] >= 0 && dp[i-1][j-items[i]]==true) {
console.log(items + ' '); // 购买这个商品
j = j - items[i];
} // 没有购买这个商品,j不变
}
if (j != 0) {
console.log(items[0]);
}
}
动态规划理论
前面通过0-1背包问题大致介绍了动态规划的用法,移花接木,我们可以通过这个例子的解题思路来解决其他动态规划问题。
什么问题可以用动态规划来解决?
用动态规划来解决最优问题,在解决问题的过程中,需要经历多个决策阶段,而且满足下面3个特征:
最优子结构,指的是问题的最优解包括子问题的最优解,也就是说子问题的解能够推导出下一个问题的解,也可以理解为后面阶段的状态可以由前面的状态推导过来。
无后效性,第一层含义是推导后面状态的时候,只关心前一个状态的状态值,不关心这个值是怎么来的;第二层含义是一旦某阶段的状态决定,就不会受后面的状态影响。
重复子问题,不同的决策序列,到达某个相同的阶段的时候,可能会产生重复的状态。
动态规划解题思路
1、状态转移表法
当我们拿到问题的时候,先用简单的回溯算法看能不能解决,看能不能将问题分解为多个状态,比如是不是可以化成一颗递归树,是否存在重复子问题,以及重复子问题是否可以产生,依次来找规律,看是不是能用动态规划解决问题。
状态表一般是二维的,我们根据决策的先后顺序,从前往后,根据递推关系,分阶段填充状态表中的每个状态,最后将这个递归填表的过程翻译成代码,就是动态规划代码了。
假设有一个n乘n的矩阵w[n][n]。矩阵存储的是整数。棋子起始位置在左上角,终止位置在右下角。将棋子从左上角移动到右下角,每次只能向下或向右移动一位,那么会有很多不同的路径走,我们把每条路径经过的数字加起来看成路径的长度,求最短路径。
从(0,0)走到(n-1,n-1)一共有2*(n-1)步,也就是一共有2*(n-1)个阶段,每个阶段对应一个状态集合
用回溯法解决如下:
let minDist = MAX; // 全局变量
function minDistBT(i,j,dist,w,n) {
// 到达了n-1,n-1这个位置了
if (i == n && j == n) {
if (dist < minDist) minDist = dist;
return ;
}
if (i < n) { // 往下走
minDist(i+1,j,dist+w[i][j],w,n);
}
if (j < n) { // 往右走
minDist(i,j+1,dist+w[i][j],w,n);
}
}
minDistBT(0,0,0,w,n);
有了回溯代码之后,画出递归树:
从图中可以看到,存在重复子问题,所以用动态规划来解决。
画出二维状态表如下:
将表转换为代码:
function minDisDP(matrix,n) {
let dp = new Array(n);
for (let i = 0; i < n; ++i) { // 创建一个二维矩阵
dp[i] = new Array(n);
}
let sum = 0;
for (let j = 0; j < n; ++j) { // 初始化第一行数据
sum += matrix[0][j];
dp[0][j] = sum;
}
sum = 0;
for (let j = 0; j < n; ++j) { // 初始化第一列数据
sum += matrix[j][0];
dp[j][0] = sum;
}
for (let i = 1; i < n;++i) {
for (let j= 1; j < n;++j) {
dp[i][j] = min(dp[i-1][j] + matrix[i][j],dp[i][j-1] + matrix[i][j]);
}
}
return dp[n-1][n-1];
}
function min(a,b) {
return a>b?b:a;
}
2、状态转移方程法
状态转移方程是解决动态规划的关键
同样一个问题,通过状态转移方程来实现:
min_dist(i,j)=w[i][j] + min(min_dist(i,j-1),min_dist(i-1,j))
下面这种写法和状态转移表法实现是一样的,只是思路不同:
let martix = [[1,3,5,9],[2,1,3,4],[5,2,6,7],[6,8,4,3]];
let n = 4;
let memo = new Array(4);
for (let i = 0; i < 4; ++i) {
memo[i] = new Array(4);
}
function minDist(i,j) {
if (i == 0||j ==0) return martix[0][0];
if (memo[i][j] > 0) return memo[i][j];
let minLeft = Math.max;
if (j-1 >= 0) {
minLeft = minDist(i,j-1);
}
let minUp = Math.min;
if (i-1 >= 0) {
minUp = minDist(i-1,j);
}
let currMinDist = martix[i][j] + Math.min(minLeft,minUp);
memo[i][j] = currMinDist;
return currMinDist;
}
console.log(minDist(n-1,n-1));
console.log(minDist(3,3)); // 3
总结
动态规划的两种思路:状态转移表法和状态转移方程方法。
- 状态转移表法的大致思路为:回溯算法实现-定义状态-画递归树-找重复子问题-画状态转移表-根据递推关系填表-将填表过程翻译成代码。
- 状态转移方程法的大致思路为:最优子结构-写状态转移方程-将状态转移方程翻译成代码