算法通关村第十九关:青铜-动态规划是怎么回事

news2024/11/25 12:56:46

青铜挑战-动态规划是怎么回事

动态规划(简称DP,Dynamic Programming):最热门、最重要的算法之一。面试中大量出现,整体偏难。

1. 热身:重复计算和记忆化搜索(如何说一万次"我爱你")

举例:看谁说更多的我爱你

class FibonacciTest:
    def __init__(self):
        self.count = 0

    def main(self, n):
        self.fibonacci(n)
        print(f"n: {n}, count: {self.count}")

    def fibonacci(self, n):
        print("我爱你")
        self.count += 1
        if n == 0:
            return 1
        elif n == 1 or n == 2:
            return n
        else:
            return self.fibonacci(n - 1) + self.fibonacci(n - 2)


if __name__ == '__main__':
    for i in range(32):
        FibonacciTest().main(i)

斐波那契数列,重复打印 “我爱你”
n=20时,count=13529;n=30时,count=1664079

n=30时,count高达160多万,因为里面存在大量的重复计算,数越大,重复越多。

n=8时,结构如下
在这里插入图片描述

计算 f(8) 时,f(6)、f(5)等需要重复计算,这就是重叠子问题

优化:
主要问题是很多数据到会频繁重复计算
将计算的结果保存到一个一维数组中,arr[n] = f(n)
执行的时候如果某个位置已经被计算出来就更新对应位置的数组值,下次计算的时候可直接读取

记录化搜索
在执行递归之前先查数组看是否被计算过,如果重复计算了,就直接读取,这就叫记忆化搜索

class FibonacciTest:
    def __init__(self):
        self.count = 0
        self.arr = []

    def main(self, n):
        self.arr = [-1] * (n + 1)
        self.fibonacci(n)
        print(f"n: {n}, count: {self.count}")

    def find_in_arr(self, n):
        return self.arr[n] if 0 <= n < len(self.arr) else -1

    def fibonacci(self, n):
        # print("我爱你")
        self.count += 1
        if n == 1 or n == 2:
            self.arr[n] = n
            return n
        elif self.arr[n] != -1:
            return self.arr[n]
        else:
            self.arr[n] = self.fibonacci(n - 1) + self.fibonacci(n - 2)
            return self.arr[n]


if __name__ == '__main__':
    FibonacciTest().main(20)

n=20时,count=37;n=30时,count=57。
递归计算大为减少

2. 路径连环炮

本部分通过多个路径相关的问题来解释和分析DP

2.1 第一炮:基本问题-统计路径总数

LeetCode62
https://leetcode.cn/problems/unique-paths/

思路分析

本题是经典的递归问题

  • 从起点开始的每一个位置,要么向右,要么向下
  • 向右和向下都将导致剩下的区间减少一列或者一行
  • 每个区间都可以继续以当前点为起点继续上述操作,所以这是一个递归的过程

在这里插入图片描述

分析3x2矩阵
在这里插入图片描述

分析3x3矩阵
在这里插入图片描述

分析可以看出,对于一个m x n的矩阵,求路径总数的方法 search(m, n) = search(m-1, n) + search(m, n-1)

对于上述过程,也可以用二叉树表示出来,以3x3矩阵为例
在这里插入图片描述

总的路径就是叶子结点数,图中有6个,这与二叉树的递归遍历本质上是一样的

代码实现

class Solution:
    def uniquePaths(self, m: int, n: int) -> int:
        if m == 1 or n == 1:
            return 1
        return self.uniquePaths(m - 1, n) + self.uniquePaths(m, n - 1)


if __name__ == '__main__':
    print(Solution().uniquePaths(3, 2))

注:功能没有问题,LeetCode上提交,判断超时了

2.2 第二炮:使用二维数组优化递归

在第一炮中,存在重复计算的问题,可以结合二维数组实现记忆化搜索

在这里插入图片描述

从数可以看到,在递归的过程中,存在重复计算的情况
例如 {1,1} 出现了两次,如果m=n,{1,0} 和 {0,1}的后续计算也是一样的
从二维数组的角度,例如在位置(1,1)处,不管是从(0,1)还是(1,0)到来,接下来都会产生2种走法,因此不必每次都重新遍历

