算法刷题重温(八): 硬核动态规划

1. 写在前面

这篇文章复习动态规划系列的题目, 这里由于我是刚走完了一遍之后过来的总结复刷, 所以应该印象会更加深刻一点,所以赶紧把学到的知识点和题目整理思路总结过来,便于后面的再复习。这次主要是跟着Carl大神的GitHub项目学习的。动态规划这里的题目时比较多,也变化多端,有些不太容易想,这里刷的时候是分成了几个大系列, 包括普通的动态规划, 路径规划,背包系列,打家劫舍系列,股票交易系列,子序列系列等,每个系列有非常类似的地方,而又各有各的特色,所以可以通过这一篇文章, 可以好好享受下这缤纷的一程 😉

PS: 下面的第一部分总结动态规划的解题神曲,第二部分背包问题的基础补充,主要是背包问题的DP分析,第三部分是我目前做过的题目整理(思路和代码),第四部分是第三部分的题目汇总,只有题目,这个方便复习用,如果是为了回顾思路和复习,直接看第四部分,如果想不起来思路和代码了,再瞟一眼第三部分哈哈,尽量不要从头开始读,那样会非常难受,并且失去了刷题复习的趣味性了!

关于动态规划的具体原理啥的,这里不整理,只整理非常关键的东西了。

动态规划的常见面试题分类(从大面上看)

  1. 线性规划
    各个子问题的规模线性的方式分布,并且子问题的最佳状态或结果可以存储在一维线性的数据结构里,例如一维数组,哈希等。

    解法中, 经常会用dp[i]去表示第i个位置的结果, 或者从0开始到第i个位置的最佳状态或结果。比如,最长上升子序列中,dp[i]表示从数组第0个元素开始到第i个元素为止的最长上升子序列。

    dp[i]常用的两种形式:

    1. 当前所求的值仅仅依赖于有限个先前计算好的值, 也就是说dp[i]仅仅依赖于有限个dp[j], j < i.

      比如斐波那契数列: dpl[i] = dp[i-1] + dp[i-2], 当前值只依赖于前面两个计算好的值

      再比如LeetCode198题,给定一个数组, 不能选择相邻的数, 求如何选择才能使总数最大,这个题目就是dp[i]=max(nums[i]+dp[i-2], dp[i-1]), 也是仅仅依赖于有限个dp[j], 其中j=i-1, i-2

    2. 当前所求的值依赖于所有先前计算好的值,也就是说, dp[i]是各个dp[j]的某种组合, 其中j从0遍历到i-1

      比如最长上升子序列: dp[i]=max(dp[j]+1), 0<=j<i, 当前值依赖于前面所有计算好的值。

  2. 区间规划
    各个子问题的规模由不同的区间来定义,一般子问题的最佳状态或结果存储在二维数组。一般用dp[i][j]代表从第i个位置到第j个位置之间的最佳状态或结果。

    这类问题的时间复杂度一般是多项式时间, 对于一个大小为n的问题, 时间复杂度不超过n的多项式倍数。例如 O ( n ) = n k O(n)=n^k O(n)=nk

    比如LeetCode第516, 在一个字符串S中求最长的回文子序列。 这里求的递推公式如下:

    • 当首尾的两个字符相等, dp[0] [n-1] = dp[1] [n-2] + 2
    • 否则, dp[0] [n-1] = max(dp[1] [n-1], dp[0] [n-2])

    还比如, 给定一系列矩阵, 求矩阵相乘的总次数最少的相乘方法。

  3. 约束规划
    在普通的线性规划和区间规划里,一般题目又两种需求: 统计和最优解。

    这些题目不会对输出结果中的元素有什么限制, 只要满足最终的一个条件就好, 但是很多情况下,题目会对输出结果的元素添加一定的条件约束或者限制。比如0-1背包问题, 承受的最大重量会有限制。

动态规划的关键点

  1. 最优子结构:dp[n] = best_of(dp[n-1], dp[n-2], ...)
  2. 储存中间状态:dp[i]
  3. 递推公式(状态转移方程或dp方程)
    • dp[i] = dp[i-1] + dp[i-2]
    • 二维路径: dp[i,j] = dp[i+1, j] + dp[i, j+1]

整体的思考逻辑:

做动规的题目,写代码之前一定要把状态转移在dp数组的上具体情况模拟一遍,心中有数,确定最后推出的是想要的结果。然后再写代码,如果代码没通过就打印dp数组,看看是不是和自己预先推导的哪里不一样。

  • 如果打印出来和自己预先模拟推导一样,那就是递归公式,初始化或者遍历顺序有问题
  • 如果打印出来和自己预先模拟推导不一样,那就是代码实现细节有问题

动态规划的五步解题神曲(四确定一模拟)

  1. 确定dp数组以及下标的含义(一定要牢牢把握住dp数组的每个元素表示的含义)
  2. 确定动态转移方程或者递推公式
  3. 确定dp数组初始化的方式(这个要依赖于动态转移方程)
  4. 确定遍历顺序,求解dp数组
  5. 举例模拟一下dp数组的求解

这五步神曲,只要遇到动态规划的题目,都一一分析清楚之后,写代码就会非常容易了。这个感觉非常有用, 下面的所有动态规划题目我也都是按照这五步神曲走的

动态规划的debug方式: 找问题的最好方式就是把dp数组打印出来,看看究竟是不是按照自己思路推导的。

下面会整理具体的题目,这里的题目划分成了很多个系列,后面会一一整理,但是整理之前,还得先补充一些重要的知识点,主要针对的是背包问题系列。如果对背包问题很熟悉,可以跳过2了。

2. 基础知识点

2.1 0-1背包问题的dp分析

0-1背包问题:有N件物品和一个最多能被重量为W 的背包。第i件物品的重量是 w e i g h t [ i ] weight[i] weight[i],得到的价值是 v a l u e [ i ] value[i] value[i]每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。详细内容参考:关于0-1背包需要知道的

常见解题方法: 动态规划(之所以用这个,是因为暴力复杂度太高)

2.1.1 dp分析0-1背包问题的五部神曲(二维dp)
  1. 确定dp数组及其含义

    0-1背包问题常用二维数组dp[i][j], 也就是二维数组, dp[i][j]表示从下标为 [ 0 − i ] [0-i] [0i]的物品里任意取, 放进容量为 j j j的背包, 价值总和的最大值, 大小是物品数量行, 背包重量列

在这里插入图片描述

  1. 确定动态转移方程

    对于当前的状态dp[i][j], 可以由之前的两个方向推导过来:

    1. dp[i-1][j]方向: 这个的意思是我先不考虑当前的物品 i i i, 而是从前面的 i − 1 i-1 i1个物品里面任选物品来填满我当前的容量 j j j, 此时最大价值为dp[i-1][j]
    2. dp[i-1][j-weight[i]]方向: 这个的意思是我肯定要考虑当前的物品 i i i, 这时候只需要从前面的 i − 1 i-1 i1个物品里面任选物品来填满容量 j − w e i g h t [ i ] j-weight[i] jweight[i], 因为我要考虑我当前物品了,容量得空出来, 此时最大价值为dp[i-1][j-weight[i]]+value[i]

    有了上面的分析之后,对于当前状态的dp[i][j]就确定下来了: dp[i][j]=max(dp[i-1][j], dp[i-1][j-weight[i]]+value[i])

  2. 确定dp数组初始化方式:

    关于初始化,一定要和dp数组的定义吻合。 首先从dp[i][j]的定义出发,如果背包容量 j j j为0的话,即dp[i][0], 无论选取哪些物品,背包价值总和一定为0, 毕竟放不进东西去啊

    状态转移方程 dp[i][j]=max(dp[i-1][j], dp[i-1][j-weight[i]]+value[i]), 可以看出 i i i 是由 i − 1 i-1 i1 推导出来,那么i为0的时候就一定要初始化。 i = 0 i=0 i=0, 也就是存放编号为0物品的时候, 各个容量的背包所能存放的最大价值,这时候需要倒序遍历:

    for j in (bagWeight, weight[0]-1, -1):  # 得放的下0物品
    	dp[0][j] = dp[0][j-wieght[0]] + value[0]
    

    这里一定要注意是倒序遍历,因为这是初始化第一行的dp,后面的空子会借助于前面空子进行初始化, 所以必须前面的空子先是0, 从最后面一个空子开始考虑加物品0才行,如果是前面第一个空格子加物品0, 那么后面的成了一种累加的形式了,那就相当于物品 i i i放入多次了。这一点要理解清楚

    所以上面两步初始化之后:

    在这里插入图片描述

    下面再分析全局的初始化,这个如果每个value都是正整数, 那么全局初始化为0即可,这样不影响取最大值,如果value中有负数, 那么全局初始化为负无穷会比较好,这样才能让dp数组在递归公式的过程中取最大的价值,而不是被初始值覆盖了。所以最终初始化代码:

    dp = [[0 for _ in range(bagWegiht)] for _ in range(len(weight))]   # [物品个数行, 背包重量列]
    for j in (bagWeight, weight[0]-1, -1):  # 得放的下0物品
    	dp[0][j] = dp[0][j-wieght[0]] + value[0]
    
  3. 确定遍历顺序:

    有两个维度可以进行遍历, 都可以, 但是遍历物品比较好理解,也就是遍历行:

    for i in range(1, len(weight)):     # 遍历物品
    	for j in range(1, bagWegiht+1):   # 遍历背包容量
    		# 如果当前包的容量放不下当前物品i, 那么就不放,dp[i][j]继续保持前面的累积最大价值
    		if j < weight[i]: dp[i][j] = dp[i-1][j]
    		else:
    			# 动态转移方程
    			dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]]+values[i])
    

    而遍历列:

    for j in range(1, bagWeight+1):   # 遍历背包容量
    	for i in range(1, len(weight)):   # 遍历物品
    		if j < weight[i]: dp[i][j] = dp[i-1][j]
    		else:
    			# 动态转移方程
    			dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]]+values[i])
    

    为什么会一样呢? 其实经过上面的转移方程分析, 当前状态dp[i][j]依赖于dp[i-1][j]dp[i-1][j-weight[i]]这两个值的,而这俩哥们都在dp[i][j]的左上角方向,所以只要在当前时刻下有左上角方向的值就可以,而至于是先遍历行有的左上角的值还是先遍历列有的左上角的值没有影响。

  4. 模拟一下dp

    做动态规划的题目,最好的过程就是自己在纸上举一个例子把对应的dp数组的数值推导一下,然后在动手写代码!

2.1.2 dp分析0-1背包问题的五部神曲(一维dp)

0-1背包问题也可以用一维dp搞定的,并且还简单些,这个的原理就是滚动数组了,根据二维数组那里理解的话就是二维数组的动态转移方程:dp[i][j] = max(dp[i-1][j], dp[i-1][j - weight[i]] + value[i]), 如果把dp[i-1]这一层拷贝到dp[i]这一层,那么表达式就变成了dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]), 这时候,就可以只用一个一维数组来玩,这就是滚动的由来。

那么依然是动态规划的五步神曲:

  1. 确定dp数组及其含义

    这里的dp数组不用考虑物品了,而是只当前包的容量,所以dp[j]: 容量为 j j j的背包,所背的物品的最大价值, 长度当然是bagWeight。

  2. 确定动态转移方程

    对于当前的dp[j]的推导过程,依然是来自两个方向,也就是考虑当前物品和不考虑当前物品的情况:

    • dp[j]: 不考虑当前物品, 那么最大价值还是原来的dp[j]
    • dp[j-weight[i]]: 考虑当前的物品了, 那么最大价值就是dp[j-weight[i]]+value[i]

    所以这样分析之后,动态转移方程: dp[j]=max(dp[j], dp[j-weight[i]]+value[i]),相对于二维,就是把物品的那个维度去掉了。

  3. 确定初始化方式

    dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j],那么dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0。

    全局初始化也为0, 但如果是有负数价值的话,这个初始化负无穷

  4. 确定遍历顺序

    这里要注意啦, 和二维数组的不太一样了, 这里需要先遍历物品,然后遍历容量,且容量遍历,得采用倒序

    for i in range(len(weight)):   # 遍历物品
    	for j in range(bagWeight, weight[i]-1, -1):   # 倒序遍历背包容量
    		dp[j] = max(dp[j], dp[j-weight[i]]+value[i])
    

    因为上面二维的时候分析过, 倒序遍历是为了保证物品 i i i只被放入一次, 上面给的链接里面给的例子比较好,可以看下。 这里简单理解就是必须得保证在未放物品之前,dp[j-weight[i]]不会变。前向往后遍历背包容量的话这玩意会变。而二维数组之所以不用倒序,是因为它是从上一层推导本层,不会受当前层前面值的影响。

    还有就是这里的遍历顺序,不能互换了。因为这里背包容量遍历只能倒序走,如果放到第一行的话,接下来遍历物品, 那么只会保留价值最大的那个物品, 而所有的格子都会是价值最大的物品价值,这时候相当于只放了一个价值最大的物品, 可以试一下。

  5. 模拟dp
    可以举个例子来模拟一遍。

2.2 完全背包的dp分析

有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。和0-1背包不同的是,这里的每个物品有无限件。

01背包和完全背包唯一不同就是体现在遍历顺序上。

0-1背包中,我们说在遍历容量的时候,要逆向遍历,当时的目的是为了保证每个物品仅被添加一次。

 for i in range(len(weight)):   # 遍历物品
   	for j in range(bagWeight, weight[i]-1, -1):   # 倒序遍历背包容量
   		dp[j] = max(dp[j], dp[j-weight[i]]+value[i])

