面试官:换人!他连动态规划的一个模型三个特征都不懂

即将开播:6月19日,互联网银行架构师魏生谈互联网开放银行实施路径的探索与思考

 前言

之前我们简单总结了一下动态规划的解题套路,不少人反馈受益颇丰(如果是动态规划初学者,强烈建议看看!)不过有位读者说虽然动态规划的解题套路是看懂了,不过一些动态规划的主要特征,如无后效性没有提到,所以今天我们就简单以一道题再来温习一下动态规划的解题套路及其主要特征。

面试官:换人!他连动态规划的一个模型三个特征都不懂

什么样的问题适合用动态规划实现呢,我们在一文学会动态规划解题技巧中曾经提到,只要问题符合「递归+重复」,则基本断定可以用动态规划来解题。简单来说能用动态规划解决的问题符合「一个模型三个特征」

一个模型: 多阶段决策最优解模型,多阶段意味着问题可以分解为多个阶段进行求解,每个阶段通常都有一个最优解,每个阶段的最优解通过递推公式可以求得下个阶段的最优解,求得了每个阶段的最优解,自然而然全局最优解也就解决了

三个特征

  1. 最优子结构:上文一个模型中所述每个阶段的最优解即最优子结构
  2. 无后效性:当前阶段的最优解只与它上个阶段的最优解有关,它不 Care 上个阶段的最优解是怎么得来的,之后阶段的决策(最优解)也不会影响之前阶段问题的求解
  3. 重复子问题: 如果问题采用自顶向下的方式解决,通常都会有重复子问题的,即我们所说的「递归+重复」,此时我们可以采用自下而上的套路来求解问题

如果大家对上文中的「一个模型三个特征」没感觉也没关系,我们可以套用如下动态规划解题模板

  1. 判断是否可用递归来解,可以的话进入步骤 2
  2. 分析在递归的过程中是否存在大量的重复子问题
  3. 采用备忘录的方式来存子问题的解以避免大量的重复计算(剪枝)
  4. 改用自底向上的方式来递推,即动态规划

接下来我们先通过一道比较有意思的习题来套用以上解题模板来解题,然后再来解释一下动态规划的「一个模型三个特征」

问题定义

有如下 8 x 8 格子,机器人需要从开始位置走到结束位置,每次只能朝右或朝下走,粉色格子为障碍物,机器人不能穿过,问机器人从开始位置走到结束位置最多共有多少种走法?

面试官:换人!他连动态规划的一个模型三个特征都不懂

这题如果用动态规划可能一时半会儿看不出来,那我们就用穷举法来看看怎么解,所谓穷举法就是把机器人所有的路径全部算出来,然后再相加

用穷举法怎么做呢,对于机器人起始位置来说,它可以选择向下或向右,所以它到终点的路径数为其右边格子到终点的路径数与其下边格子到终点的路径数之和,伪代码如下

paths(start, end) = paths(start下方格子, end) + paths(start右边格子, end) 

面试官:换人!他连动态规划的一个模型三个特征都不懂

对于每个格子来说,它也可以选择向左或向右,由于可以得出对于每个选中的格子,它都符合以下递推公式:

paths(机器人所在格子, end) = paths(机器人所在格子下方格子, end) + paths(机器人所在格子右方格子, end) 

表示成代码如下:

private int countPaths(boolean[][] grid, int row, int col) { 
    if(isObstacle(grid, row, col)) return 0; 
        if (isAtEnd(grid, row, col)) return 1; 
        return countPaths(grid, row+1, col) + countPath(grid, row, col+1) 
} 

看出规律了吗,这其实是一个递归,那么如果用递归会有什么问题呢?

以上图起始点出发为例,如果选择了向右或向下,则右或下格子也可以选择向右或向下,如果右格子选择向下,下格子选择向右,则这两条路径都经过了相同的格子,这两条路径相交了!也就是出现了重复子问题!

面试官:换人!他连动态规划的一个模型三个特征都不懂

每次机器人可以选择向右或向下走,如果我们把它抽象成一颗二叉树,则结构如下 :

面试官:换人!他连动态规划的一个模型三个特征都不懂

注:蓝色代表相同的方块

由于是一颗二叉树,时间复杂度显然是 O(2^n),指数级别!原因当然是由于解题中存在大量的重复子问题(图中蓝色代表相同方块,即重复子问题),为了减少重复子问题,我们需要用备忘录来保存中间的结果 (即减枝),这样能让时间复杂度大幅度减少,如下

面试官:换人!他连动态规划的一个模型三个特征都不懂

经过「递归+备忘录」后其实时间复杂度和动态规划已经差不多了,不过我们之前一直强调尽量不要用递归的方式解题,因为递归层级过深,很可能导致栈溢出。

所以接下来我们改用动态规划的方式看看该如何解决。

递归是采用自顶向下的方式来解决问题,对应到本题,就是从起点出发,推导出到终点的所有路径和,而动态规划则是自下而上,即从终点出发推导出到起点的所有路径和。对于最右边和最底边的格子来说,由于它们只能向下或向右走,所以分别在它们的位置上标 1,代表从这些格子出发只有一种路径,如下图示:

面试官:换人!他连动态规划的一个模型三个特征都不懂