在这里插入图片描述

可以采取一个二维数组来进行记忆化搜索

在这里插入图片描述

每个格子的数字:表示从起点开始到达当前位置有几种方式

  • 第1行和第1列都是1
  • 其他格子的值是其左侧和上侧格子数字之和 f[i][j] = f[i-1][j] + f[i][j-1]

在这里插入图片描述

在这里插入图片描述

代码实现

class Solution:
    def uniquePaths(self, m: int, n: int) -> int:
        arr = [[-1] * n for _ in range(m)]
        for i in range(m):
            for j in range(n):
                if i == 0 or j == 0:
                    arr[i][j] = 1
                else:
                    arr[i][j] = arr[i - 1][j] + arr[i][j - 1]
        return arr[m - 1][n - 1]

2.3 第三炮:滚动数组-用一维数组代替二维数组

上面的缓存空间使用的二维数组,占空间太大;可以使用滚动数组来优化此问题

滚动数组

在这里插入图片描述

  • 第1行和第1列都是1
  • 每个位置都是其左侧和上方的格子之和

以上可以简化为一个大小为n的一维数组来解决

  1. 遍历数组,将数组所有元素赋值为1,[1,1,1,1,1]
  2. 再次遍历数组,除了第1个,后面每个位置值=原始值+前一个位置值,[1,2,3,4,5]
  3. 再次遍历数组,除了第1个,后面每个位置值=原始值+前一个位置值,[1,3,6,10,15]
  4. 继续循环,循环m次
    此处3*5,m=3,循环3次,输出最后的15为结果

上面这几个一维数组拼接起来就是原先的二维数组

这种反复更新数组的策略就是滚动数组,计算公式 dp[j] = dp[j] + dp[j-1]

代码实现

class Solution:
    def uniquePaths(self, m: int, n: int) -> int:
        arr = [1] * n
        for i in range(1, m):
            for j in range(1, n):
                arr[j] = arr[j] + arr[j - 1]
        return arr[-1]

总结

本题涵盖了DP里的多个方面,比如重复子问题、记忆化搜索、滚动数组等等
这就是最简单的动态规划了,只不过我们这里的规划是 dp[j] = dp[j] + dp[j-1],不用进行复杂的比较和计算

这个题目非常重要,对后面理解递归、动态规划等算法有非常大的作用

2.4 第四炮:题目拓展-最小路径和

上面的题目(LeetCode62)还有个重要的问题体现的不明显:最优子结构

LeetCode64
https://leetcode.cn/problems/minimum-path-sum/

思路分析

这道题目就是在上面题目的基础上,增加了路径成本的概念。

在这里插入图片描述

由于题目限定只能 往下 或者 往右,可以按照当前位置可由哪些位置移动过来进行分析

  • 通过 往下 移动过来,f[i][j] = f[i-1][j] + grid[i][j]
  • 通过 往右 移动过来,f[i][j] = f[i][j-1] + grid[i][j]
  • 通过 往下 或者 往右 移动过来,f[i][j] = min(f[i-1][j], f[i][j-1]) + grid[i][j]

二维数组的更新过程如下
在这里插入图片描述

引入新概念

状态与状态转移方程
状态:就是下面表格更新到最后的二维数组(?不太理解,简单的就是状态转移方程计算出来的值)

状态转移方程:通过前面格子状态计算后面格子状态的公式就叫状态转移方程

数组表达:
f[i][j] 从(0,0)开始到达位置(i,j)的最小路径成本总和,f[m-1][n-1]就是我们最终的答案

起始状态 f[0][0] = grid[0][0]

状态转移方程
f[i][j] = min(f[i-1][j], f[i][j-1]) + grid[i][j]

在这里插入图片描述

注:确定状态转移方程就是要找递推关系,通常我们会从分析首尾两端的变化规律入手

代码实现

class Solution:
    def minPathSum(self, grid: List[List[int]]) -> int:
        m, n = len(grid), len(grid[0])
        arr = [[0] * n for _ in range(m)]
        for i in range(m):
            for j in range(n):
                if i == 0 and j == 0:
                    arr[i][j] = grid[i][j]
                elif i == 0:
                    arr[i][j] = arr[i][j - 1] + grid[i][j]
                elif j == 0:
                    arr[i][j] = arr[i - 1][j] + grid[i][j]
                else:
                    arr[i][j] = min(arr[i][j - 1], arr[i - 1][j]) + grid[i][j]
        return arr[m - 1][n - 1]
