动态规划(DP)的重要性我就不用说了,
LeetCode
上DP问题多达228道,仅次于数组301题。
个人感觉,DP问题就像斐波那契数列一样,你需要找到能够递归的通式子,我们把这个式子称作状态转移方程( t r a n s f e r e q u t i o n transfer\ eqution transfer eqution)。本文采取题目加讲解的方式,中等题目强调找出状态转移方程,难题则附加代码研究细节,此篇可作为你刷DP类问题的指南。
然后,现在我们干一件事情,把DP题目罗列出来,找到共同点,未来我们要做到看一眼题目就知道用什么方法。
-
爬楼梯 (Easy)
-
使用最小花费爬楼梯 (Easy)
-
打家劫舍(Easy)
-
最小路径和(Medium)
-
不同路径(Medium)
-
不同路径2(Medium)
-
不同的搜索二叉树(Medium)
-
最大正方形(medium)
-
最大字序和(Easy)
-
单词拆分(Medium)
-
乘积最大子数组(Medium)
-
完全平方数(Medium)
-
分割数组的最大和(Hard)
-
正则表达式匹配(Hard)
-
通配符匹配(Hard)
-
编辑距离(Hard)
-
移除盒子(Hard)
配合Huahua’s problem set. 食用更佳!
下面结合实例分析
70. 爬楼梯
非常经典!
用
d
p
[
i
]
dp[i]
dp[i]表示爬上第i个梯子的方法数。那么
状态转移方程
d
p
[
i
]
=
d
p
[
i
−
1
]
+
d
p
[
i
−
2
]
;
dp[i] = dp[i-1] + dp[i-2];
dp[i]=dp[i−1]+dp[i−2];
边界条件: d p [ 0 ] = 1 , d p [ 1 ] = 1 dp[0] = 1, dp[1]=1 dp[0]=1,dp[1]=1
746. 使用最小花费爬楼梯
用
d
p
[
i
]
dp[i]
dp[i]表示爬上第i个梯子的最小
消耗。那么
状态转移方程
d
p
[
i
]
=
m
i
n
d
p
[
i
−
1
]
+
c
o
s
t
[
i
−
1
]
,
d
p
[
i
−
2
]
+
c
o
s
t
[
i
−
1
]
;
dp[i] = min{dp[i-1] + cost[i-1], dp[i-2] + cost[i-1]};
dp[i]=mindp[i−1]+cost[i−1],dp[i−2]+cost[i−1];
边界条件: d p [ 0 ] = 0 , d p [ 1 ] = 0 dp[0] = 0, dp[1]=0 dp[0]=0,dp[1]=0
总结,这两题能这么做是因为,它们相邻两项的间距是恒定的要么为1,要么为2.
198. 打家劫舍
老dp了,还贪讷
一眼看上去以为是跳跃游戏类似的贪心算法,没想到是老dp换了层皮。
以
d
p
[
i
]
dp[i]
dp[i]表示前ii个元素中最大金额。
我们这样想,第
i
−
1
i-1
i−1个元素
n
u
m
s
[
i
−
1
]
nums[i-1]
nums[i−1]是否取到取决于前面一个元素是否取,如果前一个元素不取就是
d
p
[
i
−
2
]
+
n
u
m
s
[
i
−
1
]
dp[i-2]+nums[i-1]
dp[i−2]+nums[i−1],如果前一个元素取到就是
d
p
[
i
−
1
]
dp[i-1]
dp[i−1]。
边值条件 d p [ 0 ] = 0 , d p [ 1 ] = n u m s [ 0 ] dp[0]=0,dp[1]=nums[0] dp[0]=0,dp[1]=nums[0],注意下标对应关系。
再看跟路径有关的问题
64. 最小路径和
用
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j]表示从原点到达(m,n)的最小
路径和。那么
状态转移方程
d
p
[
i
]
[
j
]
=
m
i
n
(
d
p
[
i
]
[
j
−
1
]
,
d
p
[
i
−
1
]
[
j
]
)
+
g
r
i
d
[
i
]
[
j
]
;
dp[i][j] = min(dp[i][j-1], dp[i-1][j])+grid[i][j];
dp[i][j]=min(dp[i][j−1],dp[i−1][j])+grid[i][j];
边界条件:
d
p
[
0
]
[
i
]
=
d
p
[
0
]
[
i
−
1
]
+
g
r
i
d
[
0
]
[
i
]
(
0
≤
i
≤
m
)
,
d
p
[
j
]
[
0
]
=
d
p
[
j
−
1
]
[
0
]
+
g
r
i
d
[
j
]
[
0
]
(
0
≤
j
≤
n
)
dp[0][i] = dp[0][i-1] + grid[0][i](0 \le{i}\le{m}), \\ dp[j][0] = dp[j-1][0] + grid[j][0](0 \le{j}\le{n})
dp[0][i]=dp[0][i−1]+grid[0][i](0≤i≤m),dp[j][0]=dp[j−1][0]+grid[j][0](0≤j≤n)
这题很特殊的地方是,先求完边界条件才能进行DP操作。
62. 不同路径
这题和上一题的区别是,不用管路径开销,这样的话我们可以把路径都设为1。
用
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j]表示从原点到达(m,n)的不同
路径数目。那么
状态转移方程
d
p
[
i
]
[
j
]
=
d
p
[
i
]
[
j
−
1
]
+
d
p
[
i
−
1
]
[
j
]
;
dp[i][j] = dp[i][j-1]+ dp[i-1][j];
dp[i][j]=dp[i][j−1]+dp[i−1][j];
边界条件:
d
p
[
0
]
[
i
]
=
1
(
0
≤
i
≤
m
)
,
d
p
[
j
]
[
0
]
=
1
(
0
≤
j
≤
n
)
dp[0][i] = 1(0 \le{i}\le{m}), \\ dp[j][0] = 1(0 \le{j}\le{n})
dp[0][i]=1(0≤i≤m),dp[j][0]=1(0≤j≤n)
63. 不同路径2
用
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j]表示从原点
到
(
i
,
j
)
(i,j)
(i,j)的路径总数,只不过这题玩了一点花样,加入障碍物,和62
异曲同工。上一题我们把边界,包括上边界和左边界,都设为1。这一题,我们想如果遇到障碍物在
(
i
,
j
)
(i,j)
(i,j),那么肯定
d
p
[
i
]
[
j
]
=
0
dp[i][j]=0
dp[i][j]=0对吧?然后对于边界,一旦
d
p
[
0
]
[
j
]
=
=
0
或
d
p
[
i
]
[
0
]
=
=
0
dp[0][j]==0或dp[i][0]==0
dp[0][j]==0或dp[i][0]==0表明,之后的全到不了,因为上左边界分别只有一条路径。
d p [ i ] [ j ] = { d p [ i − 1 ] [ j ] + d p [ i ] [ j − 1 ] , o b s t a c l e G r i d [ i ] [ j ] ! = 1 0 , o b s t a c l e G r i d [ i ] [ j ] = = 1 dp[i][j]=\left\{ \begin{array}{lcl} dp[i-1][j]+dp[i][j-1], & & {obstacleGrid[i][j]!=1}\\ 0, & & {obstacleGrid[i][j]==1}\\ \end{array} \right. dp[i][j]={dp[i−1][j]+dp[i][j−1],0,obstacleGrid[i][j]!=1obstacleGrid[i][j]==1
边界
d p [ 0 ] [ j ] = { 0 , d p [ 0 ] [ j − 1 ] = = 0 ∣ ∣ o b s t a c l e G r i d [ 0 ] [ j ] = = 0 1 , d p [ 0 ] [ j − 1 ] ! = 0 , d p [ i ] [ 0 ] = { 0 , d p [ i − 1 ] [ 0 ] = = 0 ∣ ∣ o b s t a c l e G r i d [ i ] [ 0 ] = = 0 1 , d p [ i − 1 ] [ 0 ] ! = 0 dp[0][j] =\left\{ \begin{array}{lcr} 0, {dp[0][j-1]==0||obstacleGrid[0][j]==0}\\ 1, {dp[0][j-1]!=0}\\ \end{array} \right.\\ , \\ dp[i][0] =\left\{ \begin{array}{lcr} 0, {dp[i-1][0]==0||obstacleGrid[i][0]==0}\\ 1, {dp[i-1][0]!=0}\\ \end{array} \right. dp[0][j]={0,dp[0][j−1]==0∣∣obstacleGrid[0][j]==01,dp[0][j−1]!=0,dp[i][0]={0,dp[i−1][0]==0∣∣obstacleGrid[i][0]==01,dp[i−1][0]!=0
总结,因为路径问题只能向下或向右走和爬楼梯的只能走一步或者两步都是异曲同工的,把状态转移方程和边界条件想出来有助于快速解决问题
此外,涉及到求规律的问题,一般先列出几项再使用dp
96. 不同的搜索二叉树
给定一个整数 n,求以 1 … n 为节点组成的二叉搜索树有多少种?
示例:
输入: 3
输出: 5
解释:
给定 n = 3, 一共有 5 种不同结构的二叉搜索树:
1 3 3 2 1
\ / / / \ \
3 2 1 1 3 2
/ / \ \
2 1 2 3
f [ i ] = { 2 × ( f [ i − 1 ] + ⋅ ⋅ ⋅ + f [ ( i − 1 ) / 2 ] ) + f [ i / 2 ] 2 , i 为 奇 数 2 × ( f [ i − 1 ] + ⋅ ⋅ ⋅ + f [ ( i − 1 ) / 2 ] ) , i 为 偶 数 f[i] = \begin{cases} 2×(f[i-1]+···+f[(i-1)/2])+f[i/2]^2,i为奇数\\ 2×(f[i-1]+···+f[(i-1)/2]),i为偶数\\ \end{cases} f[i]={2×(f[i−1]+⋅⋅⋅+f[(i−1)/2])+f[i/2]2,i为奇数2×(f[i−1]+⋅⋅⋅+f[(i−1)/2]),i为偶数
事实上我们在方法一中推导出的 G ( n ) G(n) G(n)函数的值在数学上被称为卡塔兰数 $C_n $
。卡塔兰数更便于计算的定义如下: C 0 = 1 , C n + 1 = 2 ( 2 n + 1 ) n + 2 C n C_0 = 1, \qquad C_{n+1} = \frac{2(2n+1)}{n+2}C_n C0=1,Cn+1=n+22(2n+1)Cn,证明过程可以参考上述文献,此处不再赘述。
下面我们看一些富有技巧而实际很简单 的dp问题
221. 最大正方形
在一个由 0 和 1 组成的二维矩阵内,找到只包含 1 的最大正方形,并返回其面积。
示例:
输入:
1 0 1 0 0
1 0 1 1 1
1 1 1 1 1
1 0 0 1 0
输出: 4
可以使用动态规划降低时间复杂度。我们用 d p ( i , j ) dp(i, j) dp(i,j) 表示以 ( i , j ) (i, j) (i,j) 为右下角,且只包含 1 的正方形的边长最大值。如果我们能计算出所有 d p ( i , j ) dp(i, j) dp(i,j)的值,那么其中的最大值即为矩阵中只包含 1 的正方形的边长最大值,其平方即为最大正方形的面积。
那么如何计算 dp 中的每个元素值呢?对于每个位置 ( i , j ) (i, j) (i,j),检查在矩阵中该位置的值:
如果该位置的值是 0,则 d p ( i , j ) = 0 dp(i,j)=0 dp(i,j)=0,因为当前位置不可能在由 1 组成的正方形中;
如果该位置的值是 1,则
d
p
(
i
,
j
)
dp(i, j)
dp(i,j) 的值由其上方、左方和左上方的三个相邻位置的 dp 值决定。具体而言,当前位置的元素值等于三个相邻位置的元素中的最小值加 1,状态转移方程如下:
d
p
(
i
,
j
)
=
m
i
n
(
d
p
(
i
−
1
,
j
)
,
d
p
(
i
−
1
,
j
−
1
)
,
d
p
(
i
,
j
−
1
)
)
+
1
dp(i, j)=min(dp(i−1, j), dp(i−1, j−1), dp(i, j−1))+1
dp(i,j)=min(dp(i−1,j),dp(i−1,j−1),dp(i,j−1))+1
形子矩阵的官方题解,其中给出了详细的证明。
此外,还需要考虑边界条件。如果 i i i 和 j j j 中至少有一个为 0 0 0,则以位置 ( i , j ) (i,j) (i,j) 为右下角的最大正方形的边长只能是 1,因此 d p ( i , j ) = 1 dp(i, j) = 1 dp(i,j)=1。
扩展85.最大矩形(Hard)
1277.统计全为1的正方形子矩阵
53. 最大字序和
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
示例:
输入: [-2,1,-3,4,-1,2,1,-5,4]
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
进阶:
如果你已经实现复杂度为 O(n) 的解法,尝试使用更为精妙的分治法求解。
一般人思路是暴力法,我承认,这的确很舒服,但是告诉你有 O ( n ) O(n) O(n)的方法,你会怎么想呢。
用
d
p
[
i
]
dp[i]
dp[i],如何表示才具有可行性。刚开始想的是序号从
0
−
n
−
1
0-n-1
0−n−1的最大字序和。后来发现存在
d
p
[
i
−
1
]
与
n
u
m
s
[
i
−
1
]
dp[i-1]与nums[i-1]
dp[i−1]与nums[i−1]断开的情况,而且中间的和一定不大于0. 官解给的是 *以第i个数结尾的连续数组最大和。*也就是不存在断开的情况。妙,实在是妙!
d
p
[
i
]
=
m
a
x
(
d
p
[
i
−
1
]
+
n
u
m
s
[
i
−
1
]
,
n
u
m
[
i
−
1
]
)
dp[i] = max(dp[i-1]+nums[i-1],num[i-1])
dp[i]=max(dp[i−1]+nums[i−1],num[i−1])
max里面前者表示存在新的后续元素使之更大,后者是新元素比原来的和更大。
边界条件:
d p [ 0 ] = 0 dp[0] = 0 dp[0]=0
此外还可以用滚动数组降低空间复杂度。
139.单词拆分
给定一个非空字符串 s 和一个包含非空单词列表的字典 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。
说明:
拆分时可以重复使用字典中的单词。
你可以假设字典中没有重复的单词。
示例 1:
输入: s = "leetcode", wordDict = ["leet", "code"]
输出: true
解释: 返回 true 因为 "leetcode" 可以被拆分成 "leet code"。
这一题可以正好填补一些我们思路上的空白。即,动态规划的迭代并不一定是连续的,很有可能存在跳跃。
用 d p [ i ] dp[i] dp[i]表示s前i个字符能否由字典中单词组成。那么 d p [ i ] = d p [ j ] and d i c t . c h e c k ( s . s u b s t r ( j , i − j ) ) ; dp[i]=dp[j]\ \text{and}\ dict.check(s.substr(j,i-j)); dp[i]=dp[j] and dict.check(s.substr(j,i−j));,check在这里检查 s [ j : i − 1 ] s[j:i-1] s[j:i−1]与字典中某一个单词匹配。初始条件为 d p [ 0 ] = t r u e dp[0]=true dp[0]=true空字符一定匹配,实际上,dp中大部分元素都是 f a l s e false false。
152. 乘积最大子数组
给你一个整数数组 nums ,请你找出数组中乘积最大的连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
示例 1:
输入: [2,3,-2,4]
输出: 6
解释: 子数组 [2,3] 有最大乘积 6。
示例 2:
输入: [-2,0,-1]
输出: 0
解释: 结果不能为 2, 因为 [-2,-1] 不是子数组。
这道题我们很容易陷入惯性思维,与最大子序和相似,我们可以设定
d
p
[
i
]
dp[i]
dp[i]为以
n
u
m
s
[
i
−
1
]
nums[i-1]
nums[i−1]为末尾的最大乘积,很容易知道
d
p
[
i
]
=
m
a
x
{
d
p
[
i
−
1
]
∗
n
u
m
s
[
i
−
1
]
,
n
u
m
s
[
i
−
1
]
}
dp[i]=max\{dp[i-1]*nums[i-1],nums[i-1]\}
dp[i]=max{dp[i−1]∗nums[i−1],nums[i−1]}
但是这一题与53不同的是,负负得正,比如
[
−
9
,
1
,
−
8
]
[-9,1,-8]
[−9,1,−8]得到最大和是72而不是1. 所以我们需要分类讨论,
n
u
m
[
i
−
1
]
num[i-1]
num[i−1]正负性,还要设计一个求最小积的
m
d
p
[
i
]
mdp[i]
mdp[i],与求最大积
M
d
p
[
i
]
Mdp[i]
Mdp[i]相对,具体如下:
M
d
p
=
{
max
{
M
d
p
[
i
−
1
]
∗
n
u
m
s
[
i
−
1
]
,
n
u
m
s
[
i
−
1
]
}
,
n
u
m
s
[
i
−
1
]
>
0
max
{
m
d
p
[
i
−
1
]
∗
n
u
m
s
[
i
−
1
]
,
n
u
m
s
[
i
−
1
]
}
,
o
t
h
e
r
w
i
s
e
m
d
p
=
{
min
{
m
d
p
[
i
−
1
]
∗
n
u
m
s
[
i
−
1
]
,
n
u
m
s
[
i
−
1
]
}
,
n
u
m
s
[
i
−
1
]
>
0
min
{
M
d
p
[
i
−
1
]
∗
n
u
m
s
[
i
−
1
]
,
n
u
m
s
[
i
−
1
]
}
,
o
t
h
e
r
w
i
s
e
Mdp=\begin{cases} \max\{Mdp[i-1]*nums[i-1],nums[i-1]\},nums[i-1]>0\\ \max\{mdp[i-1]*nums[i-1],nums[i-1]\},otherwise\\ \end{cases}\\ \\ \\ \\ mdp=\begin{cases} \min\{mdp[i-1]*nums[i-1],nums[i-1]\},nums[i-1]>0\\ \min\{Mdp[i-1]*nums[i-1],nums[i-1]\},otherwise\\ \end{cases}
Mdp={max{Mdp[i−1]∗nums[i−1],nums[i−1]},nums[i−1]>0max{mdp[i−1]∗nums[i−1],nums[i−1]},otherwisemdp={min{mdp[i−1]∗nums[i−1],nums[i−1]},nums[i−1]>0min{Mdp[i−1]∗nums[i−1],nums[i−1]},otherwise
初始条件
m
d
p
[
1
]
=
n
u
m
s
[
0
]
,
M
d
p
[
1
]
=
n
u
m
s
[
0
]
.
mdp[1]=nums[0],Mdp[1]=nums[0].
mdp[1]=nums[0],Mdp[1]=nums[0].
279. 完全平方数
给定正整数 n,找到若干个完全平方数(比如 1, 4, 9, 16, …)使得它们的和等于 n。你需要让组成和的完全平方数的个数最少。
示例 1:
输入: n = 12
输出: 3
解释: 12 = 4 + 4 + 4.
示例 2:
输入: n = 13
输出: 2
解释: 13 = 4 + 9.
这个例子表明dp算法可以和其它算法结合在一起,比如贪心算法。
我们设
m
i
n
S
q
u
a
r
e
(
i
)
minSquare(i)
minSquare(i)表示数字
i
i
i对应的最小完全平方数数目,那么:
m
i
n
S
q
u
a
r
e
(
i
)
=
min
1
≤
k
<
i
(
m
i
n
S
q
a
u
r
e
(
i
−
k
)
+
1
)
minSquare(i) = \min\limits_{1\le k < i}(minSqaure(i-k)+1)
minSquare(i)=1≤k<imin(minSqaure(i−k)+1)
但其实我们并不需要全部计算出
m
i
n
S
q
u
a
r
e
(
i
−
k
)
minSquare(i-k)
minSquare(i−k)的值,因为中间结果可能出现
m
i
n
S
q
u
a
r
e
(
i
−
k
)
minSquare(i-k)
minSquare(i−k),因此我们可以采用哈希表加速。
边界条件:minSquare(0)=0; // 注意这个条件是虚构的
我们先找到小于n的所有完全平方数然后从1开始到n,找到最小数。
复杂度分析
- 时间复杂度: O ( n ⋅ n ) \mathcal{O}(n\cdot\sqrt{n}) O(n⋅n),在主步骤中,我们有一个嵌套循环,其中外部循环是 n n n 次迭代,而内部循环最多需要 n \sqrt{n} n 迭代。
- 空间复杂度: O ( n ) \mathcal{O}(n) O(n),使用了一个一维数组 dp。
下面我们看一些复杂的DP问题。
410. 分割数组的最大和
给定一个非负整数数组和一个整数 m,你需要将这个数组分成 m 个非空的连续子数组。设计一个算法使得这 m 个子数组各自和的最大值最小。
注意:
数组长度 n 满足以下条件:
- 1 ≤ n ≤ 1000
- 1 ≤ m ≤ min(50, n)
示例:
输入:
nums = [7,2,5,10,8]
m = 2
输出:
18
解释:
一共有四种方法将nums分割为2个子数组。
其中最好的方式是将其分为[7,2,5] 和 [10,8],
因为此时这两个子数组各自的和的最大值为18,在所有情况中最小。
将数组分割为m段,求...
是动态规划的常见问法。
我们可以令 d p [ i ] [ j ] dp[i][j] dp[i][j]表示数组前 i i i个数分割为 j j j段,所能得到最大连续子数组和的最小值,我们可以考虑第 j j j段的具体范围,即我们可以枚举 k k k,将前 k k k个数分割为 j − 1 j-1 j−1段,而第 k + 1 k+1 k+1到第 i i i个数为第 j j j段,此时,这 j j j段数组中和的最大值等于 d p [ k ] [ j − 1 ] dp[k][j-1] dp[k][j−1]与 s u m ( k + 1 , i ) sum(k+1,i) sum(k+1,i)中和的较大值,其中 s u m ( a , b ) sum(a,b) sum(a,b)表示 n u m s [ i ] 在 [ a , b ] nums[i]在[a,b] nums[i]在[a,b]的范围和。
状态转移方程:
d p [ i ] [ j ] = min k = 0 i − 1 ( max ( d p [ k ] [ j − 1 ] , ∑ k + 1 i n u m s [ i ] ) dp[i][j]=\min\limits_{k=0}^{i-1}(\max(dp[k][j-1],\sum\limits_{k+1}^i{nums[i]}) dp[i][j]=k=0mini−1(max(dp[k][j−1],k+1∑inums[i])
边界条件:
d
p
[
0
]
[
0
]
=
0
;
dp[0][0] = 0;
dp[0][0]=0;
时间复杂度:
O
(
n
2
m
)
O(n^2m)
O(n2m),其中
n
n
n是数组长度,
m
m
m是分成非空的连续子数组个数,总状态数
O
(
n
×
m
)
O(n×m)
O(n×m),状态转移时间
O
(
n
)
O(n)
O(n)。
空间复杂度:
O
(
n
×
m
)
O(n×m)
O(n×m)为动态规划数组开销。
“我🤮饱了,后面还有吗”
“当然”
下面介绍一下字符串中的dp解法,比如10. 正则表达式匹配 和 44. 通配符匹配。 都是很经典的dp。 寥寥几句足以把超复杂的可能性涵盖其中,真让人不尽感叹造物主的鬼斧神工。
10.正则表达式匹配
给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 ‘.’ 和 ‘*’ 的正则表达式匹配。
'.' 匹配任意单个字符
'*' 匹配零个或多个前面的那一个元素
所谓匹配,是要涵盖 整个字符串 s的,而不是部分字符串。
说明:
s 可能为空,且只包含从 a-z 的小写字母。
p 可能为空,且只包含从 a-z 的小写字母,以及字符 . 和 *。
示例 1:
输入:
s = "aa"
p = "a"
输出: false
解释: "a" 无法匹配 "aa" 整个字符串。
示例 2:
输入:
s = "aa"
p = "a*"
输出: true
解释: 因为 '*' 代表可以匹配零个或多个前面的那一个元素, 在这里前面的元素就是 'a'。因此,字符串 "aa" 可被视为 'a' 重复了一次。
示例 3:
输入:
s = "ab"
p = ".*"
输出: true
解释: ".*" 表示可匹配零个或多个('*')任意字符('.')。
示例 4:
输入:
s = "aab"
p = "c*a*b"
输出: true
解释: 因为 '*' 表示零个或多个,这里 'c' 为 0 个, 'a' 被重复一次。因此可以匹配字符串 "aab"。
示例5
输入:
s = "mississippi"
p = "mis*is*p*."
输出: false
我们用 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示 s 的前 i 个字符与 pp 中的前 j 个字符是否能够匹配。在进行状态转移时,我们考虑 pp 的第 jj 个字符的匹配情况:
- 如果 p 的第 j 个字符是一个小写字母,那么我们必须在 s 中匹配一个相同的小写字母,即
d p [ i ] [ j ] = ( s [ i − 1 ] = = p [ j − 1 ] & & d p [ i − 1 ] [ j − 1 ] dp[i][j] = (s[i-1]==p[j-1] \&\& dp[i-1][j-1] dp[i][j]=(s[i−1]==p[j−1]&&dp[i−1][j−1]
如果我们通过这种方法进行转移,那么我们就需要枚举这个组合到底匹配了 ss 中的几个字符,会增导致时间复杂度增加,并且代码编写起来十分麻烦。我们不妨换个角度考虑这个问题:字母 + 星号的组合在匹配的过程中,本质上只会有两种情况:
-
匹配 s 末尾的一个字符,将该字符扔掉,而该组合还可以继续进行匹配;
-
不匹配字符,将该组合扔掉,不再进行匹配。
如果按照这个角度进行思考,我们可以写出很精巧的状态转移方程:
d p [ i ] [ j ] = { d p [ i − 1 ] [ j ] ∣ ∣ d p [ i ] [ j − 2 ] , s [ i ] = = p [ j − 1 ] d p [ i ] [ j − 2 ] , s [ i ] ≠ p [ j − 1 ] dp[i][j]=\left\{ \begin{array}{lcr} dp[i-1][j]||dp[i][j-2],\ s[i]==p[j-1] \\ dp[i][j-2],\ s[i]\ne p[j-1] \end{array} \right. dp[i][j]={dp[i−1][j]∣∣dp[i][j−2], s[i]==p[j−1]dp[i][j−2], s[i]=p[j−1]
- 在任意情况下,只要
p
[
j
]
p[j]
p[j] 是
.
,那么 p [ j ] p[j] p[j] 一定成功匹配 ss 中的任意一个小写字母。
最终的状态转移方程如下:
d p [ i ] [ j ] = { if ( p [ j ] ≠ ′ ∗ ′ ) = { d p [ i − 1 ] [ j − 1 ] , m a t c h e s ( s [ i ] , p [ j ] ) f a l s e , o t h e r w i s e o t h e r w i s e = { d p [ i − 1 ] [ j ] ∣ ∣ d p [ i ] [ j − 2 ] , m a t c h e s ( s [ i ] , p [ j − 1 ] ) d p [ i ] [ j − 2 ] , o t h e r w i s e dp[i][j] = \begin{cases} \text{if}\ (p[j]\ne '*')=\begin{cases} dp[i-1][j-1], matches(s[i],p[j]) \\ false, \ otherwise \end{cases} \\ \\ otherwise = \begin{cases} dp[i-1][j]\ || \ dp[i][j-2], \ matches(s[i],p[j-1]) \\ dp[i][j-2], otherwise \end{cases} \end{cases} dp[i][j]=⎩⎪⎪⎪⎪⎪⎪⎨⎪⎪⎪⎪⎪⎪⎧if (p[j]=′∗′)={dp[i−1][j−1],matches(s[i],p[j])false, otherwiseotherwise={dp[i−1][j] ∣∣ dp[i][j−2], matches(s[i],p[j−1])dp[i][j−2],otherwise
其中
matches
(
x
,
y
)
\textit{matches}(x, y)
matches(x,y) 判断两个字符是否匹配的辅助函数。只有当 y 是 .
或者 x 和 y 本身相同时,这两个字符才会匹配。
细节
动态规划的边界条件为 $ dp[0][0] = true $,即两个空字符串是可以匹配的。最终的答案即为 d p [ m ] [ n ] dp[m][n] dp[m][n],其中 m和 n 分别是字符串 s 和 p 的长度。由于大部分语言中,字符串的字符下标是从 0 开始的,因此在实现上面的状态转移方程时,需要注意状态中每一维下标与实际字符下标的对应关系。
在上面的状态转移方程中,如果字符串 p 中包含一个字符+星号的组合(例如 a ∗ a* a∗),那么在进行状态转移时,会先将 a 进行匹配(当 p [ j ] p[j] p[j] 为 a 时),再将 a* 作为整体进行匹配(当 p [ j ] p[j] p[j] 为 * 时)。然而,在题目描述中,我们必须将 a* 看成一个整体,因此将 a 进行匹配是不符合题目要求的。看来我们进行了额外的状态转移,这样会对最终的答案产生影响吗?这个问题留给读者进行思考。
C++代码
class Solution {
public:
bool isMatch(string s, string p) {
int m = s.size();
int n = p.size();
auto matches = [&](int i, int j) {
if (i == 0) {
return false;
}
if (p[j - 1] == '.') {
return true;
}
return s[i - 1] == p[j - 1];
};
vector<vector<int>> f(m + 1, vector<int>(n + 1));
f[0][0] = true;
for (int i = 0; i <= m; ++i) {
for (int j = 1; j <= n; ++j) {
if (p[j - 1] == '*') {
f[i][j] |= f[i][j - 2];
if (matches(i, j - 1)) {
f[i][j] |= f[i - 1][j];
}
}
else {
if (matches(i, j)) {
f[i][j] |= f[i - 1][j - 1];
}
}
}
}
return f[m][n];
}
};
44. 通配符匹配
给定一个字符串 (s) 和一个字符模式 § ,实现一个支持 ‘?’ 和 ‘*’ 的通配符匹配。
'?' 可以匹配任何单个字符。
'*' 可以匹配任意字符串(包括空字符串)。
两个字符串完全匹配才算匹配成功。
说明:
- s 可能为空,且只包含从 a-z 的小写字母。
- p 可能为空,且只包含从 a-z 的小写字母,以及字符 ? 和 *。
我们用 d p [ i ] [ j ] dp[i][j] dp[i][j]表示s的第 i i i个字符与p的第 j j j个字符是否匹配。
-
第一种情况是字母与字母匹配,即
d p [ i ] [ j ] = ( s [ i − 1 ] = = p [ j − 1 ] & & d p [ i − 1 ] [ j − 1 ] ) dp[i][j] = (s[i-1]==p[j-1] \&\& dp[i-1][j-1]) dp[i][j]=(s[i−1]==p[j−1]&&dp[i−1][j−1]) -
第二种情况是 p [ j ] p[j] p[j]是问好,则对 s [ i ] s[i] s[i]没有任何要求
d p [ i ] [ j ] = ( p [ j − 1 ] = = ′ ? ′ & & d p [ i − 1 ] [ j − 1 ] ) dp[i][j] = (p[j-1]=='?'\&\&dp[i-1][j-1]) dp[i][j]=(p[j−1]==′?′&&dp[i−1][j−1]) -
第三种情况,是遇到 ′ ′ ∗ ′ ′ ''*'' ′′∗′′,这种情况最为复杂,因为不知道星号要匹配多少个字符,这里很容易想到
回溯
方法,但是回溯一般会超时,著名的KMP算法是因为了避免回溯才会那么快。所以我们想这个星号可以使用多次,也可以一次都不使用。
d p [ i ] [ j ] = d p [ i − 1 ] [ j ] ∣ ∣ d p [ i ] [ j − 1 ] dp[i][j] = dp[i-1][j]||dp[i][j-1] dp[i][j]=dp[i−1][j]∣∣dp[i][j−1]
后面一项表示不使用星号,前面一项表示使用星号。总结一下:
d p [ i ] [ j ] = { s [ i − 1 ] = = p [ j − 1 ] & & d p [ i − 1 ] [ j − 1 ] , ′ a − z ′ p [ j − 1 ] = = ′ ? ′ & & d p [ i − 1 ] [ j − 1 ] , ′ ? ′ d p [ i − 1 ] [ j ] ∣ ∣ d p [ i ] [ j − 1 ] , ′ ∗ ′ dp[i][j]=\left\{ \begin{array}{lcr} s[i-1]==p[j-1] \&\& dp[i-1][j-1],'a-z' \\ p[j-1]=='?'\&\&dp[i-1][j-1],'?' \\ dp[i-1][j]||dp[i][j-1], '*' \end{array} \right. dp[i][j]=⎩⎨⎧s[i−1]==p[j−1]&&dp[i−1][j−1],′a−z′p[j−1]==′?′&&dp[i−1][j−1],′?′dp[i−1][j]∣∣dp[i][j−1],′∗′边界条件:
也就是
d
p
[
0
]
[
0
]
dp[0][0]
dp[0][0],我们不能单纯认为开始两个字符相等就是
d
p
[
0
]
[
0
]
=
=
T
r
u
e
dp[0][0]==True
dp[0][0]==True。因为
p
p
p有星号和问号开头的情况。
d
p
[
0
]
[
0
]
=
(
s
[
0
]
=
=
p
[
0
]
)
∣
∣
(
p
[
0
]
=
=
′
?
′
)
∣
∣
(
p
[
0
]
=
=
′
∗
′
)
dp[0][0]=(s[0]==p[0])||(p[0]=='?')||(p[0]=='*')
dp[0][0]=(s[0]==p[0])∣∣(p[0]==′?′)∣∣(p[0]==′∗′)
- 若两个字符串为空, d p [ 0 ] [ 0 ] dp[0][0] dp[0][0]也为True.
d
p
[
i
]
[
0
]
=
!
s
.
s
i
z
e
(
)
dp[i][0]=!s.size()
dp[i][0]=!s.size()
即空字符串无法匹配非空字符串。
- 若s为空,p全为"*",才能完成匹配。
d p [ 0 ] [ j ] = ( p [ j ] = = ′ ∗ ′ ) & & d p [ 0 ] [ j − 1 ] dp[0][j] = (p[j]=='*')\&\&dp[0][j-1] dp[0][j]=(p[j]==′∗′)&&dp[0][j−1]
我们可以发现, d p [ i ] [ 0 ] dp[i][0] dp[i][0]的值恒为假, d p [ 0 ] [ j ] dp[0][j] dp[0][j] 在 j j j 大于模式 p p p 的开头出现的星号字符个数之后,值也恒为假,而 d p [ i ] [ j ] dp[i][j] dp[i][j] 的默认值(其它情况)也为假,因此在对动态规划的数组初始化时,我们就可以将所有的状态初始化为 False,减少状态转移的代码编写难度。
此外还要考虑字符串的硬边界。此外,注意下标对 d p [ i ] [ j ] dp[i][j] dp[i][j]表示 s [ i − 1 ] s[i-1] s[i−1]与 p [ j − 1 ] p[j-1] p[j−1]匹配,因为下标是从0开始的。
Python 实现:
def isMatch(self, s: str, p: str) -> bool:
if not p and s: return False
if not s and not p: return True
m = len(s); n = len(p)
dp = [[False for _ in range(n+1)] for _ in range(m+1) ]
dp[0][0] = (p[0]=='?') or (p[0]=='*') or (s and s[0]==p[0])
for i in range(1,m+1):
dp[i][0] = not len(s)
for j in range(1,n+1):
dp[0][j] = (p[j-1] == '*') and dp[0][j-1]
for i in range(1,m+1):
for j in range(1,n+1):
dp[i][j] = (s[i-1] == p[j-1] and dp[i-1][j-1]) or \
(p[j-1] == '?' and dp[i-1][j-1]) or \
(p[j-1] == '*' and (dp[i-1][j] or dp[i][j-1]))
print("(%d,%d):"%(i,j),dp[i][j])
return dp[m][n]
C++实现:
class Solution {
public:
bool isMatch(string s, string p)
{
if (!p.size() && s.size()) return false;
if (!s.size() && !p.size()) return true;
int m = s.size(), n = p.size();
vector<vector<bool>> dp(m+1,vector<bool>(n+1,false));
dp[0][0] = (p[0]=='?')||(p[0]=='*')||(s.size()&&s[0]==p[0]);
for(int i = 1; i < m+1; i++) dp[i][0] = !s.size();
for(int j = 1; j < n+1; j++) dp[0][j] = (p[j-1]=='*') && dp[0][j-1];
for(int i = 1; i < m+1; i++)
for(int j = 1; j < n+1; j++)
dp[i][j] = (s[i-1] == p[j-1] && dp[i-1][j-1]) || \
(p[j-1] == '?' && dp[i-1][j-1]) || \
(p[j-1] == '*' && (dp[i-1][j] || dp[i][j-1]));
return dp[m][n];
}
};
明显是C++要快些(),嘻嘻😂,而且内存占用要小些
老规矩,下面分析时间复杂度和空间复杂度.
时间复杂度: O ( M N ) O(MN) O(MN)
空间复杂度:
O
(
N
M
)
O(NM)
O(NM)
N
和
M
N和M
N和M分别表示目标串和模式串的长度。我们可以使用滚动数组
对空间进行优化,即用两个长度为
n
+
1
n+1
n+1 的一维数组代替整个二维数组进行状态转移,空间复杂度为$ O(n)$。
当然这题也有贪心解法,有兴趣的小伙伴可以研究一下。
72 编辑距离
这一题在LC上属于难题分类,但实际上通过率高达59.6%,是一道名副其实的Easy
题。
但是我们想说的dp千变万化,不离其宗。题目做多了自然就有想法了。
给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
- 插入一个字符
- 删除一个字符
- 替换一个字符
我们用
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j]表示word1前i个字符转换为word2前j个字符所需的最小
操作数。
然后在word1插入一个字符相当于 d p [ i ] [ j ] = d p [ i − 1 ] [ j ] + 1 dp[i][j]=dp[i-1][j]+1 dp[i][j]=dp[i−1][j]+1,一定会多出来一个步骤。
然后在word1删除一个字符相当于 d p [ i ] [ j ] = d p [ i ] [ j − 1 ] + 1 dp[i][j]=dp[i][j-1]+1 dp[i][j]=dp[i][j−1]+1,也一定会多出来一个步骤。
如果word1最后一个字符通过替换得到word2,那么要分情况,如果最后一个字符相同,那么 d p [ i ] [ j ] = d p [ i − 1 ] [ j − 1 ] dp[i][j]=dp[i-1][j-1] dp[i][j]=dp[i−1][j−1],否则 d p [ i ] [ j ] = d p [ i − 1 ] [ j − 1 ] + 1 dp[i][j]=dp[i-1][j-1]+1 dp[i][j]=dp[i−1][j−1]+1.
边界条件 d p [ 0 ] [ j ] = j , d p [ i ] [ 0 ] = i dp[0][j] = j,dp[i][0]=i dp[0][j]=j,dp[i][0]=i
就是完全的删除或者完全插入。
代码
class Solution {
public:
int minDistance(string word1, string word2) {
if(!word1.size()) return word2.size();
if(!word2.size()) return word1.size();
//dp[i][j]表示word1的前i位替换为word2前i位所需的最小步数
int m = word1.size(), n = word2.size();
vector<vector<int>> dp(m+1,vector<int>(n+1));
dp[0][0] = 0;
dp[1][1] = (word1[0]==word2[0])? 0:1;
for(int i = 1; i <=m ;i++) dp[i][0] = i;
for(int j = 1; j <=n ;j++) dp[0][j] = j;
for(int i = 1; i <=m; i++ )
for(int j = 1; j <=n; j++ )
{
int exchange = (word1[i-1]==word2[j-1])?dp[i-1][j-1]:dp[i-1][j-1]+1;
dp[i][j] = min(exchange, min(dp[i-1][j]+1,dp[i][j-1]+1));
}
return dp[m][n];
}
};
小伙伴如果有收获,请点赞留言哦Σ(っ °Д °;)っ