完全背包的物品是可以添加多次的,所以遍历背包容量的时候,要从小到大遍历

 for i in range(len(weight)):   # 遍历物品
   	for j in range(weight[i], bagWeight+1):   # 正序遍历背包容量
   		dp[j] = max(dp[j], dp[j-weight[i]]+value[i])

那么,这两个for循环能否换顺序呢? 就是先遍历背包容量,然后遍历物品, 这其实是要分具体问题的, 在纯背包问题上,这个顺序是无关紧要的,也就是可以换。 但是在实际的题目上面,往往会有限制。下面题目里面会感受到。

2.3 多重背包问题

N N N种物品和一个容量为 V V V 的背包。第 i i i种物品最多有 M i M_i Mi件可用,每件耗费的空间是 C i C_i Ci ,价值是 W i W_i Wi 。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。 这里就是在0-1背包的基础上加上了每件物品的个数限制了,完全背包的话是每个物品个数无限。 而多重背包的常见解决方式, 就是转成0-1背包问题。就是每件物品把个数进行摊开, 这就是0-1背包问题了详细的可以参考这里,代码如下:

# 这里做个测试

weight = [1, 3, 4]     # 物品重量
value = [15, 20, 30]   # 物品价值
nums = [2, 3, 2]      # 物品数量

# 先把数量摊开,得到0-1背包问题 
new_weights = []
new_values = []
for i in range(len(nums)):
	 new_weights.extend([weight[i]]*nums[i])
	 new_values.extend([value[i]]*nums[i])    


# 下面就是0-1背包的代码逻辑即可 用new_weights, new_values
dp = [0 for _ in range(len(new_weights)+1)]

for i in range(len(new_weights)):  # 遍历物品
	for j in range(bagWeight, new_weights[i]-1, -1):   # 逆序遍历背包容量
		dp[j] = max(dp[j], dp[j-new_weights[i]] + new_values[i])

print(dp[len(new_weights)])

这种题目目前没在LeetCode上出现过,即使出现了, 可以通过上面的这种方式转成0-1背包问题来解决。

3. 题目思路和代码整理

3.1 一般问题系列

  • LeetCode509: 斐波那契数列:这个是比较简单的题目了,直接动规五步神曲:

    1. 确定dp数组及下标含义:dp[i]的定义:第i个数的斐波那契数组是dp[i]
    2. 确定递推公式:dp[i] = dp[i-1] + dp[i-2]
    3. 确定dp数组初始化方式: dp[0] = 0, dp[1] = 1
    4. 确定遍历顺序: 从递归公式看出, dp[i]是依赖dp[i-1]和dp[i-2]的,那么遍历顺序一定是从前往后
    5. 举例模拟

    代码如下:
    在这里插入图片描述
    斐波那契数列本身并不是太复杂,但是它有很多生活当中的应用场景,比如剑指offer上的青蛙跳台阶问题,再比如下面的爬楼梯问题。剑指offer上还有一个矩形覆盖的题目P79页,这个也是斐波那契数列的变式,感兴趣的可以看下。

  • LeetCode70: 爬楼梯:这个题是斐波那契数列在生活中的应用,依然是五部曲:

    1. 确定dp数组及含义:dp[i] 表示的是到第i阶台阶的方法总数
    2. 确定动态转移方程: dp[i] = dp[i-1] + dp[i-2], 解释到第 i i i阶台阶,要么从第 i − 1 i-1 i1阶台阶迈一步, 要么从第 i − 2 i-2 i2阶台阶迈两步,所以到第 i i i阶台阶的方法总数,就是到第 i − 1 i-1 i1台阶的方法总数与到第 i − 2 i-2 i2台阶方法总数之和
    3. 确定dp数组初始化: dp[1] = 1, dp[2] = 2
    4. 确定遍历顺序: 从前往后
    5. 举例模拟

    代码:
    在这里插入图片描述

  • LeetCode746: 使用最小花费爬楼梯: 这个题有花费体力值了,依然是五步神曲:

    1. 确定dp数组及其含义:
      dp数组的长度和给定的数组cost一样长, dp[i]表示的到达当前的第i阶台阶所需要花费的最小体力。
    2. 确定动态转移方程
      dp[i]=min(dp[i-1]+cost[i-1], dp[i-2]+cost[i-2], 解释我到达第 i i i阶台阶所需要花费的最小体力, 应该是我在第 i − 1 i-1 i1阶台阶所需要花费的最小体力+我从 i − 1 i-1 i1阶跳一步到第 i i i阶所需要的体力和我在 i − 2 i-2 i2阶所需要花费的最小体力+我从 i − 2 i-2 i2阶跳两步到第 i i i阶体力的最小值。
    3. 确定dp数组初始化方式:
      根据动态转移方程, 当前状态是依赖于前两个状态的,所以要初始化两个状态dp[0], dp[1], 而这两个状态的初始化,应该是依赖于cost数组的。dp[0] = 0, dp[1]=0, 解释在0阶的时候,我是不用爬的,不需要花费体力,而第1阶台阶,我也是先不用爬的,因为题目里面说可以把0或者1作为初始台阶, 直接站上去即可。
    4. 确定遍历顺序,还是从前往后
    5. 举例模拟

    代码如下:
    在这里插入图片描述

  • LeetCode264: 丑数II: 这个题目是生成第n个丑数,最简单的思路就是遍历,如果是丑数,然后计数这种,但显然这个会超时, 所以这种生成式方法,应该采用自底向上的方式,那么就需要动态规划了。动规五步神曲分析:

    1. 确定dp数组及初始化含义
      这里的dp用的是一维数组,长度是n,dp[i]表示的是第 i i i个丑数。

    2. 确定动态转移方程
      i i i个丑数是怎么过来的呢? 有三个方向, 前面的某个丑数×2, 前面的某个丑数×3, 前面的某个丑数×5,那么这个前面的某个丑数是哪个呢? 由于我们是自底向上数个数,所以前面的某个丑数含义:没有乘过相应因子的最小丑数。

      • dp[l2] * 2: l2表示的是 i i i前面没乘过2的最小丑数的位置
      • dp[l3] * 3: l3表示的是 i i i前面没乘过3的最小丑数的位置
      • dp[l5] * 5: l5表示的是 i i i前面没乘过5的最小丑数的位置

      所以,当前的dp[i]也是选择3个方向的最小值: dp[i] = min(dp[l2]*2, dp[l3]*3, dp[l5]*5), 但要注意是前面的没有乘过的最小, 一旦乘过了,就需要相应的下标加1.所以要记好l2,l3,l5的含义。

    3. 确定初始化方式
      全局初始化都是0, dp[0]=1, 也就是第一个丑数是1

    4. 确定遍历顺序
      从1开始正向遍历

    5. 举例模拟

    代码:
    在这里插入图片描述

  • LeetCode343:整数拆分:这个题目依然是动态规划, 还是五部曲,一个个的来看看:

    1. 确定dp数组及初始化含义
      这个dp数组也是长度为n+1的数组, dp[i]表示的是当前第i个数能拆分成了至少两个正整数的和,而得到的这些正整数的最大的那个乘积。

    2. 确定动态转移方程:
      这里就得掰扯掰扯了,因为这个可能并不是很好想。我给定当前 i i i之后, 怎么获得最大的那个乘积呢? 这个就得从比他所有小的数遍历试试了,首先 j j j从1开始到 i − 1 i-1 i1

      • j × ( i − j ) j\times (i-j) j×(ij)可以得到一个 i i i的乘积, 这个是每个数拆分成2个正整数相加的情况的乘积
      • 那当前 i i i不一定是拆分成2个正整数相加呀,还可能3个,4个, 好多个呢? 这时候怎么算这些时候的乘积?那就是 d p [ i − j ] dp[i-j] dp[ij]干的活了,这个东西存的是 i − j i-j ij这个数进行分解,然后分解的正整数构成的最大乘积,所以 j × d p [ i − j ] j\times dp[i-j] j×dp[ij]就可以得到大于2个正整数相加的情况的乘积。 因为即使当前数能分成3个,4个好多个相加,这种情况就是多次两两分解,比如10=3+3+4, 其实就等同于10=3+7的两两分解, 而7又可以拆分成多个数相加, 比如2+2+3, 而这个等同于2+5的两两分解, 所以这个过程中能保留住当前数分解的乘积最大值就行,这个就是 d p [ i ] dp[i] dp[i]干的活。
      • 所以从当前 j j j这里获得的最大乘积为: m a x ( j × ( i − j ) , j × d p [ i − j ] ) max(j\times(i-j), j\times dp[i-j]) max(j×(ij),j×dp[ij])


      而当前 i i i处获得的最大乘积: d p [ i ] = m a x ( m a x ( j × ( i − j ) , j × d p [ i − j ] ) , d p [ i ] ) dp[i] = max(max(j\times(i-j), j\times dp[i-j]), dp[i]) dp[i]=max(max(j×(ij),j×dp[ij]),dp[i])

      比如10分解成3+3+4这个例子,其实这个是3+7, 而7这里又可以分解成3+4, dp[7]保存了这个最大乘积12, 所以10分解的时候有个3和7的乘积21, 有个3和dp[7]的乘积36, 取得最大36。

    3. 确定dp数组初始化方式:dp[0], d[1]是没法分解的,所以从dp[2]开始,初始化为1即可

    4. 确定遍历顺序,这个题目需要两层遍历了,因为当前 i i i的dp值,需要依赖于前面的所有dp值。
      i i i从3到 n n n遍历
         j j j 1 1 1 i − 1 i-1 i1遍历

    5. 动态模拟

    代码如下:
    在这里插入图片描述

  • 剑指offer14-I: 剪绳子: 做题的境界就是能把生活问题转成似曾相识的问题,然后解决。 如果做了上面那个题之后,这个题其实和上面那个基本上是一样的。 对于一段绳子,我们剪成 m m m段,其实就是这 m m m段的和就是 n n n, 而需要让这 m m m段的乘积最大。这个分析dp的含义会更加清晰些。

    1. dp数组: dp[i]表示的是长度为 i i i的绳子剪成 m m m段得到的最大乘积
    2. 动态转移方程: 对于长度为i的绳子,先尝试剪成2段,其中一段为j,另一段长度为i-j,其中j小于i,大于2,因为j如果是1话对于后面的乘积没有影响。 剪完了第一段之后,剩下的i-j可以剪,可以不剪, 如果不剪, 这两段乘积最大 j × ( i − j ) j\times (i-j) j×(ij),如果剪开,这些段乘积最大 j × d p [ i − j ] j\times dp[i-j] j×dp[ij], 这里面我们要取最大值。 而换做不同的 j j j,都会有这样的一个操作,所以 d p [ i ] = m a x ( d p [ i ] , m a x ( j × ( i − j ) , j × d p [ i − j ] ) ) dp[i]=max(dp[i], max(j\times (i-j), j\times dp[i-j])) dp[i]=max(dp[i],max(j×(ij),j×dp[ij]))

    那不就和上面这个一样了。拿下来就能A掉:
    在这里插入图片描述
    这个题还有一种贪心的解题思路,我整理到贪心的部分了,之所以在这里提一下,是贪心的解题思路最好要了解下,因为剑指offer上有个剪绳子II,这个对于python没有啥影响,但是其他语言,乘法那里会有溢出的现象了,所以需要用贪心的思路去解。

  • LeetCode96: 不同的二叉搜索树:这个题目考虑起来还是有些难度的, dp数组的定义其实比较好想,而动态转移方程这个题目不太简单想到,得先画几棵树来看看。
    在这里插入图片描述
    五步神曲如下:

    1. 确定dp数组及其含义:
      dp数组的长度是n+1, dp[i]表示的是从1…i为节点组成的二叉搜索树的个数
    2. 确定动态转移方程
      d [ i ] = s u m ( d p [ j − 1 ] × d p [ i − j ] ) j = 1.... i d[i] = sum(dp[j-1] \times dp[i - j]) j=1....i d[i]=sum(dp[j1]×dp[ij])j=1....i
    3. dp数组初始化:
      d p [ 0 ] = 1 dp[0] = 1 dp[0]=1, 空节点的时候算是一棵
    4. 遍历顺序
      i i i从1开始到 n n n
         j j j从1开始到 i i i

    代码如下:
    在这里插入图片描述

  • 剑指offer 62: 圆圈中最后剩下的数字: 这就是经典约瑟夫环的问题, 虽然是纯模拟,但是经过一顿数学分析之后,可以转成动态规划的问题。 首先假设 f ( n , m ) f(n,m) f(n,m)表示从 n n n个数字 0.... n − 1 0....n-1 0....n1中每次删除第 m m m个数字之后剩下的数字。 那么在这 n n n个数字中, 第一个被删除的是 ( m − 1 ) % n (m-1)\%n (m1)%n,假设这个数用 k k k表示,那么删除 k k k剩下的 n − 1 n-1 n1个数字 0 , 1 , . . k − 1 , k + 1 , . . . n − 1 0, 1, ..k-1, k+1, ...n-1 0,1,..k1,k+1,...n1, 并且下一次要从 k + 1 k+1 k+1开始计数。那么我们假设再换成上面那种方式,把 k + 1 k+1 k+1映射成0, k + 2 k+2 k+2映射成1,…那这样这个问题就和上面那个一样了,这个新问题就变成了从 n − 1 n-1 n1个数字 0... n − 2 0...n-2 0...n2中每次删除第 m m m个数字之后剩下的数字,记这个为 f ′ ( n − 1 , m ) f'(n-1,m) f(n1,m)。注意这个 f ′ f' f和前面 f f f不是一个函数了,经过了映射。显然,最初序列最后剩下的数字一定是删除一个数字之后序列最后剩下的数字,即 f ( n − 1 , m ) = f ′ ( n − 1 , m ) f(n-1,m)=f'(n-1,m) f(n1,m)=f(n1,m)。那么接下来的任务就是 f ′ f' f f f f有啥关系呢?
    在这里插入图片描述

所以 f ′ ( n − 1 , m ) = ( k + 1 + f ( n − 1 , m ) ) % n f'(n-1,m) = (k+1+f(n-1,m))\%n f(n1,m)=(k+1+f(n1,m))%n, 这样就得到了最终的递推公式:
f ( n , m ) = ( ( m − 1 ) % n + 1 + f ( n − 1 , m ) ) % n = ( m + f ( n − 1 , m ) ) % n f(n,m)=((m-1)\%n +1+ f(n-1,m))\%n=(m+f(n-1,m)) \% n f(n,m)=((m1)%n+1+f(n1,m))%n=(m+f(n1,m))%n
这样,动态转移方程就出来了dp[i]=(m+dp[i-1]) % i, 而初始状态, n = 1 n=1 n=1的时候,只会剩余0,所以初始化 f ( 1 , m ) = 0 f(1,m)=0 f(1,m)=0,从2开始递推到 n n n即可得出 f ( n , m ) f(n,m) f(n,m)
在这里插入图片描述

  • 剑指offer 66: 构建乘积数组: 这个题考察的是发散思维, 如果可以用除法的话,就实现计算出A数组的所有数乘积,然后遍历的时候除以相应位置的A数字就是B, 但如果不能用除法的话,可以考虑用两个dp数组,来保存前后乘积。这样最后两个数组对应位置相乘即最终答案。
    在这里插入图片描述

3.2 路径规划系列

  • LeetCode62: 不同路径: 这个得需要二维dp数组了,依然是动规五步神曲:

    1. 确定dp数组及其含义:

      • 此时dp数组是二维数组,m行n列, dp[i][j]表示从左上角到第i行j列这个位置的路径条数
    2. 确定动态转移方程:
      dp[i][j] = dp[i-1][j]+ dp[i][j-1] 解释: 我到达当前位置的路径条数应该是我到上面的路径条数加上我到左边的路径条数,因为这两种情况的基础上,我只需要下走一步或者右走一步就到了当前位置

    3. 确定dp数组初始化:
      根据动态转移方程,这个题需要把边界都给初始化了,也就是第一行,第一列,都初始化为1即可,因为dp[0][0]=1, 解释当start和end是一个点的时候, 此时算是一条路径。 而第一行,只能由第一个第一个位置向右走, 第一列只能由开始位置向下走。路径条数也是1。这时候全局初始化为1即可.

    4. 确定遍历方向: 此时的遍历就是二维数组遍历即可,从(1,1)位置开始。

    5. 举例模拟:可以m=3, n=2手动模拟下

    代码:
    在这里插入图片描述

  • LeetCode63: 不同路径II: 区间规划, 这个在上面的基础上加了障碍物。有些地方不能走,那么那个位置的路径数就会是0, 所以在上面的基础上全局初始化变一下, 然后有障碍的时候,跳过去即可。

    • dp数组初始化:这里的初始化方式是把边上的都初始化完了,这样下面遍历的时候从(1,1)开始遍历
      • 边上的初始化思路就是开始所有的格子都初识化0
      • 然后,第一行和第一列再遍历一遍, 在没遇到障碍之前,全都改成1即可, 这样障碍后面的全是0表示不可达
    • 还是遍历二维数组, 这里修改dp的时候,跳过opstacleGrid[i][j]=1的情况了


    代码:
    在这里插入图片描述

  • LeetCode980: 不同路径III: 这个有点难,先不做了,占个坑在这

  • LeetCode64: 最小路径和: 这个和不同路径的两个题目非常类似,当前状态只依赖于上方和左方的状态, 只不过和那里不同的不是求路径总条数而是求路径最小长度,下面动规五步神曲:

    1. 确定dp数组及下标含义:
      此时dp数组是二维数组,m行n列, dp[i][j]表示从左上角到第i行j列这个位置的最小路径和
    2. 确定动态转移方程
      由于当前状态只能从上方和左方过来,那么当前位置的最小路径和,是上方和左方最小路径和的最小值然后加上当前格子的路径。即dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
    3. 确定初始化方式
      这里的全局初始化方式用最大值,这样不影响后面求最小。 而由于这个求当前会用到其上方或者左方的数,那么需要把边界都初始化了。即第一行和第一列都需要初始化。
    4. 确定遍历顺序
      从[1,1]开始遍历, 然后更新
    5. 举例模拟

    代码如下:

    在这里插入图片描述
    类似题目: 剑指offer47: 礼物的最大价值

  • LeetCode120: 三角形最小路径和:这个题dp是一个三角形的二维列表,下面五步神曲分析下。

    1. 确定dp及下标含义
      这个dp是三角形的二维列表, 元素是和三角形一一对应的, dp[i][j]表示的是从顶上到当前层ij个节点的路径最小和
    2. 确定动态转移方程
      由于只能由上一层的相邻节点走过来, 所以当前的状态,只能由上一层的当前下标位置以及上一层前一个下标位置的值推导过来, 而它这里的最小路径就是上面两个的最小值+当前的路径,当然这个要防止越界,还得考虑边界上,如果是边界上,比如j=0或者j=len(triangle[i]-1),所以这里还要分类讨论下:
      1. 如果是j=0, dp[i][j] = dp[i-1][j] + triangle[i][j]
      2. 如果是j=len(triangle[i]-1)dp[i][j] = dp[i-1][-1] + triangle[i][j]
      3. 中间的情况:dp[i][j] = min(dp[i-1][j], dp[i-1][j-1]) + triangle[i][j]
    3. 确定初始化方式
      全局初始化依然是最大值, 而dp[0][0] = traingle[0][0]
    4. 确定遍历顺序
      从第一层开始遍历即可
    5. 举例模拟

    代码如下:
    在这里插入图片描述

  • LeetCode5728: 最少侧跳次数: 这是某周赛的一个题目, 第一次参加周赛, 碰到了这样的一个题目,但是差点A掉,只可惜有点想错了,当天下午才A掉。 这是一个动态规划的题目,我们用动规五步神曲分析下:

    1. 确定dp以及下标含义
      这里的dp我用了一个二维数组,第一个维度表示的是当前位置,这个和obstacles一样长。第二个维度表示的三条道路, 取值0,1,2三种。 dp[i][0]表示的是到当前第i个位置,第1条道路上发生的最少侧跳次数。后面的一样。

    2. 确定动态转移方程
      这里的动态转移方程要分几种情况,如果当前第一条路堵了,那么只能更新第二条和第三条路。

      • 第二条只能由它前面或者第三条路侧跳过来,所以dp[i][1] = min(dp[i-1][1], dp[i-1][2]+1)
      • 第三条路只能由它前面或者第二条路侧跳过来,所以dp[i][2] = min(dp[i-1][2], dp[i-1][1]+1)


      第二条路和第三条路堵了的时候,同理。如果都没有堵, 那么当前情况就可以由三个状态过来,前一个位置,或者是其他两条路侧跳过来。

    3. 确定初始化方式
      由于算最小侧跳次数,所以全局初始化为最大值,不影响算最小值。 而第一个状态上dp[0][0]=1, dp[0][1]=0, dp[0][2]=1, 因为是从第二条路上开始走。第一条或者第三条会发生一次侧跳。

    4. 遍历方式
      从前往后遍历,从第一个位置开始。

    代码如下:
    在这里插入图片描述
    做周赛的感想,周赛是检验自己刷题效果的好机会,因为这些题都是新的,并且会有计时,和我们懒散的刷普通题目还是不一样的,感觉做的时候会逼迫着自己注意力高度集中,然后能体会到刷题的快感。也能还原下真实笔试的那种环境,所以以后还是要多多参加这种比赛的。

  • LeetCode221: 最大正方形: 这个题目建议看后面的第二个题解, 原理说的比较清晰, 这个的核心在于动规方程的推导, 这里先摘一个图过来:

    在这里插入图片描述
    这里假设的是dp[i][j]表示以第i行第j列这个正方形为右下角的正方形的最大边长, 这个东西会受到上,左上和左边三个状态的影响,如上图。根据最短木桶效应,最后确定的转移方程

    if grid[i - 1][j - 1] == '1':
        dp[i][j] = min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]) + 1
    

    代码如下:
    在这里插入图片描述

