阅读 10720

「算法与数据结构」一张脑图带你看动态规划算法之美

前言

算法中有个专题,动态规划,它十分的重要,大厂面试中或多或少有所涉及,来网易之前,刷了部分dp,这次正好再次梳理一遍,希望对你们有一点点帮助。

如果你已经懂了dp思路,或者已经掌握了常见的dp解法,可以直接跳过。

如果你还不了解,或者知道动态规划,但是还没有开始刷题的话,可能这篇文章适合你。

公众号前端UpUp,回复dp,即可获取脑图。

联系👉TianTianUp,遇到问题的话,可以联系作者噢,愿意陪你一起学习一起探讨问题。

脑图👇


什么是动态规划

动态规划(Dynamic Programming),因此常用 DP 指代动态规划。动态规划,首先我们得清楚,它的概念是啥,它能解决什么问题,维基百科对它的解释👇

动态规划在寻找有很多重叠子问题的情况的最佳解时有效。它将问题重新组合成子问题,为了避免多次解决这些子问题,它们的结果都逐渐被计算并被储存,从简单的问题直到整个问题都被解决。因此,动态规划储存递归时的结果,因而不会在解决同样的问题时花费时间。

动态规划只能应用于有最佳子结构的问题。最佳子结构的意思是局部最佳解能决定全域最佳解(对有些问题这个要求并不能完全满足,故有时需要引入一定的近似)。简单地说,问题能够分解成子问题来解决。

我稍微总结一下👇

  • 将一个大的问题拆分成一个个子问题,我们把它称之为子结构。
  • 每个最优解,也就是最优值均由[这些小规模子问题]推到而来。
  • 更重要的就是利用历史记录,来避免我们重复的计算。

什么是重复计算,那怎么样可以利用历史记录来减少我们不必要的运算呢👇

我们拿斐波那契数列这题来看👇

如果我们按照这个递归的写法来看,那么它的过程如下👇

它会多次计算结果,这个符合动态规划的点,**动态规划在寻找有很多重叠子问题的情况的最佳解时有效。**而且对于这个问题而言,它可以继续去拆分,变成更小的子问题去解决,它也符合动态规划的预期。

那么这里只是举个例子,后续会将做题思路👇

动态规划解题三大步骤

对于初学者而言,需要短时间就掌握的话,我觉得挺难的,所以这里我推荐大家可以看看经典的动态规划👉背包九讲,点这里,看完它,或许对你有点帮助吧。

解题思路,三大步骤👇

  1. 状态定义
  2. 列出状态转移方程
  3. 初始化状态

状态定义

  • 我们需要借助数组来保存之前计算的结果,所以一般采用的就是数组来维护我们的结果,一般dp数组。
  • dp数组的含义一定要明确,也就是说,dp[i]表达是啥意思,举个例子,dp[i]表达到达第i个阶梯时的方案数。

列出状态转移方程

  • 通俗易懂的话,找出数组间关系式,这个时解决动态规范问题中,最难也是最重要的一步。
  • 通常情况而言,dp[i]个状态的转移方程,跟dp[i-1] 与 dp[i-2] 之间存在某种联系。

举个例子,后续爬梯子的题目中,状态转移方程👇

dp[i] = dp[i-1] + dp[i-2]
复制代码
  • 首先dp[i] 表示的就到第i个阶梯的方案数
  • 那么爬到第i阶梯,有两种情况👇
    • 从第i-1阶梯再爬1阶就到第i阶
    • 从第i-2阶梯再爬2阶就到第i阶
  • 那么它的状态方程转移就是上面的式子

初始化状态

  • 我们会发现,dp数组的第n项结果,是由状态转移方程求解而言的,所以我们需要的是第n-1项,n-2项,或者n-3项的值。
  • 这个时候,我们就需要初始话dp数组的值,一般而言,比如dp[1],dp[2],dp[1][1],dp[1][2]

从上面的场景,爬楼梯来说,我们需要初始话哪一项dp数组呢,当我们依次迭代到dp[3] = dp[1] + dp[2],接下来就不再需要去分解了。

这个时候,我们实际意义而言,就需要我们去初始化,dp[1]和dp[2]数组。


动态规划分类

这么久以来,随着动态规划这种算法思路被很多牛人去探索,动态规划这类问题,被分为了很多种,参考网上的资料,列举了几个常见的dp,我们接下来看看吧。

我的经验之谈,按照不同dp专题来刷,效果很明显,当然了,具体看你自己掌握情况,以及刷题速度了。

背包dp

这算是状态规划中比较经典的题目了,对于理解dp的话,我个人觉得很有帮助,也是我入门dp最开始看的专题👇

dd大牛的《背包九讲》 👈,可以看看这篇,这里就不展开了。

这里推荐几道题👇


线性dp

  • 顾名思义,线性DP就是在一条线上进行DP。
  • 或者我的理解是,就是在线性空间上的递推。

这里推荐几道题👇


区间dp

  • 顾名思义,在一段区间上dp,类似于dp[l][r]构成的,我们也是将大问题拆分成小问题来处理,这里就是拆分成小区间来处理。

  • 然后对小区间处理后,再回溯的求出大区间的值。

  • 主要的方法有两种,记忆化搜索和递推。

这里推荐几道题👇


树形dp

  • 准确的来说,树形dp准确的说是一种dp的思想,将dp建立在树状结构的基础上。
  • 你可以理解成,在一颗树上定义dp方程式,完成相应的操作。

这里推荐几道题👇


数位dp

  • 数位dp是一种计数用的dp,一般就是要统计一个区间[le,ri]内满足一些条件数的个数。
  • 所谓数位dp,字面意思就是在数位上进行dp咯。
  • 数位还算是比较好听的名字,数位的含义:一个数有个位、十位、百位、千位......数的每一位就是数位啦!

