分治、贪心、回溯、动态规划四大算法

前言

根据自己对贪心算法、分治算法、回溯算法、动态规划四种算法思想的理解对其分别做一个引入和介绍。

 

一、贪心算法

首先介绍贪心算法(greedy algorithm)。贪心算法有很多经典的应用,比如:

1)霍夫曼编码(Huffman Coding)

2)Prim 和 Kruskal 最小生生成树算法

3)Dijkstra 单源最短路径算法

...

 

1.什么是贪心算法?

       我们先看下面这个例子。假设我们有一个可以容纳 100kg 物品的背包,可以装各种物品。我们有以下 5 种豆子,每种豆子的总量和总价值都各不相同。为了让背包中所装物品的总价值最大,我们如何选择在背包中装哪些豆子?每种豆子又该装多少呢?

或许你一眼就能看出来,我们只要先算一算每个物品的单价,按照单价由高到低依次来装就好了。单价从高到低排列,依次是:黑豆、绿豆、红豆、青豆、黄豆,所以,我们可以往背包里装 20kg 黑豆、30kg 绿豆、50k红豆。

这个问题的解决思路显而易见,它本质上借助的就是贪心算法。结合这个例子,我总结一下贪心算法解决问题的步骤,我们一起来看看。

第一步,当我们看到这类问题的时候,首先要联想到贪心算法:针对一组数据,我们定义了限制值和期望值,希望从中选出几个数据,在满足限制值的情况下,期望值最大。

类比到刚刚的例子,限制值就是重量不能超过100kg,期望值就是物品的总价值。这组数据就是 5 种豆子。我们从中选出一部

分,满足重量不超过 100kg,并且总价值最大。

第二步,我们尝试看下这个问题是否可以用贪心算法解决,可以从两个方面思考:

1)每次选择当前情况下,在对限制值同等贡献量的情况下,对期望值贡献最大的数据或者。

2)对期望值相同贡献下,对限制值更友好。

类比到刚刚的例子,我们每次都从剩下的豆子里面,选择单价最高的,也就是重量相同的情况下,对价值贡献最大的豆子。

第三步,我们举几个例子看下贪心算法产生的结果是否是最优的。大部分情况下,举几个例子验证一下就可以了。严格地证明贪心算法的正确性,是非常复杂的,需要涉及比较多的数学推理。而且,从实践的角度来说,大部分能用贪心算法解决的问题,贪心算法的正确性都是显而易见的,也不需要严格的数学推导证明。

 

2.贪心算法举例

1. 分糖果

我们有 m 个糖果和 n 个孩子。我们现在要把糖果分给这些孩子吃,但是糖果少,孩子多(m<n),所以糖果只能分配给一部分孩子。每个糖果大小不等,每个孩子对糖果的需求也不一样,只有糖果的大小大于等于孩子的对糖果大小的需求的时候,孩子才得

到满足。

我的问题是,如何分配糖果,能尽可能满足最多数量的孩子?

思路:我们可以把这个问题抽象成,从 n 个孩子中,抽取一部分孩子分配糖果,让满足的孩子的个数(期望值)是最大的。这个问题的限制值就是糖果个数 m。

我们现在来看看如何用贪心算法来解决。对于一个孩子来说,如果小的糖果可以满足,我们就没必要用更大的糖果,这样更大的就可以留给其他对糖果大小需求更大的孩子。另一方面,对糖果大小需求小的孩子更容易被满足,所以,我们可以从需求小的孩子开始分配糖果。因为满足一个需求大的孩子跟满足一个需求小的孩子,对我们期望值的贡献是一样的(这就是第二步中第二种考虑的意思)。

 

2. 钱币找零

这个问题在我们的日常生活中更加普遍。假设我们有 1 元、2元、5 元、10 元、20 元、50 元、100 元这些面额的纸币,它们的张数分别是 c1、c2、c5、c10、c20、c50、c100。我们现在要用这些钱来支付 K 元,最少要用多少张纸币呢?


在生活中,我们肯定是先用面值最大的来支付,如果不够,就继续用更小一点面值的,以此类推,最后剩下的用 1 元来补齐。

在贡献相同期望值(纸币数目)的情况下,我们希望多贡献点金额,这样就可以让纸币数更少(同样是第二步中第二种考虑),这就是一种贪心算法的解决思路。直觉告诉我们,这种处理方法就是最好的。但实际上钱币找零问题是不能用贪心算法做的,考虑币值为100,99和1的币种,每种各一百张,找396元。动态规划可求出四张99元,但贪心算法解出需三张一百和96张一元。(这里是第三步举例验证第三步的结果,发现不能用贪心算法)

 

