vlambda博客
学习文章列表

浅谈动态规划(2)进阶篇

上篇文中()我们简单了解了动态规划的使用方法,下面我们通过一些例子来加深理解。

基础:三角形最小路径和

如图所示,以上三角形由一连串的数字构成,求从顶点 2 开始走到底边的最短路径,每次只能向当前节点下面的两个节点走,如点 3 可向点 6 或 点5 走,不可直接走到点 7。

浅谈动态规划(2)进阶篇

(最短路径为  2+3+5+1 = 11即为我们所求)

我们需要用一个二维数组来表示这个三个角形的节点

浅谈动态规划(2)进阶篇

定义好数据结构之后,按照动态规划的解题基本思路来进行求解:

判断是否可递归:

对于每个节点都可以走它的左或右节点,假设我们定义 traverse(i, j) 为节点a[i][j]下一步要走的节点,则可以得出递归公式要走的伪代码如下:

traverse(i, j) = {    traverse(i+1, j);    //向节点i,j 下面的左节点走一步 traverse(i+1, j+1); //向节点i,j 下面的右节点走一步}

而何时终止,显然是遍历到三角形最后一条边的节点时终止,我们不难发现,对每个节点来说,在往下(无论是往左还是往右)遍历的过程中,问题规模不断地在缩小,也有临界条件(到达最后一条边的节点时终止),分解的子问题也有相同的解决问题的思路(对于每个节点的遍历都是往左或往右),而这恰恰符合了递归的条件,于是我们得到递归代码如下

privatestaticint[][] triangle = { {2, 0, 0, 0}, {3, 4, 0, 0}, {6, 5, 7, 0}, {4, 1, 8, 3}};
public static int traverse(int i, int j) { int totalRow = 4; // 总行数 if (i >= totalRow - 1) { return0; } // 往左下节点走时 int leftSum = traverse(i+1, j) + triangle[i+1][j]; // 往右下节点走时 int rightSum = traverse(i+1, j+1) + triangle[i+1][j+1]; // 记录每个节点往左和往右遍历的路径和的最小值 return Math.min(leftSum, rightSum);}
public static void main(String[] args) throws Throwable { int sum = traverse(0, 0) + triangle[0][0]; System.out.println("sum = " + sum);}

对于每个节点,要么向左或向右,每个问题都分解成了两个子问题,和斐波那契数列一样,如果画出递归树也是个二叉树,所以其时间复杂度是 O(2^n),也是指数级别。

分析在递归的过程中是否存在大量的重复子问题

为什么时间复杂度是指数级别,我们来分析一下

浅谈动态规划(2)进阶篇

对于节点 3 和 4 来说,如果节点 3 往右遍历, 节点 4 往左遍历,都到了节点 5,节点 5 往下遍历的话就会遍历两次,所以此时就会出现重复子问题

采用备忘录的方式来存子问题的解以避免大量重复计算(剪枝)

我么用备忘录来储存中间节点:

privatestaticint[][] triangle = { {2, 0, 0, 0}, {3, 4, 0, 0}, {6, 5, 7, 0}, {4, 1, 8, 3} };
// 记录中间状态的 mapprivatestatic HashMap<String, Integer> map = new HashMap();
public static int traverse(int i, int j) { String key = i + "" + j; if (map.get(key) != null) { return map.get(key); }
int totalRow = 4; // 总行数 if (i >= totalRow - 1) { return0; } // 往左下节点走时 int leftSum = traverse(i+1, j) + triangle[i+1][j]; // 往右下节点走时 int rightSum = traverse(i+1, j+1) + triangle[i+1][j+1]; // 记录每个节点往左和往右遍历的路径和的最小值 int result = Math.min(leftSum, rightSum); map.put(key, result); return result;}

这样就达到了剪枝的目的,避免了重复子问题,时间复杂度一下子下降到 O(n), 空间复杂度的话由于我们用Hash table存储了所有的节点的状态,所以空间复杂度是 O(n)。

改用自下而上的方式来递推

要求节点 2 到底边的最短路径,只要先求得节点 3 和 节点 4 到底部的最短路径值,然后取这两者之中的最小值再加 2 就是从 2 到底部的最短路径,同理,要求节点 3 或 节点 4 到底部的最小值,只要求它们的左右节点到底部的最短路径再取两者的最小值再加节点本身的值(3 或 4)即可。

我们知道对于三角形的最后一层节点,它们到底部的最短路径就是其本身,于是问题转化为了已知最后一层节点的最小值怎么求倒数第二层到最开始的节点到底部的最小值了。先观察倒数第二层到底部的最短路径怎么求

浅谈动态规划(2)进阶篇

同理,第二层对于节点 3 ,它到最底边的最短路径转化为了 3 到 7, 6 节点的最短路径的最小值,即 9, 对于节点 4,它到最底层的最短路径转化为了 4 到 6, 10 的最短路径两者的最小值,即 10。

浅谈动态规划(2)进阶篇

那么要求 2 到底部的路径就十分简单了,只要求 2 到节点 9 与 10 的最短路径即可,显然为 11。

所以最终的 11 即为我们所求的值,接下来我们来看看怎么定义 DP 的状态与状态转移方程。我们要求每个节点到底部的最短路径,于是 DP 状态 DP[i,j] 定义为 i,j 的节点到底边的最小值,DP状态转移方程定义如下:

DP[i,j] = min(DP[i+1, j], D[i+1, j+1]) + triangle[i,j]

这个状态转移方程代表要求节点到最底部节点的最短路径只需要求左右两个节点到最底部的最短路径两者的最小值再加此节点本身。

DP 状态 DP[i,j] 有两个变量,需要分别从下而上,从左到右循环求出所有的 i,j, 有了状态转移方程求出代码就比较简单了:

privatestaticint[][] triangle = { {2, 0, 0, 0}, {3, 4, 0, 0}, {6, 5, 7, 0}, {4, 1, 8, 3}};public static int traverse() { int ROW = 4; int[] mini = triangle[ROW - 1]; // 从倒数第二行求起,因为最后一行的值本身是固定的 for (int i = ROW - 2; i >= 0; i--) { for (int j = 0; j < triangle[j].length; j++) { mini[j] = triangle[i][j] + Math.min(mini[j], mini[j+1]); } } return mini[0];}
public static void main(String[] args) throws Throwable { int minPathSum = traverse(); System.out.println("sum = " + minPathSum);}

可能有一些人对 mini 数组的定义有疑问,这里其实用了一个比较巧妙的方式,首先我们定义 mini 的初始值为最后一行的节点,因为最后一行的每个节点的 DP[i,j] 是固定值,只要从倒数第二行求起即可,其次我们知道每个节点到底部的最短路径只与它下一层的 D[I+1,j], D[i+1, j+1] 有关,所以只要把每一层节点的 DP[i,j] 求出来保存到一个数组里即可,就是为什么我们只需要定义一下 mini 一维数组的原因

如图,求节点 2 到底部的最短路径,只考虑节点 9, 10,之前层数的节点无需再考虑。因为 9,10 已经是最优子结构了,所以只保存每层节点(即一维数组)的最值即可。

当自下而上遍历完成了,mini[0] 的值即为 DP[0,0],即为节点 2 到其底部的最短路径。mini 的定义可能有点绕,大家可以多思考几遍,当然,你也可以定义一个二维数组来保存所有的 DP[i,j],只不过这样做多耗些空间罢了。

再来谈谈看看最优子结构,在以上的推导中我们知道每一层节点到底部的最短路径依赖于它下层的左右节点的最短路径,求得的下层两个节点的最短路径对于依赖于它们的节点来说就是最优子结构,最优子结构对于子问题来说属于全局最优解,这样我们不必去求节点到最底层的所有路径了,只需要依赖于它的最优子结构即可推导出我们所要求的最优解,所以最优子结构有两层含义,一是它是子问题的全局最优解,依赖于它的上层问题只要根据已求得的最优子结构推导求解即可得全局最优解,二是它有缓存的含义,这样就避免了多个依赖于它的问题的重复求解(消除重叠子问题)。

明天继续深入讲解。