<数据结构与算法>——动态规划入门(1)

动态规划是一种解决问题的指导思想。

一、例题

  1. Triangle
    Given a triangle, find the minimum path sum from top to bottom. Each step you may move to adjacent numbers on the row below.
    For example, given the following triangle
    [
    [2],
    [3,4],
    [6,5,7],
    [4,1,8,3]
    ]
    The minimum path sum from top to bottom is 11 (i.e., 2 + 3 + 5 + 1 = 11).
    NOTE:
    Bonus point if you are able to do this using only O(n) extra space, where n is the total number of rows in the triangle.

二、分析

1. 根据题目,可采用深度优先搜索方法。

深度优先搜索中,类似于二叉树的递归算法,有两种策略: 遍历和分治。注意这两种策略都是递归算法。

1) 采用遍历 traverse, 把要变化的值(这里是sum)作为dfs递归函数的参数,在传递过程中使用。
这里dfs的定义是,走到当前下(x,y)这个点的和为sum,这个sum随着点的下移在变化,因此把sum作为dfs的一个参数。

// traverse
void dfs(int x, int y, int sum) {
    if (x == n) {
        if (sum < best) {
            best = sum;
        }
        return;
    }
    // 每次往下走有两个选择
    dfs(x + 1, y, sum + a[x][y]);  // 向正下方走
    dfs(x + 1, y + 1, sum + a[x][y]);  // 向右下方走
}
dfs(0,0);

// Java实现
class Solution {
    private int minSum = Integer.MAX_VALUE;
    public int minimumTotal(List<List<Integer>> triangle) {
        dfs(triangle, 0, 0, 0);
        return minSum;
    }
    private void dfs(List<List<Integer>> triangle, int x, int y, int sum) {
        if (x == triangle.size()) {
            minSum = Math.min(minSum, sum);
            return;
        }
        sum += triangle.get(x).get(y);
        dfs(triangle, x + 1, y, sum);
        dfs(triangle, x + 1, y + 1, sum);
    }
}

分析其复杂度:O(2^n)
本题中的三角形本质上是一个二维数组,数据结构如下图所示。

如果我们想要到达图中的节点 e ,经过的路径会有:a->b->e, a->c->e 两种。
同理,如果想要到达节点 i ,经过的路径会有:abei, acei, acfi 三种
可以看到,随着层的增加,到达每个节点的路径种类会逐渐增加,遍历的时间复杂度会随着层数增加迅速增加。
具体而言,从顶点出发,每个节点往下走会有两个选择,根据数学的排列知识,到达最底层,一共有2 * 2 * ... * 2 * 2 种到达底端的路径,其中有 n 个2。因此,总的时间复杂度为O(n * 2^n) = O(2^n),这样的时间复杂度基本上是不能用的。

2)采用Divide & Conquer 分治思想
分治方法的核心要素: 一定要明确函数要做一件什么事情,返回什么结果,把函数的定义写在开头。分治法一个很重要的区分与traverse的方面是:traverse的返回值一般都是void,而分治法一般都有一个返回值,这个返回值就是在当前的参数下的最优解。

// Divide & Conquer
// dfs:从(x,y)出发走到最底层所能找到的最小路径之和
int dfs(int x, int y) { 
    if (x == n) {
        return 0;
    }
    return min(dfs(x + 1, y), dfs(x + 1, y + 1)) + a[x][y];
}

dfs(0, 0);

// Java 实现代码
class Solution {
    public int minimumTotal(List<List<Integer>> triangle) {
        return dfs(triangle, 0, 0);
    }
    private int dfs(List<List<Integer>> triangle, int x, int y) {
        if (x == triangle.size()) { return 0; }
        return Math.min(dfs(triangle, x + 1, y), dfs(triangle, x + 1, y + 1)) + 
            triangle.get(x).get(y);
        
    } 
}

分析复杂度:复杂度和traverse一样,还是O(2n),每次都有两个选择,一共n层,因此是O(2n)

根据上述的分析,dfs无法解决该问题,需要对dfs进行优化。