3. 区间覆盖

假设我们有 n 个区间,区间的起始端点和结束端点分别是 [l1, r1],[l2, r2],[l3, r3],……,[ln, rn]。我们从这 n 个区间中选出一部分区间,这部分区间满足两两不相交(端点相交的情况不算相交),最多能选出多少个区间呢?

这题的处理思想很重要,在很多贪心算法问题中都有用到,比如任务调度、教师排课等等问题。

这个问题的解决思路是这样的:我们假设这 n 个区间中最左端点是lmin,最右端点是 rmax。这个问题就相当于,我们选择几个不相交的区间,从左到右将 [lmin, rmax] 覆盖上。我们按照起始端点从小到大的顺序对这 n 个区间排序。

我们每次选择的时候,左端点跟前面的已经覆盖的区间不重合的,右端点又尽量小的,这样可以让剩下的未覆盖区间尽可能的大(依然是第二步中第二种考虑),就可以放置更多的区间。这实际上就是一种贪心的选择方法。

 

不要刻意去记忆贪心算法的原理,多练习才是最有效的学习方法。

 

补充:贪心算法例子

1.在一个非负整数 a 中,我们希望从中移除 k 个数字,让剩下的数字值最小,如何选择移除哪 k 个数字呢?

由最高位开始,比较低一位数字,如高位大,移除,若高位小,则向右移一位继续比较两个数字,直到高位大于低位则移除,循环k次,如:4556847594546移除5位-》455647594546-》45547594546-》4547594546-》4447594546-》444594546

2.假设有 n 个人等待被服务,但是服务窗口只有一个,每个人需要被服务的时间长度是不同的,如何安排被服务的先后顺序,才能让这n 个人总的等待时间最短?

由需要被服务的时间长度短的人先开始服务(试想如果让需要被服务的时间长度长的人先服务,那么其他人都需要等待很长的时长,这样总的时长肯定是最长的)

 

 

二、分治算法

1.如何理解分治算法

分治算法(divide and conquer)的核心思想其实就是四个字,分而治之 ,也就是将原问题划分成 n 个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。

这个定义看起来有点类似递归的定义,但二者并不等价。分治算法是一种处理问题的思想,递归是一种编程技巧。实际上,分

治算法一般都比较适合用递归来实现。分治算法的递归实现中,每一层递归都会涉及这样三个操作:

1)分解:将原问题分解成一系列子问题;

2)解决:递归地求解各个子问题,若子问题足够小,则直接求解;

3)合并:将子问题的结果合并成原问题;

分治算法能解决的问题,一般需要满足下面这几个条件:

1)原问题与分解成的小问题具有相同的模式;

2)原问题分解成的子问题可以独立求解,子问题之间没有相关性(这一点是分治算法跟动动态规划的明显区别);

3)具有分解终止条件,也就是说,当问题足够小时,可以直接求解;

4)可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法总体复杂度的效果了。

 

2.分治算法举例

1.逆序对个数

假设我们有 n 个数据,我们期望数据从小到大排列,那完全有序的数据的有序度就是 n(n-1)/2,逆序度等于 0;相反,倒序排列的数据的有序度就是 0,逆序度是 n(n-1)/2。除了这两种极端情况外,我们通过计算有序对或者逆序对的个数,来表示数据的有序度或逆序度。

如何编程求出一组数据的有序对个数或者逆序对个数呢?

最笨的方法是,拿每个数字跟它后面的数字比较,看有几个比它小的。我们把比它小的数字个数记作 k,通过这样的方式,把每个数字都考察一遍之后,然后对每个数字对应的 k 值求和,最后得到的总和就是逆序对个数。不过,这样操作的时间复杂度是 O(n^2)。那有没有更加高效的处理方法呢?

我们用分治算法来试试。我们套用分治的思想来求数组 A 的逆序对个数。我们可以将数组分成前后两半 A1 和 A2,分别计算A1 和 A2 的逆序对个数 K1 和 K2,然后再计算 A1 与 A2 之间的逆序对个数 K3。那数组 A 的逆序对个数就等于 K1+K2+K3。

前面说过,使用分治算法其中一个要求是,子问题合并的代价不能太大,否则就起不了降低时间复杂度的效果了。那回到这个问题,如何快速计算出两个子问题 A1 与 A2 之间的逆序对个数呢?

