首先,该文章来自于极客时间网站,王争的专栏——《数据结构与算法之美》,我这里只是做简单的解释、记录并添加自己的见解,只是作为个人笔记,若侵权,马上删除。最后建议直接去该网站上购买该课程看原作者的讲解,一来是支持作者,二来是作者写的确实不错。
双十一有很多促销活动,比如“满200元减50元”。假如购物车有 n 个(n>100)商品,要从中选几个,在凑够满减条件前提下,让选出的商品价格总和最大程度地接近满减条件(200)元。这怎么使用代码解决呢?这就需要用到今天讲到的动态规划(Dynamic Programming)。
动态规划学习路线
动态规划比较适合来求解最优问题,比如求最大值、最小值等。它可以显著降低时间复杂度,提高代码执行效率。不过,跟递归类似,它求解问题的过程不太符合人类常规的思维方式。
这次,主要分为三节来讲解:
- 初识动态规划:使用两个非常经典的动态规划问题模型,展示为什么要动态规划、动态规划问题解题方法是如何演化出来的。其它很多动态规划问题,都可以套用类似的思路来解决。
- 动态规划理论:总结动态规划适合解决的问题的特征,以及动态规划解题思路。另外,还将贪心、分治、回溯、动态规划这四种算法放在一起,对比分析它们各自的特点以及适用的场景。
- 动态规划实战:根据第二节讲的动态规划理论知识,实战解决三个非常经典的动态规划问题,加深你对理论的理解。
0-1 背包问题
从一组不同重量、不可分割的物品中选择一些装入背包,在满足背包最大重量前提下,背包中物品总重量的最大值是多少?
上一节使用了回溯的解决方法,也就是穷举搜索所有可能的装法,找到满足条件的最大值。但是回溯算法的复杂度很高,是指数级别的。那能不能借助规律,有效降低时间复杂度呢?
1 | // 回溯算法实现。注意:我把输入的变量都定义成了成员变量。 |
画图如下,假设背包的最大承载重量是 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) 的时候,可以直接从备忘录中取出来用,就不用再递归计算了,这样就可以避免冗余计算。
1 | private int maxW = Integer.MIN_VALUE; // 结果放到maxW中 |
实际上,它已经跟动态规划的执行效率相差不大了。现在看看动态规划是怎么做的?
把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。每个物品决策(放入或者不放入)完之后,背包里的物品质量会有很多种情况。对应到递归树中,就是有很多不同的节点。
观察上面的递归树图,可以把每一层中重复的状态(节点)合并,只记录不同的状态,然后基于上一层的状态集合,来推导下一层的状态集合。可以通过合并每一层重复的状态,保证每一层不同状态的个数都不会超过 w 个(w 表示背包的承载重量),也就是例子中的 9。避免每层状态个数的指数级增长。可以用一个二维数组 states[n][w+1],记录每层可以达到的不同状态。因为含有重量为0的情况,所以二维数组第二维的大小为 w+1,而不是w。
第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)的值,就是背包中物品总重量的最大值。
对应的代码如下:
1 | weight:物品重量,n:物品个数,w:背包可承载重量 |
实际上, 这就是一种用动态规划解决问题的思路。把问题分解为很多个阶段,每个节点对应一个决策。记录每一个阶段可达的状态集合(去掉重复的),然后通过当前阶段的状态集合,来推导下一个阶段的状态集合,动态地往前推进。这就是动态规划名词的由来。
回溯算法解决这个问题的时间复杂度为$O(2^n)$,为指数级别。而动态规划解决这个问题。耗时最多的就是代码中的两层for循环,时间复杂度为$O(n*w)$。n 表示物品个数,w 表示背包可以承载的总重量。
尽管动态规划的执行效率比较高,但是需要额外申请一个 n 乘以 w+1 的二维数组,对空间的消耗比较多。所以有时也说,动态规划是一种拿空间换时间的解决思路。那如何讲的空间消耗呢?实际上,可以用一个大小为 w+1 的一维数组解决这个问题。动态规划状态转移的过程,都可以基于这个一维数组来操作。代码如下:
1 | public static int knapsack2(int[] items, int n, int w) { |
这里特别强调一下代码中的第 8 行,j 需要从大到小来处理。如果我们按照 j 从小到大处理的话,会出现 for 循环重复计算的问题。
0-1 背包问题升级版
之前的背包问题,只涉及背包重量和物品重量。现在引入物品价值这一变量。对于一组不同重量、不同价值、不可分割的物品,在满足背包最大重量限制的前提下,如何挑选物品装入背包,使得背包中可装入物品的总价值最大。
这个同样可以用回溯算法解决,代码如下:
1 | private int maxV = Integer.MIN_VALUE; // 结果放到maxV中 |
针对代码,画出递归树。在递归树中,每个节点表示一个状态。现在需要使用三个变量(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 值最大的那个状态,然后基于这些状态来推导下一层的状态。对应的代码如下:
1 | public static int knapsack3(int[] weight, int[] value, int n, int w) { |
跟上一个例子相同,这个例子的时间复杂度为$O(nw)$,空间复杂度为$O(nw)$。同样,这个时间复杂度是可以优化的。
解答开篇
对于这个问题,可以采用回溯算法,穷举所有可能的排列组合,看大于等于 200 并且最接近 200 的组合是哪一个?但是时间复杂度很高,是成指数级形式的。
实际上,它跟第一个例子中讲的 0-1 背包问题很像,只不过是把“重量”换成了“价格”而已。针对购物车中的 n 个商品中的每一个,均可以决策是否购买。每次决策之后,对应不同的状态集合。我们还是用一个二维数组 states[n][x],来记录每次决策之后所有可达的状态。0-1 背包问题中,找的是小于等于 w 的最大值,x 就是背包的最大承载重量 w+1。对于本问题,要寻找的是大于等200的值中最小的,同时也不能超过200太多了,所以可以限定 x 值为1001。同样的,对于这个问题,还要会利用 states 数组,倒推出这个最小总价格对应都要购买哪些商品。对应的代码如下:
1 | // items商品价格,n商品个数, w表示满减条件,比如200 |
代码前半部分和 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 个商品。我们从中选择一个可达的状态(如果两个都可达,就随意选择一个),然后,继续迭代地考察其他商品是否有选择购买。
内容小结
本节不涉及动态规划的理论,只是利用两个例子,展示了动态规划是如何解决问题的,并且一点一点详细给你讲解了动态规划解决问题的思路。
从例子中发现,大部分动态规划能解决的问题,都可以通过回溯算法来解决,只不过回溯算法解决起来效率比较低,时间复杂度是指数级的。动态规划算法,在执行效率方面,要高很多。尽管执行效率提高了,但是动态规划的空间复杂度也提高了,所以可以说,动态规划是一种空间换时间的算法思想。
贪心、分治、回溯、动态规划,这四个算法思想有关的理论知识,大部分都是“后验性”的,也就是说,在解决问题的过程中,往往是先想到如何用某个算法思想解决问题,然后才用算法理论知识,去验证这个算法思想解决问题的正确性。
课后思考
下面为改造过的”杨辉三角“。每个位置的数字可以随意填写,经过某个数字只能到达下面一层相邻的两个数字。设你从第一层,往下移动,把移动到最底层所经过的所有数字之和,定义为路径的长度。编程求出从最高层移动到最底层的最短路径长度。
答:参考代码如下:
1 | int[][] matrix = {{5},{7,8},{2,3,4},{4,9,6,1},{2,7,9,4,5}}; |