2. 优化

1) 分析分治算法的过程如下:

计算dfs(0, 0) 依赖于dfs(1, 0) 和 dfs(1, 1),依次往下,可以看到,分治算法中存在了大量的重复计算,例如dfs(2, 1) 2次,dfs(3, 1)和dfs(3, 2) 3次。
因此,优化的思路就是避免重复计算。加入HashMap,每次求dfs结果之前,先看HashMap中是否已经有了对应的结果,如果有,直接拿来用,如果没有,计算dfs(x, y),并将此时的键值对作为结果存放到HashMap。
记忆化搜索
这个题也可以使用一个二维数组存储计算过的dfs(x, y)值:
NOTE: 下面这段伪代码看似使用了hashTable,但是实际上没有利用起来

// Divide & Conquer
int dfs(int x, int y) {
    if (x == n) {
        return 0;
    }
    // -1 表示还没有计算过
    if (hashTable[x][y] != -1) {   // NOTE: 这个伪代码是错误的,实际上并没有利用上hashTable,因为dfs(x, y)采用递归最先计算出来的是靠近终点的值,因此,计算dfs(x, y)的时候,hashTable[x][y] 必然还没有结果。
        return hashTable[x][y];
}
    hashTable[x][y] = min(dfs(x + 1, y), dfs(x + 1, y + 1)) + a[x][y];
    return hashTable[x][y];
}

正确的java实现如下:

// Java 实现:
// Divide & Conquer
class Solution {
    public int minimumTotal(List<List<Integer>> triangle) {
        int[][] map = new int[triangle.size() + 1][triangle.size() + 1] ;
        for (int i = 0; i <= triangle.size(); i++) {
            for (int j = 0; j <= i; j++) {
                map[i][j] = Integer.MIN_VALUE; // 初始化矩阵中每个没有计算过的值为Integer.MIN_VALUE
            }
        }        
        
        return dfs(triangle, 0, 0, map);
    }
    
    // 计算从 (x, y) 出发,到达最底端,最短路径和
    private int dfs(List<List<Integer>> triangle, int x, int y, int[][] map) {
        if (x == triangle.size()) { return 0; }
        
        if (map[x + 1][y] == Integer.MIN_VALUE) {  // 靠近终点中的值才可能被map存起来
            map[x + 1][y] = dfs(triangle, x + 1, y, map);
        }
        
        if (map[x + 1][y + 1] == Integer.MIN_VALUE) { // 靠近终点中的值才可能被map存起来
            map[x + 1][y + 1] = dfs(triangle, x + 1, y + 1, map);
        }
        
        return Math.min(map[x + 1][y], map[x + 1][y + 1]) + triangle.get(x).get(y);
    } 
}

可以看到 这种记忆化搜索的策略和分治算法能很好的结合,而和traverse不太可。

记忆化搜索缺点

  1. 有递归的开销,矩阵有多少层,递归就有多少层。

2)继续优化
a、上述采用记忆化搜索,是一种自顶向下的策略,每次计算当前的dfs(x, y)时候,需要递归的计算出下一层的dfs(x + 1, y) 和 dfs(x + 1, y + 1)。该数组有多少层,递归深度就有多少层,因此递归开销很大。因此,我们可以把思路反过来,采用一种自底向上的方法。先计算最底层,让后逐渐上升。
采用两层循环方式:

A[][]

// 状态定义
f[i][j] 表示从i,j出发到达最后一层的最小路径和

// 初始化,终点先有值
for(int i = 0; i < n; i++) {
    f[n - 1][i] = A[n - 1][i];
}