3.3 背包问题系列

3.3.1 0-1背包问题
  • LeetCode416: 分割等和子集: 这个题目的核心是转换成0-1背包问题, 然后通过上面的思想解题, 怎么转呢? 首先, 给定一个数组, 把它分割成两部分,使两部分的总和相等, 那么也就是一部分等于sum/2, 所以首先,先算和,如果这个数是小数, 那么直接返回false。 而如果不是小数,只需要在给定的数组中找到等于sum/2的集合,而剩下的一组也是sum/2,那么两组集合就相等了。 那么这个题和0/1背包怎么扯上关系呢? 0-1背包问题,有四大要素,背包承受重量, 物品自身重量,物品自身价值,物品不能重复。 而想办法对应上就是:

    1. 背包承受重量: sum/2
    2. 物品自身重量: 每个元素本身
    3. 物品自身价值:每个元素本身
    4. 每个元素不重复

    优化目标: 在给定的数组里面放物品,找到在背包可承受重量的前提下,物品的价值得到最大。下面动态规划四部曲:

    1. 确定dp数组及其含义:
      由于这是个0-1背包问题, dp用一维数组, 大小是 s u m / 2 + 1 sum/2 + 1 sum/2+1就好
      dp[i]: 表示在承受重量为i时, 背包内的物品所能达到的最大价值, 由于物品重量就是自身价值,所以这个最大价值肯定不会超过它承受重量i, 而正好等于承受重量i的时候,其实就是我们要找的答案了。 如果这样说不好理解, 那么换种说法,就是dp[i]表示在给定数组中,最大和不能超过 i i i的子集所能得到的最大和。 啥意思,比如[1,5,11,5]这个例子, sum/2=11, 那么可以定义一个dp[12]的数组, 而dp[7]表示的是总和不能超过7的子集达到的最大和, 而这里面符合条件的就是[1,5], 得到最大总和是6, 所以dp[7]=6, dp[8]同样是这个意思, dp[8], dp[9], d[10]都是6, 而dp[11]=11了,因为这时候符合条件的是[1,5, 5]了, 而这时候找到的子集正是sum的一半, 那这样就能拆成两个子集了。 这个过程一定要理解。

    2. 确定动态转移方程
      由于这个和背包问题一样, 那么就直接可以用背包问题的动态转移方程, dp[j] = max(dp[j], dp[j-nums[i]] + nums[i]), 解释,dp[j]表示的是承受重量为j是, 背包内物品能达到的最大价值。 那么它过来的方向依然是两个,也就是对当前的物品i取还是不取。

      • 如果考虑取, 那么背包内的容量是dp[j-nums[i]], 此时考虑加上当前物品的价值,即dp[j-nums[i]]+nums[i]
      • 如果不考虑取, 那么背包内的价值是原来的,dp[j]
      • 所以这俩取最大的那个,就是当前容量为j时候,背包内物品达到的最大价值
    3. 确定初始化方法:
      全局初始化, 由于都是正整数,所以可以都初始化0, 而dp数组初始化的话,dp[0] = 0

    4. 确定遍历顺序
      这个和0-1背包的一致, 先对每个物品(数组的元素)正向遍历, 然后对于每个容量的背包逆向遍历(因为不能让前面的值给影响了)

    5. 举例模拟

    代码如下:
    在这里插入图片描述

  • LeetCode1049: 最后一块石头的重量II: 这题竟然也是个0-1背包的题目,我没有想到,看了题解之后才发现,原来还可以这么玩啊, 感觉解决这题的关键就是想明白如果想让一堆石头,每次任选两块相撞,最后剩下的重量最小,其实就是把石头分成重量相近或者相同的两组,这两组碰撞,就是最后想要的结果。所以这么一想,就和上面那个非常相似了。所以也是用0-1背包思想解题。五步神曲:

    1. 确定dp数组及其含义
      这里的dp数组的大小依然是sum(stones)//2+1dp[i]表示的是: 背包的容量为i的情况下,所能得到物品的最大值
      这里的物品容量和物品价值都是石头的重量,和上面题一样
    2. 确定动态转移方程:
      这里和上面的题目一模一样,所以不解释了, dp[j] = max(dp[j], dp[j-stones[i]]+stones[i]), 对于每块石头,也是选和不选
    3. 确定dp数组初始化方法
      和上面的一样, 全局初始化为0(没有负数), 而d[0] = 0
    4. 确定初始化顺序
      正向遍历石头, 而反向遍历容量
    5. 举例模拟

    代码:
    在这里插入图片描述

  • LeetCode494: 目标和: 这个题又可以转成0-1的背包问题,只不过会和之前的有区别了。背包问题我们知道是选择子集的问题,所以这个题目得想办法去选子集,即可以用子集的和来定, 假设在数组中选择一组子集,得到的和为x,那么根据最终的目标为S,减法对应的总和sum-x, 即x - (sum-x) = S 立即推: x = (S+sum) / 2, 由于S是定的, 加法与减法子集的和也是固定的,那么x就是固定的。 所以该背包问题转成了给定的物品里面选出子集来装满固定容量的背包,有多少种选择方法?, 这个和之前的0-1背包问题不太一样的是,之前都是求容量为j的背包,最多能装多少。而这里是要把容量为j的装满,有多少种装法,这个涉及到了组合问题。五步神曲:

    1. 确定dp数组及下标含义
      dp数组的大小是(S+sum) / 2, 注意这里的dp[j]和以前的含义不一样了, 之前表示的是在容量j的限制下所能装的最大容量或者达到的最大价值。 而这里的dp[j]表示的是,装满当前的容量j, 有多少种装法?, 存的是方法的数量
    2. 确定动态转移方程
      那么这个dp[j]怎么确定呢? 这里只能考虑装下当前物品的情况,因为这里要装满容量j,这时候对于当前的i物品, 我们有dp[j-nums [i]]种装法, 因为在j-nums[j]确定的前提下,只需要加上nums[i]就得到了当前的i容量。所以动态转移方程:dp[j] += dp[j-nums[i]]
    3. 确定dp数组初始化方法
      这里的全局初始化依然是0, 而dp[0]=1, 装满容量为0的背包,有一种方法, 就是啥也不装。
    4. 确定遍历顺序
      依然是物品正向遍历, 背包容量反向遍历
    5. 举例模拟

    代码:
    在这里插入图片描述

  • 剑指offer 60: n个骰子的点数: 这个题目是求投掷 n n n枚骰子得到的点数出现的概率, 而概率的求解就是投掷n枚骰子出现点数和的次数/总的出现次数, 我们知道投掷 n n n枚骰子,每一枚有6种可能,所以n枚出现的点的总次数,其实就是一个组合数,6的 n n n次方。 所以这个题的关键就是求n枚骰子出现的点数和的次数

    由于投掷第 n n n枚骰子的时候,点数和会取决于第 n − 1 n-1 n1枚骰子累积的总点数和,所以这是一个动规问题, 而其实可以看成一个0-1背包问题。 所以后面的思路我们用
    0-1背包问题的思路分析。 首先,0-1背包问题的两大要素: 背包容量以及物品重量。

    1. 背包容量:这里的背包容量就是投掷骰子的点数和
    2. 物品重量: 这里的物品就是每个骰子,而每个骰子的重量有1-6的6种情况,这是和0-1背包问题不一样的地方,那里的每个物品重量只有一种情况,不过思路一样

    这样一转换,求点数和出现次数的问题就变成了,装满固定点数和的背包, 有多少种选择方法? 这样就和上面这个题一样了。动规五步神曲:

    1. 确定dp数组及下标含义
      dp数组是一维,大小是[6*n+1], 这里要注意下,背包的容量会和物品的重量有关系了,投掷n枚的骰子,背包的容量范围是[n,6n],用2枚想一下,出现的点数和会是[2,12]的范围。 所以这里的大小可以是 6 ∗ n + 1 6*n+1 6n+1, 而dp[j]表示的是装满当前的点数和 j j j, 有多少种装法,求的点数出现的次数。
    2. 动态转移方程
      dp[j]怎么确定呢? 依然是考虑当前物品(骰子), 只考虑装下该骰子的情况,由于该骰子出现的重量是1-6的六种情况,所以对于当前的i物品,我们有 dp[j-1]+dp[j-2]+dp[j-3]+dp[j-4]+dp[j-5]+dp[j-6]种装法, 所以这里的动态转移方程:
      for cur in range(1, 7):
          dp[j] += dp[j-cur]
      
    3. 确定dp初始化方法
      全局初始化都是0, 而这里需要初始化第一枚骰子的情况,也就是dp[1]~dp[6]开始的时候都是1, 后面遍历的时候从第二枚骰子开始
    4. 确定遍历顺序
      物品正向遍历,从2开始,容量逆序遍历,这里由于背包的容量会和当前是第几个物品有关系了,所以容量范围6*i, i

    代码如下:

    在这里插入图片描述

  • 一和零: 两个维度的0-1背包,求的是在背包容量和重量的双重限制下能装下的物品的最大数量。依然是动态五步神曲:

    1. 确定dp数组以及下标含义
      这个dp数组是个2维的,m+1行n+1列, dp[i][j] 表示在i个0, j个1的限制下最大子集的大小

    2. 确定动态转移方程
      考虑dp[i][j] 的来历: 对于当前的物品k, 选择或者不选择,所以两个方向:

      • dp[i][j]: 这个是不考虑当前的物品k, 所以子集的最多数量还是它原来的
      • dp[i-cou_k_0][j-cou_k_1]: 这个是考虑当前的物品k, 所以子集数是dp[i-cou_k_0][j-cou_k_1]+1
    3. 确定初始状态
      全局初始化都是0, 而dp[0] [0] = 0 这个就是0个0,0个1的子集个数为0

    4. 确定遍历顺序
      物品正向遍历, 而背包的两个维度上都是逆向遍历

    5. 模拟: 手动模拟自己都转迷糊了,还是交给程序吧

    代码如下:
    在这里插入图片描述