可以借助归并排序来做,归并排序中有一个非常关键的操作,就是将两个有序的小数组,合并成一个有序的数组。实际上,在这个合并的过程中,我们就可以计算这两个小数组的逆序对个数了。每次合并操作,我们都计算逆序对个数,把这些计算出来的逆序对个数求和,就是这个数组的逆序对个数了。


 
  1. private int num = 0; // 全局变量或者成员变量
  2. public int count(int[] a, int n) {
  3. num = 0;
  4. mergeSortCounting(a, 0, n- 1);
  5. return num;
  6. }
  7. private void mergeSortCounting(int[] a, int p, int r) {
  8. if (p >= r) return;
  9. int q = (p+r)/ 2;
  10. mergeSortCounting(a, p, q);
  11. mergeSortCounting(a, q+ 1, r);
  12. merge(a, p, q, r);
  13. }
  14. private void merge(int[] a, int p, int q, int r) {
  15. int i = p, j = q+ 1, k = 0;
  16. int[] tmp = new int[r-p+ 1];
  17. while (i<=q && j<=r) {
  18. if (a[i] <= a[j]) {
  19. tmp[k++] = a[i++];
  20. } else {
  21. num += (q-i+ 1); // 统计 p-q 之间,比 a[j] 大的元素个数
  22. tmp[k++] = a[j++];
  23. }
  24. }
  25. while (i <= q) { // 处理剩下的
  26. tmp[k++] = a[i++];
  27. }
  28. while (j <= r) { // 处理剩下的
  29. tmp[k++] = a[j++];
  30. }
  31. for (i = 0; i <= r-p; ++i) { // 从 tmp 拷贝回 a
  32. a[p+i] = tmp[i];
  33. }
  34. }

 

2.二维平面上有 n 个点,如何快速计算出两个距离最近的点对?

 

3.有两个 n*n 的矩阵 A,B,如何快速求解两个矩阵的乘积C=A*B?

 

 

三、回溯算法

回溯算法的应用:

深度优先搜索(DFS)

八皇后

0-1背包问题

图的着色

旅行商问题

数独

全排列

正则表达式排列

...

1.如何理解回溯算法?

在我们的一生中,会遇到很多重要的岔路口。在岔路口上,每个选择都会影响我们今后的人生。有的人在每个岔路口都能做出最正确的选择,最后生活、事业都达到了一个很高的高度;而有的人一路选错,最后碌碌无为。如果人生可以量化,那如何才能在岔路口做出最正确的选择,让自己的人生“最优”呢?

贪心算法:每一步都选择当前看起来是最好的选择,以期望能获得人生的"最优",但是贪心算法并不能得到最优解。

回溯算法:回溯的处理思想,有点类似枚举搜索。我们枚举所有的解,找到满足期望的解。每个阶段,我们都会面对一个岔路口,我们先随意选一条路走,当发现这条路走不通的时候(不符合期望的解),就回退到上一个岔路口,另选一种走法继续走。

回溯算法很多时候都应用在“搜索”这类问题上。不过这里说的搜索,并不是狭义的指我们前面讲过的图的搜索算法,而是在一组可能的解中,搜索满足期望的解。

2.回溯算法举例

1.八皇后问题

我们有一个 8x8 的棋盘,希望往里放 8 个棋子(皇后),每个棋子所在的行、列、对角线都不能有另一个棋子,下面左图是满足条件的一种方法,右图是不满足条件的。八皇后问题就是期望找到所有满足这种要求的放棋子方式。

我们把这个问题划分成 8 个阶段,依次将 8 个棋子放到第一行、第二行、第三行……第八行。在放置的过程中,我们不停地检查

当前的方法,是否满足要求。如果满足,则跳到下一行继续放置棋子;如果不满足,那就再换一种方法,继续尝试。

