------ 本文是学习算法的笔记,《数据结构与算法之美》,极客时间的课程 ------
淘宝的“双十一”购物有各种促销活动,比如“满200减50”。假设你女朋友的购物车中有 n 个(n > 100)想买的商品,她希望从里面选几个,在凑够满减的前提下,让选出来的商品价格总和和最大程度地接近满减条件(200元)。作为程序员,能不能编个代码来帮她搞定呢?解决这个问题,就要用到今天讲的动态规划(Dynamic Programming)。
动态规划比较合适用来求解最优问题,比如求最大值、最小值等等。它可以非常显著地降低复杂度,提高代码的执行效率。不过,它也是出了名的难学。它的主要学习难点跟递归类似,那就是,求解问题的过程不太符合人类常规的思维方式。对于新手来说,想入门确实不容易。不过,等你掌握了之后,你会发现,实际上并没有想象中的那么难。
咱们分三节来讲解,分别是初识动态规划、动态规划理论、动态规划实践。
第一节,我会通过两个非常经典的动态规划问题模型,向你展示我们为什么需要动态规划,以及动态规划解题方法是如何演化出来的。实际上,你只要掌握了这两个例子的解决思路,对于其他很多动态规划问题,你都可以套用类似的思路来解决。
第二节,我会总结动态规划适合解决的问题的特征,以及动态规划思路。除此之外,我还会将贪心、回溯、动态规划这四种算法思想放在一直,对比分析它们各自的特点以及适用场景。
第三节,我会教你应用第二节讲的动态规划的理论知识,实战解决三个非常经典的动态规划问题,加深你对理论的理解。弄懂了这三节中的例子,对于动态规划这个知识点,你就算是入门了。
在讲贪心算法、回溯算法的时候,多次讲到背包问题,今天,我们依旧拿这个问题来举例。
对于一组不同质量、不可分割的物品,我们需要选择一些装入背包,在满足背包最大重量限制的前提下,背包中物品总重量的最大值是多少呢?
关于这个问题,我们上一节讲了回溯的解决方法,也就是穷举搜索所的可能的装法,然后找出满足条件的最大值。不过,回溯算法的复杂度比较高,是指数级别的。那有没有什么规律,可以有效降低时间复杂度呢?
public int maxW = Integer.MIN_VALUE; // 结果放到 maxW 中
private int[] weight = {2, 2, 4, 6, 3}; // 物品重量
private int n = 5; //物品个数
private int w = 9; // 背包承受的最大重量
public void f(int i, int cw) {
if (cw == w || i == n) { // 已装满了,或是已经考察完所有的物品
if(cw > maxW) {
maxW = cw;
}
return;
}
f(i + 1, cw); // 选择不装第 i 个物品
if (cw + weight[i] <= w) { // 已经超过可以承受的重量的时候,就不要再装了
f(i + 1,cw + weight[i]); // 选择装第 i 个物品
}
}
规律是不是不好找?那我们就举个例子、画个图看看。我们假设背包的最大承重是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) 的时候,直接从备忘录中取出来用,就不用再递归计算了,这样就可以避免冗余计算。
public int maxW = Integer.MIN_VALUE; // 存储背包中物品中总重量的最大值
private int[] weight = {2, 2, 4, 6, 3}; // 物品重量
private int n = 5; //物品个数
private int w = 9; // 背包承受的最大重量
private boolean[] [] men = new boolean[5] [10]; // 备忘录,默认值 false
public void f(int i, int cw) { // 调用f(0, 0)
if (cw == w || i == n) { // 已装满了,或是已经考察完所有的物品
if(cw > maxW) {
maxW = cw;
}
return;
}
if(men[i][w]){
return; // 重复状态
}
men[i][w] = true; // 记录(i, cw)这个状态
f(i + 1, cw); // 选择不装第 i 个物品
if (cw + weight[i] <= w) {
f(i + 1, cw + weight[i]); // 选择装第 i 个物品
}
}
这种解决方法非常好。实际上,它怩跟动态零碎的执行效率基本上没有差别。但是,多一种方法就多一种解决思路,我们现在来看看动态规划是怎么做的。
我们把整个求解种过分为 n 个阶段,每个阶段会决策第一个物品是否放到背包中。每个物品决策(放入或者不放入背包)完之后,背包中的物品的重量会有多种情况,也就是说,会达到多种不同状态,对应递归树中,就是有很多不同的节点。
我们把第一层重复的状态(节点)合并,只记录不同的状态,然后基于上一层状态集合,来推导下一层状态集合。我们通过合并每一层重复状态,这样就保证每一层不同状态的个数都不会超过 w 个(w 表示背包的承载重量),也就是例子中的 9 。于是,我们就成功避免了每层状态个数的指数级增长。
我们用一个二维数组 states[n][w+1],来记录每层可以达到的不同状态。
第0个(下标从0开始编号)物品的重量是2,要么装入背包,要么不装入背包,决策完之后,会对应背包的两种状态,背包中物品的总重量是0或者2。我们用 states[0][0] = true 和 states[0][2] 来表示两种状态。
第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)的值,就是背包中物品总重量的最大值
// weight:物品重量, n:物品个数,w:背包可承载重量
public int knapsack(int[] weight, int n, int w) {
boolean[][] states = new boolean[n][w+1]; // 默认值false
states[0][0] = true; // 第一行的数据要特殊处理,可以利用哨兵优化
states[0][weight[0]] = true;
for (int i = 0; i < n; i++) { // 动态规划状态转移
for (int j = 0; j < w; j++) { // 不把第 i 物品放入背包
if(states[i -1][j] == true) {
states[i][j] = states[i -1][j];
}
}
for (int j = 0; j < w - weight[i]; j++) { // 把第个物品放入背包
if(states[i -1][j] == true) {
states[i][j + weight[i]] = true;
}
}
}
for (int i = w; i >= 0; --i) {
if (states[n-1][i] == true) {
return i;
}
}
return 0;
}
实际上,这就是一种用动态规划解决问题的思路。我们把问题分解为多个阶段,每个阶段对应一个决策。我们记录每一个阶段可达的状态集合(去掉重复的),然后通过当前阶段的状态集合,来推导下一个阶段状态集合,动态地往前推进。这也是动态规划这个名字的由来,你可以自己体会一下,是不是挺形象的?
前面我们讲到,用回溯算法解决这个问题的时间复杂度是O(n2),是指数级的。那动态规划解决方案的时间复杂度是多少?
这个代码耗时最多的部分就是代码的两层 for 循环,所以时间复杂度是 O(n*w)。n 表示物品个数,w 表示背包可以承载的总重量。
从理论上讲,指数级的时间复杂度肯定要比 O(n*w) 高很多,但是为了让你有更加深刻的感受,我来举个例子比较下。
我们假设有10000个物品,重量分布在 1 到 15000之间,背包可以承载的总重量是30000。如果我们用回溯算法解决,用全体的数值表示出时间复杂度,就是210000,这是一个相当大的一个数字。如果我们用动态规划解决,用具体的数值表示出时间复杂度,就是10000*30000。看起来也是很大,但是和210000比起来,要小太多了。
尽管动态规划的执行效率比较高,但是就刚刚的代码实现来说,我们需要额外申请一个 n 乘以 w+1 的二维数组,对空间的消耗比较多。所以,有时候,我们会说,动态规划是一种空间换时间的解决思路。你可能要问了,有什么办法可以降低空间消耗吗?
实际上,我们只需要一个大小为 w+1的一维数组就可以解决这个问题。动态规划状态转移的过程,都可以基于这个一维数组业操作。具体的代码实现如下。
public static int knapsack2(int[] items, int n, int w) {
boolean[] states = new boolean[w+1]; // 默认值 false
states[0] = true; // 第一行的数据要特殊处理,可以利用哨兵优化
states[items[0]] = true;
for (int i = 1; i < n; i++) {
for (int j = w - items[i]; j >= 0; j--) {
if (states[j] == true) {
states[j + items[i]] = true;
}
}
}
for (int i = w; i >= 0; i--) {
if (states[i] == true) {
return i;
}
}
return 0;
}
这里我特别强调一下代码中的第6行,j 需要从大到小来处理。如果我们按照 j 从小到大处理的话,会出现 for 循环重复计算的问题。你可以自己想一想,这里就不详细说了。
我们继续升级难度。我性乱了一下刚刚背包问题。你看这个问题又该如何用动态规划解决?
现在我们引入物品价值这一变量。对于一组不同重量、不可分割物品。我们选择将某些物品装入背包,在满足背包最大重量限制前提下,背包中可装入物品的总价值最大是多少呢?
这个问题依旧可以用回溯算法来解决。这个问题并不复杂,所以具体思路,我就不用文字描述了,直接看代码。
private int maxV = Integer.MIN_VALUE; //结果放到maxV中
private int[] items = {2, 2, 4, 6, 3}; // 物品的重量
private int[] value = {3, 4, 8, 9, 6}; // 物品的价值
private int n = 5; // 物品的个数
private int w = 9; // 背包承受的最大重量
private void f(int i, int cw, int cv) { // 调用f(0, 0, 0)
if(cw == w || i == n) {
if (cv > maxV) {
maxV = cv;
}
return;
}
f(i + 1, cw, cv);
if(cw + items[i] <= w) {
f(i + 1, cw + items[i], cv + value[i]); // 选择装入第 i 个物品
}
}
针对上面的代码,我们还是照例画出递归树。在递归树中,每个节点表示一个状态。现在我们需要3个变量(i, cw, cv)来表示一个状态。其中,i 表示即将要决策第 i 个物品是否装入背包,cw 表示当前背包中物品的总重量,cv 表示当前背包中物品的总价值。
我们发现,在递归树中,有几个节点的 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值最大的那个状态,然后基于这些状态来推导下一层的状态。
public static int knapsack3(int[] weight, int[]value, int n, int w) {
int[][] states = new int[n][w+1];
for (int i = 0; i < n; i++) { // 初始化states
for (int j = 0; j < w + 1; j++) {
states[i][j] = -1;
}
}
states[0][0] = 0;
states[0][weight[0]] = value[0];
for (int i = 0; i < n; i++) { // 动态规划,状态转移
for (int j = 0; j < w; j++) { // 不选择第i个物品
if (states[i -1][j] > 0) {
states[i][j] = states[i -1][j];
}
}
for (int j = 0; j < w - weight[i]; j++) { // 选择第i个物品
if (states[i -1][j] > 0) {
int v = states[i -1][j] + value[i];
if (v > states[i][j + weight[i]]) {
states[i][j + weight[i]] = v;
}
}
}
}
// 找出最大值
int maxvalue = -1;
for (int j = 0; j < w; j++) {
if (states[n-1][j] > maxvalue) {
maxvalue = states[n-1][j];
}
}
return maxvalue;
}
对于这个问题,你当然可以用回溯算法,穷举所有的排列组合,看大于等于200并且最接近200的组合是哪一个?但是,这样的效率太低了点,时间复杂度非常高,是指数级的。当 n 很大的时候,可能“双十一”已经结束了,你的代码还没有运行结果,这显然会让你的女朋友心中的形象大大减分。
实际上,它跟第一个例子中讲的0-1背包问题很像,只不过是把“重量”换成了“价格”而已。购物车中有 n 个商品,我们针对每个商品都决策是否购买。每次决策之后,对应不同的状态集合。我们还是用一个二维数组 states[n][x],来记录每次决策之后所有可达的状态。不过,这里的 x 值是多少呢?
0-1背包问题中,我们找的是小于等于 w 的最大值, x 就是背包的最大承载重量 w+1。对于这个问题来说,我们要找大于等于200(满减条件)的传下中最小的,所以就不能设置为200加1了。就这个实际的问题而言,如果要购买的物品的总价格超过200太多,满减也没什么意义了。所以,我们可以限定 x 值为1001。
不过,这个问题不仅要求要求大于等于200的总价格中的最小的,我们还要找出最小总价格对应要购买的哪些商品。实际上,我们可以利用 states数组,倒推出这个被选择的商品序列。先看代码,待会儿解析。
public static void advance11(int[] items, int n, int w ) {
boolean[][] states = new boolean[n][3*w+1]; // 设置为三倍
states[0][0] = true; // 第一行数据要特殊处理
states[0][items[0]] = true;
for (int i = 1; i < n; i++) { // 动态规划
for (int j = 0; j < 3*w; j++) {
if (states[i-1][j] == true) {// 不购买第i个商品
states[i][j] = true;
}
}
for (int j = 0; j < 3*w; j++) {
if (states[i-1][j] == true) {// 购买第i个商品
states[i][j + items[i]] = true;
}
}
}
int j;
for (j = w; j < 3*w+1; j++) {
if (states[n-1][j] == true) {
break; // 输出结果大于等于 w 的最小值
}
}
if(j == -1) {
return; // 没有可行解
}
for(int i = n-1; i >=1; --i) { // i 表示二维数组中的行,j 表示列
if(j - items[i] >= 0 && states[i-1][j - items[i]] == true) {
System.out.println(items[i] + " "); // 购买这个商品
j = j - items[i];
}
}
if (j != 0) {
System.out.println(items[0]);
}
}
代码的前半部分跟0-1背包问题没有什么不同,后半部分,看它是如何打印出选择购买哪些商品的。
状态(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 商品。我们从中选择一个可达的状态(如果两个都可达,就随意选择一个),然后,继续迭代地考察其他商品是否有选择购买。