class Solution:
    def minPathSum(self, grid: List[List[int]]) -> int:
        m, n = len(grid), len(grid[0])
        for i in range(m):
            for j in range(n):
                if i == 0 and j == 0:
                    continue
                elif i == 0:
                    grid[i][j] = grid[i][j - 1] + grid[i][j]
                elif j == 0:
                    grid[i][j] = grid[i - 1][j] + grid[i][j]
                else:
                    grid[i][j] = min(grid[i][j - 1], grid[i - 1][j]) + grid[i][j]
        return grid[-1][-1]
class Solution:
    def minPathSum(self, grid: List[List[int]]) -> int:
        m, n = len(grid), len(grid[0])
        arr = [[0] * n for _ in range(m)]
        for i in range(m):
            for j in range(n):
                if i == 0 and j == 0:
                    arr[i][j] = grid[i][j]
                else:
                    top = arr[i - 1][j] + grid[i][j] if i - 1 >= 0 else float('inf')
                    left = arr[i][j - 1] + grid[i][j] if j - 1 >= 0 else float('inf')
                    arr[i][j] = min(top, left)
        return arr[-1][-1]

2.5 第五炮:题目拓展-三角形最小路径和

LeetCode120
https://leetcode.cn/problems/triangle/description/

本题就是LeetCode64最小路径和的简单变换

思路分析

处理过程如下:
为了方便处理,我们可以先处理第1列和对角线

在这里插入图片描述

引入新概念 无后效性

无后效性:
我们转移某个状态需要用到某个值,但是并不关心该值是如何而来的
或者说,当前某个状态确定后,之后的状态转移与之前的决策无关

确定一道题目是否可以用 DP 解决,要从有无后效性进行分析。有后效性,不能用DP;无后效性,可以用DP。

本题中

  • 路径从上到小,最后一个点必然落在最后一行
  • 最后一行某个位置的值,只能从上一行的某一个位置或者某两个位置之一转移而来
  • 我们只关注前一位的累加值是多少,而不关心这个累加值结果是由什么路径而来,满足无后效性

找递推关系,确定状态

f[i][j] 代表到达某个点的最小路径和,min(f[n-1][i]) 就是答案,最后一行的每列的路径和的最小值

  • 第i行有i+1个数组(i从0开始)
  • 第一列位置上的数(j=0),只能从 上方 转移而来
  • 最后一列位置上的数(j=i),只能从 左上方 转移而来
  • 其他位置上的数(0<j<i),可以从 上方 或者 左上方转移而来

代码实现

class Solution:
    def minimumTotal(self, triangle: List[List[int]]) -> int:
        m = len(triangle)
        n = len(triangle[-1])
        arr = [[0] * n for _ in range(m)]
        for i in range(m):
            for j in range(n):
                if j > i:
                    continue
                elif j == 0:
                    arr[i][j] = arr[i - 1][j] + triangle[i][j]
                elif j == i:
                    arr[i][j] = arr[i - 1][j - 1] + triangle[i][j]
                else:
                    arr[i][j] = min(arr[i - 1][j], arr[i - 1][j - 1]) + triangle[i][j]
        return min(arr[-1])

if __name__ == '__main__':
    print(Solution().minimumTotal([[2], [3, 4], [6, 5, 7], [4, 1, 8, 3]]))  # 11

代码优化

class Solution:
    def minimumTotal(self, triangle: List[List[int]]) -> int:
        m = len(triangle)
        arr = [[0] * m for _ in range(m)]
        arr[0][0] = triangle[0][0]

        for i in range(1, m):
            arr[i][0] = arr[i - 1][0] + triangle[i][0]
            for j in range(1, i):
                arr[i][j] = min(arr[i - 1][j], arr[i - 1][j - 1]) + triangle[i][j]
            arr[i][i] = arr[i - 1][i - 1] + triangle[i][i]

        return min(arr[m - 1])

题目拓展