回溯算法非常适合用递归代码实现,下面是代码实现:


 
  1. public class Bahuanghou {
  2. private static int[] result = new int[ 8]; // 全局或成员变量, 下标表示行, 值表示 queen 存储在哪一列
  3. public static void main(String[] args){
  4. cal8queens( 0);
  5. }
  6. public static void cal8queens(int row) { // 调用方式:cal8queens(0);
  7. if (row == 8) { // 8 个棋子都放置好了,打印结果
  8. printQueens(result);
  9. return; // 8 行棋子都放好了,已经没法再往下递归了,所以就 return
  10. }
  11. for ( int column = 0; column < 8; ++column) { // 每一行都有 8 中放法
  12. if (isOk(row, column)) { // 有些放法不满足要求
  13. result[row] = column; // 第 row 行的棋子放到了 column 列
  14. cal8queens(row+ 1); // 考察下一行
  15. }
  16. }
  17. }
  18. private static boolean isOk(int row, int column) { // 判断 row 行 column 列放置是否合适
  19. int leftup = column - 1, rightup = column + 1;
  20. for ( int i = row- 1; i >= 0; --i) { // 逐行往上考察每一行
  21. if (result[i] == column) return false; // 第 i 行的 column 列有棋子吗?
  22. if (leftup >= 0) { // 考察左上对角线:第 i 行 leftup 列有棋子吗?
  23. if (result[i] == leftup) return false;
  24. }
  25. if (rightup < 8) { // 考察右上对角线:第 i 行 rightup 列有棋子吗?
  26. if (result[i] == rightup) return false;
  27. }
  28. --leftup; ++rightup;
  29. }
  30. return true;
  31. }
  32. private static void printQueens(int[] result) { // 打印出一个二维矩阵
  33. for ( int row = 0; row < 8; ++row) {
  34. for ( int column = 0; column < 8; ++column) {
  35. if (result[row] == column) System.out.print( "Q ");
  36. else System.out.print( "* ");
  37. }
  38. System.out.println();
  39. }
  40. System.out.println();
  41. }
  42. }

 

2.   0-1背包

0-1 背包问题有很多变体,我这里介绍一种比较基础的。我们有一个背包,背包总的承载重量是 Wkg。现在我们有 n 个物品,每个物品的重量不等,并且不可分割。我们现在期望选择几件物品,装载到背包中。在不超过背包所能装载重量的前提下,如何让背包所能装载重量的前提下,如何让背包中物品的总重量最大?

物品是不可分割的,要么装要么不装,显然是不能通过贪心算法来做的(举例背包重10kg,有6kg,5kg,5kg的物品),那么我们来分析用回溯算法怎么做。

分析:对于每个物品来说,都有两种选择,装进背包或者不装进背包。对于n 个物品来说,总的装法就有 2^n 种,去掉总重量超过Wkg 的,从剩下的装法中选择总重量最接近 Wkg 的。不过,我们如何才能不重复地穷举出这 2^n 种装法呢?

这里就可以用回溯的方法。我们可以把物品依次排列,整个问题就分解为了 n 个阶段,每个阶段对应一个物品怎么选择。先对第一个个物品进行处理,选择装进去或者不装进去(解空间是一个棵二叉树的形式,所以可以使用DFS搜索解空间),然后再递归地处理剩下的物品。

看下面代码,这里还稍微用到了一点搜索剪枝的技巧,就是当发现已经选择的物品的重量超过 Wkg 之后,我们就停止继续探测剩下的物品。


 
  1. package lianxi;
  2. public class ZeroOneBag {
  3. private static int maxW = Integer.MIN_VALUE; // 存储背包中物品总重量的最大值
  4. // cw 表示当前已经装进去的物品的重量和;i 表示考察到哪个物品了;
  5. // w 背包重量;items 表示每个物品的重量;n 表示物品个数
  6. // 假设背包可承受重量 100,物品个数 10,物品重量存储在数组 a 中,那可以这样调用函数:
  7. // f(0, 0, a, 10, 100)
  8. public static void main(String[] args){
  9. int[] a = { 6, 5, 5};
  10. f( 0, 0,a, 3, 10);
  11. System.out.println(maxW);
  12. }
  13. public static void f(int i, int cw, int[] items, int n, int w) {
  14. if (cw == w || i == n) { // cw==w 表示装满了 ;i==n 表示已经考察完所有的物品
  15. if (cw > maxW) {
  16. maxW = cw;
  17. }
  18. return;
  19. }
  20. f(i+ 1, cw, items, n, w); //不装第i个物品
  21. if (cw + items[i] <= w) { //不超过可以背包承受的重量w的时候,才有装第i个物品的情况
  22. f(i+ 1,cw + items[i], items, n, w); //装了第i个物品
  23. }
  24. }
  25. }

3.正则表达式

 

四、动态规划

1.初识动态规划

0-1背包问题

