Ⅰ 前言
淘宝的双十一大家相比都经历过,双十一里有很多满减活动,比如“满 200 元减 50 元”。假如你的女朋友的购物车中有 n 个(n > 100)想买的商品,她希望能从里面选出来几个,在凑够满减条件的前提下,选出来的商品价格总和最大程度地接近满减条件(200 元),这样就可以极大限度地“薅羊毛”。作为程序员的你,能不能换个女朋友?奥不对,能不能写个代码帮她搞定这件事呢?
要想高效地解决这个问题,就要用到这篇文章要讲的动态规划(Dynamic Programming)。
动态规划比较适合用来求解最优问题,比如求最大值、最小值等等。它可以非常显著地降低时间复杂度,提高代码的执行效率。不过,动态规划也是出了名的难学,对于新手入门很不容易。但是,只要入了门,就一马平川了。
这篇文章,我先来带大家从两个经典动态规划的问题模型入手,先理解一下动态规划的思想。在后面的两篇文章,我再从理论层面和与其他思想的比较着手,深入了解动态规划,最后再通过实战应用,解决三个非常经典的动态规划的问题,我们就开始吧。
Ⅱ 0-1 背包问题
在前面讲 贪心算法 和 回溯算法 的文章中,我提到了很多次背包问题。我们再从动态规划来看看这个问题。
对于一组不同重量、不可分割的物品,我们需要选择一些装入背包,在满足背包最大重量限制的前提下,背包中物品总重量的最大值是多少呢?
回溯法处理这类问题就是穷举所有可能的装法,然后找出满足条件的最大值。不过,回溯算法的时间复杂度比较高,是指数级别的。那有没有什么规律,是我们可以找到用以降低时间复杂度的呢?
public void findSolve(int index, int currentWeight, int[] items,
int num, int weight) {
if (currentWeight == weight || index == num) { //已经装满 || 考察完所有物品
if (currentWeight > maxWeight) {
maxWeight = currentWeight;
return;
}
}
findSolve(index + 1, currentWeight, items, num, weight); //items[index]的物品不装进背包
if (currentWeight + items[index] <= weight) {
findSolve(index + 1, currentWeight + items[index], items, num, weight);//装进背包
}
}
}
规律是很不好找,我们来画一张图。假设背包的最大承载重量是 9,我们有 5 个不同的物品,每个物品的重量分别是 2,2,4,6,3。如果我们把这个例子的回溯求解过程,用递归树画出来,就是下面这个的样子:
递归树中的每个节点表示一种状态,我们用 (i, cw) 来表示,其中 i 表示将要决策第几个物品是否要放入背包。cw 表示当前背包中物品的总重量。比如,(2, 2) 表示我们将要决策第 2 个物品是否装入背包,在决策前,背包中物品的总重量是 2。
从递归树中,你应该能发现,有些子问题的求解是重复的,比如图中 f(2, 2) 和 f(3, 4) 都被重复计算了两次。我们可以用“备忘录”的方法解决这个问题:记录已经计算好的 f(i, cw),当再次计算到重复的 f(i, cw) 的时候,就可以直接从备忘录中取出来用。 这样就不用再递归计算了,可以避免很多冗余计算。
package com.tyz.core;
public class ZeroOneBackpack {
private int maxWeight = Integer.MIN_VALUE; //结果放在maxWeight中
private int[] weights = {2, 2, 4, 6, 3}; //物品重量
private int num = 5; //物品个数
private int weight = 9; //背包承受的最大重量
private boolean[][] mem = new boolean[5][10]; //备忘录,默认值为false
public ZeroOneBackpack() {}
public void findSolve(int i, int cw) {
if (cw == weight || i == num) {
if (cw > maxWeight) {
maxWeight = cw;
}
return;
}
if (mem[i][cw]) {
return; //重复状态
}
mem[i][cw] = true; //记录(i, cw)这个状态
findSolve(i + 1, cw); //选择不装第i个物品
if (cw + weights[i] <= weight) {
findSolve(i + 1, cw + weights[i]); //选择装第i个物品
}
}
}
这种解决方法非常好,实际上,它已经和动态规划的执行效率基本上没有差别,但是,多一种方法就多一种解决思路,我们现在来看看动态规划是怎么做的。
我们把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。每个物品决策(放入或不放入背包)完之后,背包中的物品的重量会有多种情况,也就是说,会达到多种不同的状态,对应到递归树中,就是有很多不同的节点。
我们把每一次重复的状态(节点)合并,只记录不同的状态,然后基于上一层的状态集合,来推导下一层的状态集合。我们可以通过合并每一层重复的状态,这样就保证每一层不同状态的个数都不会超过 w 个(w 表示背包的承载重量),也就是例子中的 9。于是,我们就成功避免了每层状态个数的指数级增长。
我们用一个二维数组 states[n][w+1],来记录每层可以达到的不同状态。
第 0 个(下标从 0 开始编号)物品的重量是 2,要么装入背包,要么不装入背包,决策完之后,会对应背包的两种状态,背包中物品的总重量是 0 或者 2。我们用 states[0][0] = true 和 states[0][2] = true 来表示这两种状态。
第 1 个物品的重量也是 2,基于之前的背包状态,在这个物品决策完之后,不同的状态有 3 个,背包中物品总重量分别是 0(0 + 0),2(0 + 2 or 2 + 0),4(2 + 2)。我们用 states[1][0] = true ,states[1][2] = true,states[1][4] = true 来表示这三种状态。
以此类推,直到考察完所有的物品后,整个 states 状态数组就计算好了。我把整个计算的过程画了出来,大家可以做个参考,理解这个过程。图中 0 表示 false,1 表示 true。我们只需要在最后一层,找一个值为 true 的最接近 w(这里是 9)的值,就是背包中物品总重量的最大值。
我们直接来看代码。
package com.tyz.first.core;
/**
* 动态规划求0-1背包的装的物品最大重量
* @author Tong
*/
public class ZeroOneKnapsack {
public ZeroOneKnapsack() {
}
/**
* 动态规划查找0-1背包的解
* @param weights 物品的重量
* @param num 物品的数量
* @param weight 背包承受的最大重量
* @return 最大重量
*/
public int findSolve(int[] weights, int num, int weight) {
boolean[][] states = new boolean[num][weight+1];
states[0][0] = true;
if (weights[0] <= weight) {
states[0][weights[0]] = true;
}
//动态规划状态转移
for (int i = 1; i < num; i++) {
for (int j = 0; j <= weight; j++) { //不把第i个元素装进背包
if (states[i-1][j] == true) {
states[i][j] = states[i-1][j];
}
}
for (int j = 0; j <= weight-weights[i]; j++) { //把第i个元素装进背包
if (states[i-1][j] == true) {
states[i][j+weights[i]] = true;
}
}
}
for (int i = weight; i >= 0; i--) { //输出结果
if (states[num-1][i] == true) {
return i;
}
}
return 0;
}
}
实际上,这就是一个用动态规划解决问题的思路。我们把问题分解为多个阶段,每个阶段对应一个决策。我们记录每一个阶段可达的状态集合(去掉重复的),然后通过当前阶段的状态集合,来推导下一个阶段的状态集合,动态地往前推进。这也是动态规划这个名字的由来。
我们可以再和其他几个算法做个对比。
- 贪心算法: 一条路走到黑,就一次机会,每次选一条最优的路线。
- 回溯算法: 一条路走到黑,但是有无数次重来的机会,选错了就倒回去重新选。
- 动态规划: 上帝视角,同时选择所有的选择,同时发展出每一个未来
在回溯算法中,我们解决这个问题的时间复杂度是 O(2n),是指数级的。那动态规划解决方案的时间复杂度是多少呢》我们来分析一下。
这个代码的时间复杂度非常很好想,耗时最多的部分就是代码中的两层 for 循环,所以时间复杂度是 O(n * w)。n 表示物品个数,w 表示背包可以承载的总重量。
从理论上来讲,指数级的时间复杂度肯定要比 O(n*w) 高很多,我们举个例子来感受一下。
我们假设有 10000 个物品,重量分布在 1 到 15000 之间,背包可以承载的总重量是 30000。如果我们用回溯算法来解决,用具体的数值表示出时间复杂度,就是 210000,这是一个相当大的数字了。如果我们用动态规划解决,用具体的数值表示出时间复杂度,就是 10000 * 30000。虽然看起来也很大,但是 210000 比起来,要小太多了。
尽管动态规划的执行效率比较高,但是就上面的代码来说,我们需要额外申请一个 n 乘以 w + 1 的二维数组,对空间的消耗比较多。所以,有时候我们说,动态规划是一种空间换时间的解决思路。那有没有什么方法可以降低空间消耗呢?
实际上,我们只需要一个大小为 w+1 的一维数组就可以解决这个问题。动态规划状态转移的过程,都可以基于这个一维数组来操作。代码如下👇
/**
* 一维数组动态规划求0-1背包的解
* @param items 物品重量
* @param num 物品数量
* @param weight 背包能承受的最大重量
* @return
*/
public int findSolve2(int[] items, int num, int weight) {
boolean[] states = new boolean[weight+1];
states[0] = true;
if (items[0] <= weight) {
states[items[0]] = true;
}
//动态规划
for (int i = 1; i < num; i++) { //把第i个物品放入背包
for (int j = weight-items[i]; j >= 0; j--) {
if (states[j] == true) {
states[j+items[i]] = true;
}
}
}
for (int i = weight; i >= 0; i--) { //输出结果
if (states[i] == true) {
return i;
}
}
return 0;
}
可能这里的代码还是不太好理解,我再对这个代码做个解释。
states 表示的是当前背包总重量所有可能取值的集合。如果将第 i 个物品放入背包,我们需要在当前背包总重量的所有取值中,找到小于等于 j 的,因此 for 循环中,j = weight-items[i]
还有一个需要特别注意的,for (int j = weight-items[i]; j >= 0; j--)
,这里为什么要将 j 递减的循环,为什么不还是 j 从 0 开始,用j++
循环?这里其实特别巧妙。
在 for 循环里有一个 If 的条件语句
if (states[j] == true) {
states[j+items[i]] = true;
}
如果条件成立的话,做的操作是 states[j+items[i]] = true;
,大家可以仔细想想,这个操作是不是相当于 states[j + X] = true;
,这个+ X
,是不是就相当于做了很多次 j++
? 所以如果 j 从 0 开始遍历,在前面做了一次这个操作后,后面就会再次遍历到那个位置,但是由于那个位置对应的 states[j]
在前面已经被设置成 true
了,所以再次到这里,就会进行重复运算,影响了真正的结果。
但是如果我们从后向前遍历,就可以避免这个问题,大家可以再思考一下。
Ⅲ 0-1 背包问题 Plus
基于上面的 0-1 背包,我们再提高一层难度。前面的背包问题,只涉及了背包重量和物品重量。我们现在引入物品价值这一变量。对于一组不同重量、不同价值、不可分割的物品,我们选择将某些物品装入背包,在满足背包最大重量限制的前提下,背包中可装入物品的总价值最大是多少呢?
这个问题依旧可以用回溯算法来解决,这个问题并不复杂,和前面是一样的,我直接贴出代码。
private int maxValue = Integer.MIN_VALUE;
private int[] weights = {2, 2, 4, 6, 3}; //物品重量
private int[] value = {3, 4, 8, 9, 6}; //物品价格
private int num = 5; //物品个数
private int weight = 9; //背包承受的最大重量
private boolean[][] mem = new boolean[5][10]; //备忘录,默认值为false
public ZeroOneBackpack() {}
public void findSolve(int i, int cw, int cv) {
if (cw == weight || i == num) {
if (cv > maxValue) {
maxValue = cv;
}
return;
}
findSolve(i + 1, cw, cv);
if (cw + weights[i] <= weight) {
findSolve(i + 1, cw + weights[i], cv + value[i]);
}
}
针对上面的代码,我们还是画出递归树。
我们发现,在递归树中,有几个节点的 i 和 cw 是完全相同的。比如 f(2, 2, 4) 和 f(2, 2, 3)。在背包中物品总重量一样的情况下,f(2, 2, 4) 对应的物品总价值更大,我们可以舍弃 f(2, 2, 3) 这种状态,只需要沿着 f(2, 2, 4) 这条决策路线继续往下决策就可以。
也就是说,对于 (i, cw) 相同的不同状态,那我们只需要保留 cv 值最大的那个,继续递归处理,其他状态不予考虑。
我们还是把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。每个阶段决策完以后,背包中的物品的总重量以及总价值,会有多种情况,也就是会达到多种不同的状态。
我们用一个二维数组 states[n][w+1],来记录每层可以达到的不同状态。不过这里数组存储的值不再是 boolean 类型的了,而是当前状态对应的最大总价值。我们把每一层中 (i, cw) 重复的状态(节点)合并,只记录 cv 值最大的那个状态,然后基于这些状态来推导下一层的状态。
我还是将代码贴出来,大家可以做一个参考。
/**
* 动态规划求0-1背包中最接近背包承受的最大重量且价格最大的值
* @param weights 物品重量
* @param value 物品价格
* @param num 物品数量
* @param weight 背包能承受的最大重量
* @return 找到的最接近背包最大重量中的价值最高的值
*/
public int findSolve(int[] weights, int[] value, int num, int weight) {
int[][] states = new int[num][weight+1];
for (int i = 0; i < num; i++) {
for (int j = 0; j < weight+1; j++) {
states[i][j] = -1;
}
}
states[0][0] = 0;
if (weights[0] <= weight) {
states[0][weights[0]] = value[0];
}
for (int i = 1; i < num; i++) { //动态规划,状态转移
for (int j = 0; j <= weight; j++) { //不选择第i个物品
if (states[i-1][j] >= 0) {
states[i][j] = states[i-1][j];
}
}
for (int j = 0; j <= weight-weights[i]; j++) { //选择第i个物品
if (states[i-1][j] > 0) {
int v = states[i-1][j] + value[i];
if (v > states[i][j+weights[i]]) {
states[i][j+weights[i]] = v;
}
}
}
}
//找到最大值
int maxValue = -1;
for (int j = 0; j <= weight; j++) {
if (states[num-1][j] > maxValue) {
maxValue = states[num-1][j];
}
}
return maxValue;
}
关于这个例子的时间空间复杂度和前面的例子分析方法是一样的,我不再赘述,直接给出结论,这个例子的时间复杂度和空间复杂度都是 O(n*w)。
Ⅳ 如何利用动态规划来凑满减
在看完前面的内容后,相信你对这个问题已经有了自己的思路了,这个问题其实和前面举的例子都有异曲同工之妙。
解决这个问题我们当然可以用回溯算法,穷举所有的排列组合,看大于等于 200 的组合是哪一个。但是,这样效率也太低了,时间复杂度非常高,是指数级的。当 n 很大的时候,可能双十一都结束了。你的代码还没有运行出结果,那就在女朋友面前太没有面子了。
所以,我们还是可以将这个问题理解成基础的 0-1 背包问题,只是将重量换成了价格,然后用动态规划解决。
购物车中有 n 个商品,我们针对每个商品都决策是否购买。每次决策之后,对应不同的状态集合。我们还是用一个二维数组 states[n][x],来记录每次决策之后所有可达的状态。不过,这里的 x 值是多少呢?
0-1 背包问题中,我们找的是小于等于 w 的最大值,x 就是背包的最大承载重量 w + 1。对于这个问题来说,我们要找的是大于等于 200(满减条件)的值中的最小的,所以就不能设置为 200 加 1 了。就这个实际问题而言,如果要购买的物品的总价格超过 200 太多,比如 1000,那这个羊毛薅得就没有什么意义了。所以,我们可以限定 x 值为 1001。
不过,这个问题不仅要求大于等于 200 的总价格中的最小的,我们还要找出这个最小总价格对应都要买哪些商品。实际上,我们可以利用 states 数组,倒推出这个被选择的商品序列。
我们按照这个思路来写代码👇
package com.tyz.first.core;
/**
* 用动态规划凑满减
* @author Tong
*/
public class Double11Advance {
public Double11Advance() {
}
/**
* 决策要购买的商品以达到凑满减而且花钱最少
* @param items 商品价格
* @param num 商品数量
* @param limit 满减条件
*/
public static void double11Advance(int[] items, int num, int limit) {
boolean[][] states = new boolean[num][3*limit+1];
states[0][0] = true;
if (items[0] <= 3*limit+1) {
states[0][items[0]] = true;
}
for (int i = 1; i < num; i++) { //动态规划
for (int j = 0; j <= 3*limit; j++) { //不购买第i个商品
if (states[i-1][j] == true) {
states[i][j] = states[i-1][j];
}
}
for (int j = 0; j <= 3*limit-items[i]; j++) { //购买第i个商品
if (states[i-1][j] == true) {
states[i][j+items[i]] = true;
}
}
}
int k;
for (k = limit; k < 3*limit+1; k++) {
if (states[num-1][k] == true) {
break; //找到大于等于limit的最小值,即满足满家条件的最小金额
}
}
if (k == 3*limit+1) {
return; //没有可行解
}
System.out.print("购买价格为 ");
for (int i = num-1; i >= 1; i--) {
if (k-items[i] >= 0 && states[i-1][k-items[i]] == true) {
System.out.print(items[i] + " "); //购买这个商品
k = k - items[i];
}
}
System.out.println("的商品");
if (k != 0) {
System.out.println(items[0]);
}
}
}
这里的前半部分经过前面几个例子想必已经很好理解了,我们着重看一下后半部分,看是如何打印出选择购买哪些商品的。
状态 (i, j) 只有可能从 (i-1, j) 或者 (i-1, j-value[i]) 两个状态是否是可达的,也就是 states[i-1][j]
或者 states[i-1][j-value[i]]
是否为 true。
如果states[i-1][j]
可达,就说明我们没有选择购买第 i 个商品,如果 states[i-1][j-value[i]]
可达,那就说明我们选择了购买第 i 个商品。我们从中选择一个可达的状态(如果两个都可达,就随便选择一个),然后,继续迭代地考察其他商品是否有选择购买。
测试代码如下:
package com.tyz.first.test;
import com.tyz.first.core.Double11Advance;
public class Test {
public static void main(String[] args) {
int[] items = {66, 21, 31, 48, 18, 9, 47, 12, 6, 3, 71, 99, 130, 170, 88, 100};
Double11Advance.double11Advance(items, 16, 300);
}
}
测试结果如下👇
如果你还要薅羊毛到毛到分,那把每个价格都乘以 10 或者 100 就好,结果是一样的。
在给女朋友展示了这个程序之后,她表示不满意,要让我显示三组,那我就再改一下,我们可以显示前三名最优的组合,给女朋友一个选择的机会。
package com.tyz.first.core;
/**
* 用动态规划凑满减
* @author Tong
*/
public class Double11Advance {
public Double11Advance() {
}
/**
* 决策要购买的商品以达到凑满减而且花钱最少
* @param items 商品价格
* @param num 商品数量
* @param limit 满减条件
*/
public static void double11Advance(int[] items, int num, int limit) {
boolean[][] states = new boolean[num][3*limit+1];
states[0][0] = true;
if (items[0] <= 3*limit+1) {
states[0][items[0]] = true;
}
for (int i = 1; i < num; i++) { //动态规划
for (int j = 0; j <= 3*limit; j++) { //不购买第i个商品
if (states[i-1][j] == true) {
states[i][j] = states[i-1][j];
}
}
for (int j = 0; j <= 3*limit-items[i]; j++) { //购买第i个商品
if (states[i-1][j] == true) {
states[i][j+items[i]] = true;
}
}
}
int[] index = new int[3];
for (int i = 0; i < index.length; i++) {
index[i] = -1;
}
int k;
int j = 0;
for (k = limit; k < 3*limit+1; k++) {
if (states[num-1][k] == true) {
if (index[index.length-1] != -1) {
break;
}
index[j++] = k;
continue; //找到大于等于limit的最小值,即满足满家条件的最小金额
}
}
if (k == 3*limit+1) {
return; //没有可行解
}
System.out.print("最优购买价格为 ");
for (int i = num-1; i >= 1; i--) {
if (index[0]-items[i] >= 0 && states[i-1][index[0]-items[i]] == true) {
System.out.print(items[i] + " "); //购买这个商品
index[0] = index[0] - items[i];
}
}
if (index[0] != 0) {
System.out.println(items[0]);
}
System.out.println("的商品");
System.out.print("次优购买价格为 ");
for (int i = num-1; i >= 1; i--) {
if (index[1]-items[i] >= 0 && states[i-1][index[1]-items[i]] == true) {
System.out.print(items[i] + " "); //购买这个商品
index[1] = index[1] - items[i];
}
}
if (index[1] != 0) {
System.out.println(items[0]);
}
System.out.println("的商品");
System.out.print("次次优购买价格为 ");
for (int i = num-1; i >= 1; i--) {
if (index[2]-items[i] >= 0 && states[i-1][index[2]-items[i]] == true) {
System.out.print(items[i] + " "); //购买这个商品
index[2] = index[2] - items[i];
}
}
if (index[2] != 0) {
System.out.println(items[0]);
}
System.out.println("的商品");
}
}
测试代码运行结果如下👇
关于动态规划更进一步的内容,可以跳转去看我下面的文章👇