更多请查看我的专栏:LeetCode(力扣)刷题指南
可直接在
LeetCode
中搜索题目名称
1.思考状态
先尝试“题目问什么,就把什么设置为状态”。然后考虑“状态如何转移”,如果“状态转移方程”不容易得到,尝试修改定义,目的仍然是为了方便得到“状态转移方程”。
2.思考状态转移方程(核心、难点)
状态转移方程是非常重要的,是动态规划的核心,也是难点,起到承上启下的作用。
技巧是分类讨论。对状态空间进行分类,思考最优子结构到底是什么。即大问题的最优解如何由小问题的最优解得到。
归纳“状态转移方程”是一个很灵活的事情,得具体问题具体分析,除了掌握经典的动态规划问题以外,还需要多做题。
如果是针对面试,请自行把握难度,我个人觉得掌握常见问题的动态规划解法,明白动态规划的本质就是打表格,从一个小规模问题出发,逐步得到大问题的解,并记录过程。动态规划依然是“空间换时间”思想的体现。
3.思考初始化
初始化是非常重要的,一步错,步步错,初始化状态一定要设置对,才可能得到正确的结果。
角度 1:直接从状态的语义出发;
角度 2:如果状态的语义不好思考,就考虑“状态转移方程”的边界需要什么样初始化的条件;
角度 3:从“状态转移方程”方程的下标看是否需要多设置一行、一列表示“哨兵”,这样可以避免一些边界的讨论,使得代码变得比较短。
4.思考输出
有些时候是最后一个状态,有些时候可能会综合所有计算过的状态。
5.思考状态压缩
“状态压缩”会使得代码难于理解,初学的时候可以不一步到位。先把代码写正确,然后再思考状态压缩。
状态压缩在有一种情况下是很有必要的,那就是状态空间非常庞大的时候(处理海量数据),此时空间不够用,就必须状态压缩。
给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
示例 1:
输入: “babad”
输出: “bab”
注意: “aba” 也是一个有效答案。
示例 2:
输入: “cbbd”
输出: "bb”
回文是一个正读和反读都相同的字符串,例如,“aba” 是回文,而“abc” 不是。
方法:反转S,使之成为S’,找到S和S’之间最长的公共子串。这也必然是最长的回文子串。
例如:
S=“caba”,S’=“abac”
S和S’之间的最长公共子串为“aba”,恰好是答案
让我们尝试一下这个例子:S =“abacdfgdcaba”,S =“abacdgfdcaba”:
S 以及 S’之间的最长公共子串为“abacd”。显然,这不是回文。
错误:我们可以看到,当S的其他部分中存在非回文子串的反向副本时,最长公共子串法就会失败。
为了纠正这一点,每当我们找到最长的公共子串的候选项时,都需要检查子串的索引是否与反向子串的原始索引相同。如果相同,那么我们尝试更新目前为止找到的最长回文子串;如果不是,我们就跳过这个候选项并继续寻找下一个候选。
复杂度分析:
这给我们提供了一个复杂度为 O(n^2)动态规划解法,它将占用 O(n^2)的空间(可以改进为使用 O(n)的空间)。
很明显,暴力法将选出所有子字符串可能的开始和结束位置,并检验它是不是回文。
class Solution:
# 暴力匹配(超时)
def longestPalindrome(self, s: str) -> str:
# 特判
size = len(s)
if size < 2:
return s
max_len = 1
res = s[0]
# 枚举所有长度大于等于 2 的子串
for i in range(size - 1):
for j in range(i + 1, size):
if j - i + 1 > max_len and self.__valid(s, i, j):
max_len = j - i + 1
res = s[i:j + 1]
return res
def __valid(self, s, left, right):
# 验证子串 s[left, right] 是否为回文串
while left < right:
if s[left] != s[right]:
return False
left += 1
right -= 1
return True
# 超时测试用例
# "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabcaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
复杂度分析:
时间复杂度:O(n^3),假设n是输入字符串的长度,则n(n-1)/2的为此类子字符串(不包括字符本身是回文的一般解法)的总数。因为验证每个子字符串需要O(n) 的时间,所以运行时间复杂度是 O(n^3)。
空间复杂度:O(1)。
为了改进暴力法,我们首先观察如何避免在验证回文时进行不必要的重复计算。考虑 “ababa” 这个示例。如果我们已经知道“bab” 是回文,那么很明显,“ababa” 一定是回文,因为它的左首字母和右尾字母是相同的。
我们给出P(i,j)的定义如下:
P ( i , j ) = { t r u e , 如 果 S i ⋯ S j 是 回 文 子 串 f a l s e , 其 他 情 况 P(i,j)=\begin{cases}true,如果S_i\cdots S_j是回文子串\\false,其他情况\end{cases} P(i,j)={true,如果Si⋯Sj是回文子串false,其他情况
因此,
P ( i , j ) = ( P ( i + 1 , j − 1 ) a n d S i = = S j ) P(i,j)=(P(i+1,j−1)\ \ and\ \ S_i==S_j) P(i,j)=(P(i+1,j−1) and Si==Sj)
基本示例如下:
P ( i , i ) = t r u e P ( i , i + 1 ) = ( S i = = S i + 1 ) P(i,i)=true\\ P(i,i+1)=(S_i==S_{i+1}) P(i,i)=trueP(i,i+1)=(Si==Si+1)
这产生了一个直观的动态规划解法,我们首先初始化一字母和二字母的回文,然后找到所有三字母回文,并依此类推…
这道题比较烦人的是判断回文子串。因此需要一种能够快速判断原字符串的所有子串是否是回文子串的方法,于是想到了“动态规划”。
“动态规划”最关键的步骤是想清楚“状态如何转移”,事实上,“回文”是天然具有“状态转移”性质的:
一个回文去掉两头以后,剩下的部分依然是回文(这里暂不讨论边界)。
依然从回文串的定义展开讨论:
1、如果一个字符串的头尾两个字符都不相等,那么这个字符串一定不是回文串;
2、如果一个字符串的头尾两个字符相等,才有必要继续判断下去。
(1)如果里面的子串是回文,整体就是回文串;
(2)如果里面的子串不是回文串,整体就不是回文串。
即在头尾字符相等的情况下,里面子串的回文性质据定了整个子串的回文性质,这就是状态转移。因此可以把“状态”定义为原字符串的一个子串是否为回文子串。
复杂度分析
事实上,只需使用恒定的空间,我们就可以在O(n^2)的时间内解决这个问题。
我们观察到回文中心的两侧互为镜像。因此,回文可以从它的中心展开,并且只有2n - 1个这样的中心。
你可能会问,为什么会 2n - 1个,而不是 n 个中心?原因在于所含字母数为偶数的回文的中心可以处于两字母之间(例如“abba” 的中心在两个‘b’ 之间)。
偶:n-1
奇:n
因此,中心扩散法的思路是:遍历每一个索引,以这个索引为中心,利用“回文串”中心对称的特点,往两边扩散,看最多能扩散多远。
枚举“中心位置”时间复杂度为 O(N),从“中心位置”扩散得到“回文子串”的时间复杂度为O(N),因此时间复杂度可以降到 O(N^2)。
回文串在长度为奇数和偶数的时候,“回文中心”的形式是不一样的,可以设计一个方法,兼容以上两种情况:
如果传入重合的索引编码,进行中心扩散,此时得到的回文子串的长度是奇数;
如果传入相邻的索引编码,进行中心扩散,此时得到的回文子串的长度是偶数。
class Solution:
def longestPalindrome(self, s: str) -> str:
size = len(s)
if size < 2:
return s
# 至少是 1
max_len = 1
res = s[0]
for i in range(size):
palindrome_odd, odd_len = self.__center_spread(s, size, i, i)
palindrome_even, even_len = self.__center_spread(s, size, i, i + 1)
# 当前找到的最长回文子串
cur_max_sub = palindrome_odd if odd_len >= even_len else palindrome_even
if len(cur_max_sub) > max_len:
max_len = len(cur_max_sub)
res = cur_max_sub
return res
def __center_spread(self, s, size, left, right):
"""
left = right 的时候,此时回文中心是一个字符,回文串的长度是奇数
right = left + 1 的时候,此时回文中心是一个空隙,回文串的长度是偶数
"""
i = left
j = right
while i >= 0 and j < size and s[i] == s[j]:
i -= 1
j += 1
return s[i + 1:j], j - i - 1
算法思路:
s
是空的,直接返回空子串;如果不是,执行下一步;dp[i][j]
是否是回文子串,取值为True:1
或False:0
。dp[i][j]
=1;dp[i+1][j-1]==1
且s[i]==s[j]
,那么这是一个回文子串版本1.0:
class Solution:
def longestPalindrome(self, s: str) -> str:
len_s=len(s)
if len_s<2:
return s
matrix=[[0 for i in range(len_s)]for i in range(len_s)]
map_s={}
for j in range(len_s): #列
for i in range(j+1): #行
if s[i]==s[j]:
if i<len_s-1 and j>0 and matrix[i+1][j-1]==1:
matrix[i][j]=1
map_s[(i,j)]=j-i+1
elif j-i==0 or j-i==1:
matrix[i][j]=1
map_s[(i,j)]=j-i+1
value=max(map_s.values())
key=list(map_s.keys())[list(map_s.values()).index(value)]
return s[key[0]:key[1]+1]
上述算法思路中,没有必要设置字典;可以设置一个变量动态地存储最长回文子串及其起始位置,这样就可以节省很多空间,也可减少重复代码。
另外,因为含单个字符的子串一定是回文子串,所以可以对二维数组进行一定的初始化,可以减少二层迭代的工作量,也可以使得二层迭代中的if判断变得简单可读。
上个版本中的if判断很繁琐,没有必要,只要判断当j-i<3
时,则为真;否则matrix[i][j]=matrix[i+1][j-1]
(因为二层迭代中,单字符的子串不包含在内,所以这个赋值不会超出界限)。
版本1.1:
class Solution:
def longestPalindrome(self, s: str) -> str:
len_s=len(s)
if len_s<2:
return s
matrix=[[0 for i in range(len_s)]for i in range(len_s)]
max_len=1
start=0
for i in range(len_s):martrix[i][i]=1
for j in range(1,len_s): #列
for i in range(j+1): #行
if s[i]==s[j]:
if j-i<3:
matrix[i][j]=1
else:
matrix[i][j]=matrix[i+1][j-1]
if matrix[i][j]==1 and j-i+1>max_len:
max_len=j-i+1
start=i
return s[start:start+max_len]
class Solution:
def longestPalindrome(self, s: str) -> str:
len_s=len(s)
if len_s<2:return s
max_s1=1
start=0
for i in range(len_s):
odd=self.solution1(s,len_s,i,i)
even=self.solution1(s,len_s,i,i+1)
s1=odd if odd[0]>even[0] else even
if s1[0]>max_s1:
max_s1=s1[0]
start=s1[1]
return s[start:start+max_s1]
def solution1(self,s:str,len_s,i,j):
while i>=0 and j<=len_s-1 and s[i]==s[j]:
i-=1
j+=1
return (j-i-1,i+1)
给你一个字符串 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
如果没有星号(正则表达式中的 *
),问题会很简单——我们只需要从左到右检查匹配串 s
是否能匹配模式串 p
的每一个字符。
当模式串中有星号时,我们需要检查匹配串 s
中的不同后缀,以判断它们是否能匹配模式串剩余的部分。一个直观的解法就是用回溯的方法来体现这种关系。
算法:
如果没有星号,我们的代码会像这样:
def match(text, pattern):
if not pattern: return not text #如果模式串pattern为空,则返回判断匹配串text是否为空
first_match = bool(text) and pattern[0] in {text[0], '.'} #判断第一个字符是否匹配
#如果第一个字符不匹配,返回false
#如果第一个字符匹配,去掉第一个字符后再判断是否匹配
return first_match and match(text[1:], pattern[1:])
按照上述算法的思路,那么如果模式串中有星号,它会出现在第二个位置,即pattern[1]
。这种情况下,我们可以直接忽略模式串中这一部分,或者删除匹配串的第一个字符,前提是它能够匹配模式串当前位置字符,即pattern[0]
。如果两种操作中有任何一种使得剩下的字符串能匹配,那么初始时,匹配串和模式串就可以被匹配。
class Solution(object):
def isMatch(self, text, pattern):
if not pattern: #如果模式串pattern为空,则返回判断匹配串text是否为空
return not text
first_match = bool(text) and pattern[0] in {text[0], '.'} #判断第一个字符是否匹配
if len(pattern) >= 2 and pattern[1] == '*':
return (self.isMatch(text, pattern[2:]) or
first_match and self.isMatch(text[1:], pattern))
else:
return first_match and self.isMatch(text[1:], pattern[1:])
算法思想:
pattern
是否为空:
test
是否为空:非空,则返回false
;否则返回true
。pattern
与匹配串test
的第一个字符串:first_match = bool(text) and pattern[0] in {text[0], '.'}
*
*
仅匹配零个字符:self.isMatch(text, pattern[2:]
;test
的第一个字符后进行匹配:first_match and self.isMatch(text[1:], pattern)
(在该算法下,如果模式串与匹配串匹配,*
终将被假设为近匹配零个字符)first_match and self.isMatch(text[1:], pattern[1:])
Example(这里假设字符串中没有空格):
0 1
test ='i i s p p i'
pattern='i s * i s * p * .'
0 1
Step1:
1.判断test[0]==pattern[0]:true
2.判断pattern长度足够的情况下pattern[1]=='*':false
2.test=test[1:];pattern=pattern[1:]
==========================================
0 1
test ='i s p p i'
pattern='s * i s * p * .'
0 1
Step2:
1.判断test[0]==pattern[0]:false
2.判断pattern长度足够的情况下pattern[1]=='*':true
3.(由图可知'*'仅匹配0个字符)test=test;pattern=pattern[2:]
==========================================
0 1
test ='i s p p i'
pattern='i s * p * .'
0 1
Step3:
1.判断test[0]==pattern[0]:true
2.判断pattern长度足够的情况下pattern[1]=='*':false
3.test=test[1:];pattern=pattern[1:]
==========================================
0 1
test ='s p p i'
pattern='s * p * .'
0 1
Step4:
1.判断test[0]==pattern[0]:true
2.判断pattern长度足够的情况下pattern[1]=='*':true
3.(由图可知'*'匹配1个字符)test=test[1:];pattern=pattern
==========================================
0 1
test ='p p i'
pattern='s * p * .'
0 1
Step5:
1.判断test[0]==pattern[0]:false
2.判断pattern长度足够的情况下pattern[1]=='*':true
3.(由图可知'*'仅匹配0个字符)test=test;pattern=pattern[2:]
==========================================
0 1
test ='p p i'
pattern='p * .'
0 1
Step6:
1.判断test[0]==pattern[0]:true
2.判断pattern长度足够的情况下pattern[1]=='*':true
3.(由图可知'*'匹配2个字符)test=test[1:];pattern=pattern
==========================================
0 1
test ='p i'
pattern='p * .'
0 1
Step7:
1.判断test[0]==pattern[0]:true
2.判断pattern长度足够的情况下pattern[1]=='*':true
3.(由图可知'*'匹配1个字符)test=test[1:];pattern=pattern
==========================================
0
test ='i'
pattern='p * .'
0 1
Step8:
1.判断test[0]==pattern[0]:false
2.判断pattern长度足够的情况下pattern[1]=='*':true
3.(由图可知'*'匹配0个字符)test=test;pattern=pattern[2:]
==========================================
0
test ='i'
pattern='.'
0
Step9:
1.判断test[0]==pattern[0]:true
2.判断pattern长度足够的情况下pattern[1]=='*':false
3.test=test[1:];pattern=pattern[1:]
==========================================
test =''
pattern=''
Step10:
1.pattern为空
2.test为空
3.返回true
==========================================
复杂度分析:不会!!!
因为题目拥有 最优子结构 ,一个自然的想法是将中间结果保存起来。我们通过用 dp(i,j) 表示 text[i:] 和 pattern[j:] 是否能匹配。我们可以用更短的字符串匹配问题来表示原本的问题。
我们借助 [方法 1:回溯法] 中同样的回溯方法,除此之外,因为函数 match(text[i:], pattern[j:])
只会被调用一次,我们用 dp(i, j) 来应对剩余相同参数的函数调用,这帮助我们节省了字符串建立操作所需要的时间,也让我们可以将中间结果进行保存。
简言之,就是动态递归是在原来的模式串和匹配串上判断,并不对字符串进行重新建立操作(即类似于方法1中的test=test[1:];pattern=pattern[1:]
操作);并且还有一个优点是把中间结果保存在一个字典中。
自顶而下的方法:(算法思想类似于回溯法)
class Solution(object):
def isMatch(self, text, pattern):
memo = {}
def dp(i, j):
if (i, j) not in memo:
if j == len(pattern): #递归结束基本条件(如果模式串比匹配串短)
ans = i == len(text)
else:
first_match = i < len(text) and pattern[j] in {text[i], '.'}
if j+1 < len(pattern) and pattern[j+1] == '*':
ans = dp(i, j+2) or first_match and dp(i+1, j)
else:
ans = first_match and dp(i+1, j+1)
memo[i, j] = ans
return memo[i, j]
return dp(0, 0) #最终返回memo中键为[0,0]的值
自底向上的算法思想:
dp[i][j]
,元素表示test[i:]
是否匹配于pattern[j:]
,初始化为False
;dp[-1][-1] = True
dp[0][0]
class Solution(object):
def isMatch(self, text, pattern):
#创建一个二维数组存储中间结果
dp = [[False] * (len(pattern) + 1) for _ in range(len(text) + 1)]
dp[-1][-1] = True
for i in range(len(text), -1, -1):
for j in range(len(pattern) - 1, -1, -1):
first_match = i < len(text) and pattern[j] in {text[i], '.'}
if j+1 < len(pattern) and pattern[j+1] == '*':
dp[i][j] = dp[i][j+2] or first_match and dp[i+1][j]
else:
dp[i][j] = first_match and dp[i+1][j+1]
return dp[0][0]
自底向上方法的复杂度分析:
dp(i, j)
只会被计算一次,所以后面每次调用都是 O(1)O(1) 的时间。因此,总时间复杂度为 O(TP)O(T**P) 。 自底向上方法提交后的平均运行时间为60ms往上,自顶而下方法提交后的平均运行时间为40ms往上。为什么要自顶而下方法要比自底向上方法要快?因为自顶而下的递归速度比自底向上的迭代速度要快。
二维数组dp[i][j]
很多元素是无用的,该方法能否改进呢?
自顶而下:
自底向上: