<数据结构与算法>——动态规划入门(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不太可。
记忆化搜索缺点:
- 有递归的开销,矩阵有多少层,递归就有多少层。
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)
三、动态规划
记忆化搜索本质上就是一种动态规划。
动态规划的本质就是解决重复计算的问题。
- 分治+记忆化搜索
- 自底向上的动态规划:先计算离终点最近的,一步一步向上计算,最后算出结果。
- 自顶向下的动态规划:顺着推
四、如何想到要使用动态规划
-
以下三种类型的问题,很大概率需要采用动态规划:
a、问最大值/最小值:eg:上面的问题,问从上到下路径和的最小值
b、问Yes/No eg:从上到下能否刚好找到一条和为target的路径
c、问Count(*):从(0, 0) 到 (n, 1) 一共有多少种
出现以上三种类型的问题,90%用动态规划求解,考虑采用哪种形式的动态规划。 -
当题目参数 Can not sort / swap 90%可能是动态规划
(反过来,能够排序/交换元素的 很可能不能用动态规划)
五、动态规划的4点要素
-
状态 State 「灵感+创造力」「总结了四类问题对应的固定的状态表示」
eg:f[i][j]的意义 , D&C + 记忆化搜索 dfs() 定义 -
方程 Function 「状态之间的联系,如何从一个小的状态去求一个大的状态」
自顶向下中:离起点越近,状态越小
eg: f[i][j] -
初始化 Initialization
最极限的小状态是什么,起点 -
答案 Answer
最大的那个状态是什么,终点