最右和最下边的格子的路径数都填上了,其它格子就简单了,显然对于其它格子来说,

paths(格子, end) = paths(下边的格子, end) + paths(右边的格子, end) 

注:如果格子为障碍物,则其到终点的路径数为 0

也就是说每个格子到终点的路径和等于其右边格子到终点的路径数与其下边格子到终点的路径数之和,这样从右到左,从下到上根据以上递推公式依次填上每个格子的路径数即可,如下

面试官:换人!他连动态规划的一个模型三个特征都不懂

显然从起点到终点的路径和为 10 + 17 = 27。时间复杂度是多少呢,从下到上,从右到左,两层循环,显然是 O(n^2),比起最开始用递归的 O(2^n) 大幅度提升了。

知道解题思路,用代码实现相信不是什么大问题,我们以一个二维数组 grid[row][column] 来表示图中的格子, 如果格子为障碍物,则其元素值初始化为 -1,其它格子元素初始化为 0,这样我们只要做个两层循环即可求得最终的解,代码如下,注释很详尽,相信大家应该能看懂

public class Solution { 
 
    /** 
     * 初始化格子, -1 代表格子为障碍物 
     */ 
    private static final int GRIDS[][] = { 
            {0,0,0,0,0,0,0,0}, 
            {0,0,-1,0,0,0,-1,0}, 
            {0,0,0,0,-1,0,0,0}, 
            {-1,0,-1,0,0,-1,0,0}, 
            {0,0,-1,0,0,0,0,0}, 
            {0,0,0,-1,-1,0,-1,0}, 
            {0,-1,0,0,0,-1,0,0}, 
            {0,0,0,0,0,0,0,0} 
    }; 
 
   static private int sumOfPaths() { 
 
       // 格子为 8 x 8 
       int N = 8; 
       /** 
        * 初始化最右边的格子 
        */ 
       for (int j = 0; j < N; j++) { 
           GRIDS[N-1][j] = 1; 
       } 
 
       /** 
        * 初始化最底部的格子 
        */ 
       for (int i = 0; i < N; i++) { 
           GRIDS[i][N-1] = 1; 
       } 
 
       /** 
        * 从右到左,从下到上填满每个格子的值,由于最右和最底部的格子都初始化了, 
        * 所以从倒数第二行,倒数第二列开始遍历 
        */ 
       for (int i = N - 2; i >= 0; i--) { 
           for (int j = N - 2; j >= 0; j--) { 
               // 说明是障碍物,无需计算 
               if (GRIDS[i][j] == -1) { 
                   continue; 
               } 
 
               /** 
                * 每个要计算的格子到终点的路径和等于其右边格子到终点的路径数与其下边格子到终点的路径数之和 
                * 如果右边或下边的格子为障碍物,则其到终点的路径数设置为 0 
                */ 
               int rightPath = GRIDS[i+1][j] == -1 ? 0 : GRIDS[i+1][j]; 
               int bottomPath = GRIDS[i][j+1] == -1 ? 0 : GRIDS[i][j+1]; 
               GRIDS[i][j] = rightPath + bottomPath; 
           } 
       } 
 
       /** 
        * 遍历完之后起点格子对应的值即为最终所求的解 
        */ 
       return GRIDS[0][0]; 
   } 
 
    public static void main(String[] args) { 
        int result = Solution.sumOfPaths(); 
        System.out.println("result = " + result); 
    } 
}     

可以看到,采用自底向上的动态规划解法整体思路还是比较清晰且高效的。

问题解决了,现在我们回头来看下动态规划的一个模型和三个特征该如何理解

一个模型: 即多阶段决策最优解模型,首先来看多阶段,起始位置走向终点,第一阶段可以看作是从起点向右或向下走,第一阶段选中格子后,第二阶段就要从第一阶段选中的格子往右或往下走。。。,可以看到它的问题解确实是由多阶段的最优组成的。

三个特征

1、最优子结构

上文我们说对于每个格子,它到终点的路径数为其右边格子到终点的路径数与其下边格子到终点的路径数之和

paths(格子, end) = paths(下边的格子, end) + paths(右边的格子, end) 

即对于每个格子来说,只要算出它的最优子结构(其右边格子到终点的路径数与其下边格子到终点的路径数),则此格子到终点的路径和得出的就是最优解,进而可知上文中计算的 GRID[0][0] 也是全局最优解。

2、无后效性

每个格子到终点的路径数只与其右边格子到终点的路径数与其下边格子到终点的路径数有关,它不 care 这两者的路径数是怎么得出的,也就是当前阶段的解只与它上一层阶段的解有关,它并不关心这些解是怎么算出来的,同时当前阶段的解算完后,它并不会对之前的阶段的解产生影响,这就是无后效性的含义。

3、 重复子问题

如果用递归的方式来做,我们之前分析过了,显然存在重复子问题。

综上,此题符合动态规划的「一个模型三个特征」,所以可以用动态规划来解题

总结本文用一道比较常见的习题来帮助大家重新温习了一下动态规划的特点:一个模型三个特征,相信大家对这些概念应该有比较深刻的认识了,其实忘记这些概念,使用前文所述动态规划解题四步曲基本可以套路大多数动态规划的问题,不过了解这些概念能进一步地加深我们对动态规划的理想,知其然,知其所以然也很重要。