类似题目还有 LeetCode931 下降路径最小和 和 LeetCode1289 下降路径最小和II

3,. 理解动态规划

3.1 动态规划(DP)能解决哪类问题

  • 直观上,DP一般让找最值。例如最长公共子序列等。
  • 关键:DP问题的子问题不是相互独立的,如果递归直接分解会导致重复计算指数级增长。
  • DP最大的价值:消除冗余,加速计算

DP要满足 无后效性

  • 对于某个状态,可以只关注状态的值,而不需要关注状态是如何转移过来,可以考虑用 DP 解决

例如:
上面路径问题,从左上角走到右下角,两个问题:

  1. 求有多少种走法
  2. 输出所有的走法

分析:

  • 动态规划是无后效性的,只记录数量,不管怎么来的。A是DP问题,B不能用DP
  • 回溯可以记录所有的路径,B是个回溯的问题

回溯与DP的比较

回溯:能解决,但是解决效率不高
DP:计算效率高,但是不能找到满足要求的路径

如何区分:
DP只关心当前结果是什么,怎么来的就不管了,所以动态规划无法获得完整的路径
回溯能够获得一条甚至所有满足要求的完整路径

DP的基本思想:

  • 将待求解问题分解成若干个子问题,先求子问题,在从这些子问题中得到原问题的解。

注:既然要找“最”值,必然要做的就是穷举来找所有的可能,然后选择“最”的那个,这就是为什么在DP代码中大量判断逻辑都会被套上min()或者max()

既然穷举,那为啥还要有 DP 的概念?

  • 因为穷举过程中存在大量重复计算,效率低下,我们要使用记忆化搜索等方式来消除不必要的计算
  • 所谓记忆化搜索就是将已经计算好的结果先存在数组里,后面直接读就不再重复计算了

既然记忆化能解决问题,为啥DP这么难?

  • 因为DP问题一定具备“最优子结构”,这样才能让记忆时得到准确的结果
  • 什么是 最优子结构,还是要等后面具体问题再看

状态转移方程

  • 有了最优子结构之后,还要写出正确的状态转移方程,也就是递归关系,才能正确的穷举
  • 在DP里,大部分递推都可以通过数组实现,因此看起来代码结构一般是如下所示的for循环,这就是DP代码的基本模板

DP代码的基本模板

// 初始化base case,也就是初始化刚开始的几种场景,有几种枚举几种
dp[0][0][...] = base case

// 进行状态转移
for 状态1 in 状态1的所有值
    for 状态2 in 状态2的所有值
        for ...
            dp[状态1][状态2][...] = 求最值max(选择1, 选择2, ...)

我们一般写状态规划只有一两层,不会太深,代码看起来特别简洁

动态规划的常见类型

常见类型比较多,从形式上看,有坐标型、序列型、划分型、区间型、背包型、博弈型等等
解题基本思路是一致的

一般来说,DP题目有以下三种基本的类型

  1. 计数相关
    有多少方式走到右下角,有多少种方式选出K个数使得***等
    不关心具体路径是什么

  2. 求最大最小值,最多最少等
    例如最大数字和、最长上升子序列长度、最长公共子序列、最长回文序列等

  3. 求存在性
    例如取石子游戏,先手是否必胜,能不能选出K个数使得***等

但是不管哪一种,解决问题的模板是类似的,都是:

  1. 第一步:确定状态和子问题,也就是枚举出某个位置所有的可能性
    对于DP,大部分题目分析最后一步更容易一些得到递推关系,同时将问题转换为子问题
  2. 第二步:确定状态转移方程,也就是数组要存储什么内容
    很多时候状态确定之后,状态转移方程也就确定了,我们可以将第一二部作为一个步骤
  3. 第三步:确定初始条件和边界情况
    注意细心,尽力考虑周全
  4. 第四步:按照从大到小的顺序计算:f[0] f[1] f[2]
    虽然我们计算是从f[0]开始,但是对于大部分DP问题,先分析最后一个往往更有利于寻找状态表达式
    因此我们后面的问题基本都是从右向左找递归,从左向右来计算

以上是我们分析DP问题的核心模板

