适用情况
求最值,有子序列相关
模式
- dp表法
思路
- 根据题目建立dp表,明确dp[i]的含义。
- 设置 base case ,初始化 dp 数组。
- 运用数学归纳法的思想,假设 dp[0…i-1] 都已知,想办法求出 dp[i]。动态找dp[i], dp[j]的变化关系,在什么条件下满足题目要求的变化。可以把初始化的值也考虑进来
(但如果无法完成这一步,很可能就是 dp 数组的定义不够恰当,需要重新定义 dp 数组的含义;或者可能是 dp 数组存储的信息还不够,不足以推出下一步的答案,需要把 dp 数组扩大成二维数组甚至三维数组。)
def f(s1, s2):
<1.创建dp表(初始化)>
dp = [[0 for i in range(len(s2)+1)] for j in range(len(s1)+1)]
<2.循环修改dp表>
for i in range(1,len(s1)+1): # 大循环表示列
for j in range(1,len(s2)+1): # 小循环表示行
<3.如果相等>
if s1[i-1] == s2[j-1]:
dp[i][j] =
<4.如果不等>
else:
dp[i][j] = 最值()
return dp[-1][-1]
- 备忘录法 递归
思路
- 找原问题和子问题的变化量
- 根据题目确定函数的因变量
- 确定状态转移方程,子问题可以做出什么选择得到原问题
<1.定义备忘录>
memo = dict()
def f(n, arr):
<2.查备忘录:避免重复>
if n in memo: return memo[n]
<3.递归中止条件>
if n == 0: return 0
if n < 0: return -1
res = float('INF')
for i in arr:
<4.子问题无解>
if f(n - i) == -1: continue
<5.动态转移方程递归>
res = min(res, 1 + f(n - i))
<6.记入备忘录>
memo[n] = res if res != float('INF') else -1
return memo[n]
例题1 双字符二维dp表
leetcode 1143. (medium) 最长公共子序列
给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,“ace” 是 “abcde” 的子序列,但 “aec” 不是 “abcde” 的子序列。两个字符串的「公共子序列」是这两个字符串所共同拥有的子序列。
若这两个字符串没有公共子序列,则返回 0。
输入:text1 = “abcde”, text2 = “ace”
输出:3
解释:最长公共子序列是 “ace”,它的长度为 3。
解题思路:
创建dp表,注意dp表跟循环内ij的对应,大循环的索引i代表行,小循环的索引j代表列。
class Solution:
def longestCommonSubsequence(self, text1: str, text2: str) -> int:
# <1.创建dp表(初始化)> 注意dp表的对应关系
dp = [[0 for i in range(len(text2)+1) ] for j in range(len(text1)+1 )]
# <2.循环修改dp表>注意是先i行后j列
for i in range(1, len(text1)+1):
for j in range(1, len(text2)+1):
# <3.如果相等>
if text1[i-1] == text2[j-1]:
dp[i][j] = dp[i-1][j-1] + 1
# <4.如果不相等>
else:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
return dp[-1][-1]
例题二 双字符二维的dp表
leetcode 72 (hard)编辑距离
给定两个单词 word1 和 word2,计算出将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
插入一个字符;删除一个字符;替换一个字符
输入: word1 = “horse”, word2 = “ros”
输出: 3
解释:
horse -> rorse (将 ‘h’ 替换为 ‘r’)
rorse -> rose (删除 ‘r’)
rose -> ros (删除 ‘e’)
解题思路
- 列出dp表
- 格子的左、左上和上格子到本格的状态转移
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
dp = [[0 for i in range(len(word2)+1)] for j in range(len(word1)+1)]
# <1.创建dp表(初始化)>
for i in range(len(word1)+1):
dp[i][0] = i
for j in range(len(word2)+1):
dp[0][j] = j
# <2.循环修改dp表>
for i in range(1,len(word1)+1):
for j in range(1,len(word2)+1):
# <3.如果相等>则跳过
if word1[i-1] == word2[j-1]:
dp[i][j] = dp[i-1][j-1]
# <4.如果不相等>则删除、插入或替换
else:
dp[i][j] = min( # 都是相对于s2,改s1,加一表示进行一个操作
dp[i-1][j]+1, # 直接把 s[i] 这个字符删掉,前移i
dp[i][j-1]+1, # 在s[i]插入跟s[j]一样的字符,前移j
dp[i-1][j-1]+1) # 把s[i]替换成s[j],然后i、j要一起前移
return dp[-1][-1] # dp表的最后一个值就是最终编辑步数
例题三 双重一维dp表
leetcode 673 (medium)最长递增子序列的个数
给定一个未排序的整数数组,找到最长递增子序列的个数。
输入: [1,3,5,4,7]
输出: 2
解释: 有两个最长递增子序列,分别是 [1, 3, 4, 7] 和[1, 3, 5, 7]。
解题思路
- 以终为始:最长递增子序列的个数记为res,建立双重dp表,参考求最长递增子序列的方法length[i]表示对应nums[i]的子序列长度,count[i]表示对应nums[i]的子序列长度的个数
- 数学归纳法,假设 dp[0…i-1] 都已知,想办法求出 dp[i],找length(i), length(j), count[i], count[j]的变化关系。基于初始化的值,正常都是length前面的值小于等于length后面的值(初始化为1),即这个长度的递增子序列第一次出现。
如果这个i前面有两个相同的值,第二次进入这个条件时,这个i已经是做过length[i] = length[j] + 1,即有两个长度相同的子序列
- 上面求出来的count[i]只是以nums[i]结尾的最长子序列个数,要求最长递增子序列个数还要遍历找最长递增子序列(可能有多个),求count的和
class Solution:
def findNumberOfLIS(self, nums: List[int]) -> int:
if not nums: return 0
# <1.创建dp表(初始化)>
count = [1 for i in range(len(nums))] # 以x结尾的最长子序列个数
length = [1 for i in range(len(nums))] # 以x结尾的最长子序列长度
res = 0
# <3.循环修改dp表>
for i in range(len(nums)):
for j in range(i):
if nums[i] > nums[j]:
# 在初始化的length表基础上,正常都是length前面的值小于等于length后面的值(初始化为1)
if length[j] >= length[i]:
length[i] = length[j] + 1
count[i] = count[j]
# 如果这个i前面有两个相同的值,第二次进入这个条件时,这个i已经是做过length[i] = length[j] + 1,即有两个长度相同的子序列
elif length[j]+1 == length[i]:
count[i] += count[j]
maxSub = max(length)
# 是找最长递增子序列,要在length中找长度最长子序列的个数之和
for i in range(len(nums)):
if length[i] == maxSub:
res += count[i]
return res
例题五 回文系列 一维dp转为二维
leetcode 516(medium)最长回文子序列
给定一个字符串s,找到其中最长的回文子序列。可以假设s的最大长度为1000。
输入: “bbbab”
输出: 4
一个可能的最长回文子序列为 “bbbb”。
解题思路
- 根据题目建立dp表,dp[i…j]表示范围从i到j的最长回文子序列长度
为了便于找状态转移方程,将最长回文子序列的长度呈现,而且这里有i和j,将一维dp表转为二维,一维为正序s,一维为逆序s,dp[i][j]相当于dp[i…j]表示范围从i到j的最长回文子序列长度。因为i始终小于j,所以左下方的dp[i][j]都为0。
- 初始化,只有一个字符的最长回文子序列长度为1,即i == j时的dp都为1
- 数学归纳找规律:
如果s[i] == s[j] 则在上一个回文子序列长度上dp[i+1][j-1](即dp[i+1…j-1])加上两个字符的长度+2。
如果s[i] != s[j] 则把s[i]和s[j]分别加入s[i+1][j-1]中,即变为s[i][j-1]和s[i+1][j],看哪一边的回文子序列更长(max)。
- 遍历dp表时
对i要反向遍历for i in range(len(s)-1, -1, -1),
对j正向遍历并避开左下方的范围for j in range(i+1, len(s))
- 最后要求的是dp[0][len(s)-1]即dp[0…len(s)-1]的最长回文子序列
class Solution:
def longestPalindromeSubseq(self, s: str) -> int:
# <1.创建dp表(初始化)>
dp = [[0 for i in range(len(s))] for j in range(len(s))]
for i in range(len(s)):
dp[i][i] = 1
# <2.循环修改dp表>
for i in range(len(s)-1, -1, -1): # 对i要反向遍历
for j in range(i+1, len(s)): # 对j正向遍历且避开j小于i的情况
# <3.如果相等>则在上一个回文子序列长度上加上两个字符的长度
if s[j] == s[i]:
dp[i][j] = dp[i+1][j-1] + 2
# <4.如果不相等>则对比哪边的回文子序列更长
elif s[j] != s[i]:
dp[i][j] = max(dp[i][j-1], dp[i+1][j])
return dp[0][len(s)-1] # 返回dp[0...len(s)-1]的最长回文子序列
leetcode (medium) 5 最长回文子串
给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
输入: “babad”
输出: “bab”
注意: “aba” 也是一个有效答案。
解题思路
从上到下思考状态:1、如果一个字符串的头尾两个字符都不相等,那么这个字符串一定不是回文串;
2、如果一个字符串dp[i][j]的头尾两个字符相等,才有必要继续判断下去。
- 如果s[i][j]长度j-i+1<=3,则一定是回文
- 如果s[i][j]长度j-i+1>3,则无法判断,要看里面的子串是否是回文,如果里面子串是回文,就是回文,如果不是则一定不是(判断里面子串是不是回文则又回到判断里面子串长度的问题,因为循环的伸缩窗口是从小到大,从长度最小的开始判断,所以按这个逻辑进行迭代)
class Solution:
def longestPalindrome(self, s: str) -> str:
if len(s) < 2:
return s
# 以终为始,求子串时只要获取起始点
start = 0
maxlen = 0
# <1.创建dp表(初始化)>
dp = [[0 for i in range(len(s))] for j in range(len(s))]
for i in range(len(s)):
dp[i][i] = 1
# <2.循环修改dp表>
for i in range(len(s)-1,-1,-1): # 为了排除错误,还是要逆序,从少到多循环
for j in range(i,len(s)):
# <3.如果相等>
if s[i] == s[j]:
# 如果子串长度小于等于3,一定是回文
if j - i + 1 <= 3:
dp[i][j] = 1
# 否则再看子问题
else:
dp[i][j] = dp[i+1][j-1]
# <4.如果不等>情况可以忽略,因为这里只要判断是否为回文子串
# 循环比较,找最长子串
if dp[i][j] == 1:
curlen = j-i+1
if curlen > maxlen:
maxlen = curlen
start = i
# 到return的时候再截取,不用循环截取,占用内存
return s[start:start+maxlen]
练习1 leetcode 322 零钱兑换(最少硬币) medium
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1
解题思路
- 根据题目构建dp表,变化的状态是总金额amount设为i,明确dp[i]的含义,即总额为1时的最少硬币数
- base case要把dp表除了首位,其他设为amount+1,便于后续取最小值
- 状态转移方程为
贴一个分析贴 动态规划算法思想
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
# <1.创建dp表(初始化)>
dp = [amount + 1] * (amount + 1)
dp[0] = 0
# <2.循环修改dp表>
for i in range(1, amount + 1): # 大循环表示金额
for j in range(len(coins)): # 小循环表示对应的硬币面值
# 金额大于硬币面值时才有必要计算
if i >= coins[j]:
dp[i] = min(dp[i], dp[i - coins[j]] + 1) # 对比dp[i]和选择不同硬币的情况
return dp[-1] if dp[-1] != amount + 1 else -1
练习2 《剑指offer》 面试题10- II. 青蛙跳台阶问题
一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个 n 级的台阶总共有多少种跳法。
答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。
示例 1:
输入:n = 2
输出:2
解题思路
- 斐波那契数列的同类型题,区别在于
青蛙跳台阶f(0)=1, f(1)=1, f(2)=2。斐波那契数列f(0) = 0, f(1) = 1, f(2)=1
var numWays = function(n) {
if (n == 0){
return 1
}
if (n == 1 || n == 2){
return n
}
var cur = 1n,
pre = 1n
for (var i = 2n; i <= n; i++){
var sum = cur + pre
pre = cur
cur = sum
}
return sum % 1000000007n
};
语法笔记
- 在数值后面加n把Number类型转成BigInt类型,防止数值大于 2 53 − 1 2^{53} - 1 253−1 超出Number类型的最大数值
参考资料:
labuladong算法小抄