相关文章:
数据结构与算法 -- 使用DFS算法处理组合类和排列类问题(一维)
在之前的两篇文章中,我们重点介绍了DFS算法处理一维和二维的问题,但是使用DFS算法依然是存在弊端的,因此在一些算法题中明确规定了运行耗时,在处理一些大数据量问题的时候,因为时间限制无法ac,所以我们需要通过其他的方式来规避这些问题,例如通过动态规划。
动态规划算是算法题中比较难的题目,但是如果熟悉了DFS算法,动态规划也不是什么难题了,基本上所有的DFS都可以转为动态规划,但是不代表所有的动态规划都可以转成DFS,使用动态规划的目的,就是避免DFS中的重复计算问题。 那么在学习动态规划之前,我们先对记忆化搜索有一个初步的了解,这个是动态规划的基础。
1 记忆化搜索入门
首先,我们通过上一篇文章中的最小三角形路径和为例,对记忆化搜索有一个初步的认识。
1.1 LeetCode120 - 最小三角形路径和
给定一个三角形 triangle
,找出自顶向下的最小路径和。
每一步只能移动到下一行中相邻的结点上。相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。也就是说,如果正位于当前行的下标 i
,那么下一步可以移动到下一行的下标 i
或 i + 1
。
示例 1:
lua
lua
复制代码
输入: triangle = [[2],[3,4],[6,5,7],[4,1,8,3]]
输出: 11
解释: 如下面简图所示:
2
3 4
6 5 7
4 1 8 3
自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。
我们再看下这张决策树,在第1层和第2层都是正常的计算,但是到了第三层,我们在深度优先搜索的时候,会先在左侧搜索,此时会计算(2,1)的值并求和,那么当左侧搜索完成之后,来到右侧。
我们看到,在右侧的树里,我们依然需要计算(2,1),(3,1),(3,2)的值,这就是重复计算,这也是递归函数存在最大的一个问题。
1.1.1 二叉树分治DFS算法
首先我们先采用二叉树分治的思想,来做这道题,为啥不采用上一篇文章的算法,原因后续再说。如果采用分治,那么就是将大问题先拆解为小问题。
因为要求最小的路径和,那么每个节点的最小路径和,就是左孩子和右孩子的最小值加自身的值,最终取到的就是某个点的最小路径;依次往上找,那么根节点的最小路径,就是左子树的最小路径和右子树的最小路径中的最小路径,与根节点的值相加。
java
public static int minimumTotal(List<List<Integer>> triangle) {
if (triangle == null || triangle.size() == 0) {
return 0;
}
return dfs(triangle, 0, 0);
}
private static int dfs(List<List<Integer>> triangle,
int x,
int y) {
if (x == triangle.size()) {
return 0;
}
//只有两步路可以走
//往下走 left
int left = dfs(triangle, x + 1, y);
//往右走 right
int right = dfs(triangle, x + 1, y + 1);
Log.d(TAG, "dfs: x=" + x + " y=" + y);
return Math.min(left, right) + triangle.get(x).get(y);
}
这道题,如果扔到LeetCode中运行,会发现:
在执行大数据量的时候,超出了时间限制,为啥会超时,其实我们从决策树中可以看到,当我们在计算完左侧子树之后,右侧会多出很多重复的计算。
1.1.2 使用记忆化搜索算法
所谓记忆化搜索,就是在前期处理的过程中,对解进行缓存,缓存的方式有多种,例如使用HashMap,那么在后续的递归中,如果从缓存中可以拿到值,那么就直接return,去另一个分支查找。
java
public static int minimumTotal(List<List<Integer>> triangle) {
if (triangle == null || triangle.size() == 0) {
return 0;
}
Map<Pair<Integer,Integer>,Integer> map = new HashMap<>();
return dfs(triangle, 0, 0,map);
}
private static int dfs(List<List<Integer>> triangle,
int x,
int y,
Map<Pair<Integer,Integer>,Integer> visited) {
if (x == triangle.size()) {
return 0;
}
//如果之前已经查找到了,就直接rerun
if(visited.containsKey(new Pair(x,y))){
return visited.get(new Pair<>(x,y));
}
//只有两步路可以走
//往下走 left
int left = dfs(triangle, x + 1, y,visited);
//往右走 right
int right = dfs(triangle, x + 1, y + 1,visited);
int sum = Math.min(left, right) + triangle.get(x).get(y);
visited.put(new Pair<>(x,y),sum);
Log.d(TAG, "dfs: x=" + x + " y=" + y);
return sum;
}
这个算法的优化就是使用了Map缓存,从下面的日志可以看到,其实在递归的过程中其实有很多点都直接跳过了,提高了算法的效率。
java
2023-12-30 15:29:28.958 32280-32280 DFS com.example.myapplication D dfs: x=3 y=0
2023-12-30 15:29:28.958 32280-32280 DFS com.example.myapplication D dfs: x=3 y=1
2023-12-30 15:29:28.958 32280-32280 DFS com.example.myapplication D dfs: x=2 y=0
2023-12-30 15:29:28.958 32280-32280 DFS com.example.myapplication D dfs: x=3 y=2
2023-12-30 15:29:28.958 32280-32280 DFS com.example.myapplication D dfs: x=2 y=1
2023-12-30 15:29:28.958 32280-32280 DFS com.example.myapplication D dfs: x=1 y=0
2023-12-30 15:29:28.958 32280-32280 DFS com.example.myapplication D dfs: x=3 y=3
2023-12-30 15:29:28.958 32280-32280 DFS com.example.myapplication D dfs: x=2 y=2
2023-12-30 15:29:28.958 32280-32280 DFS com.example.myapplication D dfs: x=1 y=1
2023-12-30 15:29:28.958 32280-32280 DFS com.example.myapplication D dfs: x=0 y=0
2023-12-30 15:29:28.958 32280-32280 DFS com.example.myapplication D onCreate: 11
然后这个算法在LeetCode中是ac了,但是这也不是最优解,记忆化搜索依然不是最完美的算法。
1.1.3 记忆化搜索的弊端
当然如果要使用记忆化搜索,首先必要条件是递归函数要有返回值,就像分治法中的这种方法,因为没有返回值就无法return结果,这里也回答了前面提出的问题,为什么不用上一篇文章中的算法。
当然我们可以认为记忆化搜索就是动态规划,但是通过递归来实现记忆化搜索能解决大部分的问题,但如果遇到解决时间复杂度为O(n)的问题,就会出现StackOverFlow的问题,此时就不能通过递归,需要使用for循环来解决。
但是记忆化搜索的好处在于便于理解,在dfs的基础上使用缓存记录结果集,一定程度上实现了算法效率的提升。
2 动态规划
前面我们在第1小节中,介绍了记忆化搜索,那么它是属于动态规划问题的一种解决方案,其实动态规划的核心就是解决重复子问题,这一节我会介绍使用for循环的方式处理动态规划问题。
2.1 自底向上的动态规划
依然拿1.1三角形最小路径问题来看,这道题是求自顶向下的最小路径,其中走法按照题目要求来即可,那么这就是一道典型的自底向上的动态规划问题。
java
public static int minimumTotal(List<List<Integer>> triangle) {
if (triangle == null || triangle.size() == 0) {
return 0;
}
//定义一个状态数组
//dp[i][j] 指的是i,j到底层的最短路径
int[][] dp = new int[triangle.size()][triangle.get(triangle.size() - 1).size()];
//如果是最底层到最底层,那么最短路径就是自身
int h = triangle.size();
for (int i = 0; i < triangle.get(h - 1).size(); i++) {
//初始化底层数组
dp[h - 1][i] = triangle.get(h - 1).get(i);
}
//开始初始化上层的数组元素
for (int i = h - 2; i >= 0; i--) {
//外层行数
for (int j = 0; j < i + 1; j++) {
//内层列数
dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + triangle.get(i).get(j);
}
}
// 0,0 代表 从0,0到底层的最小路径和
return dp[0][0];
}
首先我们定义了一个状态数组,如下图所示:
因为是一个三角矩阵,因此只会用到斜对角线的一半;状态数组的定义就是第i,j位置的元素到最底层的最短路径,因此最底层的元素到自身的最短路径就是本身,我们先初始化最后一行的元素。
然后我们通过for循环来填充剩余的格子,具体怎么填,我们可以根据决策树来看:
因为最底层的元素我们已经有了,我们从下向上依次填充就会很简单,最终我们返回的结果就是dp[0][0],代表第一层元素到底层的最小路径和。
2.2 自顶向下的动态规划
既然有自底向上,那么也有自顶向下,这个过程就比较顺了。
java
public static int minimumTotal2(List<List<Integer>> triangle) {
if (triangle == null || triangle.size() == 0) {
return 0;
}
//定义一个状态数组
//dp[i][j] 指的是从0,0 到 i,j的最小路径
int[][] dp = new int[triangle.size()][triangle.get(triangle.size() - 1).size()];
dp[0][0] = triangle.get(0).get(0);
int h = triangle.size();
for (int i = 1; i < h; i++) {
//外层行数
dp[i][0] = dp[i - 1][0] + triangle.get(i).get(0);
dp[i][i] = dp[i - 1][i - 1] + triangle.get(i).get(i);
}
for (int i = 2; i < h; i++) {
for (int j = 1; j < i; j++) {
dp[i][j] = Math.min(dp[i - 1][j - 1], dp[i - 1][j]) + triangle.get(i).get(j);
}
}
int min = Integer.MAX_VALUE;
for (int i = 0; i < triangle.get(h - 1).size(); i++) {
if (min > dp[h - 1][i]) {
min = dp[h - 1][i];
}
}
return min;
}
首先定义一个dp数组,代表从(0,0)到(i,j)的最短路径,其中斜对角线以及最左侧的一列,因为只能由上到下,或者从上一个斜对角元素下来,因此单独处理。
剩余的点,则是会通过两种路径过来,例如正上方,或者斜上方,因此需要判断最小值与当前元素相加。最终最底层一行的元素会存储从(0,0)到底部每一个元素的最短路径,选取一个最小值即可。
2.3 矩阵路径最小和问题
给定一个包含非负整数的 m x n
网格 grid
,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明: 每次只能向下或者向右移动一步。
示例 1:
lua
输入: grid = [[1,3,1],[1,5,1],[4,2,1]]
输出: 7
解释: 因为路径 1→3→1→1→1 的总和最小。
这道题就是一道典型的自上而下的动态规划问题。
java
public static int minPathSum(int[][] grid) {
if (grid == null || grid.length == 0) {
return 0;
}
int[][] dp = new int[grid.length][grid[0].length];
//起点
dp[0][0] = grid[0][0];
for (int i = 1; i < grid[0].length; i++) {
//处理第一行数据
dp[0][i] = dp[0][i - 1] + grid[0][i];
}
for (int i = 1; i < grid.length; i++) {
//处理第一列数据
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
//剩下的处理
for (int i = 1; i < grid.length; i++) {
//i 行
for (int j = 1; j < grid[i].length; j++) {
//j 列
//与上方 右方元素有关,取其的最小值
dp[i][j] = Math.min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j];
}
}
return dp[grid.length - 1][grid[0].length - 1];
}
首先定义一个dp动态数组,其实这个问题和三角形类似的是,也只有两个走向,看下图:
而对于第一行和第一列的数字来说,他们的来向只能是从右或者下,这也是一开始的时候,单独处理了dp[0][i]
和dp[i][0]
的原因;然后其他位置的元素,都会有来自两个方向上的元素,我们需要取其中的最小值,与当前位置的元素相加。
2.4 动态规划四要素
- 动态规划状态的定义
dp[][]
通过前面我们列举了几个算法题,使用动态规划,都需要定义一个状态数组,我们对这个数组的定义可以根据题意来定义,例如dp[i][j]
代表dp[0][0]
到dp[i][j]
的最小距离和等等。
- 动态规划的初始化
对于动态规划的问题,有些case是没有状态判断的,也就是说无法拆解成更小的问题,此时可以对这部分case进行初始化,一般是初始化dp[0][i]
或者dp[i][0]
.
- 动态规划问题拆分
当一切准备就绪之后,就需要对其他元素进行求解,利用min、max、sum、or等方式获取实际的值。
- 动态规划问题输出
最终答案的输出根据题意,输出dp[i][j]
.