// 循环递推求解
for (int i = n - 2; i >= 0; i--) {
    for (int j = 0; j <= i; j++) {
        f[i][j] = Math.min(f[i + 1][j], f[i + 1][j + 1]) + A[i][j];
    }
}
return f[0][0];
// 自底向上解法
class Solution {
    public int minimumTotal(List<List<Integer>> triangle) {
        // map[i][j] 存放从(i, j)出发到最后一行最小路径和
        int[][] map = new int[triangle.size()][triangle.size()];
        
        // 初始化最后一行,根据map[i][j]定义:最后一行的值为triangle最后一行值本身
        for (int i = 0; i < triangle.size(); i++) {
            map[triangle.size() - 1][i] = triangle.get(triangle.size() - 1).get(i);
        }
        
        // 两重循环,计算map矩阵
        for (int x = triangle.size() - 2; x >= 0; x--) {
            for (int y = 0; y <= x; y++) {
                map[x][y] = Math.min(map[x + 1][y], map[x + 1][y + 1]) + triangle.get(x).get(y); 
            }
        }
        
        return map[0][0];
        
    }
}

时间复杂度: O(n^2)

b、采用自顶向下的动态规划
此时f[i][j]的含义变为:从起点到(i, j)这个点的最短路径。
计算的方法为:f[i][j] = Math.min(f[i - 1][j - 1], f[i - 1][j]) + A[i][j];
注意边界情况

// 自顶向下的动态规划
// f[i][j] 是从起点到(i, j)的最短路径

// 初始化
f[0][0] = A[0][0];

// 递推求解
for (int i = 1; i < n; i++) {
    for (int j = 1; j <= i; j++) {
        // to do
    }
}
// 求结果:终点

Java实现:

// top-bottom
class Solution {
    public int minimumTotal(List<List<Integer>> triangle) {
        // map[i][j] 存放从 起点到(i, j) 的最小路径和
        int[][] map = new int[triangle.size()][triangle.size()];
        
        // 初始化map, 此时第一列和对角线为triangle对应的值本身只和
        map[0][0] = triangle.get(0).get(0);
        
        // 2-loop 计算矩阵剩余部分
        for (int i = 1; i < triangle.size(); i++) {
            for (int j = 0; j <= i; j++) {
                if (j == 0) {
                    map[i][j] = map[i - 1][j] + triangle.get(i).get(j);
                } else if (j == i) {
                    map[i][j] = map[i - 1][j - 1] + triangle.get(i).get(j);
                } else {
                    map[i][j] = Math.min(map[i - 1][j - 1], map[i - 1][j]) + triangle.get(i).get(j);
                }
            }
        }
        
        // 从矩阵的最后一行中计算最后的结果
        int result = map[triangle.size() - 1][0];
        for (int i = 0; i < triangle.size(); i ++) {
            result = Math.min(result, map[triangle.size() - 1][i]);
        }
        return result;
    } 
}

时间复杂度O(n^2)

三、动态规划

记忆化搜索本质上就是一种动态规划。
动态规划的本质就是解决重复计算的问题

  1. 分治+记忆化搜索
  2. 自底向上的动态规划:先计算离终点最近的,一步一步向上计算,最后算出结果。
  3. 自顶向下的动态规划:顺着推

四、如何想到要使用动态规划

  1. 以下三种类型的问题,很大概率需要采用动态规划:
    a、问最大值/最小值:eg:上面的问题,问从上到下路径和的最小值
    b、问Yes/No eg:从上到下能否刚好找到一条和为target的路径
    c、问Count(*):从(0, 0) 到 (n, 1) 一共有多少种
    出现以上三种类型的问题,90%用动态规划求解,考虑采用哪种形式的动态规划。

  2. 当题目参数 Can not sort / swap 90%可能是动态规划
    (反过来,能够排序/交换元素的 很可能不能用动态规划)

五、动态规划的4点要素

  1. 状态 State 「灵感+创造力」「总结了四类问题对应的固定的状态表示」
    eg:f[i][j]的意义 , D&C + 记忆化搜索 dfs() 定义

  2. 方程 Function 「状态之间的联系,如何从一个小的状态去求一个大的状态」
    自顶向下中:离起点越近,状态越小
    eg: f[i][j]

  3. 初始化 Initialization
    最极限的小状态是什么,起点

  4. 答案 Answer
    最大的那个状态是什么,终点

posted @ 2019-09-07 18:05  guoqiangliu  阅读(336)  评论(0)    收藏  举报