总结

  • 我们要自始至终,都要在大脑里装一个数组 (可能是一维,或者二维)
  • 要看这个数组每个元素表示的含义是什么(状态)
  • 要看每个数组位置是根据谁来算(状态转移方程)
  • 然后就是从小到大挨着将数组填满(从小到大计算,实现记忆化搜索)
  • 最后看哪个位置是我们想要的结果(目的)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1004054.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Windows驱动开发(一)

1. 引言 很难为术语 “驱动程序”提供一个精确的定义。 就最基本的意义而言&#xff0c;驱动程序是一个软件组件&#xff0c;可让操作系统和设备彼此通信。 例如&#xff0c;假设应用程序需要从设备中读取某些数据。 应用程序会调用由操作系统实现的函数&#xff0c;操作系统…

WPF——Control与Template理解

文章目录 一、前言二、控件三、模板3.1 DataTemplate3.2 ControlTemplate3.3 ContentPresenter 四、结语 一、前言 最近又翻看了下刘铁猛的《深入浅出WPF》&#xff0c;发现对模板章节中的部分内容有了更深的体会&#xff0c;所以写篇文扯扯。 文章标题是Control与Template&a…

画流程图用什么软件好?安利这几款

画流程图用什么软件好&#xff1f;画流程图是一项非常重要的技能&#xff0c;它可以帮助我们更好地规划和管理工作流程&#xff0c;提高工作效率。在现代的企业中&#xff0c;流程图已经成为了不可或缺的一部分&#xff0c;它可以用来描述各种业务流程、流程控制、组织结构等等…

数据治理实战步骤

写在前面:数据治理是数字化转型的基础,是数字要素流通的首要任务。但是面对不同的情况,数据治理的手段不同。 数据治理专员要转换思想,数据治理中单靠技术、软件是不行的,比如一些单位认为数据治理平台是万能的,直接上平台一般是做不好的,需基于企业的组织文化、愿景等对…

vue全局使用sass变量

需求&#xff1a;框架需要使用scss&#xff0c;之后不想把很多重复的css一个一个写&#xff0c;就提取出来咯&#xff0c;到时候只需要更改scss文件就可以了&#xff0c;不用一个一个的找 1.下载sass 这我下的俩个版本&#xff0c;如果你们下载最新版不兼容可以参考我的版本下…

高效办公利器:批量重命名与翻译文件名一步到位

在我们的日常工作中&#xff0c;我们经常需要处理大量的文件&#xff0c;包括图片、文档、视频等各种类型。有时候&#xff0c;我们需要对文件进行重命名或者翻译&#xff0c;以便于我们更方便地管理和使用这些文件。但是&#xff0c;如果一个一个手动操作&#xff0c;将是非常…

《树莓派4B家庭服务器搭建指南》第二十一期:安装开源远程桌面服务rustdesk, 内网丝滑,外网流畅控制

title: 《树莓派4B家庭服务器搭建指南》第二十一期&#xff1a;安装开源远程桌面服务rustdesk, 内网丝滑,外网流畅控制Windows,macOS,Linux设备 tags: 个人成长 categories:树莓派不吃灰 前段时间, 有一台老式MacBook Pro被我改造成了影视资源解码主机, 《树莓派4B家庭服务器搭…

老师怎样发布查询

作为一名老师&#xff0c;我们经常需要向家长发布各种查询&#xff0c;比如成绩查询、作业查询等。以往&#xff0c;我们可能会将查询结果整理成Excel表格&#xff0c;然后通过各种渠道发送给家长&#xff0c;这样既繁琐又不够高效。幸好&#xff0c;现在有了易查分&#xff0c…

MybatisPlus(4)

前言&#x1f36d; ❤️❤️❤️SSM专栏更新中&#xff0c;各位大佬觉得写得不错&#xff0c;支持一下&#xff0c;感谢了&#xff01;❤️❤️❤️ Spring Spring MVC MyBatis_冷兮雪的博客-CSDN博客 在之前我们讲解了大部分查询相关的操作&#xff0c;接下来进行增删改的学…

SpringBoot项目--电脑商城【增加/减少购物车商品数量】