前面讲了回溯算法解决0-1背包问题,也就是穷举搜索所有可能的装法,然后找出满足条件的最大值。不过,回溯算法的复杂度比较高,是指数级别的。先看回溯算法代码:


 
  1. public class ZeroOneBag{
  2. // 回溯算法实现。注意:我把输入的变量都定义成了成员变量。
  3. private static int maxW = Integer.MIN_VALUE; // 结果放到 maxW 中
  4. private static int[] weight ; // 物品重量
  5. private static int n = 5; // 物品个数
  6. private static int w = 9; // 背包承受的最大重量
  7. public static void main(String[] args){
  8. int[] temp = { 2, 2, 4, 6, 3};
  9. weight = temp;
  10. f( 0, 0);
  11. System.out.println(maxW);
  12. }
  13. public static void f(int i, int cw) { // 调用 f(0, 0)
  14. if (i == n) { //i==n 表示物品都考察完了
  15. if (cw > maxW) maxW = cw;
  16. return;
  17. }
  18. f(i+ 1, cw); // 选择不装第 i 个物品
  19. if (cw + weight[i] <= w) {
  20. f(i+ 1,cw + weight[i]); // 选择装第 i 个物品
  21. }
  22. }
  23. }

有没有什么规律,可以有效降低时间复杂度呢?

假设背包的最大承载重量是 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 中
  2. private int[] weight = { 22463}; // 物品重量
  3. private int n = 5; // 物品个数
  4. private int w = 9; // 背包承受的最大重量
  5. private boolean[][] mem = new boolean[ 5][ 10]; // 备忘录,默认值 false
  6. public void f(int i, int cw) { // 调用 f(0, 0)
  7. if (cw == w || i == n) { // cw==w 表示装满了,i==n 表示物品都考察完了
  8. if (cw > maxW) maxW = cw;
  9. return;
  10. }
  11. if (mem[i][cw]) return; // 重复状态
  12. mem[i][cw] = true; // 记录 (i, cw) 这个状态
  13. f(i+ 1, cw); // 选择不装第 i 个物品
  14. if (cw + weight[i] <= w) {
  15. f(i+ 1,cw + weight[i]); // 选择装第 i 个物品
  16. }
  17. }

这种解决方法非常好。实际上,它已经跟动态规划的执行效率基本上没有差别。但是,多一种方法就多一种解决思路,我们现在来看看动态规划是怎么做的。

我们把整个求解过程分为 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)的值就是背包中物品总重量的最大值。


 
  1. public class ZeroOneBagDG {
  2. public static void main(String[] args){
  3. int[] a = { 2, 2, 4, 6, 3};
  4. System.out.println(knapsack(a, 5, 9));
  5. }
  6. // weight: 物品重量,n: 物品个数,w: 背包可承载重量
  7. public static int knapsack(int[] weight, int n, int w) {
  8. boolean[][] states = new boolean[n][w+ 1]; // 默认值 false
  9. states[ 0][ 0] = true; // 第一行的数据要特殊处理,可以利用哨兵优化
  10. states[ 0][weight[ 0]] = true;
  11. for ( int i = 1; i < n; ++i) { // 动态规划状态转移
  12. for ( int j = 0; j <= w; ++j) { // 不把第 i 个物品放入背包
  13. if (states[i- 1][j] == true) states[i][j] = states[i- 1][j];
  14. }
  15. for ( int j = 0; j <= w-weight[i]; ++j) { // 把第 i 个物品放入背包
  16. if (states[i- 1][j]== true) states[i][j+weight[i]] = true;
  17. }
  18. }
  19. for ( int i = w; i >= 0; --i) { // 输出结果
  20. if (states[n- 1][i] == true) return i;
  21. }
  22. return 0;
  23. }
  24. }

这个代码的时间复杂度非常好分析,耗时最多的部分就是代码中的两层 for 循环,所以时间复杂度是 O(n*w)。n 表示物品个数,w 表示背包可以承载的总重量。

尽管动态规划的执行效率比较高,但是就刚刚的代码实现来说,我们需要额外申请一个 n 乘以 w+1 的二维数组,对空间的消耗比较多。所以,有时候,我们会说,动态规划是一种空间换时间的解决思路。那么,有什么办法可以降低空间消耗吗?

