数据结构与算法 -- 从记忆化搜索到动态规划(1)

相关文章:

数据结构与算法 -- 使用DFS算法处理组合类和排列类问题(一维)

数据结构与算法 -- 使用DFS算法处理路径问题(二维)

在之前的两篇文章中,我们重点介绍了DFS算法处理一维和二维的问题,但是使用DFS算法依然是存在弊端的,因此在一些算法题中明确规定了运行耗时,在处理一些大数据量问题的时候,因为时间限制无法ac,所以我们需要通过其他的方式来规避这些问题,例如通过动态规划。

动态规划算是算法题中比较难的题目,但是如果熟悉了DFS算法,动态规划也不是什么难题了,基本上所有的DFS都可以转为动态规划,但是不代表所有的动态规划都可以转成DFS,使用动态规划的目的,就是避免DFS中的重复计算问题。 那么在学习动态规划之前,我们先对记忆化搜索有一个初步的了解,这个是动态规划的基础。

1 记忆化搜索入门

首先,我们通过上一篇文章中的最小三角形路径和为例,对记忆化搜索有一个初步的认识。

1.1 LeetCode120 - 最小三角形路径和

给定一个三角形 triangle ,找出自顶向下的最小路径和。

每一步只能移动到下一行中相邻的结点上。相邻的结点 在这里指的是 下标上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。也就是说,如果正位于当前行的下标 i ,那么下一步可以移动到下一行的下标 ii + 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 动态规划四要素

  1. 动态规划状态的定义dp[][]

通过前面我们列举了几个算法题,使用动态规划,都需要定义一个状态数组,我们对这个数组的定义可以根据题意来定义,例如dp[i][j]代表dp[0][0]dp[i][j]的最小距离和等等。

  1. 动态规划的初始化

对于动态规划的问题,有些case是没有状态判断的,也就是说无法拆解成更小的问题,此时可以对这部分case进行初始化,一般是初始化dp[0][i]或者dp[i][0].

  1. 动态规划问题拆分

当一切准备就绪之后,就需要对其他元素进行求解,利用min、max、sum、or等方式获取实际的值。

  1. 动态规划问题输出

最终答案的输出根据题意,输出dp[i][j].

相关推荐
pianmian11 小时前
python数据结构基础(7)
数据结构·算法
好奇龙猫3 小时前
【学习AI-相关路程-mnist手写数字分类-win-硬件:windows-自我学习AI-实验步骤-全连接神经网络(BPnetwork)-操作流程(3) 】
人工智能·算法
sp_fyf_20244 小时前
计算机前沿技术-人工智能算法-大语言模型-最新研究进展-2024-11-01
人工智能·深度学习·神经网络·算法·机器学习·语言模型·数据挖掘
ChoSeitaku4 小时前
链表交集相关算法题|AB链表公共元素生成链表C|AB链表交集存放于A|连续子序列|相交链表求交点位置(C)
数据结构·考研·链表
偷心编程4 小时前
双向链表专题
数据结构
香菜大丸4 小时前
链表的归并排序
数据结构·算法·链表
jrrz08284 小时前
LeetCode 热题100(七)【链表】(1)
数据结构·c++·算法·leetcode·链表
oliveira-time4 小时前
golang学习2
算法
@小博的博客4 小时前
C++初阶学习第十弹——深入讲解vector的迭代器失效
数据结构·c++·学习
南宫生5 小时前
贪心算法习题其四【力扣】【算法学习day.21】
学习·算法·leetcode·链表·贪心算法