1.持久层[Mapper] 1.1规划需要执行的SQL语句 1.更新该商品的数量.此SQL语句无需重复开发 update t_cart set num?,modified_user?,modified_time? where cid? 2.首先进行查询需要操作的购物车数据信息【查看该条数据是否存在】 SELECT * FROM t_cart WHERE cid?2.接口…

如何解决实时语音通讯技术的延迟问题?

实时语音通讯技术的延迟问题一直是人们关注的焦点。在实时通讯中&#xff0c;延迟会影响到通话的质量和用户体验&#xff0c;因此如何解决实时语音通讯技术的延迟问题是一个重要的挑战。本文将探讨如何解决实时语音通讯技术的延迟问题。 一、延迟的定义和分类 延迟是指从说话…

C语言和汇编到底谁更厉害呢?

今日话题&#xff0c;C语言和汇编到底谁更厉害呢&#xff1f; 有位毕业生与我分享了他的经历。在学校&#xff0c;他学习了汇编和C语言。毕业后&#xff0c;他加入了一家嵌入式企业&#xff0c;发现C语言因其可移植性、开发效率和可读性而更为通用和适用。事实证明&#xff0c;…

排序算法-----冒泡排序与选择排序

目录 前言: 冒泡排序 原理图 代码实现 分析总结 选择排序 原理图 代码实现 分析总结 前言: 今天我们就开始学习排序算法&#xff0c;排序算法也是数据结构与算法在重要组成部分之一&#xff0c;排序算法是最经典的算法知识。因为其实现代码短&#xff0c;应该广&#x…

【设计模式】桥接模式在开发中的应用

1. 概述 桥接模式是一个非常简单的设计模式&#xff0c;可能大家在开发的过程中已经使用到了这种模式而不自知。总的来说&#xff0c;桥接模式最大的作用就是解耦&#xff0c;所谓的解耦&#xff0c;就是通过转换代码的设计&#xff0c;减少类与类&#xff0c;模块与模块之间的…

监听器,过滤器,拦截器

参考博文 文章目录 作用三者区别启动顺序拦截器简要说明实现接口HandlerInterceptor自定义拦截器配置拦截器 过滤器简要说明在springboot 启动类添加该注解ServletComponentScan写个过滤器类&#xff0c;实现Filter接口 监听器简要说明如何使用自定义事件自定义过滤器接口调用…

图片怎么转换成pdf格式?几种方法轻松转换

图片怎么转换成pdf格式&#xff1f;将图片转换成PDF格式的主要原因是方便共享和存储。PDF格式可以在不同的设备和操作系统上轻松打开和查看&#xff0c;而且可以保持原始图片的质量和分辨率。如果你需要将一些图片转换成PDF格式&#xff0c;你可能会问&#xff0c;“该如何做呢…

12个最受欢迎的3D打印机械臂【开源|DIY|套件】

推荐&#xff1a;用 NSDT编辑器 快速搭建可编程3D场景 机器人手臂的用途各不相同&#xff0c;但大多数都能够执行拾取和放置任务&#xff0c;而有些则配备用于 CNC 工作、激光雕刻&#xff0c;甚至 3D 打印。 机械臂具有广泛的应用和各个领域&#xff0c;从执行精密手术和进行工…

两行代码实现Redis消息队列,简单易用

Redis列表数据类型非常适合作为消息队列使用。将新的消息插入到列表尾部&#xff0c;然后从列表头部取出消息进行处理。该方案简单易用&#xff0c;并且支持多个消费者并行处理消息。 两行核心代码即可实现消息队列&#xff0c;如下&#xff1a; // 推送消息 redisTemplate.o…

Java中级编程大师班<第一篇:初识数据结构与算法-数组(2)>

数组&#xff08;Array&#xff09; 数组是计算机编程中最基本的数据结构之一。它是一个有序的元素集合&#xff0c;每个元素都可以通过索引进行访问。本文将详细介绍数组的特性、用法和注意事项。 数组的基本特性 数组具有以下基本特性&#xff1a; 有序性&#xff1a; 数…

初探词法分析实验

本次实验使用C对编译过程中的分词进行初步探究&#xff0c;以下是实验代码&#xff0c;输入文件需要在main函数中自己填写文本所在地址 #include <iostream> #include <stdlib.h> #include <stdio.h> #include<string> #define M 20 using namespace…