3.3.2 完全背包

在求解完全背包问题的时候,有类问题是求装满背包的情况下有几种方案, 这类问题遍历顺序是非常关键的:

  • 如果求组合数: 外层for遍历循环物品,内层for遍历循环背包容量
  • 如果求排列数: 外层for遍历循环背包容量, 内层for遍历循环物品

为啥呢? 直观的感觉, 组合数的话1+2和2+1是没有区别的,所以只能这样走一次1+2,这时候,外层遍历物品,内层遍历容量的方式,可以保证1+2这个东西只会被计算一次, 而排序1+2和2+1有区别,这时候外层遍历循环容量,内层循环物品,因为当前容量是依赖于前面的结果嘛,这时候会使得物品的这种组合1+2走多次,相当于考虑了2+1的情况。具体的题目下面的零钱兑换II和组合总和IV。

  • LeetCode518: 零钱兑换II: 完全背包问题, 求的是装满某个固定容量的背包, 有多少种装法, 和目标和那个不一样的是,这个的物品可以被使用多次。注意这里有个组合数和排列数的区别, 这个是个组合数的问题,也就是不同的装法里面,相同的一批物品,不同的顺序,算作一种装法, 动规五步神曲:

    1. 确定dp以及下标含义
      这里的dp是大小为amount的一维数组, dp[i]表示的是装满容量为i的背包,有多少种装法
    2. 确定动态转移方程
      既然是统计方法的总个数, 这里又涉及到累计的问题,也就是当前的dp[j]会依赖于之前的每个状态 dp[j] += dp[j-coins[i]]
    3. 确定初始化方式
      全0初始化, dp[0]=1, 这里的初始值必须是1, 否则结果都是0了,也就是容量为0的背包,取物品的方法为1种,啥也不取
    4. 确定遍历顺序
      对于物品, 依然是正向遍历,而对于背包,也是正向遍历,因为这里的物品可以取多次
    5. 模拟

    代码如下:
    在这里插入图片描述

  • LeetCode377: 组合总和IV: 完全背包问题, 求得依然是装满某个固定容量的背包,有多少种装法,和上面的零钱兑换II不同的是,不同的装法里面相同的一批物品,不同的顺序,算作不同的装法。也就是排列数的问题。

    1. 确定dp以及下标含义
      dp数组的大小是target+1, 那么这时候背包的容量才能取到target, dp[j]表示的是装满容量为[j]的背包有多少种方法?
    2. 动态转移方程
      这个和上面的零钱兑换II的一样 dp[j] += dp[j-nums[i]]
    3. 初始化
      全局初始化为0, 而dp[0] = 1, 这样才能进行后面的方法计算
    4. 确定遍历顺序
      这个就关键了, 此时由于是算排列数,所以需要外层遍历背包容量,内层遍历物品,这样就相当于一种物品的组合用了多次,正好是排列。这个也好理解,因为遍历背包容量的时候,对于每种背包容量其实都会遍历所有物品的,相当于会考虑一次物品的组合,而计算后面的dp,又是依赖于前面很多个dp的值, 那这其实会发现,后面的dp是用了很多次物品的组合的,所以这时候会是排列。 而如果是外层遍历物品,内层遍历背包容量的时候呢? 是对于每个物品, 遍历所有类型的背包,那么此时对于背包来说,是只考虑了一种物品组合的。 这时候会是组合,这是两者不一样的地方。
    5. 模拟

    代码如下:
    在这里插入图片描述

  • LeetCode70: 爬楼梯 加强版: 这个题目稍微改一下,就能成完全背包的难度。 即每次可以爬 1 、 2或者m 个台阶。问有多少种不同的方法可以爬到楼顶呢? 这个题目就是完全背包了,和上面这个组合总和IV成一个题目了。这里的target,就是阶数。 具体的参这篇文章

  • LeetCode322: 零钱兑换: 这竟然是个完全背包问题, 这里再兑换一次。

    1. 确定dp以及下标的含义
      dp数组是个amout+1的数组,dp[j]表示的是装满容量为j的背包所需要的最少物品的个数

    2. 确定动态转移方程
      对于当前物品i, 有两种选择决定了dp[j]的推导方向:

      • 选择当前物品i: 那么dp[j] = dp[j-nums[i]], 因为在j-nums[i]的基础上加上1个nums[i]就是容量j了
      • 不选择当前物品i, 那么还是保留原来的最少物品个数
        这里面要选择最小的 dp[j] = min(dp[j], dp[j-nums[i]]+1)
    3. 确定初始化
      这个题由于是每次选最小值,所以全局初始化的时候, 初始成最大值了。 而dp[0]=0,没有容量没法装。

    4. 确定遍历顺序
      那这是个组合还是个排列问题呢? 其实无所谓,因为这个题目是求硬币数量,而不是求具体的方法数,排列也好,组合也好,求得的最优解的硬币数量都是一样的。

    5. 模拟

    代码如下:
    在这里插入图片描述

  • LeetCode279: 完全平方数: 这是一个完全背包的问题, 因为每个完全平方数可以使用多次, 另外这个题目也没有确定出物品来,需要自己定义物品,也就是完全平方数, 最大是sqrt(n)的平方即可。 其他的就是和上面这个差不多了。动规四步神曲:

    1. 确定dp数组及下标含义
      这里的dp数组是一个大小为n的一维数组, dp[j] 表示装满容量为j的背包需要的最少物品个数
    2. 确定动态转移方程
      对于当前的物品来说,依然是考虑装和不装: dp[j]=min(dp[j], dp[j-nums[i]]+1)
    3. 确定初始化方式
      对于全局的初始化, 由于这里又是求最小值,所以为了不影响,都声明为int("inf"), 而初始的dp[0] = 0, 容量为0的背包装不下,所以没有物品
    4. 确定遍历方式
      由于这又是求物品个数,所以排列组合问题都行,而是完全背包,所以外层正向遍历物品,内层正向遍历容量
    5. 动态模拟

    代码如下:
    在这里插入图片描述

  • LeetCode139: 单词拆分: 这个题的关键又是问题的转化问题,这里可以转成完全背包, 其中s字符串就是背包,拆分的单词就是物品, 问s字符串能不能拆分成1个或多个单词, 其实就是问物品能不能装满背包。 而题目中又说拆分时,可以重复使用单词,也就是每件物品可以被使用多次,所以完全背包出来了, 下面动态规划的五步神曲:

    1. 确定dp数组以及下标含义
      这里的dp数组用一个len(s)长度的一维数组即可, dp[j]表示的是长度为 j j j的字符串(容量为 j j j的背包)能否被拆分成1个或者多个单词里面的值, 所以这里的dp[j]非1即0
    2. 确定动态转移方程
      这里的dp[j]表示长度为 j j j的字符串, 它来自于比他更短的字符串(长度假设为i), 那么如果dp[i]==True 并且s[i:j]这段子串也在字典里的话,那么dp[j]就是True
    3. 确定初始化方法
      首先全局初始化的话都是False, 只要没有被覆盖说明都是不可拆分为一个或多个在字典中出现的单词。而dp[0]需要初始化True,这里为了往下递推,如果也是False的话,那就都是False了。
    4. 确定遍历顺序
      由于这里是完全背包问题,那么都是正向遍历, 而这里并不关注顺序, 所以先遍历物品和先遍历背包是一样的,只不过这种分割的题目最好是外层遍历背包容量,内层遍历物品, 好理解, 当然,我试过,先遍历物品再遍历背包也一样, 但外层循环那里总感觉怪怪的。
    5. 动态模拟

代码如下:
在这里插入图片描述

3.3.3 对背包问题来个小总结 (重点知识)

关于动态规划,我们可以用五步神曲来解题, 而解背包问题的时候, 有很重要的三点需要总结下。