这里推荐几道题👇

我印象中,这个是模板,自己写很难,但是这是个板子题,哈哈哈,打过Acm都知道,这里就不展开了。


状态压缩DP 计数型DP 递推型DP 概率型DP 博弈型DP,嗯太多了,只当是抛砖引玉吧。

3个例子

接下来,我们就以三题为例子,来强化我们解题思路的三大步骤吧👇

爬楼梯⭐

链接:爬楼梯

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

**注意:**给定 n 是一个正整数。

示例 1:

  1. 输入: 2
    输出: 2
    解释: 有两种方法可以爬到楼顶。
       
    1.  1 阶 + 1 阶
    2.  2 阶
    复制代码

    示例 2:

  2. 输入: 3
    输出: 3
    解释: 有三种方法可以爬到楼顶。
       
    1.  1 阶 + 1 阶 + 1 阶
    2.  1 阶 + 2 阶
    3.  2 阶 + 1 阶
    复制代码

来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/climbing-stairs 著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。


我们按照解题思路走一遍👇

第一步:状态定义

dp[i] 表示的含义:到第i阶方案数

第二步: 确定状态转移方程

根据实际的情况,我们很容易想到👇

  • 到第i阶梯有两种方式
  • 第一种, 从i-1向上走一步即可
  • 第二中,从i-2向上走二步即可
  • 所以 dp[i] = dp[i-1] + dp[i-2]

第三步,初始化状态,dp数组

dp[1] = 1,dp[2] = 2
复制代码

按照这个三步走的话,我们就可以写出完整的解题代码

代码👇

代码点这里☑️


打家劫🐍⭐⭐

链接:打家劫舍 II

你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。

示例 1:

输入: [2,3,2]
输出: 3
解释: 你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。
复制代码

示例 2:

输入: [1,2,3,1]
输出: 4
解释: 你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4 。
复制代码

来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/house-robber-ii 著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。


我们按照解题思路走一遍👇

第一步:状态定义

// 这里就利用二维状态,既然可以选择偷或者是不偷

// dp[i][0] 表示不偷当前第i个房间,获取最高金币数

// dp[i][1] 表示的是偷第i房间,获取最高金币数
复制代码

第二步: 确定状态转移方程

// 第i个房间偷的话,dp[i][1] = nums[i] + dp[i-1][0]
// 第i个房间不偷的话, dp[i][0] = Math.max(dp[i-1][0],dp[i-1][1])
复制代码

第三步,初始化状态,dp数组

// dp[0][0] = 0
// dp[0][1] = nums[0]
复制代码

但是这个题目的难点在于👇

第一个房子跟最后一个房子是挨着的,意味着我们需要做些改变,这个我也是在提示下完成的,写法很巧妙,我们具体看下代码下👇

按照这个三步走的话,我们就可以写出完整的解题代码👇

代码点这里☑️


买卖股票的最佳时机 IV⭐⭐⭐

给你一个二叉树,请你返回其按 层序遍历 得到的节点值。 (即逐层地,从左到右访问所有节点)。

链接:买卖股票的最佳时机 IV


给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。

注意: 你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:

输入: [2,4,1], k = 2
输出: 2
解释: 在第 1 天 (股票价格 = 2) 的时候买入,在第 2 天 (股票价格 = 4) 的时候卖出,这笔交易所能获得利润 = 4-2 = 2 。
复制代码

示例 2:

输入: [3,2,6,5,0,3], k = 2
输出: 7
解释: 在第 2 天 (股票价格 = 2) 的时候买入,在第 3 天 (股票价格 = 6) 的时候卖出, 这笔交易所能获得利润 = 6-2 = 4 。
     随后,在第 5 天 (股票价格 = 0) 的时候买入,在第 6 天 (股票价格 = 3) 的时候卖出, 这笔交易所能获得利润 = 3-0 = 3 。
复制代码

来源:力扣(LeetCode) 链接:https://leetcode-cn.com/problems/best-time-to-buy-and-sell-stock-iv 著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

第一步:状态定义

 // 可以定义如下
 // dp[i][j][0] 表示第i天交易了j次时卖出后的累计最大利润
 //  dp[i][j][1] 表示第i天交易了j次时买入后的累计最大利润
复制代码

第二步: 确定状态转移方程

  // 第二步:确定状态转移方程
 // dp[i][j][0] 当第i天不持股的话,我们需要确定昨天是否持有股票
 // dp[i][j][0] = max(dp[i - 1][j][0], dp[i - 1][j][1] + prices[i])
 // dp[i][j][1] 同样的第i天,我们需要去确定昨天是否持有股票
 // dp[i][j][1] = max(dp[i - 1][j][1], dp[i - 1][j - 1][0] - prices[i])
复制代码

第三步,初始化状态,dp数组

// 所有不持股的状态值初始化的时候为 0。所有持股的状态值都设置为一个很大的负数(至少应该是最大的股价的负数 - 1),表示未知。
// dp[0][j][0] = 0;
// dp[0][j][1] = -prices[i];
复制代码

但是这个题目的难点在于👇

当k大于len/2的时候,我们需要做一个处理,或者说,我们需要利用状态压缩,好难,我们看看该如何写吧👇

代码点这里☑️


进阶题目汇总

以下是我收集的部分题目,希望对你们有帮助。

简单


中等


困难


参考

❤️ 感谢大家

如果你觉得这篇内容对你挺有有帮助的话:

  1. 点赞支持下吧,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓 -_-)
  2. 关注公众号前端UpUp,联系作者,我们一起学习一起进步。
  3. 觉得不错的话,也可以阅读TianTian近期梳理的文章(感谢掘友的鼓励与支持🌹🌹🌹):