实际上,我们只需要一个大小为 w+1 的一维数组就可以解决这个问题。动态规划状态转移的过程,都可以基于这个一维数组来操作。具体的代码实现我贴在这里,你可以仔细看下。


 
  1. public static int knapsack2(int[] items, int n, int w) {
  2. boolean[] states = new boolean[w+ 1]; // 默认值 false
  3. states[ 0] = true; // 第一行的数据要特殊处理,可以利用哨兵优化
  4. states[items[ 0]] = true;
  5. for ( int i = 1; i < n; ++i) { // 动态规划
  6. for ( int j = w-items[i]; j >= 0; --j) { // 把第 i 个物品放入背包
  7. if (states[j]== true) states[j+items[i]] = true;
  8. }
  9. }
  10. for ( int i = w; i >= 0; --i) { // 输出结果
  11. if (states[i] == true) return i;
  12. }
  13. return 0;
  14. }

强调一下代码中的第 6 行,j 需要从大到小来处理。如果我们按照 j 从小到大处理的话,会出现 for 循环重复计算的问题。

 

0-1 背包问题升级版

我们刚刚讲的背包问题,只涉及背包重量和物品重量。我们现在引入物品价值这一变量。对于一组不同重量、不同价值、不可分割的物品,我们选择将某些物品装入背包,在满足背包最大重量限制的前提下,背包中可装入物品的总价值最大是多少呢?

这个问题依旧可以用回溯算法来解决。


 
  1. private int maxV = Integer.MIN_VALUE; // 结果放到 maxV 中
  2. private int[] items = { 22463}; // 物品的重量
  3. private int[] value = { 34896}; // 物品的价值
  4. private int n = 5; // 物品个数
  5. private int w = 9; // 背包承受的最大重量
  6. public void f(int i, int cw, int cv) { // 调用 f(0, 0, 0)
  7. if (cw == w || i == n) { // cw==w 表示装满了,i==n 表示物品都考察完了
  8. if (cv > maxV) maxV = cv;
  9. return;
  10. }
  11. f(i+ 1, cw, cv); // 选择不装第 i 个物品
  12. if (cw + weight[i] <= w) {
  13. f(i+ 1,cw+weight[i], cv+value[i]); // 选择装第 i 个物品
  14. }
  15. }

针对上面的代码,我们还是照例画出递归树。在递归树中,每个节点表示一个状态。现在我们需要 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 值最大的那个,继续递归处理,其他状态不予考虑。

如果用回溯算法,这个问题就没法再用“备忘录”解决了(因为涉及2个以上的状态属性了?)。

考虑动态规划的解法,我们还是把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。每个阶段决策完之后,背包中的物品的总重量以及总价值,会有多种情况,也就是会达到多种不同的状态。我们用一个二维数组 states[n][w+1],来记录每层可以达到的不同状态。不过这里数组存储的值不再是 boolean 类型的了,而是当前状态对应的最大总价值。我们把每一层中(i, cw) 重复的状态(节点)合并,只记录 cv 值最大的那个状态,然后基于这些状态来推导下一层的状态。


 
  1. public static int knapsack3(int[] weight, int[] value, int n, int w) {
  2. int[][] states = new int[n][w+ 1];
  3. for ( int i = 0; i < n; ++i) { // 初始化 states
  4. for ( int j = 0; j < w+ 1; ++j) {
  5. states[i][j] = - 1;
  6. }
  7. }
  8. states[ 0][ 0] = 0;
  9. states[ 0][weight[ 0]] = value[ 0];
  10. for ( int i = 1; i < n; ++i) { // 动态规划,状态转移
  11. for ( int j = 0; j <= w; ++j) { // 不选择第 i 个物品
  12. if (states[i- 1][j] >= 0) states[i][j] = states[i- 1][j];
  13. }
  14. for ( int j = 0; j <= w-weight[i]; ++j) { // 选择第 i 个物品
  15. if (states[i- 1][j] >= 0) {
  16. int v = states[i- 1][j] + value[i];
  17. if (v > states[i][j+weight[i]]) {
  18. states[i][j+weight[i]] = v;
  19. }
  20. }
  21. }
  22. }
  23. // 找出最大值
  24. int maxvalue = - 1;
  25. for ( int j = 0; j <= w; ++j) {
  26. if (states[n- 1][j] > maxvalue) maxvalue = states[n- 1][j];
  27. }
  28. return maxvalue;
  29. }

 

2.动态规划理论

一个模型:

“多阶段决策最优解模型”,我们一般是用动态规划来解决最优问题。而解决问题的过程,需要经历多个决策阶段。每个决策阶段都对应着一组状态。然后我们寻找一组决策序列,经过这组决策序列,能够产生最终期望求解的最优值(实际上贪心、回溯、动态规划可以解决的问题都可以抽象成这个模型)。

