概念
动态规划,英文:Dynamic Programming,简称DP。如果当前状态可以由之前的状态推导出来,那么这个问题可以用dp解决。
五部曲
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组
背包问题
来自Carl哥的代码随想录的图片
01 背包
一般来说给你一组物品/一组数,每个都有且只有选/不选两种选择,并且每次只能取一个,那么就可以用01背包来解决。
dp含义
常见的dp[i][j]
的含义是从0-i
个数字/物品中选择,装到体积/容量/上限为j
的包里,取得的最大价值/最多种取法/最少种取法
递推公式
-
求价值最大:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
前者相当于不选下标为i
的物品的价值,后者相当于在满足背包当前容量j
的情况下,选当前物品的价值(容量固定、选了当前物品,那么肯定就得往前推到体积为j - weight[i]
的状态) -
求装满背包有几种方法(排列组合有多少种取法):
dp[i][j] = dp[i-1][j] + dp[i-1][j-nums[i]]
一共j
个数,每个数的体积为nums[i],假设我不取当前第i
个数,那么就直接继承dp[i-1][j]
,如果取了当前的数,那么我又会多出几种方法呢,需要dp[j-nums[i]]
个数。
举个例子:dp[j],j 为5,
已经有一个1(nums[i]) 的话,有 dp[4]种方法 凑成 容量为5的背包
已经有一个2(nums[i]) 的话,有 dp[3]种方法 凑成 容量为5的背包
已经有一个3(nums[i]) 的话,有 dp[2]中方法 凑成 容量为5的背包
已经有一个4(nums[i]) 的话,有 dp[1]中方法 凑成 容量为5的背包
已经有一个5 (nums[i])的话,有 dp[0]中方法 凑成 容量为5的背包
-
能否装满背包(最多装多少):
dp[i][j] = max(dp[i][j], dp[i-1][j-nums[i]]+nums[i])
-
装满背包所有物品的最小个数
dp[i][j] = min(dp[i][j], dp[i-1][j-nums[i]]+1)
扫描二维码关注公众号,回复: 16427729 查看本文章
初始化
初始化需要根据递推公式来判断,如果递推公式中出现[i-1]
的索引(通常是二维dp的写法),那么一定是从1开始,因为如果从0开始的话,dp[-1]
并不越界(python),但是它却表示最后一个值,因此可能会出问题。因此要具体情况具体分析,尽量不出现索引是-1的情况。
此外,无论是一维dp还是二维dp,都要注意dp[0]、dp[0][0]的初始化,要具体情况具体分析,不一定要解释它的具体含义,而是保证递推公式正常计算为主!
确定遍历顺序
二维dp:物品[i]
、体积[j]
都是正序遍历,并且二者可以交换顺序【排列和组合不能调换顺序】。
一维dp:物品[i]
是正序遍历,体积[j]
是【倒序】遍历(因为要保证每个东西最多取一次,如果正序遍历则前面的值已经改变了,再在这个基础上递推的话,那就相当于取了多次了),并且二者不可以交换顺序。
code实例
- 二维dp
初始化
# 初始化,先创建二维空间,然后初始化第一行
dp = [[0 for j in range(maxweight+1)] for i in range(n)]
for j in range(maxweight+1):
if j>=weight[0]: dp[0][j] = value[0]
# 先遍历物品,再遍历背包体积
for i in range(1,n): # 注意i从1开始遍历
for j in range(maxweight+1):
if j>=weight[i]:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]]+value[i])
else:
dp[i][j] = dp[i-1][j]
# 先遍历背包体积,再遍历物品
# 交换遍历顺序,不影响,因为当前值取决于左上角的值
for j in range(maxweight+1):
for i in range(1, n):
if j>=weight[i]:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]]+value[i])
else:
dp[i][j] = dp[i-1][j]
- 一维dp
# 初始化,只需要创建空间并设为0即可,
# 不需要对第一行额外初始化,这是区别于二维dp的一点
dp = [0 for j in range(maxweight+1)]
# i从【0】开始遍历了
for i in range(n):
# j是【倒序】遍历,并且只需要到weight[i]即可,小于直接继承
for j in range(maxweight,weight[i]-1,-1):
dp[j] = max(dp[j], dp[j-weight[i]]+value[i])
一维dp不可以交换物品和背包数量,因为如果背包体积在外循环,物品在内循环,那么当前体积下,后一个物品就会覆盖前一个物品,每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品
完全背包
与01背包的区别
- 与01背包不同的是:每个物品可以取无数次
- 区别只在于遍历顺序:(1)前面提到,01背包在一维dp时倒序遍历j,是为了防止利用已经更新的值再更新,这样就相当于重复取了,因此要倒序遍历;(2)此外01背包在一维dp时不能交换遍历顺序,只能先遍历物品,再遍历背包体积,而完全背包可以交换二者顺序。
code实例
- 一维dp
# 先遍历物品,再遍历背包体积
for i in range(n):
# 和01背包遍历的不同,这里是正序遍历,因为可以取无数个物品
for j in range(weight[i],maxweight+1):
dp[j] = max(dp[j], dp[j-weight[i]]+value[i])
# 先遍历背包体积,再遍历物品
# 注意区分和上面的j遍历,这个必须从0开始遍历,因为要遍历所有的体积
for j in range(maxweight+1):
for i in range(n):
if j>=weight[i]:
dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
- 二维dp
比较麻烦。实际和一维dp一样。
# 初始化,创建dp空间并初始化第一行
dp = [[0 for j in range(maxweight + 1)] for i in range(n)]
for j in range(maxweight + 1):
if j >= weight[0]:
dp[0][j] = dp[0][j-weight[0]] + value[0]
for i in range(1, n):
for j in range(1, maxweight + 1):
if j >= weight[i]:
# 这里改成dp[i][j - weight[i]] + value[i],只要每次取前面更新过的值就行
dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i]] + value[i])
else:
dp[i][j] = dp[i - 1][j]
背包中的排列与组合问题
- 初始化dp数组全为0,dp[0]须为1
dp[j] += dp[j-weight[i]]
求解排列组合的递推公式,在前面01背包的地方提到了
装满背包有几种方法?
# 先遍历物品,再遍历背包体积 =》 求【组合数】
for i in range(n):
# 注意区分和二维dp的遍历,以及和01背包遍历的不同,这里是正序遍历,因为可以取无数个物品
for j in range(weight[i],maxweight+1):
dp[j] += dp[j-weight[i]]
# 先遍历背包体积,再遍历物品 =》 求【排列数】
for j in range(maxweight+1): # 注意区分和上面的j遍历,这个必须从0开始遍历
for i in range(n):
if j>=weight[i]:
dp[j] += dp[j-weight[i]]
排列可以重复,组合不可以重复,比如{1,2}和{2,1}算作两个排列,但是是同一个组合。
先遍历物品,后遍历背包体积时,1后面可以出现2,而2后面不能出现1,所以只会有{1,2}这种情况,不会出现{2,1},适用于组合问题
。
同理,先遍历背包体积时,后遍历物品时,根据遍历顺序,2后面可以出现1的情况,因此{1,2}和{2,1}都能出现。适用于排列问题
。
上面两幅图标明了遍历顺序,可以很明显看出两种遍历方式的区别。
多重背包
每种物品不能无限取,最多取Ci件,可以看作变种01背包问题,只需要将Ci件i号物品全部展开,看作Ci种物品。
打家劫舍问题
问题描述
给一个数组相邻之间不能连着偷,如何偷才能得到最大金钱
变种:
- 房子之间首位相连,那么就分两种情况:从第一个开始,忽略最后一个,即nums[:len(nums)];忽略第一个,直到最后一个,即nums[1:]。
- 房子以二叉树的方式遍历:那么就考虑两种情况:偷当前节点,跳过左右节点;不偷当前节点,偷左右节点,并取左右节点偷到的最大值作为当前节点的dp值,这个逻辑就是
后续遍历
dp数组含义
dp[i] 表示下标0-i(包括i)的房屋能取得的最大值
递推公式
因为不一定非要隔一个房间偷一次,我们只要保证值最大、且相邻房间不同时偷就可以。递推公式如下:
dp[i] = max(dp[i-1], dp[i-2]+nums[i])
有两种选择:
- 当前节点不偷,那么就保持上一个节点的值
dp[i-1]
,但上一个节点偷或不偷,我们完全不用在意【假设上个 节点偷了,那么当前节点不偷,符合规则;假设上个节点不偷,那么当前节点可以不偷,也符合规则】; - 当前节点偷,那么就得忽略上一个节点,从
i-2
这个节点考虑,dp[i-2]+nums[i]
。
这个递推公式可以随着题目变种而改变,比如某团笔试题分糖问题规定:取了当前节点后,前后四个节点都不能再取。那么就相当于必须相隔2个间隙,所以如果当前节点取了,只需要从i-3
这个节点考虑就可以了,即dp[i-3]+nums[i]
。
初始化
根据递推公式,需要知道i-1
,i-2
的dp值,因此必须给出dp[0]
和dp[1]
的值。
dp[0]
不必说,肯定是取0号房间的值才能最大。
dp[1]
表示0-1房间能偷到的最大值,那么肯定是从房间0和1中取最大值了。
遍历顺序
根据递推公式,i至少从2开始遍历才不会越界,并且是正向遍历。
举例
code实例
n = len(nums)
dp = [0] * n
if n<3:
return max(nums)
dp[0], dp[1] = nums[0], max(nums[0], nums[1])
for i in range(2,n):
dp[i] = max(dp[i-1], dp[i-2]+nums[i])
股票问题
推荐阅读【美国站老哥题解】及【国内翻译】
Most consistent ways of dealing with the series of stock problems
股票问题系列通解(转载翻译)
通用问题描述
给定一个整数数组 prices
,它的第i
个元素 prices[i]
是一支给定的股票在第i
天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成k
笔交易。
【注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。】
根据k的不同,可以将问题分为以下几种:
- k=1,即最多只能交易一次,可以看作最多只能卖一次。121. 买卖股票的最佳时机
- k=2,同上。123. 买卖股票的最佳时机 III
- k=∞,可以交易任意次,前一天买完第二天就能卖122. 买卖股票的最佳时机 II
- k∀N,最多交易指定次数。188. 买卖股票的最佳时机 IV
此外,还有一些变种: - 每笔交易存在
m
天冷冻期,即当天出售股票后,需要间隔m
天才能购买。这个实际上与上面的打家劫舍类似。309. 最佳买卖股票时机含冷冻期 - 每笔交易需要花费
fee
的手续费,既可以看作买入时缴纳,也可以看作卖出时缴纳。714. 买卖股票的最佳时机含手续费
dp含义
首先,我们定义两种状态:持有股票
和不持有股票
。
持有股票
又包含两种状态:第i
天买入股票->第i
天持有股票;第i天没买股票,但i-1
天持有股票->第i
天持有股票。不持有股票
也包含两种状态:第i
天卖出股票->第i
天不持有股票;第i天啥也没干,但i-1
天就不持有股票->第i
天不持有股票。
我们以k∀N
这个一般情况为例,
dp[j][i]
表示第i
天,持有/不持有股票的状态下持有的现金
其中j
为奇数时表示持有股票,j
为偶数时表示不持有股票。
一个k对应两个j,即j=1表示第一次交易持有股票,j=2表示第一次交易不持有股票(之前一定买入股票,这次卖掉)。
同理,j=3表示第二次交易持有股票(之前一定买入并卖出了一次股票,这次新买入一只股票),j=4表示第二次交易不持有股票(之前卖出了一次股票又买入了新的股票,这次卖掉了)。
j=0
是一个特殊状态,表示既不买入也不卖出,所以初始化为0,这个主要是为了后面方便递推,以及写成统一形式。
递推公式
根据前面的介绍,可以写出下面的递推公式
for i in range(1,n):
for j in range(1,k+1):
# 第k次持有股票状态下第i天的现金 dp[2*j-1][i]
# 昨天持有股票,今天不买新的(昨天有啥今天就有啥)dp[2*j-1][i-1]
# 昨天不持有股票,今天买新的 dp[2*j-2][i-1]-prices[i]
dp[2*j-1][i] = max(dp[2*j-1][i-1], dp[2*j-2][i-1]-prices[i])
# 第k次不持有股票状态下第i天的现金 dp[2*j][i]
# 今天没得卖(昨天就没有股票)dp[2*j][i-1]
# 今天把昨天持有股票的卖了dp[2*j-1][i-1]+prices[i]
dp[2*j][i] = max(dp[2*j][i-1], dp[2*j-1][i-1]+prices[i])
初始化
根据递推公式,需要用到dp[0][:]
以及dp[2*j-1][0]
的值,因此要先定义好,dp[0][:]
根据定义全部设为0,表示不买入也不卖出的现金为0。dp[2*j-1][0]
表示头天持有股票的现金(无论第几次买入),全部初始化为当天的股票价格的负数,因为买入股票花钱了嘛。
dp = [[0 for _ in range(n)]for _ in range(2*k+1)]
for j in range(1,k+1):
dp[2*j-1][0] = -prices[0]
遍历顺序
根据递推公式,外层循环按照天数遍历,内层循环按照交易次数遍历,全部正序遍历。
其他变种
- k=1,不需要遍历天数了,此外由于最多买入一次,所以购买股票当天现金一定为
0-prices[i]
,因为前一天不可能有利润。
for i in range(1,n):
dp = [[0 for _ in range(n)]for _ in range(2)]
# 0-当天持有股票
# 1-当天不持有股票
# 今天不买新的,保持昨天的股票 今天买新的股票
dp[0][i] = max(dp[0][i-1], -prices[i])
# 今天啥也不干,保持昨天没有股票的状态 把昨天持有的股票卖了
dp[1][i] = max(dp[1][i-1], dp[0][i-1] + prices[i])
- k=∞,不需要遍历天数,但是在购买时需要考虑到昨天的利润
dp[i-1][1]
,因此现金为dp[i-1][1]-prices[i]
。【当k为任意值时,且k>n//2,那么股票交易次数不影响dp值,该问题变为股票交易次数无限次的情况】
dp = [[0 for _ in range(n)]for _ in range(2)]
# 0-当天持有股票
# 1-当天不持有股票
dp[0][0] = -prices[0]
dp[1][0] = 0
for i in range(1,n):
# 今天不买新的,保持昨天的股票 今天买新的股票(用昨天的现金【需要考虑到昨天可能有利润】)
dp[0][i] = max(dp[0][i-1], dp[1][i-1]-prices[i])
# 今天啥也不干,保持昨天没有股票的状态 把昨天持有的股票卖了
dp[1][i] = max(dp[1][i-1], dp[0][i-1] + prices[i])
- 考虑冷冻期的话可以按照打家劫舍的做法,假如当天买股票的话,则前一天就不能卖股票了,就得从
i-2
天开始考虑。即将dp[1][i-1]-prices[i]
改为dp[1][i-2]-prices[i]
dp = [[ 0 for _ in range(n)] for _ in range(2)]
# 0-当天持有股票
# 1-当天不持有股票
dp[0][0] = -prices[0]
for i in range(1,n):
# 今天不买新的,保持昨天的股票 今天买新的股票(需要从前天开始考虑)
dp[0][i] = max(dp[0][i-1], dp[1][i-2]-prices[i])
# 今天啥也不干,保持昨天没有股票的状态 把昨天持有的股票卖了
dp[1][i] = max(dp[1][i-1], dp[0][i-1]+prices[i])
- 交易需要缴纳手续费
fee
,既可以看作在买入时缴纳,也可以看作在卖出时缴纳,任选一次扣除就行。
这个题用贪心做不太好理解,可以看我的题解714. 买卖股票的最佳时机含手续费【贪心】【换个思路理解问题】
dp = [[0 for j in range(n)]for i in range(2)]
dp[0][0] = -prices[0]-fee
for i in range(1,n):
dp[0][i] = max(dp[0][i-1], dp[1][i-1]-prices[i]-fee)
dp[1][i] = max(dp[1][i-1], dp[0][i-1]+prices[i])
子序列问题
首先需要明确,子序列与连续子序列的区别:子序列包括连续子序列。
子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。关键词:不改变顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
连续子序列条件更为苛刻,该子序列必须是原数组中连续的部分。
根据问题不同,dp和递推公式也都不同
最长递增子序列
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
dp含义
dp[i]
表示以nums[i]
为结尾的子序列的最大长度。
递推公式
因为dp[i]
记录了以nums[i]
为结尾的子序列的最大长度,那么只要当前数nums[i]
比之前的nums[j]
大,就可以把当前数作为结尾加入到之前的子序列中,此时长度在之前的长度dp[j]
上+1,所以递推公式如下:
dp[i] = max(dp[i], dp[j]+1)
该公式表示在之前所有子序列+1中取最大的那个。
初始化
因为每一个数都可以看作一个递增子序列,所以最短都是1,dp[i]都初始化为1
遍历顺序
根据递推公式可知,需要双重循环,外层循环遍历结尾的数,内层循环遍历之前的序列。都是正向遍历
递推实例
nums = [10, 9, 2, 5, 3, 7, 101, 18]
dp = [1, 1, 1, 2, 2, 3, 4, 4]
code实例
n = len(nums)
dp = [1] * n
for i in range(1,n):
for j in range(0,i):
if nums[i]>nums[j]:
dp[i] = max(dp[i], dp[j]+1)
最长连续递增子序列
与上面的区在于需要保证连续,因此递推公式也更简单,只需要比较num[i-1]
与nums[i]
的关系即可,如果nums[i]
大,那么直接在dp[i-1]
长度上+1。
code实例
n = len(nums)
dp = [1] * n
for i in range(1,n):
if nums[i]>nums[i-1]:
dp[i] = dp[i-1]+1
最长重复子数组(连续子序列)
给两个整数数组 A 和 B ,返回两个数组中公共的、长度最长的子数组的长度。
dp含义
dp[i][j]
: 以nums[i-1
]和nums[j-1]
结尾的连续子序列的最大长度
听起来很抽象,其实是为了初始化方便这么设计的,实际上也可以用以nums[i
]和nums[j]
结尾的连续子序列的最大长度。可以看下面的递推图,因为在前面多加了一行和一列,所以dp[i][j]的
下标i和j相比于原始数组偏移了一位,所以在这里要-1。
如果不增加一行和一列,也可以,看下面这个图,实际上是一样的,但是需要对第一行和第一列额外初始化,所以方便起见采用上面那种方法。
递推公式
如果nums[i-1
]和nums[j-1]
相等,说明长度至少为1,然后两个序列分别往前看一位,即dp[i-1][j-1]
,如果以他们为结尾的也相等(表现为dp[i-1][j-1]
>0),那么再分别往后加一位也满足重复子数组;如果不相等(表现为dp[i-1][j-1]
=0),那么dp[i][j]
就是1了。综上所述可以写成统一的形式:
if nums1[i-1]==nums2[j-1]: dp[i][j] = dp[i-1][j-1] + 1
初始化
按照这种方式,所有位置初始都填0就可以,(否则要先求出来第一列和第一行的dp值,即相等就赋值1)
遍历顺序
根据递推公式,从左往右,从上往下遍历即可。两个for循环的遍历顺序无所谓。
code实例
n1 = len(nums1)
n2 = len(nums2)
res = 0
dp = [[0 for j in range(n2+1)]for i in range(n1+1)]
for i in range(1,n1+1):
for j in range(1,n2+1):
if nums1[i-1]==nums2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
res = max(dp[i][j],res)
最长公共子序列
给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。
与最长重复子数组(连续子序列)相比,缺少了连续的约束,因此计算时麻烦一些。
初始化和遍历顺序都一样,主要是dp含义和递推公式不同。
在前面最长递增子序列问题中我们介绍了,最长序列要以nums[i]
为结尾,遍历之前的dp值取最大值并+1。(实际上最长递增子序列问题dp也可以写成 二维dp[i][j]
的形式,只是我们把它压缩成了一维。)
在这里我们依然可以用这个方法。
如下图所示(ps:在carl哥例图上改的QAQ)
dp含义
dp[i][j]
: 表示nums1[0:i]
(实际上是0->i-1)和nums2[0:j]
(0->j-1)的最长公共子序列
与最长相同连续子序列的区别在于不一定需要以nums1[i-1]
或nums2[j-1]
结尾了,它可以继承之前的结果。比如[1,2,3,2,1]与[3,4,2,1]的最长公共子序列是[3,2,1],长度是3;[1,2,3,2,1]与[3,4,2,1,4,7]的最长公共子序列还是[3,2,1],长度是3。但是最长相同连续子序列不可以继承,可以结合上面的图理解一下。
递推公式
相等的情况就不说了,当nums1[i-1]
和nums2[j-1]
不等时,就要继承之前的值,看看nums1[0, i - 2]
与nums2[0, j - 1]
的最长公共子序列 和 nums1[0, i - 1]
与nums[0, j - 2]
的最长公共子序列,取最大的,体现在dp上就是dp[i][j-1]
, dp[i-1][j]
。
if nums1[i-1]==nums2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
else:
dp[i][j] = max(dp[i][j-1], dp[i-1][j])
code实例
n1 = len(text1)
n2 = len(text2)
res = 0
dp = [[0 for j in range(n2+1)]for _ in range(n1+1)]
for i in range(1,n1+1):
for j in range(1,n2+1):
if text1[i-1] == text2[j-1]:
dp[i][j] = dp[i-1][j-1]+1
else:
dp[i][j] = max(dp[i][j-1], dp[i-1][j])
return dp[-1][-1]
相关题目(我要打三个)
1143. 最长公共子序列
1035. 不相交的线
392.判断子序列
115. 不同的子序列
注意有些题目区分了子串和主串,此时递推公式就不能考虑去掉子串的情况了,即dp[i-1][j]
,其中i是子串下标,j是主串下标。
583. 两个字符串的删除操作
编辑距离问题
上面的两个字符串的删除操作题目也可以看作编辑距离的问题,即给定任意两个字符串,找到使得 word1 和 word2 相同所需的最小步数,每步可以删除任意一个字符串中的一个字符。
dp含义
dp[i][j]
: 以word1[i-1]
与word2[j-1]
结尾的子串变得相等,需要删除字符的最少次数
可以看到,涉及到连续子序列的问题都增加了以某个字母结尾的约束,而子序列则没有这一限定,因此在递推时可以“继承”上一步的dp值。
递推公式
分为两种情况:
word1[i-1]==word2[j-1]
,如果两个子串结尾字符相同,那么当前俩字母肯定就不管了,就往前回退一位,看看他们一不一样,即dp[i][j] = dp[i-1][j-1]
word1[i-1]!=word2[j-1]
,如果两个子串结尾字符不同,那么就有三种情况:(1)各自回退一位,把俩字母都删掉,即dp[i][j] = dp[i-1][j-1] + 2
;(2)word1回退一位,删掉word1[i-1]这个字符,即dp[i][j] = dp[i-1][j] + 1
;(3)word2回退一位,删掉word2[j-1]这个字符,即dp[i][j] = dp[i][j-1] + 1
。从三种情况中取最小值min(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+2)
。
另一种简化的方式:min(dp[i-1][j]+1, dp[i][j-1]+1)
,Carl哥说:同时删word1[i - 1]和word2[j - 1],dp[i][j-1] 本来就不考虑 word2[j - 1]了,那么我在删 word1[i - 1],是不是就达到两个元素都删除的效果,即 dp[i][j-1] + 1
初始化
第一行和第一列,比如第一行,表示空子串与另一个子串需要删除的步骤,只需要把另一个子串全部删掉就等于空集啦!所以很自然dp[0][j] = j
,同样dp[i][0] = i
遍历顺序
根据递推公式,应该是从左往右,从上往下递推。
递推举例
code实例
n1 = len(word2)
n2 = len(word1)
# dp[i][j]表示让word1[0:j]与word[0:i]相同最少需要删除的字符数
dp = [[0 for _ in range(n2+1)]for _ in range(n1+1)]
for i in range(1,n1+1):
dp[i][0] = i
for j in range(1,n2+1):
dp[0][j] = j
for i in range(1,n1+1):
for j in range(1, n2+1):
# 如果相同就不用删字符,直接继承上一个字母
if word2[i-1]==word1[j-1]:
dp[i][j] = dp[i-1][j-1]
else:
# 如果不同,那么看删除word2的i-1的字符还是删除word1的j-1的字符需要的步骤更少
dp[i][j] = min(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+2)
变种
72. 编辑距离
给你两个单词 word1 和 word2, 请返回将 word1 转换成 word2 所使用的最少操作数。
你可以对一个单词进行如下三种操作:插入一个字符,删除一个字符,替换一个字符。
与上面不同的是,可以替换字符了,相当于原来dp[i-1][j-1]+2
变成了dp[i-1][j-1]+1
,本来要删除俩字符,现在只需要一步替换操作就可以了。所以只需要改一下递推公式就可以轻松解决这道困难题了。
回文子串问题
dp含义
dp[i][j]
表示区间范围[i,j]
(注意是左闭右闭)的子串是否是回文子串,如果是dp[i][j]为true,否则为false
递推公式
主要有两种情况:
s[i]==s[j]
,如果i==j,显然是回文串,比如字母‘a’;如果i和j相差1,那显然也是,比如字母‘aa’;其他情况需要掐头去尾看中间是不是回文串,即dp[i][j]=dp[i+1][j-1]
,比如‘bb’是回文串,‘abba’就是回文串,‘bc’不是回文串,‘abbc’就不是回文串。s[i]!=s[j]
,显然s[i->j]不是回文串。
初始化
全部初始化为False
遍历顺序
根据递推公式dp[i][j]=dp[i+1][j-1]
,i
要从后往前遍历,j要从i开始从前往后遍历(因为j表示子串尾)。
code实例
n = len(s)
res = 0
dp = [[0 for j in range(n)]for i in range(n)]
for j in range(n):
for i in range(j+1):
if s[i]==s[j]:
if j==i or j-i == 1:
dp[i][j] = 1
else:
dp[i][j] = dp[i+1][j-1]
res += dp[i][j]
回文子序列
与回文子串不同,没有必须连续
的约束。
主要是dp含义和递推公式不同
dp含义
dp[i][j]
:字符串s在[i, j](左闭右闭)范围内最长的回文子序列的长度。
递推公式
主要有两种情况:
s[i]==s[j]
,说明头尾加上这俩字符,最长回文子序列长度可以再加2。dp[i][j]=dp[i+1][j-1]+2
。s[i]!=s[j]
,显然同时增加这俩字符并不能增加回文子序列的长度,那么就考虑两种情况,只加头s[i],或者只加尾s[j],并取二者最大值。
递推例子
code实例
n = len(s)
dp = [[0 for j in range(n)]for i in range(n)]
for i in range(n):
dp[i][i] = 1
for i in range(n-2,-1,-1):
for j in range(i+1,n):
if s[i]==s[j]:
# 如果s[i]==s[j],那么最长回文子序列长度就是中间的长度+2
# 即使中间的不是回文串,那么最小也是1,
# 这样加上左右两边相等的字符,构成的长度为3的字符串仍然是回文串
dp[i][j] = dp[i+1][j-1] + 2
else:
# 如果s[i]!=s[j],肯定是没办法同时加入最长回文序列的
# 那么就能在一边加字符,看看加哪边构成的串更长
# dp[i+1][j] 表示在[i+1][j-1]基础上在尾部添加新的字符s[j]构成的最长回文序列
# dp[i][j-1]表示在[i+1][j-1]基础上在头部添加新的字符s[i]构成的最长回文序列
dp[i][j] = max(dp[i+1][j], dp[i][j-1])
return dp[0][-1]