一、最长回文子串
此题为leetcode第5题
思路:此题的“中心扩展法”在前面已经讲过,这里解释动态规划解法。设状态dp[i][j]的含义是在区间[i, j]的字符串是否为回文串。如果[i+1, j-1]区间内已经是回文串了,那么只要s[i] == s[j]那么[i, j]区间的字符串便是回文串。另外要主要内外循环的次序。一般是先循环i再循环j,但在这里会导致参考的状态还没有被计算出来。比如当下计算dp[i][j],会用到dp[i+1][j-1],由于先循环的i所以这个状态还没有被计算出来。因此要先循环j再循环i。
class Solution:
def longestPalindrome(self, s):
n = len(s)
if n <= 1:
return s
dp = [[False for _ in range(n)] for _ in range(n)]
for i in range(n):
dp[i][i] = True
max_len, start = 1, 0
for j in range(1, n):
for i in range(j):
if j - i <= 2 and s[i] == s[j]:
dp[i][j] = True
elif j - i > 2 and dp[i + 1][j - 1] and s[i] == s[j]:
dp[i][j] = True
if dp[i][j] and (j - i + 1) > max_len:
max_len = j - i + 1
start = i
return s[start: start + max_len]
二、最大子序和
此题为leetcode第53题
思路:设状态dp[i]的含义是以sums[i]为结尾的连续子序数组中最大的和。状态转移方程如下:
d
p
[
i
]
=
{
n
u
m
s
[
i
]
,
i
f
d
p
[
i
−
1
]
≤
0
d
p
[
i
−
1
]
+
n
u
m
s
[
i
]
,
i
f
d
p
[
i
−
1
]
>
0
dp[i]= \begin{cases} nums[i], \quad if \quad dp[i-1] \leq 0 \\ dp[i-1]+nums[i], \quad if \quad dp[i-1]>0 \end{cases}
dp[i]={nums[i],ifdp[i−1]≤0dp[i−1]+nums[i],ifdp[i−1]>0
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
dp = [0] * len(nums)
dp[0] = nums[0]
res = nums[0]
for i in range(1, len(nums)):
if dp[i - 1] <= 0:
dp[i] = nums[i]
else:
dp[i] = dp[i - 1] + nums[i]
if dp[i] > res:
res = dp[i]
return res
三、最小路径和
此题为leetcode第64题
思路:设dp[i][j]含义是从(i, j)走到右下角的最小路径和,最后的答案便是dp[0][0]。初始化的时候,dp[-1][-1]就是grid[-1][-1],并且最右边一列只能往下走,最下面一行只能往右走,这三种情况可以先初始化。遍历的顺序是右下角到左上角,状态转移方程为:
d
p
[
i
]
[
j
]
=
min
(
d
p
[
i
+
1
]
[
j
]
,
d
p
[
i
]
[
j
+
1
]
)
+
g
r
i
d
[
i
]
[
j
]
dp[i][j] = \min(dp[i + 1][j], dp[i][j+1]) + grid[i][j]
dp[i][j]=min(dp[i+1][j],dp[i][j+1])+grid[i][j]
class Solution:
def minPathSum(self, grid: List[List[int]]) -> int:
m, n = len(grid), len(grid[0])
if m <= 1 or n <= 1:
return sum(grid[0])
dp = [[0 for _ in range(n)] for _ in range(m)]
dp[-1][-1] = grid[-1][-1]
for i in range(m - 2, -1, -1):
dp[i][-1] = dp[i + 1][-1] + grid[i][-1]
for i in range(n - 2, -1, -1):
dp[-1][i] = dp[-1][i + 1] + grid[-1][i]
for i in range(m - 2, -1, -1):
for j in range(n - 2, -1, -1):
dp[i][j] = min(dp[i + 1][j], dp[i][j + 1]) + grid[i][j]
return dp[0][0]
四、不同路径
此题为leetcode第62题
思路:设状态dp[i][j]的含义是从起点到(i, j)有多少条路。可以先将边界初始化,即第0行和第0列。状态转移方程为:
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]
本题还可以将状态压缩,使空间复杂度变为O(n)。我们发现第i行的状态只和第i – 1行的相关,可以把i这个维度去掉。
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
dp = [[0 for _ in range(n)] for _ in range(m)]
for i in range(n):
dp[0][i] = 1
for i in range(m):
dp[i][0] = 1
for i in range(1, m):
for j in range(1, n):
dp[i][j] = dp[i][j - 1] + dp[i - 1][j]
return dp[-1][-1]
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
# 空间复杂度优化成O(n)
cur = [1] * n
for i in range(1, m):
for j in range(1, n):
cur[j] += cur[j-1]
return cur[-1]
五、不同路径II
此题为leetcode第63题
思路:和上面的题一样,只需判断一下当前(i, j)是否为障碍物,是障碍物的话continue即可。
class Solution:
def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
m, n = len(obstacleGrid), len(obstacleGrid[0])
dp =[[0 for _ in range(n)] for _ in range(m)]
for i in range(n):
if obstacleGrid[0][i] == 1:
break
dp[0][i] = 1
for i in range(m):
if obstacleGrid[i][0] == 1:
break
dp[i][0] = 1
for i in range(1, m):
for j in range(1, n):
if obstacleGrid[i][j] == 1:
continue
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
return dp[-1][-1]
六、最大矩形
此题为leetcode第85题
思路:假设对于每一个点,我们向上扩展直到遇上0,然后左右扩展,直到无法容纳矩形最大高度。而最大矩阵就是用这种方式构建的矩阵之一。给定一行 matrix[i],我们通过定义三个数组height,left,和right来记录每个点的高度、左边界和右边界。height[j] 对应matrix[i][j]的高,以此类推。
height的更新:
h e i g h t [ j ] = { o l d _ h e i g h t [ j ] + 1 , i f m a t r i x [ i ] [ j ] = = 1 0 , i f m a t r i x [ i ] [ j ] = = 0 height[j]= \begin{cases} old\_height[j]+1, \quad if \quad matrix[i][j]==1 \\ 0, \quad if \quad matrix[i][j]==0 \end{cases} height[j]={old_height[j]+1,ifmatrix[i][j]==10,ifmatrix[i][j]==0
考虑哪些因素会导致矩形左边界的改变。由于当前行之上的全部0已经考虑在当前版本的left中,唯一能影响left就是在当前行遇到0。更新left:
l e f t [ j ] = { min ( o l d _ l e f t [ j ] , c u r r _ l e f t ) , i f m a t r i x [ i ] [ j ] = = 1 0 and c u r r _ l e f t = j + 1 , i f m a t r i x [ i ] [ j ] = = 0 left[j]= \begin{cases} \min(old\_left[j], curr\_left), \quad if \quad matrix[i][j]==1 \\ 0 \quad \text{and} \quad curr\_left=j + 1, \quad if \quad matrix[i][j]==0 \end{cases} left[j]={min(old_left[j],curr_left),ifmatrix[i][j]==10andcurr_left=j+1,ifmatrix[i][j]==0
cur_left是我们遇到的最右边的0的序号加1。当我们将矩形向左 “扩展” ,我们知道,不能超过该点,否则会遇到0。
right的更新和left类似:
r i g h t [ j ] = { min ( o l d _ r i g h t [ j ] , c u r r _ r i g h t ) , i f m a t r i x [ i ] [ j ] = = 1 0 and c u r r _ r i g h t = j , i f m a t r i x [ i ] [ j ] = = 0 right[j]= \begin{cases} \min(old\_right[j], curr\_right), \quad if \quad matrix[i][j]==1 \\ 0 \quad \text{and} \quad curr\_right=j, \quad if \quad matrix[i][j]==0 \end{cases} right[j]={min(old_right[j],curr_right),ifmatrix[i][j]==10andcurr_right=j,ifmatrix[i][j]==0
cur_right 是我们遇到的最左边的0的序号。简便起见,我们不把 cur_right 减去1 (就像我们给cur_left加上1那样) ,这样我们就可以用height[j] * (right[j] - left[j]) 而非height[j] * (right[j] + 1 - left[j])来计算矩形面积。
class Solution:
def maximalRectangle(self, matrix: List[List[str]]) -> int:
if not matrix:
return 0
m, n = len(matrix), len(matrix[0])
# 对于每个点,记录其最大矩形的左右边界和高度
left, right, height = [0] * n, [n] * n, [0] * n
res = 0
for i in range(m):
curr_left, curr_right = 0, n
# 更新height
for j in range(n):
if matrix[i][j] == '1':
height[j] += 1
else:
height[j] = 0
# 更新left
for j in range(n):
if matrix[i][j] == '1':
left[j] = max(left[j], curr_left)
else:
left[j] = 0
curr_left = j + 1
# 更新right
for j in range(n - 1, -1, -1):
if matrix[i][j] == '1':
right[j] = min(right[j], curr_right)
else:
right[j] = n
curr_right = j
# 更新面积
for j in range(n):
res = max(res, height[j] * (right[j] - left[j]))
return res
七、解码方法
此题为leetcode第91题
思路:设dp[i]的含义是以第i个字符结尾的字符串的解码方式总数,长度为n+1,因为会涉及到dp[i-2]的操作。另外,原来字符串的第0个现在是从1开始,因此第i个字符实际上是s[i-1]。根据s[i-1]是否为0划分情况,状态转移方程分别如下:
- s[i-1] == 0,当前字符为0
- 如果s[i-2] == 1 or 2,当前字符可以和前一个字符解码,那么dp[i] = dp[I - 2]
- 否则当前字符无法和前一个字符解码,并且当前字符无法单独解码,那么dp[i] = 0
- s[i-1] != 0,当前字符不为0
- 如果s[i- 2] != 0,且当前和前一个组合可以解码,那么dp[i] = dp[i-1] + dp[i-2]
- 否则的话就是,前一个为0且当前字符可以单独解码,或者前一个和当前无法解码,那么就是dp[i] = dp[i-1]
class Solution:
def numDecodings(self, s: str) -> int:
n = len(s)
if n == 0 or s[0] == '0':
return 0
dp = [0] * (n + 1)
dp[0] = 1
for i in range(1, n + 1):
if s[i - 1] == '0': # 当前字符为0
if s[i - 2] == '1' or s[i - 2] == '2': # 能和前面的字符解码
dp[i] = dp[i - 2]
else: # 无法和前一个字符解码,当前为0,无法单独解码
dp[i] = 0
else: # 当前字符不为0
# 前一个字符不为0且能和当前字符解码,当前字符可以单独解码
if s[i - 2] != '0' and 1 <= int(s[i - 2] + s[i - 1]) <= 26:
dp[i] = dp[i - 1] + dp[i - 2]
# 前一个字符为0且当前字符可以单独解码,或者当前和前一个无法解码
else:
dp[i] = dp[i - 1]
return dp[-1]
八、不同的二叉搜索树
此题为leetcode第96题
我们定义两个函数:
- G ( n ) G(n) G(n):长度为 n n n的序列的不同二叉搜索树个数
- F ( i , n ) F(i,n) F(i,n):以 i i i为根的不同二叉搜索树个数( 1 ≤ i ≤ n 1≤i≤n 1≤i≤n)
不同的二叉搜索树的总数G(n),是对遍历所有 i i i ( 1 < = i < = n 1 <= i <= n 1<=i<=n) 的 F ( i , n ) F(i,n) F(i,n)之和,即
G ( i ) = ∑ i = 1 n F ( i , n ) G(i)=\sum_{i=1}^{n}F(i,n) G(i)=i=1∑nF(i,n)
特别地,当序列长度为1(只有根节点)或0(空树)时,只有一种情况,即 G ( 0 ) = 1 , G ( 1 ) = 1 G(0)=1,G(1)=1 G(0)=1,G(1)=1。举例而言,F(3,7),以 3 为根的不同二叉搜索树个数。为了以 3 为根从序列[1, 2, 3, 4, 5, 6, 7]构建二叉搜索树,我们需要从左子序列[1, 2]构建左子树,从右子序列[4, 5, 6, 7]构建右子树,然后将它们组合。巧妙之处在于,我们可以将 [1,2] 构建不同左子树的数量表示为G(2), 从[4, 5, 6, 7]构建不同右子树的数量表示为G(4)。这是由于G(n)和序列的内容无关,只和序列的长度有关。于是,F(3,7)= G(2)∙G(4)。 概括而言,我们可以得到以下公式:
F ( i , n ) = G ( i − 1 ) ⋅ G ( n − i ) F(i,n)=G(i-1) \cdot G(n-i) F(i,n)=G(i−1)⋅G(n−i)
最终得到递推公式:
G ( i ) = ∑ i = 1 n G ( i − 1 ) ⋅ G ( n − i ) G(i)=\sum_{i=1}^{n}G(i-1) \cdot G(n-i) G(i)=i=1∑nG(i−1)⋅G(n−i)
class Solution:
def numTrees(self, n: int) -> int:
G = [0] * (n + 1)
G[0], G[1] = 1, 1
for i in range(2, n + 1):
for j in range(1, i + 1):
G[i] += G[j - 1] * G[i - j]
return G[n]
九、单词拆分
此题为leetcode第139题
思路:定义状态dp[i]为前i个字符串s[0, 1, …, i-1]是否符合题意,我们需要枚举s[0, 1, …, j-1]中的分割点j,看s[0, 1, …, j-1]组成的字符串s1(默认j = 0时s1为空串)和s[j, …, i-1]组成的字符串 s2是否都合法。s1是否合法由dp[j]可以得知,只需判断s2是否在字典中。因此可以得到下面状态转移方程:
d p [ i ] = d p [ j ] & & c h a c k ( s [ j , . . . , i − 1 ] ) dp[i] = dp[j] \quad \&\& \quad chack(s[j, ..., i-1]) dp[i]=dp[j]&&chack(s[j,...,i−1])
其中check表示s2是否在字典中。对于边界条件,我们定义 dp[0]=true 表示空串且合法。另外可以考虑循环的剪枝,在遍历j的时候,如果当前dp[j]及check都满足,那么剩下的j可以不再遍历。
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
n = len(s)
word_dict = set(wordDict)
dp = [False] * (n + 1)
dp[0] = True
for i in range(1, n + 1):
for j in range(i):
if dp[j] and s[j:i] in word_dict:
dp[i] = True
break
return dp[-1]
十、正则表达式匹配
此题为leetcode第10题
思路:设状态dp[i][j]的含义是字符串s中前i个字符和p中前j个字符是否匹配。这里的“前x个字符”是从1开始计数的,因此实际取s或p中的字符时要减去1。状态转移方程如下:
- 当p[j - 1] == ‘*’时:
- 当p[j - 2]为’.’或s[i - 1] == p[j - 2]时:dp[i][j] = dp[i][j - 2] or dp[i - 1][j - 2] or dp[i - 1][j]。
- 否则:dp[i][j] = dp[i][j - 2]
- 否则,当p[j - 1] == ‘.’或者s[i - 1] == p[j - 1]时:dp[i][j] == dp[i - 1][j - 1]
class Solution:
def isMatch(self, s: str, p: str) -> bool:
m, n = len(s), len(p)
dp = [[False for _ in range(n + 1)] for _ in range(m + 1)]
dp[0][0] = True
for i in range(1, n + 1):
if p[i - 1] == '*':
dp[0][i] = dp[0][i - 2]
for i in range(1, m + 1):
for j in range(1, n + 1):
if p[j - 1] == '*':
if s[i - 1] == p[j - 2] or p[j - 2] == '.':
dp[i][j] = dp[i][j - 2] \ # p[j - 2]重复0次
or dp[i - 1][j - 2] \ # p[j - 2]重复1次,
or dp[i - 1][j] # p[j - 2]重复大于等于2次,拿出一个和s[i - 1]抵消
# p[i - 1]依然是*
else:
dp[i][j] = dp[i][j - 2]
elif s[i - 1] == p[j - 1] or p[j - 1] == '.':
dp[i][j] = dp[i - 1][j - 1]
return dp[-1][-1]
十一、通配符匹配
此题为leetcode第44题
思路:此题和正则表达式匹配比较像。设dp[i][j]的含义是s[:i]和t[:j]是否匹配。状态转移方程如下所示:
d
p
[
i
]
[
j
]
=
{
s
[
i
−
1
]
=
=
t
[
j
−
1
]
a
n
d
d
p
[
i
−
1
]
[
j
−
1
]
,
i
f
s
[
i
]
,
t
[
j
]
为
小
写
字
母
d
p
[
i
−
1
]
[
j
−
1
]
,
i
f
t
[
j
−
1
]
=
=
′
?
′
i
f
t
[
j
−
1
]
=
=
′
∗
′
:
{
d
p
[
i
]
[
j
−
1
]
,
don’t use
′
∗
′
o
r
d
p
[
i
−
1
]
[
j
]
,
use
′
∗
′
dp[i][j] = \begin{cases} s[i-1]==t[j-1] \quad and \quad dp[i-1][j-1], \quad if \quad s[i],t[j]为小写字母 \\ dp[i-1][j-1], \quad if \quad t[j-1] == '?' \\ if t[j-1]=='*': \begin{cases} dp[i][j-1], \text{don't use} \quad '*' \\ or \\ dp[i-1][j], \text{use} \quad '*' \end{cases} \end{cases}
dp[i][j]=⎩⎪⎪⎪⎪⎪⎪⎨⎪⎪⎪⎪⎪⎪⎧s[i−1]==t[j−1]anddp[i−1][j−1],ifs[i],t[j]为小写字母dp[i−1][j−1],ift[j−1]==′?′ift[j−1]==′∗′:⎩⎪⎨⎪⎧dp[i][j−1],don’t use′∗′ordp[i−1][j],use′∗′
初始化时,s和t都为空的话可以匹配,dp[0][0]=true;当s为空时,t可以匹配连读多个*。
class Solution:
def isMatch(self, s: str, t: str) -> bool:
m, n = len(s), len(t)
dp = [[False for _ in range(n + 1)] for _ in range(m + 1)]
dp[0][0] = True
for i in range(1, n + 1):
if t[i - 1] == '*':
dp[0][i] = True
else:
break
for i in range(1, m + 1):
for j in range(1, n + 1):
if 'a' <= s[i - 1] <= 'z' and 'a' <= t[j - 1] <= 'z':
dp[i][j] = s[i - 1] == t[j - 1] and dp[i - 1][j - 1]
if t[j - 1] == '?':
dp[i][j] = dp[i - 1][j - 1]
if t[j - 1] == '*':
dp[i][j] = dp[i][j - 1] or dp[i - 1][j] # 对应"不用*"和"用*"
return dp[-1][-1]
十二、最长公共子序列
此题为leetcode第1143题
思路:设状态dp[i][j]的含义是text1[0:i ]和text2[0:j]的最长公共子序列长度。状态转移方程如下:
d
p
[
i
]
[
j
]
=
{
d
p
[
i
−
1
]
[
j
−
1
]
+
1
,
i
f
t
e
x
t
1
[
i
−
1
]
=
=
t
e
x
t
2
[
j
−
1
]
max
(
d
p
[
i
−
1
]
[
j
]
,
d
p
[
i
]
[
j
−
1
]
)
dp[i][j] = \begin{cases} dp[i-1][j-1] + 1, \quad if \quad text1[i-1] == text2[j-1] \\ \max(dp[i-1][j], dp[i][j-1]) \end{cases}
dp[i][j]={dp[i−1][j−1]+1,iftext1[i−1]==text2[j−1]max(dp[i−1][j],dp[i][j−1])
此题也可以对状态进行压缩。
# 未优化状态
class Solution:
def longestCommonSubsequence(self, text1: str, text2: str) -> int:
if not text1 or not text2:
return 0
m, n = len(text1), len(text2)
dp = [[0 for _ in range(n + 1)] for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if text1[i - 1] == text2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
return dp[-1][-1]
# 优化状态为O(N)
class Solution:
def longestCommonSubsequence(self, text1: str, text2: str) -> int:
if not text1 or not text2:
return 0
m, n = len(text1), len(text2)
dp = [0 for _ in range(n + 1)]
for i in range(1, m + 1):
last = dp[0] # 表示dp[i - 1][j - 1]
for j in range(1, n + 1):
temp = dp[j] # 表示dp[i - 1][j]
if text1[i - 1] == text2[j - 1]:
dp[j] = last + 1
else:
dp[j] = max(temp, dp[j - 1])
last = temp
return dp[-1]
十三、零钱兑换
此题为leetcode第322题
思路:设状态dp[i]的含义是总金额为i时所需最少的硬币个数,状态转移方程为:
d p [ i ] = m i n ( d p [ i ] , d p [ i − c o i n s [ j ] ] ) for j in range(len(coins)) dp[i]=min(dp[i], dp[i-coins[j]]) \quad \text{for} \quad j \quad \text{in} \quad \text{range(len(coins))} dp[i]=min(dp[i],dp[i−coins[j]])forjinrange(len(coins))
注意i应该大于coins[j]
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
dp = [amount + 1] * (amount + 1)
dp[0] = 0
for i in range(1, amount + 1):
for c in coins:
if i >= c:
dp[i] = min(dp[i], dp[i - c] + 1)
if dp[-1] > amount:
return -1
else:
return dp[-1]
十四、零钱兑换II
此题为leetcode第518题
思路:和上一个题求最少的硬币个数不同,这道题求组合个数。如果用上一题的方式做,结果会大于正确结果,因为上一题的代码计算的结果是排列数,而不是组合数,也就是代码会把1,2和2,1当做两种情况,需要重新定义子问题。正确的子问题定义应该是,problem(k, i) = problem(k-1, i) + problem(k, i-k),即前k个硬币凑齐金额i的组合数等于前k-1个硬币凑齐金额i的组合数加上在原来i-k的基础上使用硬币的组合数。problem(k-1, i)的含义是不使用第k个硬币凑齐金额i,problem(k, i-k)的含义是使用第k个硬币凑齐金额i。状态转移方程为:
d p [ k ] [ i ] = { d p [ k − 1 ] [ i ] + d p [ k ] [ i − k ] , i f i ≥ k d p [ k − 1 ] [ i ] , e l s e dp[k][i]= \begin{cases} dp[k-1][i]+dp[k][i-k], \quad if \quad i≥k \\ dp[k-1][i], \quad else \end{cases} dp[k][i]={dp[k−1][i]+dp[k][i−k],ifi≥kdp[k−1][i],else
状态可以进一步压缩,将k这个维度去掉
class Solution:
def change(self, amount: int, coins: List[int]) -> int:
dp = [0 for _ in range(amount + 1)]
dp[0] = 1
for c in coins:
for i in range(1, amount + 1):
if i >= c:
dp[i] += dp[i - c]
return dp[-1]