三个特征:

1. 最优子结构

最优子结构指的是,问题的最优解包含子问题的最优解。反过来说就是,我们可以通过子问题的最优解,推导出问题的最优解。如果我们把最优子结构,对应到我们前面定义的动态规划问题模型上,那我们也可以理解为,后面阶段的状态可以通过前面阶段的状态推导出来。

2. 无后效性

无后效性有两层含义,第一层含义是,在推导后面阶段的状态的时候,我们只关心前面阶段的状态值,不关心这个状态是怎么一步一步推导出来的。第二层含义是,某阶段状态一旦确定,就不受之后阶段的决策影响。无后效性是一个非常“宽松”的要求。只要满足前面提到的动态规划问题模型,其实基本上都会满足无后效性。

3. 重复子问题

这个概念比较好理解。前面一节,我已经多次提过。如果用一句话概括一下,那就是,不同的决策序列,到达某个相同的阶段时,可能会产生重复的状态。

下面通过一个例子来理解一个模型三个特征:

假设我们有一个 n 乘以 n 的矩阵 w[n][n]。矩阵存储的都是正整数。棋子起始位置在左上角,终止位置在右下角。我们将棋子从左上角移动到右下角。每次只能向右或者向下移动一位。从左上角到右下角,会有很多不同的路径可以走。我们把每条路径经过的数字加起来看作路径的长度。那从左上角移动到右下角的最短路径长度是多少呢?

我们先看看,这个问题是否符合“一个模型”?

从 (0, 0) 走到 (n-1, n-1),总共要走 2*(n-1) 步,也就对应着 2*(n-1) 个阶段。每个阶段都有向右走或者向下走两种决策,并且每个阶段都会对应一个状态集合。我们把状态定义为 min_dist(i, j),其中 i 表示行,j 表示列。min_dist 表达式的值表示从 (0,0) 到达 (i, j) 的最短路径长度。所以,这个问题是一个多阶段决策最优解问题,符合动态规划的模型。

我们再来看,这个问题是否符合“三个特征”?

我们可以用回溯算法来解决这个问题。如果你自己写一下代码,画一下递归树,就会发现,递归树中有重复的节点。重复的节点表示,从左上角到节点对应的位置,有多种路线,这也能说明这个问题中存在重复子问题。

如果我们走到 (i, j) 这个位置,我们只能通过 (i-1, j),(i, j-1) 这两个位置移动过来,也就是说,我们想要计算 (i, j) 位置对应的状态,只需要关心 (i-1, j),(i, j-1) 两个位置对应的状态,并不关心棋子是通过什么样的路线到达这两个位置的。而且,我们仅仅允许往下和往右移动,不允许后退,所以,前面阶段的状态确定之后,不会被后面阶段的决策所改变,所以,这个问题符合“无后效性”这一特征。

刚刚定义状态的时候,我们把从起始位置 (0, 0) 到 (i, j) 的最小路径,记作 min_dist(i, j)。因为我们只能往右或往下移动,所以,我们只有可能从 (i, j-1) 或者 (i-1, j) 两个位置到达 (i, j)。也就是说,到达 (i, j) 的最短路径要么经过 (i, j-1),要么经过 (i-1, j),而且到达 (i, j) 的最短路径肯定包含到达这两个位置的最短路径之一。换句话说就是,min_dist(i, j) 可以通过 min_dist(i, j-1) 和 min_dist(i-1, j) 两个状态推导出来。这就说明,这个问题符合“最优子结构”。

min_dist(i, j) = w[i][j] + min(min_dist(i, j-1), min_dist(i-1, j))

 

两种动态规划解题思路总结

1. 状态转移表法

一般能用动态规划解决的问题,都可以使用回溯算法的暴力搜索解决。所以,当我们拿到问题的时候,我们可以先用简单的回溯算法解决,然后定义状态,每个状态表示一个节点,然后对应画出递归树。从递归树中,我们很容易可以看出来,是否存在重复子问题,以及重复子问题,以及重复子问题是如何产生的。以此来寻找规律,看是否能用动态规划解决。

找到重复子问题之后,有两种处理思路:

1)回溯加“备忘录”(从效率上讲和动态规划没有区别)

2)状态转移表