对于背包问题的动态转移方程:

  • 当前容量下装满背包有几种方法: dp[j] += dp[j-nums[i]]
  • 当前容量下能否装满或者最多能装多少: dp[j] = max(dp[j], dp[j-nums[i]]+nums[i]
  • 当前容量下装满的最大价值:dp[j] = max(dp[j], dp[j-weights[i]]+values[i]
  • 当前容量下装满所用物品的最小个数:dp[j] = min(dp[j], dp[j-nums[i]]+1

对于背包问题的初始化设置:

  • 如果是装满背包有多少种方法: 全局初始化为0, dp[0]=1, 啥也不能装也算一种方法
  • 如果是否装满或者最多能装多少: 全局初始化为0, dp[0] = 0, 0容量的背包不能装物品
  • 如果是装满的最大价值: 全局初始化为0, dp[0] = 0, 0容量背包不能装物品
  • 如果是装满所用物品的最小个数: 全局初始化为float("intf"), dp[0]=0, 0容量没法装

对于背包问题的遍历顺序:

  • 0-1背包问题: 正向遍历物品, 逆向遍历背包容量, 且必须先遍历物品,后遍历背包容量
  • 完全背包问题:正向遍历物品,正向遍历背包容量
    • 如果不是求装满背包有几种方法的累加题目, 这时候,先遍历物品或者先遍历背包容量都可,我喜欢前一种
    • 如果是涉及到求装满背包有几种方法的累加题目,分情况:
      • 求组合数, 也就是同一批物品,不管顺序关系: 先遍历物品,然后遍历背包容量
      • 求排列数, 也就是同一批物品,管顺序关系: 先遍历背包容量,后遍历物品

3.4 打家劫舍系列

  • LeetCode198: 打家劫舍:经典dp问题, 依然是动规五步神曲:

    1. 确定数组下标及含义
      dp数组和房屋数一样大小, dp[i]表示到第i个房屋的时候, 能够偷窃到的最高金额

    2. 确定动态转移方程
      对于当前的dp[i], 有两个方向,取决于能不能考虑偷当前房屋

      • 如果能考虑偷当前的房屋,那么前一个房屋肯定不能考虑,此时最高金额: dp[i-2]+nums[i]
      • 如果不能考虑偷当前房屋, 那么一定可以考虑偷前一个房屋,此时最高金额:dp[i-1]

      所以dp[i] = max(dp[i-1], dp[i-2]+nums[i])

    3. 确定初始化方式
      全局初始化都是0, 这样不妨碍最大值, 而由于当前状态会依赖于前两个状态, 所以d[0]和dp[1]必须初始化。
      dp[0] = nums[0] 解释: 只有一间房子, 废啥话,偷就完事
      dp[1] = max(num[0], nums[1]) 解释: 两间房子, 偷钱最多的那家

    4. 确定遍历顺序
      从前往后遍历即可, 但由于遍历的初始下标从2开始, 必须前面进行异常判断,防止越界

    5. 模拟

    代码如下:
    在这里插入图片描述

  • LeetCode213: 打家劫舍II:这是上面的一个升级版本,改了一个地方,就是这是一圈房子了,也就是第一家会和最后一家相连了, 即如果我偷了第一家,就不能偷最后一家了,如果我不偷第一家,就能考虑最后一家。 所以这个dp数组及下标含义,以及动态转移方程都不会变。 但是后面的初始化以及遍历这里,要结合着我偷不偷第一家来判断。 所以我这里声明了两个一模一样的dp数组, 然后分两种情况走的。

    1. 如果我偷第一家
      1. 初始化: dp[0]=nums[0], dp[1]=nums[0], 注意这个dp[1]没得选
      2. 此时不能考虑偷最后一家,所以遍历的时候,只能到倒数第二家 2~len(nums)-1
    2. 如果我不偷第一家
      1. 初始化: dp[0]=0, dp[1]=nums[1], 注意此时dp[1]依然没得选,千万不能说max(nums[0], nums[1]),因为此时又有可能考虑了第0家
      2. 此时可以考虑偷最后一家,所以遍历的时候, 2~len(nums)
    3. 返回值, 这里由于这两种情况都行得通 ,返回这两个里面的最大值

    代码如下:
    在这里插入图片描述

  • LeetCode337: 打家劫舍III:这个属于树形的动态规划题目, 也就是在二叉树上进行动态规划, 谈到树,一定要想到各种遍历方式,而这里一定要采用后序遍历,因为通过递归函数的返回值做下一步计算。这时候依然是动态规划的五部神曲,但是这次会融合递归的四大神曲,在一块就有意思了,顺便借着这个题目学习下树形dp

    1. 确定递归函数的参数和返回值
      这里是求当前的节点偷不偷的两个状态所得到的金钱,那么返回值就是一个长度为2的数组, 而这就是dp

      所以dp数组及下标的含义就确定了:

      dp数组的长度为2, 下标为0记录不偷该节点所得到的的最大金钱,下标为1记录偷该节点所得到的的最大金钱。要记好了含义

    2. 确定递归终止条件
      在遍历的过程中, 如果遇到空节点,很明显,无论偷还是不偷, 都是0

      if not root: return [0, 0]
      

      而这一步,就算dp的初始化

    3. 确定遍历顺序
      对于树的遍历,一定要使用后序遍历,要通过递归函数的返回值来做下一步的计算

      • 先递归左节点, 得到左节点偷与不偷的金钱
      • 再递归右节点, 得到右节点偷与不偷的金钱
      left = robTree(root.left)
      right = robTree(root.right)
      
    4. 确定单层的递归逻辑(动态转移方程)
      这个要分为当前节点偷或者不偷分别来讨论

      • 如果偷当前节点, 那么左右孩子就不能偷, val1 = root.val + left[0] + right[0], 下标为0记录不偷左右孩子的最大金钱
      • 如果不偷当前节点,那么左右孩子可以考虑偷了, 但偷不偷呢, 选最大的那个 val2 = max(left), max(right)

      所以最后当前节点的状态就是 [val2, val1], 解释: 【不偷当前节点得到的最大金钱,偷当前节点得到的最大金钱】

      所以到根节点的时候,返回这俩最大的即可。

    5. 举例推导

    代码如下:
    在这里插入图片描述

3.5 股票问题系列

  • LeetCode121: 买卖股票的最佳时机:股票系列的dp数组都是二维的了,表示的是两种状态,持有股票以及不持有股票。但是感觉先从一维上理解比较好理解,我们依然是动规五步神曲:

    1. 确定dp数组及下标含义
      dp[i]是一个长度为 l e n ( p r i c e s ) len(prices) len(prices)的一维数组,表示的是在第 i i i天可以获取的最大利润,但是我们知道第 i i i天其实有两种状态: 持有股票和不持有股票,所以该一维数组的每个元素又会有两个状态:

      • dp[i][0]表示的是第 i i i天不持有股票所得的最大利润
      • dp[i][1]表示的是第 i i i天持有股票所得的最大利润


      这里的持有不代表当天买入, 可能昨天就买入了,而今天还没卖,所以持有。本质上这是个二维数组,但感觉先从一维说含义,然后再说两种状态感觉会好理解些。

    2. 确定动态转移方程
      由于dp[i]会有两种状态,那么得需要分情况讨论:

      如果第 i i i天不持有股票, 即dp[i][0], 那么有两个状态推过来:

      1. i − 1 i-1 i1天不持有股票,保持现状过来的, 所得的利润就是昨天不持有股票的最大利润dp[i-1][0]
      2. i − 1 i-1 i1天持有股票,但在第 i i i天卖出去了,这时候所得利润是今天的价格加昨天的最大利润,注意我们这里
        已经把成本抛出去了,因为一开始就是算的纯利润,把买入成本算到了最开始里面,所以这里直接价格
        加前面的利润,昨天就开始赚了,所以此时dp[i-1][1] + prices[i]

      dp[i][0]选择利润最大的: dp[i][0] = max(dp[i-1][0], dp[i-1][1]+prices[i])

      如果第i天持有股票,即dp[i][1], 那么同样两个状态过来:

      1. i − 1 i-1 i1天就持有股票, 保持现状过来的, 最大利润就是昨天持有股票的最大利润dp[i-1][1]
      2. i − 1 i-1 i1天没有股票,第 i i i天刚买进来的, 那这时候的最大利润就是-prices[i]因为股票只能买卖一次, 所以前面没有股票说明就一直没有股票,利润是0,这个很重要, 所以这时候突然买股票,利润当然是负数了, 并且这里也是直接用的价格,也就是假设了我们一开始拥有的钱是0, 那么上面那个直接加price也是合理的。

      dp[i][1]选择最大的: dp[i][1] = max(dp[i-1][1], -prices[i])

    3. 确定初始化方式:
      全局初始化为0, 局部初始化根据递推公式,后面的状态依赖于前一天的状态,基础从dp[0][0]dp[0][1]过来的,必须初始化这两个

      1. dp[0][0] 表示第0天不持有股票的最大利润, 就是不买第0天的股票呗, dp[0][0]=0
      2. dp[0][1] 表示第1天持有股票的最大利润,就是买入了第0天的股票呗,利润是负值,dp[0][1]=-prices[0]
    4. 遍历顺序: 从左往右, 从1开始遍历,返回的是最后没有股票时候的最大利润,也就是dp[-1][0]

    5. 举例模拟

    代码如下:
    在这里插入图片描述

  • LeetCode122:买卖股票的最佳时机II:该题和上面不一样的是可以买卖多次了,但还是每次只能持有一支股票,卖了之后才能买。这个题目和上面唯一的区别就在于dp[i][1]的更新逻辑, 也就是当前持有股票的推导,所以上面的代码拿过来修改一句就能搞定。 那么是哪里呢? 就是上面标加粗的那个地方,我们重新看下dp[i][1]更新逻辑吧:这个表示的是第 i i i天持有股票, 那么可以从前面的两种状态推导过来:

    1. i − 1 i-1 i1天就持有股票, 保持现状过来的, 最大利润就是昨天持有股票的最大利润dp[i-1][1], 这个是不变的

    2. i − 1 i-1 i1天没有股票,第 i i i天刚买进来的, 那这时候的最大利润就是dp[i-1][0]-prices[i],注意这个不是仅仅的-prices[i]了, 因为此时可以进行多次交易了, 即使第 i − 1 i-1 i1没有股票,那么也可能会有更前面的利润传过来了,所以这里需要改成这个。 这里依然是纯利润计算即可。

      所以此时的dp[i][1]=max(dp[i-1][1], dp[i-1][0]-prices[i]), 其他的就和上面一模一样了,代码如下:
      在这里插入图片描述

  • LeetCode122:买卖股票的最佳时机III: 这个题比较难的一个地方就在于两笔上,也就是至多完成两笔交易,这就相当于对于第 i i i天的动作进行了限制,虽然第 i i i天还是只有两种状态持有股票和不持有股票,但是操作会受到前面的限制了,即如果我前面 i − 1 i-1 i1天已经够了两笔交易,此时即使我没有股票,也不能买了,我有股票也不能卖了 , 必须先体会出这种区别来,这时候,其实对于每一天的状态来讲,就不是持有和不持有股票那么简单了,还得记录下目前到底进行了几次交易了, 这时候,每种状态下面还会有三种状态,当前如果持有股票了, 那么到底进行了几次交易?0次,1次,2次的选择, 而如果当前没有股票, 那么到底进行了几次交易了? 0次,1次,2次的选择。所以这个题的dp数组可以用三维数组来解决。依然是动态规划五步神曲:

    1. 确定dp数组及其下标含义:
      这里的dp数组是3维数组,dp[i]还是表示第 i i i天可以获取的最大利润。每天会有持有股票和不持有股票两种状态,这个是第二维度,还是用0和1表示。 而对于每一种状态,这里还会有交易次数的记录0次or1次or2次, 这个是第三维度。所以dp[天数][当前是否持股][卖出的次数]

    2. 确定动态转移方程
      这里依然要先分为持股和未持股两种状态, 而两种状态下又得分三次卖出次数。

      首先,dp[i][0],也就是没有持股的状态,会有三次交易次数讨论:

      1. dp[i][0][0]: 表示的是当前未持股, 且交易了0次,说明目前是从未进行买卖, 那么此时最大利润为 dp[i][0][0]=0

      2. dp[i][0][1]: 表示的是当前未持股,且有1次交易时最大利润,卖出过1次股票,在第1次卖出的状态,那么它的状态依然来自两个方向推导过来的,可能是今天卖出去的,也可能是延续了昨天的状态

        1. 如果是延续了昨天的状态, 那么最大利润就是dp[i-1][0][1]
        2. 如果是今天刚卖出去的, 说明昨天是持有股票的,也就是昨天没有卖出(之前交易了0次),那么最大利润就是dp[i-1][1][0]+prices[i]

        所以这时候要选最大, dp[i][0][1] = max(dp[i-1][0][1], dp[i-1][1][0]+prices[i])

      3. dp[i][0][2]: 表示的是当前未持股, 且有2次交易股票时最大利润,这是第二次卖出的状态,状态依然是两个方向推导过来,可能是今天卖出去的,也可能延续了前面的状态

        1. 如果是延续了昨天的状态, 那么最大利润就是dp[i-1][0][2]
        2. 如果是今天刚卖出去的, 说明昨天是持有股票的,但是此时已经交易过了1次了,那么最大利润就是dp[i-1][1][1]+prices[i]

        所以这时候要选最大, dp[i][0][2] = max(dp[i-1][0][2], dp[i-1][1][1]+prices[i])

      然后是dp[i][1], 也就是持股状态,依然会有三次次交易讨论:

      1. dp[i][1][0]: 表示的是当前持股,且有0次股票交易时的利润,也就是第一次买入的状态, 那么它的状态依然来自两个方向推导过来的,可能是今天刚买的,也可能是延续了昨天的状态

        1. 如果是延续了昨天的状态, 那么最大利润就是dp[i-1][1][0]
        2. 如果是今天刚买的, 说明昨天是没有股票的,那么最大利润就是dp[i-1][0][0]-prices[i]

        所以这时候要选最大, dp[i][1][0] = max(dp[i-1][1][0], dp[i-1][0][0]-prices[i])

      2. dp[i][1][1]: 表示的是当前持股,且有1次股票交易时的利润,也就是第二次买入的状态,那么它的状态依然来自两个方向推导过来的,可能是今天刚买的,也可能是延续了昨天的状态

        1. 如果是延续了昨天的状态, 那么最大利润就是dp[i-1][1][1]
        2. 如果是今天刚买的, 说明昨天是没有股票的,那么最大利润就是dp[i-1][0][1]-prices[i]

        所以这时候要选最大, dp[i][1][1] = max(dp[i-1][1][1], dp[i-1][0][1]-prices[i])

      3. dp[i][1][2]: 表示的是当前持股, 且有2次股票交易时的利润,这个情况是不可能出现的,因为进行完两笔交易就不能交易了呀,这时候可以给个最小的负数即可。

    3. 确定初始化方式:
      三维数组,全局初始化为0, 然后第0天的所有状态必须都初始化出来:

      1. dp[0][0][0] = 0: 开始啥也没干, 利润0
      2. dp[0][0][1] = float("-inf"): 第0天没有持有股票,且进行了一次交易,这种情况不可能
      3. dp[0][0][2] = float("-inf"):第0天没有持有股票,且进行了两次交易,这种情况依然不可能
      4. dp[0][1][0] = -prices[0]: 第0天持有股票,且进行了0次交易,这是第一次买入,利润-prices[0]
      5. dp[0][1][1] = float("-inf"): 第0天持有股票,且进行了1次交易,这种情况不可能
      6. dp[0][1][2] = float("-inf"): 第0天持有股票,且进行了2次交易,这种情况不可能
    4. 确定遍历顺序: 从1开始正向遍历

    5. 举例模拟

    代码如下:

    在这里插入图片描述

  • LeetCode188: 买卖股票的最佳时机IV: 这个题和上面不同的一点是最多完成K笔交易了,不是两笔了,也就是完成的交易数是个变量, 但是有了两笔的那个,再玩K笔就容易多了,把上面的分析拿过来就好。 根据上面的分析,dp依然会是3维数组,第一维度表示到目前为止可以获得的最大利润,第二维度表示持有和不持有股票两种状态,第三个维度表示0-K次交易。这里依然还是要搞明白状态以及状态转换过程,其实这个和上面那个类似的。所以我们直接分析动态转移方程。

    • 未持有股票的状态 dp[i][0][k]系列
      • 如果k=0的话,那么还是dp[i][0][0]等于0,开始的状态

      • 如果k>0的话, 未持有表示要么延续之前的状态,要么是当天卖了出去

        • 延续之前的状态, 即dp[i-1][0][k]
        • 当天卖了出去,说明前一天是持有股票的, 即dp[i-1][1][k-1]+prices[i]


        这里取最大就是 dp[i][0][k]=max(dp[i-1][0][k], dp[i-1][1][k-1]+prices[i])

    • 持有股票的状态dp[i][1][k]系列
      • 如果k=0的话, 说明是第一次买入的状态, 此时利润要么是延续之前的,要么是今天刚买的

        • 延续之前的, 即dp[i-1][1][0]
        • 今天刚买的, 即昨天是没有股票的, dp[i-1][0][0]-prices[i]


        所以这里取最大是dp[i][1][0] = max(dp[i-1][1][0], dp[i-1][0][0]-prices[i])

      • 如果k>0,也是这个思路, dp[i][1][k] = max(dp[i-1][1][k], dp[i-1][0][k]-prices[i])

      • 同样的,等于k取不到,因为这时候已经完成了K笔交易,不能再买了。dp[i][1][k]=float("-inf")

    那么初始化呢? 这里和上面一样的初始化, 遍历方式会是两层循环了,首先会从1到len(prices)遍历天, 然后再从1到k遍历交易次数,正向遍历。 代码如下:
    在这里插入图片描述

  • LeetCode309: 最佳买卖股票时机含冷冻期:首先看到了尽可能多的完成交易,也就是交易不限次数,但是交易完成后没法在接下来的一天买。即这个冷冻期是会影响买入时机的,所以在哪一天卖出的股票是关键所在。下面依然是动规五步神曲分析下:

    1. 确定dp数组及下标含义
      这次的dp数组是二维的,没有交易次数限制了,大小和天数一样,dp[i]表示的是到第 i i i天能获得的利润,但这里每天的状态下就不仅仅是持股和不持股两种状态了,而是持股, 不持股当天卖出和不持股当天没卖出三种状态,因为冷冻期的意思是如果昨天卖出了,今天不可以买入,所以持股的时候如果今天买入,那么昨天肯定没有卖出,即必须要分析不持股是不是因为当天卖出了dp[i][0]表示不持有股票且不是当天没卖出, dp[i][1]表示持有股票, dp[i][2]表示不持有股票但是是因为当天卖出的

    2. 确定动态转移方程
      这里依然是从两个大状态看转移情况,首先是未持有股票的两种情况

      1. 不是当天卖出的未持有股票 dp[i][0], 这说明有可能是今天冷冻期,我昨天卖的dp[i-1][2], 也有可能是延续的之前的状态dp[i-1][0]。 我们选择最大值,即dp[i][0]=max(dp[i-1][2], dp[i-1][0])
      2. 由于今天卖了导致未持有股票dp[i][2], 那么就非常简单了,最大利润是dp[i][2]=dp[i-1][1]+prices[i], 昨天一定是持有股票的

      然后是持有股票的一种情况, 也就是买入, 这时候和上面的II是一样的,要么是延续的之前的状态dp[i-1][1],之前买的,要么是今天买的,但这时候昨天一定没有卖出股票, 否则就是冷冻期了, 所以这时候是dp[i-1][0]-prices[i], 这时候dp[i][1]=max(dp[i-1][1], dp[i-1][0]-prices[i])

    3. 确定初始化方式:
      这个就和II是一样了,全局初始化0, 局部初始化dp[0][0]=0, dp[0][1]=-prices[0], dp[0][2]=float("inf") 这种情况不可能

    4. 确定遍历方式: 从1开始遍历,正向, 最终返回的是未持股状态的最大值

    5. 举例模拟

    代码如下:

    在这里插入图片描述

  • LeetCode714: 买卖股票的最佳时机含手续费:这个题和II类似了, 只不过当天如果卖出的时候,需要扣除手续费了。买入的状态判断还是一样,所以dp初始化以及下标含义,参考II, 这里分析下卖出的情况, 也就是当前不持有股票。

    • 不持有股票是延续了昨天的, 那么dp[i][0] = dp[i-1][0]
    • 不持有股票是因为今天刚卖出,此时要拿手续费, dp[i][0]=dp[i-1][1]+prices[i]-fee


    这时候选最大的dp[i][0] = max(dp[i-1][0], dp[i-1][1]+prices[i]-fee), 其他的都和II保持一样, 代码如下:

    在这里插入图片描述

3.6 子序列问题系列

  • LeetCode300: 最长递增子序列:这个题目属于线性规划里面求dp的第二种方式,也就是当前状态的值会和前面所有状态都有关系。 动规五步神曲分析:

    1. 确定dp及其下标含义:
      dp是长度为len(nums)的数组,
      dp[i]表示以nums[i]结尾的最长子序列的长度,这个定义中 nums[i] 必须被选取,且必须是这个子序列的最后一个元素

    2. 确定动态转移方程:
      该题的dp[i]会与前面所有的状态都有关,所以需要遍历前面所有的状态, 并进行判断, 因为是以nums[i]结尾的最长递增子序列,只要前面的nums[j]nums[i]小,就说明在dp[j]的基础上加上1就可以是dp[i], 但由于是最长,我们在这里面选择最大值。

      for j in range(i):
          if nums[j] < nums[i]: 
              dp[i] = max(dp[i], dp[j]+1)
      
    3. 确定初始化方法
      全局初始化都是1, 因为最短的子序列也包括它自己

    4. 确定遍历顺序
      两层遍历, 第一层是从1到len(nums), 第二层从0-i

    5. 举例模拟

    代码如下:
    在这里插入图片描述
    当然这个题还有一种二分查找+贪心的优化方式, 可以把时间复杂度降到 O ( n l o g n ) O(nlogn) O(nlogn), 那个其实上换了一种角度去看待这个问题, 上面我们遍历了每个元素,然后取以每个元素为结尾的最长递增子序列,最后求出最后的递增子序列。 而这那里考虑问题的角度不太一样,那里是维护了一个递增子序列的长度。依然是用到了一个数组,但这里不是dp状态的数组了,而是一个维护递增子序列长度的数组, 位置i的元素表示的是长度为i+1的递增子序列的末尾元素值。理解这一点非常重要。 我们要做的就是让当前长度的子序列的末尾元素尽量的小,这样的话才更有可能找到更长的单调递增子序列。所以这里面的这个数组,假设为cell的话,就是一个单调递增的数组。 而每遍历到一个元素,就要考虑更新当前cell里面记录的单调递增子序列, 不过这个思路确实是巧妙,代码的逻辑也不复杂:

    • 如果当前元素大于cell最后一个元素,直接加入到cell的末尾,
    • 否则从前面寻找比他大的第一个数(左边界),把它替换掉。


    所以这里也放了一款优化代码, 算是练练二分吧再:

    在这里插入图片描述

  • 牛客Top200高频: 最长递增子序列:这个不是要找到最大长度了,而是要把最长递增的找出来,并且如果有多个答案,要返回字典序最小的。这才是一般公司里面会考的,也证明了仅仅找到长度是不行的,得找出具体的来。所以,这种题目两步,第一步是找到以当前位置元素结尾的最长递增子序列的长度来, 第二步根据这个长度去找递增的子序列。上面的动态规划dp里面存的就是长度,但还需要有这个长度dp之后,如何找最终的子序列。 下面的这款代码把上面的两个结合在了一块了,因为我二分的那个思路里面没有求出dp那样的一个数组,而动规那个思路里面,没有找子序列,所以我这里是用二分的思路找dp那样的数组,然后在这个基础上找的最终的子序列。代码如下:
    在这里插入图片描述

  • LeetCode673:最长递增子序列的个数: 这个题算是上面的升级版,不是最长递增子序列的长度了,而是个数了,这时候不仅要维护一个统计最长长度的dp,还要同时维护一个记录最长个数的dp。这里依然是用dp来维护最大长度,dp的定义和动态转移,包括初始化和上面一样,这里主要是count数组的五步神曲分析。

    1. dp数组定义
      这里的dp数组,dp[i]表示以nums[i]结尾的数组最长递增子序列的长度, count数组, count[i]记录以nums[i]结尾的数组,最长递增子序列的个数。

    2. 确定动态转移方程
      dp[i]是与前面的所有状态有关的,所以count[i]也和前面所有状态有关。遍历nums[i]前面的数,如果nums[j]小于nums[i]了, 这里不能用max更新dp[i]了,而是要加判断

      • if dp[j] + 1 > dp[i]: dp[i]要更新到最大长度,同时cout[i]=count[j],也要更新到最大长度的个数
      • if dp[j] + 1 == dp[i]: 这说明遇到了相同最长长度的子序列,这时候个数要累加 count[i] += count[j]
    3. 初始化方式:
      dp全1初始化,count也是全1初始化,最小个数就是1个了

    4. 遍历顺序
      外层i从前往后, 内层从0到i

    代码如下:
    在这里插入图片描述

  • LeetCode674:最长连续递增子序列: 这个题要求连续了, 所以这时候,动态转移方程更新的时候,就不能依靠于前面所有的状态了,而是只能依靠于他前面的这个状态, 这里要体会出区别来。所以这里和上面相比,需要变的两个地方,一个是dp[i]的含义,这里表示的是以nums[i]结尾的最长连续递增子序列了。另一个是动态转移方程,这里的dp[i]只依赖于它前一个状态,所以不用第二层for循环看所有的状态,而是判断下他前面一个数与他的关系,如果他前面一个数小于他,那么当前的状态就是前一个状态+1。代码如下:

    在这里插入图片描述
    这里还是得把最长的递增连续子序列找到,真正公司的面试题目,是要求找具体序列的。

  • LeetCode53: 最大子序和: 这个题目贪心那里也做过, 这里尝试用动态规划的方式再走一遍, 动规五步神曲:

    1. 确定dp以及下标含义
      这里的dp数组是一个len(nums)长度的数组, dp[i] 表示的是以num[i]结尾的数组中连续子数组的最大和。
    2. 确定动态转移方程
      首先,由于这里要求是连续的,所以dp[i]来自前一个状态dp[i-1]dp[i] = dp[i-1] + nums[i], 但是有可能这个dp[i-1]是个负数,此时如果加上nums[i],还不如从nums[i]开始的连续子数组的和大,所以这里动态转移方程: dp[i] = max(nums[i], dp[i-1]+nums[i])
    3. 确定初始化方式
      这里的全局初始化都是0, dp[0]=nums[0],max_sum=nums[0]
    4. 确定遍历方式
      从1开始遍历,更新dp数组
    5. 举例模拟

    代码如下:
    在这里插入图片描述
    这个题的升级版本一,找出最大子序的起始和终止位置。 上面考虑, dp[i]只和dp[i-1],nums[i]有关,所以先优化空间复杂度,只需要一个变量dp_i进行记录即可。也就是遍历的时候,如果发现dp_i>0, 那么当前状态在原来的基础上加nums[i],否则,把num[i]作为新状态,重新开始,但此时要更新起始位置。 这其实就转到了贪心的思路上。 然后判断是否大于最大值,如果大于了, 更新最终位置。代码如下:
    在这里插入图片描述
    升级版本二,就是面试题17.24: 最大子矩阵, 这个题把子序和问题升级到了二维上, 思路的话,就是先生成小矩阵,对小矩阵的每一列求和, 这样就得到了一维数组,在这个数组上求最大子序和即可。 那么怎么生成小矩阵呢? 需要一个i遍历行,控制上界, 要一个j遍历行,控制下界, 这样就出来小矩阵, 之后, k遍历列,对列求和。 这样,就转成了上面这个问题。具体可以看后面题解, 这里直接上代码:
    在这里插入图片描述
    这个题目在小红书的笔试题上见到过, 只不过当时的输入是一维数组, 需要先转成二维才能用上面的方式。这里也记录下如何将n*n的一维数组转成[n,n]的二维数组, 当时在这里也走了点弯路:

    def solve(s, n):
    	# 方法一
        matrix = [[0]*n for _ in range(n)]
        # 一维数组转成二维
        for i in range(n*n):
            matrix[i//n][i%n] = s[i]
    
    	# 方法二
        matrix = np.array(s).reshape(n,n)
        return getMaxMatrix(matrix)
    
  • LeetCode152: 乘积最大子数组:这个题和上面这个是类似的,但是乘积的一个问题就是如果前面的dp[i-1]是个非常小的负数, 而恰好当前的nums[i]也是个负数的话, 那么这俩的乘积也可能成为最大值。所以这个题要用到两个dp数组, 一个记录最大值,一个记录最小值才行。 动规五步神曲:

    1. 确定dp以及下标的含义
      这个题需要两个dp数组, 长度都是和nums列表一样长
      1. dp[i]: 表示以nums[i]结尾的列表中连续子数组的最大乘积(这个和上面子序和的类似)
      2. mindp[i]: 表示以nums[i]结尾的列表中连续子数组的最小乘积(需要记录一个最小)
    2. 确定动态转移方程
      其实这个和上面那个一样的分析法,由于是连续的,所以当前状态之和前一个状态有关系,无非就是dp记录的最大值,mindp记录的最小值, 那么这两个更新的时候怎么更新呢?
      1. dp[i]推导,这时候要么来自nums[i]本身,即前面如果是负的了,不如重新开始,要么来自dp[i-1]*nums[i],即前面是正的, 越乘越大, 要么来自mindp[i-1]*nums[i], 即前面是负的最小值,而当前nums[i]是个负数,那么会发生两个极端的反转。所以dp[i]在里面取最大, 即dp[i] = max(nums[i], dp[i-1]*nums[i], mindp[i-1]*nums[i])
      2. mindp[i]同理呀,只不过这个是存最小值,即 mindp[i] = min(nums[i], mindp[i-1]*nums[i], dp[i-1]*nums[i])
    3. 确定初始化方式
      全局初始化, 为了不影响计算,这里dp全是最小值,mindp全是最大值, 而dp[0], mindp[0]都是nums[0]
    4. 确定遍历顺序
      这个和上面一样, 从1开始遍历更新
    5. 举例模拟

    代码如下:
    在这里插入图片描述
    下面开始子序列的二维dp模式。

  • LeetCode1143: 最长公共子序列:这个要和下面那个题目区分开, 首先子数组是必须要连续的,而子序列不一定是连续。 所以这个题目,如果两个数组的当前字符不相等了,这时候的dp也不能是0,因为可能前面有相同的子序列。下面依然是动规五步神曲分析:

    1. 确定dp以及下标的含义
      这里的dp数组定义和上面这个题目一样,也是 m + 1 m+1 m+1行, n + 1 n+1 n+1列的二维数组, dp[i][j]表示的是长度为 i i i的字符串和长度为 j j j的字符串最长的公共子序列的长度。
    2. 确定动态转移方程
      这里由于是子序列,不强调连续,所以对于当前状态,要综合考虑前面所有的状态了,所以这里的dp会由前面所有的状态决定, 从二维表格上看,当前的格子, 会由左上方的所有格子决定。而上面那个题,是当前dp只左上的这条对角线上的格子决定。这就是这俩的区别。 所以推导过程如下:
      1. 如果当前的text1[i-1]==text2[j-1], 也就是当前末尾的这两个元素相等,这时候dp[i][j]=dp[i-1][j-1]+1, 这个是和前面一样的,也比较容易理解,在前面的基础上加1
      2. 如果当前的text1[i-1]!=text2[j-1], 这时候要注意,dp[i][j]并不是0了, 因为长度为 i − 1 i-1 i1的A和长度为 j j j的B有可能有公共子序列呀, 或者长度为 i i i的A和长度为 j − 1 j-1 j1的B也有可能有公共子序列。所以这时候的dp[i][j]会延续前面两个数组分别退一步的状态。而要取最大值,即dp[i][j]=max(dp[i-1][j], dp[i][j-1])这是和上面那个题目的区别
    3. 确定初始化方式
      后面这两个就和上面一样了, 全局初始化为0即可
    4. 遍历顺序
      依然是外层遍历A,内层遍历B
    5. 举例模拟

    代码如下:

    在这里插入图片描述
    这里加了一段代码,可以找到具体的某个最长子序列, 一般公司面试的时候会考这个,而不仅仅是求最大长度。具体牛客Top200高频: 最长公共子序列

  • LeetCode718: 最长重复子数组: 子数组,其实就是连续子序列, 而又涉及到了最长, 那么递归最适合。 那么两个数组里面找公共最长应该怎么去考虑呢? 这个就需要一个二维dp分别控制A数组和B数组的当前下标,依然是动规五步神曲。

    1. 确定dp数组及下标含义
      这里的dp数组需要二维了,毕竟有两个数组,必须每个维度控制一个数组下标, 大小的话, ( l e n ( A ) + 1 ) × ( l e n ( B ) + 1 ) (len(A)+1)\times (len(B)+1) (len(A)+1)×(len(B)+1),也就是两个数组的长度+1。而dp[i][j]表示的是长度为 i i i的A数组和长度为 j j j的B数组公共的长度最长的子数组的长度。
    2. 确定动态转移方程
      考虑下dp[i][j], 因为这里的子数组必须是连续的,所以只需要考虑前面的一个状态即可,也就是由dp[i-1][j-1]推导出来的
      1. 如果当前的两个元素相同, 即A[i]==B[i], 则说明当前元素可以构成公共子数组,那么当前俩数组组成的最长的公共连续子数组长度就是dp[i-1][j-1]+1,也就是在之前的基础上加上最后1位新的。
      2. 如果当前的两个元素不相同, 即A[i]!=B[i], 这说明当前的元素无法构成公共子数组,因为公共子数组是连续的,而当前这俩哥们不相等,那么此时d[i][i]=0,要从0开始重新算了。
    3. 确定初始化方式
      全局初始化的时候都初始化为0, 也就是全都默认最长子数组是0, 而由于这个会用到两个数组各自的前一个状态,那么必须把dp[0][j], dp[i][0]给初始化了,这个显然是为0的。所以一个全局初始化就可以啦
    4. 确定遍历顺序
      这个是需要两层for循环的,外层遍历A数组,内层遍历B数组,计算转移状态
    5. 举例模拟

    代码如下:

    在这里插入图片描述
    这个代码可以找到最长的重复子数组具体是哪个,因为公司真正考这个题目的时候,一般会要求找到具体的重复子数组,而不是单单找最长的长度。具体见最长公共子串

  • LeetCode1035: 不相交的线: 这个题是最长公共子序列的实际应用题目, 其实是和最长公共子序列的代码一模一样的,所以关键还是需要变通,直线不能相交,这就是说明在字符串A中 找到一个与字符串B相同的子序列,且这个子序列不能改变相对顺序,只要相对顺序不改变,链接相同数字的直线就不会相交。即本题说是求绘制的最大连线数,其实就是求两个字符串的最长公共子序列的长度!
    在这里插入图片描述

  • LeetCode392: 判断子序列: 这个其实也就是在判断字符串的最长子序列,只不过是看看最后的最长子序列长度是否正好等于s的长度,如果等于那么就一定包含s。所以和上面的公共子序列的代码基本一致。不一样的地方是动态转移方程的else要变下,因为这里是求s是否是t的序列,那么找最长子序列的时候,当前的字符不相等是,t要退一步,而s保持不变。
    在这里插入图片描述
    这里的编程小技巧,扩大一位, 也就是dp数组长度都比原先的大一个,这样的话一开始初始化全0就可以了,边界的不用刻意去初始化,而如果是和原先数组一样大的话,就得考虑边界了

  • LeetCode115: 不同的子序列: 这个题可以换一种问法, 字符串s中删除字符得到t,有多少种方法? 这样,可能更好想一些。动规五步神曲:

    1. 确定dp数组及下标含义
      dp数组依然是二维, len(s)+1行,len(t)+1列。dp[i][j]: 长度为i的s子串删除字符得到长度为j的t的子串的方法个数

    2. 确定动态转移方程
      这种情况一般都会分字符相等和不相等的情况。如果当前的字符相等,即s[i]==t[j],此时dp[i][j[会有两个方向来(s串是不是考虑当前字符):

      • dp[i-1][j-1]: 这个就是都退一步之后,长度为i-1的s子串删除字符得到长度为j-1的t的子串个数的基础上,考虑上当前字符
      • dp[i-1][j]: 这个是只有s退一步, 长度为i-1的s子串删除字符得到长度为j的子串个数,比如bagg与bag, 当前g相等了,s这块是可以考虑当前字符,也可以不用当前字符的。

      所以此时dp[i][i] = dp[i-1][j-1] + dp[i-1][j]

      如果s[i]!=t[j]: 这个没说的了,只能是维持前面的状态,s退一步dp[i][j] = s[i-1][j]

    3. 确定数组初始化方式
      当前的状态会依赖于左上角,上的状态,所以边界需要进行初始化。全局初始化都是0, 而最左边的一层s[i][0] = 1, 此时t是空串, s只有都删除才能得到t,只有一种方法

    4. 确定遍历顺序
      正向遍历s, 正向遍历t, 从(1,1)开始

    5. 举例模拟

    代码如下:
    在这里插入图片描述

    上面渐渐的有了一种感觉,就是这个dp的定义,如果涉及到两个数组或者字符串求子序列或者子串的问题时, 往往是个二维dp,且遍历顺序外层和里层都是从前往后推,也就是当前状态要么来自于上,要么来自左,要么来自左上或者三者等, 而如果是涉及到一维数组的这种,往往使用一维dp,且从左往右推, But, 下面这两个题目又颠覆了这样的一个感觉,哈哈。

  • LeetCode516: 最长回文子序列: 这个题目虽然是一个字符串, 但是会用到二维dp, 且遍历顺序和上面的也会不太一样, 下面依然是用动规五步神曲捋一下:

    1. 确定dp数组及下标含义
      这里的dp是二维数组, len(str)行,len(str)列。dp[i][j]表示的是字符串s在[i, j]范围内最长的回文子序列的长度为dp[i][j], 这个题和之前一维数组不太一样的是,这个是从中间向两边推才行,不是说从前往后了,下面动态转移方程就有感觉了。

    2. 确定动态转移方程:
      由于是判断回文子序列, 那么假设我知道了s在i+1到j-1范围内的最长回文子序列长度dp[i+1][j-1],也就是中间这段的最长回文子序列长度之后, 那么下面只剩下两种情况:

      1. 如果当前的s[i] == s[j], 这说明在中间那个长度的基础上加上这两边的新的字符就OK了,即最长的回文子序列长度:dp[i][j] = dp[i+1][j-1]+2
      2. 如果当前的s[i] != s[j],这说明i-j之间的最长回文子序列有两种方式转换来了,第一个就是把i位置的字符拼到中间那块,求最长回文子序列dp[i][j-1], 第二个就是把j位置的字符拼到中间那块,求最长回文子序列dp[i+1][j],那么取最长即可。dp[i][j] = max(dp[i][j-1], dp[i+1][j])

      这里如果不好理解,拿Carl大佬的一个图来解释下,这个在后面的题解里面:

      在这里插入图片描述

    3. 确定初始化方式
      由于这里是求最大值,所以全局初始化为0, 而局部的话,得先考虑i,j相同的时候,根据dp[i][j] = dp[i + 1][j - 1] + 2; 可以看出 递推公式是计算不到 ij相同时候的情况。所以需要手动初始化出来,也就是对角线上的元素,i和j相同,说明只有一个字符了,这时候肯定是回文子串,所以对角线初始化为1。

    4. 确定遍历顺序
      从递推公式dp[i][j] = dp[i + 1][j - 1] + 2dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]) 可以看出,dp[i][j]是依赖于dp[i + 1][j - 1]dp[i + 1][j],也就是从矩阵的角度来说,dp[i][j] 下一行的数据。 所以遍历i的时候一定要从下到上遍历,这样才能保证,下一行的数据是经过计算的即逆向遍历行,正向遍历列
      在这里插入图片描述

    5. 举例模拟

    代码如下:
    在这里插入图片描述

  • LeetCode5: 最长回文子串: 这个和上面的一个区别就是回文子串,又必须得连续了。并且这里还需要返回最长的回文子串,所以这里需要求出状态来之后,判断是否要更新,和上面不一样的,这里的前两步神曲需要变下。

    1. dp数组的长度和维度和上面一样,但是含义不一样了, 这里的dp[i][j] 表示的是区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i][j]为true,否则为false。 1和0表示也行,这个没有关系
    2. 动态转移方程就不一样了,毕竟dp的含义不一样了呀,依然是两种情况:
      1. 如果s[i] != s[j], 那没说的,当前的dp[i][j] = False
      2. 如果s[i] == s[j], 这时候还得分三种情况,因为我们不知道它中间的情况呀
        1. 下标i与j指向的是同一个下标, 也就是同一个字符,这时候肯定是True
        2. 下标i和j相差一个位置,比如aa, 这时候也是True
        3. 如果i和j相差大于1个位置的时候, 比如abca, abba,这种,dp[i][j]是不是回文子串又得看dp[i+1][j-1]了。如果这个是,那就是,如果这个不是,那就不是,所以此时d[i][j] = dp[i+1][i-1]
    3. 初始化方式,全局初始化False, 而对角线初始化为True,其实就是上面i,j相等的情况
    4. 遍历方式,和上面那题一模一样, 外层逆向,内层正向

    代码如下:
    在这里插入图片描述

  • LeetCode647: 回文子串: 上面那个题是最长回文子串,而这个是回文子串, 所以上面的代码拿下来就能搞掉,最长回文子串是保留长度最大的那个,而回文子串算的是True的个数,所以只要弄一个计数器,当遇到True了就计数,最后返回即可。 五部曲是和上面一样的:
    在这里插入图片描述

3.7 字符串问题

  • LeetCode72: 编辑距离: 字符串的转换问题,这个在日常中会有实用性,比如编辑错了一段文章,想替换成正确的最少操作数, 衡量两个DNA序列的相似性,衡量两个字符串的相似性等。我发现涉及到两个字符串的动规问题一般都是二维dp数组了, 这里也不例外,下面依然是动规五步神曲。

    1. 确定dp数组及下标含义
      dp是二维数组, len(word1)行, len(word2)列,dp[i][j]表示word1[:i]的单词与word2[:j]单词之间的最小编辑距离
    2. 确定动态转移方程
      dp[i][j]有三个方向可以推导过来,就是它的上方dp[i-1][j], 它的左方dp[i][j-1]以及它的左上dp[i-1][j-1], 下面来捋捋:
      1. 上方dp[i-1][j]: 表示的是word1[:i-1]word2[:j]的最小编辑距离, 也就是从word1[:i-1]就能到word2[:j], 此时只需要删除word1[i]这个字符就行了, 执行删除操作dp[i][j] = dp[i-1][j] + 1

      2. 左方dp[i][j-1]: 表示的是word1[:i]word2[:j-1]的最小编辑距离,也就是word1[:i]能到word2[:j-1]了, 那么此时再从word1上加入1个字符就到了word2[:j], 执行的是插入操作dp[i][j] = dp[i][j-1] + 1

      3. 左上方dp[i-1][j-1], 这个会有两种情况:

        1. 如果此时的word1[i]==word2[j], 也就是当前两个字符是相等的,那么我如果能从word1[:i-1]到了word2[:j-1]了, 那么我啥也不用做,就替换完毕了。此时啥也不做dp[i][j] = dp[i-1][j-1]
        2. 如果此时word1[i] != word2[j], 那么如果说我已经能从word1[:i-1]到了word2[:j-1],此时我只需要word1[i]替换成word2[j],就说明操作成功了。 执行替换操作dp[i][j] = dp[i-1][j-1] + 1

        所以此时的动态转移会有两种情况判断,一种是啥也不做的情况,一种是需要做点啥的情况。

        if word1[i] == word2[j]:
        	dp[i][j] = dp[i-1][j-1]
        else:
        	dp[i][j] = min(dp[i-1][j]+1, dp[i-1][j-1]+1, dp[i][j-1]+1)
        
    3. 确定初始化方式
      全局初始化都是最大值,而第一行和第一列需要初始化, 由于这个没法来自左上角,所以按照上面的规则,来自左方或者上方即可。
    4. 确定遍历顺序
      dp数组从[1,1]位置遍历,外层遍历word1, 内层遍历word2
    5. 举例模拟

    代码如下:

    在这里插入图片描述

    学到的一个编程小Tips:在字符串的前面加上空字符之后,会让初始化操作变得简单了很多, 类似于哨兵的那种原理。再就是如果涉及到两个字符串的动规问题,一般都是二维dp。这个题还有变式,就是最小编辑代价,是字节面试常考的一道题目。具体可以牛客Top200高频:最小编辑代码,思路和上面是一样的,只不过这里的插入,删除和替换操作会有相应的代价产生了。

    在这里插入图片描述

  • LeetCode583: 两个字符串的删除操作: 这个题目是上面那个题目的简化版本, 编辑距离的这个,是替换,删除,添加三种操作, 而这里其实只有两种操作,就是删除和添加,这里虽然他说的是每步删除任意一个字符串的字符,其实意思转换下就是相当于把word1变为word2需要的最小步数(每步可以添加可以删除,添加操作其实就等于word2的删除操作)。所以把上面的代码拿过来,只需要把替换操作的那部分去掉就可以搞定啦。五步神曲分析:

    1. 确定dp数组及下标含义: 和上面是一样的,二维dp, len(word1)+1行,len(word2)+1列,dp[i][j]表示的是word1[:i]word2[:j]相同所需要的最小步数。
    2. 确定动态转移方程,依然是分相等和不相等。
      • word1[i-1]==word2[j-1]: 当前字符相等,那么直接不用动,步数就是各自退一步的步数dp[i][j]=dp[i-1][j-1]
      • word1[i-1] != word2[j-1]: 当前字符不相等, 那么要么word1删除当前字符,要么word2删除当前字符, 那么最小步数就是min(dp[i-1][j], dp[i][j-1])+1
    3. 确定初始化方式, 全局初始化都是0, 而第一行和第一列,和上面是一样的
    4. 确定遍历顺序,依然是从(1,1)开始, 这个我老是忘

    代码:
    在这里插入图片描述

  • LeetCode91: 解码方法: 这个题边界条件挺多的, 还是应该好好考虑考虑的,另外还get到一个感觉就是如果是问方法总数,而不是具体什么方法的话,一般就动态规划,依然是动规五步神曲:

    1. 确定dp数组及其含义
      dp数组是len(s)长度的一维数组, dp[i]表示的是到i这个位置的字符串的解码方法,包括i这个位置

    2. 确定动态转移方程
      dp[i]的推导会取决于当前s[i]的字符情况以及前一位字符,具体如下:

      1. 如果当前的s[i]=='0': 这说明自己无法单独解码,需要跟着前面一位进行解码,但是
        这时候前面的一位必须是1或者2, 此时dp[i]=dp[i-2](下标为1处单独判断),否则此串无法解码,返回0
      2. 如果前面的s[i-1]=='1': 这说明当前的s[i]可以自己解码,也可以和前面的一块解码,所以
        解码方法是dp[i] = dp[i-1] + dp[i-2](下面为1处单独判断) dp[i-1]表示自己解码,dp[i-2]表示和前面一块解码
      3. 如果前面的s[i-1]=='2'且当前的s[i]在1-6之间: 这时候s[i]同样可以单独解码,可以和前面一块
        解码,动态方程和上面一样
      4. 除去上面这些特征情况,剩下的就是是s[i]单独解码的情况了,此时dp[i]=dp[i-1]

      所以这次的动态转移,要分好几种情况了,并且不太容易一下子想到

    3. 确定初始化方法
      由于这里状态推导里面会涉及到[i-1][i-2], 本来应该dp[0]dp[1]都初始化出来,但是初始化dp[0]好初始化为1, 而dp[1]的时候,就得上面那几种判断了,于是把这个写在了遍历里面判断,全局初始化为0。

    4. 确定遍历顺序
      从1开始正向遍历

    5. 举例模拟

    代码如下:

    在这里插入图片描述

  • 剑指offer46: 把数字翻译成字符串: 这个题是上面这个的简化版本, 0可以单独解码了,所以不用考虑0的情况。而具体思路的话和上面一样, 声明dp数组的长度和字符串等长, 然后遍历,如果当前字符能和前面字符一块翻译的话,那么就可以一块翻译或者单独翻译。 否则,只能单独翻译。
    在这里插入图片描述

  • LeetCode32: 最长有效括号: 这个题动态规划可以搞定, 依然是动规五步神曲

    1. 确定dp数组
      这里的dp是长度和nums一样长的数组, dp[i]表示以i位置字符为结尾的有效括号的最长长度

    2. 确定动态转移方程
      对于当前的dp[i],首先会有两种情况

      • dp[i] == (: 此时已经没法匹配, 直接dp[i]=0
      • dp[i] == ): 此时又可以分类讨论
        • 如果s[i-1] == (, 说明当前s[i]能和s[i-1]配对,此时的dp[i]=dp[i-2] + 2
        • 如果s[i-1] == ), 说明当前的dp[i-1]是圆满的,且以i-1位置为结尾的有效括号最大长度是dp[i-1], 那就需要看 i - dp[i-1] - 1这个位置的字符是不是能和s[i]配对,如果s[i-dp[i-1]-1] == (, 那么此时dp[i] = dp[i-1] + 2 + dp[i-dp[i-1]-2], 后面这个可能不是很好理解,其实也好理解, dp[i-1]的状态,就好比(()()(), 此时dp[i-1]=6, 而如果再来一个), 那正好是dp[i-1]+2的长度。而后面的dp[i-dp[i-1]-2]其实是之前的结果啦

          在这里插入图片描述
    3. 确定初始化方式
      这个全局初始化为0即可

    4. 遍历顺序
      从1开始遍历, 且dp[i]更新的时候, 得先保证i-1i-dp[i-1]-1有效。

    代码如下:
    在这里插入图片描述

4. 小总

动态规划这块难度还是挺大的, 上面完全是中规中矩dp,并且每一道题目严格按照动规五步神曲解题,并没有考虑空间优化啥的, 到时候感觉能做出来就不错了哈哈, 还要什么自行车? 等来回的多刷几遍把知识巩固扎实了,然后再去考虑空间优化以及各种骚操作,而现在,二刷总结状态下,我还是先以中规中矩解题为主吧。 动态规划的题目有些多, 目前先总结这么多,后面还会继续补充,还有一些,不过目前得先考虑别的事情,五月再回来。

题目汇总如下:


一般题目系列

路径规划系列

背包问题系列

打家劫舍系列

股票交易系列

子序列系列

字符串系列


参考

这里整理一个字节的高频面试题目:圆环回原点问题。

圆环上有10个点,编号为0~9。从0点出发,每次可以逆时针和顺时针走一步,问走n步回到0点共有多少种走法。

输入: 2
输出: 2
解释: 两种方案。分别是0->1->0和0->9->0

这个题也是一个动态规划的经典题目,尝试用动规五步神曲分析下。主要是这个过程还是有点难想的。

  1. 确定dp
    这个dp是一个二维数组, n+1行10列。dp[i][j]表示的是从0点出发,走i步到j点的方案数。这个定义一定要牢记好。

  2. 确定动态转移方程
    根据dp[i][j]的定义来看,其实又两个状态可以转过来:

    1. dp[i-1][j-1]:假设我知道了从0点走i-1步到了j-1的方案数, 那么从j-1走一步就到了j
    2. dp[i-1][j+1]: 假设我知道了从0点走i-1步到了j+1的方案数, 那么从j+1走一步就到了j


    那么, 走n步回到原点又是啥意思呢? 那就是求得dp[n][0], 按照上面的分析,其实是等于dp[n-1][1]+dp[n-1][9],就是走n步回到0点的走法。所以这里的动态转移方程就非常直观了:
    d p [ i ] [ j ] = d p [ i − 1 ] [ ( j − 1 + c u r _ l e n ) % c u r _ l e n ] + d p [ i − 1 ] [ ( j + 1 ) % c u r _ l e n ] dp[i][j]=dp[i-1][(j-1+cur\_len)\%cur\_len]+dp[i-1][(j+1)\%cur\_len] dp[i][j]=dp[i1][(j1+cur_len)%cur_len]+dp[i1][(j+1)%cur_len]
    这里由于是个环,所以公式里面要取余,以防越界。

  3. 确定初始化方式
    这个全局初始化为1, 而dp[0][0]=1, 也就是从0点走0步回到原点的方案数为1

  4. 确定遍历方式
    从1开始遍历每个步行,从0开始遍历每个点即可

代码如下:

def solve(cir_len, n):

    # dp定义: dp[i][j]: 从点0走i步到达点j的方案数
    dp = [[0] * cir_len for _ in range(n+1)]

    # 初始化
    dp[0][0] = 1

    # 开始遍历
    for i in range(1, n+1):
        for j in range(cir_len):

            # 动态转移方程
            dp[i][j] = dp[i-1][(j-1+cir_len) % cir_len] + dp[i-1][(j+1)%cir_len]

    return dp[n][0]


if __name__ == '__main__':

    cir_len, n = input().split(' ')
    cir_len, n = int(cir_len), int(n)
    
    print(solve(cir_len, n))

这里其实是可以任意的点,走任意步的。

  • 6
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值