我们先画出一个状态表。状态表一般都是二维的,所以你可以把它想象成二维数组。其中,每个状态包含三个变量,行、列、数组值。我们根据决策的先后过程,从前往后,根据递推关系,分阶段填充状态表中的每个状态。最后,我们将这个递推填表的过程,翻译成代码,就是动态规划代码了。

(大部分状态表都是二维的,但是也有高维的,这个时候就不适合用状态转移表来做了,一方面是因为高维状态转移表不好画图表示,另一方面是因为人脑确实很不擅长思考高维的东西。)

下面看看如何用状态转移表来做之前那个矩阵最短路径问题:

首先看看回溯代码:


 
  1. private int minDist = Integer.MAX_VALUE; // 全局变量或者成员变量
  2. // 调用方式:minDistBacktracing(0, 0, 0, w, n);
  3. public void minDistBT(int i, int j, int dist, int[][] w, int n) {
  4. // 到达了 n-1, n-1 这个位置了,这里看着有点奇怪哈,你自己举个例子看下
  5. if (i == n && j == n) {
  6. if (dist < minDist) minDist = dist;
  7. return;
  8. }
  9. if (i < n) { // 往下走,更新 i=i+1, j=j
  10. minDistBT(i + 1, j, dist+w[i][j], w, n);
  11. }
  12. if (j < n) { // 往右走,更新 i=i, j=j+1
  13. minDistBT(i, j+ 1, dist+w[i][j], w, n);
  14. }
  15. }

有了回溯代码之后,接下来,我们要画出递归树,以此来寻找重复子问题。在递归树中,一个状态(也就是一个节点)包含三个变量 (i, j, dist),其中 i,j 分别表示行和列,dist 表示从起点到达 (i, j) 的路径长度。从图中,我们看出,尽管 (i, j, dist) 不存在重复的,但是 (i, j) 重复的有很多。对于 (i, j) 重复的节点,我们只需要选择 dist 最小的节点,继续递归求解,其他节点就可以舍弃了。

填充状态转移表:

代码如下:


 
  1. public int minDistDP(int[][] matrix, int n) {
  2. int[][] states = new int[n][n];
  3. int sum = 0;
  4. for ( int j = 0; j < n; ++j) { // 初始化 states 的第一行数据
  5. sum += matrix[ 0][j];
  6. states[ 0][j] = sum;
  7. }
  8. sum = 0;
  9. for ( int i = 0; i < n; ++i) { // 初始化 states 的第一列数据
  10. sum += matrix[i][ 0];
  11. states[i][ 0] = sum;
  12. }
  13. for ( int i = 1; i < n; ++i) {
  14. for ( int j = 1; j < n; ++j) {
  15. states[i][j] =
  16. matrix[i][j] + Math.min(states[i][j- 1], states[i- 1][j]);
  17. }
  18. }
  19. return states[n- 1][n- 1];
  20. }

 

2. 状态转移方程法

状态转移方程法有点类似递归的解题思路。我们需要分析,某个问题如何通过子问题来递归求解,也就是所谓的最优子结构。根据最优子结构,写出递归公式,也就是所谓的状态转移方程。

有了状态转移方程,也有两种处理思路:

1)递归加“备忘录”

2)迭代地推

前面已经分析了矩阵最短路径的状态转移方程如下:

min_dist(i, j) = w[i][j] + min(min_dist(i, j-1), min_dist(i-1, j))
 

下面用递归加“备忘录”的方式,将状态转移方程翻译成来代码。对于另一种实现方式,跟状态转移表法的代码实现是一样的,只是思想不同。


 
  1. private int[][] matrix =
  2. {{ 1359}, { 2134},{ 5267},{ 6843}};
  3. private int n = 4;
  4. private int[][] mem = new int[ 4][ 4];
  5. public int minDist(int i, int j) { // 调用 minDist(n-1, n-1);
  6. if (i == 0 && j == 0) return matrix[ 0][ 0];
  7. if (mem[i][j] > 0) return mem[i][j];
  8. int minLeft = Integer.MAX_VALUE;
  9. if (j- 1 >= 0) {
  10. minLeft = minDist(i, j- 1);
  11. }
  12. int minUp = Integer.MAX_VALUE;
  13. if (i- 1 >= 0) {
  14. minUp = minDist(i- 1, j);
  15. }
  16. int currMinDist = matrix[i][j] + Math.min(minLeft, minUp);
  17. mem[i][j] = currMinDist;
  18. return currMinDist;
  19. }

 

3.动态规划实践

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值