這是leetcode上一道很棒的題目,告訴我一個道理,抓不住問題的關鍵,再優化也只是徒勞。這個題目略微有點難,我在講解的時候儘量把重點講的清晰,如果詳細展開講,會浪費太多時間,所以寫的略微粗略。
題目描述
給定一個字符串 (s) 和一個字符模式 § ,實現一個支持 ‘?’ 和 ‘’ 的通配符匹配。 ‘?’ 可以匹配任何單個字符。’’ 可以匹配任意字符串(包括空字符串)。兩個字符串完全匹配纔算匹配成功。
說明:
s 可能爲空,且只包含從 a-z 的小寫字母。
p 可能爲空,且只包含從 a-z 的小寫字母,以及字符 ? 和 *。
示例 1:輸入:
s = “aa”
p = “a”
輸出: false
解釋: “a” 無法匹配 “aa” 整個字符串。示例 2:
輸入:
s = “aa”
p = ""
輸出: true
解釋: '’ 可以匹配任意字符串。示例 3:
輸入:
s = “cb”
p = “?a”
輸出: false
解釋: ‘?’ 可以匹配 ‘c’, 但第二個 ‘a’ 無法匹配 ‘b’。示例 4:
輸入:
s = “adceb”
p = “ab”
輸出: true
解釋: 第一個 ‘’ 可以匹配空字符串, 第二個 '’ 可以匹配字符串 “dce”.示例 5:
輸入:
s = “acdcb”
p = “a*c?b”
輸入: false
題目分析
遞歸解法
對題目做簡單的分析可知,除了*可以一對多匹配外,其他都是一對一的匹配。所以只要不是 *我們就可以一直繼續往前匹配,但是遇到* ,我們到底想要讓其匹配幾個字符才合適呢,這個時候就需要進入遞歸。這個時候有兩種策略,對字符串剩下的所有子串,逐一遞歸。舉個例子,剩餘的子串s=abcde,剩餘的模式串p=*bcde,這個時候可以讓*匹配:’’,‘a’,‘ab’,‘abc’,‘abcd’,‘abcde’,然後把兩個串剩餘的子串進行逐一匹配。
顯然,讓*匹配a,然後剩餘的字符串和模式串可以成功匹配,遞歸函數返回True,整個函數返回。如果匹配失敗就得轉換下一個狀態繼續匹配。其他具體的細節就和普通的字符串匹配一致了,沒什麼好說的。
談一談裏面的優化,如果有多個*出現在一起,顯然和一個*是等價的,這樣可以減少遞歸的次數。
python 代碼
def isMatch(s: str, p: str) -> bool:
i = j = 0
lenp, lens = len(p), len(s)
if p == s or p == '*': return True # *可以匹配所有的情況
if '*' not in p and lens != lenp: return False # 如果模式串沒有*,則需要一一對應。
while i < lenp:
if p[i] == '*':
while i + 1 < lenp and p[i + 1] == '*': # 將多個連續的*轉換成一個處理
i += 1
for j in range(j, len(s) + 1):
if isMatch(s[j:], p[i + 1:]):
return True
if j == lens: return i == lenp - 1
return False
if j < lens and (p[i] == '?' or p[i] == s[j]):
i += 1
j += 1
else:
return False
return j == lens
但是實際上,這個遞歸可以通過狀態轉換來實現,假如模式串的第i個字符是*,這個時候模式串的前 i個字符已經匹配了字符串前 j個字符,這個時候就有三種狀態,第一種情況:. *匹配了0個字符,則此時待匹配的字符串 j 向後移動一個位置,而模式串的位置i向後移動一個位置,跳過*。第二種情況:* 匹配了一個字符,此時下標 i 和下標 j同時加1。第三種情況:* 匹配多於一個字符,這個時候,讓主串j往後移動,模式串的i 繼續停留在*的位置,此時表示* 匹配多於一個字符。這是一種等價處理,只需要把上面代碼if p[i] == '*'
的判斷裏面,遞歸寫成三個狀態的遞歸即可,然後可能還有一些邊界情況需要處理,但是思想是這樣的。
def isMatch( s: str, p: str) -> bool:
i=j=0
lenp,lens=len(p),len(s)
if p==s or p=='*':return True
if not lens:return all(c=='*' for c in p)
while i<lenp:
if p[i]=='*':
return isMatch(s[j:],p[i+1:]) \
or isMatch(s[j+1:],p[i+1:]) \
or isMatch(s[j+1:],p[i:])
if j<lens and (p[i]=='?' or p[i]==s[j]):
i+=1
j+=1
else:return False
return j==lens
代碼是寫出來了,以上兩種代碼有一個不雅觀的地方就是字符串切片後是多次拷貝的,如果在C++中直接傳遞指針即可,python裏面有一種解決就是傳遞字符串的起始索引。
上面的代碼如果在leetcode中直接提交,會超時,我們來做個分析,如果模式串中的*比較少,上述代碼沒什麼大問題,但是如果*比較多的時候,會過度遞歸,雖然兩個代碼寫出來有差異,但是實際上遞歸的次數是一樣的,對於每一個*,如果匹配失敗的話,都會對剩下的子串全部遞歸,這個複雜度就很恐怖了,是階乘的級別,所以遞歸的時間複雜度是很大的。
這個時候,就要聯想到算法設計思路裏面提到的優化,遞歸算法找重複計算。重複計算就是出現在前面的*會對後面的部分子串算一次,然後後面的*仍然會對後面的子串計算。舉個例子p=*ab*f,s=abababd,光是最後的’f’和’d’字符,一共就比較了8次,可見這個重複計算還是非常多的。
如果遞歸重複計算,我們很輕易的加一個備忘錄,如果我們的代碼使用子串去直接遞歸,這裏加備忘錄就只能使用字典。如果使用起始索引去遞歸的話,可以考慮使用數組,具體不展開講解。備忘錄的遞歸複雜度和動態規劃應該是一致的。
動態規劃解法
既然已經想到了帶備忘錄的遞歸,那麼動態規劃還遠嗎,如果按照遞歸的思路,去逆向思考動態規劃,動態規劃應該是從後往前計算的,但是這樣算不太方便。我們這裏直接考慮從前往後計算。
根據上面的遞歸的討論,我們只需要定義,表示從模式串前個字符和字符串前個字符是否匹配。顯然,這就是動態規劃的遞推式核心了,這個公式的意思是如果前面的部分已經匹配了,如果模式串第i個字符,字符串第j個字符是匹配的,那麼字符串到第j個字符,模式串到第i個字符也是匹配的。相反,如果前面匹配失敗,則後面一定匹配失敗。
我們還沒有討論*的情況,如果遇到p[i]是*,並且前面i-1個字符已經和j-1個字符匹配成功了,那麼對於。
python代碼
def isMatch_dp(s: str, p: str) -> bool:
lenp, lens = len(p), len(s)
dp = [[False] * (lens + 1) for i in range(lenp + 1)]
dp[0][0] = True
if p == s or p == '*': return True
for i in range(1, lenp + 1):
for j in range(lens + 1):
if p[i - 1] == '*' and dp[i - 1][j]:
dp[i][j:] = [True] * (lens - j + 1)
break
if j > 0:
dp[i][j] = dp[i - 1][j - 1] and (p[i - 1] == s[j - 1] or p[i - 1] == '?')
return dp[lenp][lens]
dp的代碼寫出來也是很簡潔的,關鍵是隻要搞清楚遞推式即可,這裏還可以做其他優化,但是不予討論。可以看到時間複雜度是,其中S表示字符串的長度,P表示模式串的長度。至於空間複雜度這裏其實可以優化到,大家可以自行編寫代碼。
回溯法
瞭解了上面的解法,我們來看回溯法,這裏必須要搞清楚的一個問題就是,我們需要回溯到什麼程度,事實上這裏直接給出結論,我們只需要回溯一個*即可。如果我們有*a*的模式串,我們有ababababa的子串。當我們匹配到第二*的時候,說明前面的b已經匹配了。我們不需要管b和字符串中的哪個b匹配了,匹配了就行了,我們只需要在失敗的時候回溯第二個*即可,因爲兩個*匹配的內容,你多的就是我少的,那麼幹脆,全給後面的*匹配即可。
搞清了我們只需要回溯一次的特點,這個代碼就很好寫了,如果失配,我們總是隻回溯後面的*,讓它匹配更多的字符,直到他匹配的再多也都是失敗,或者成功匹配到下一個*,把回溯的任務交給下一個*爲止。
python代碼
def isMatch_backtrack(self, s: str, p: str) -> bool:
# 回溯法每次最多一回溯一個*
s_idx = p_idx = 0
lenp, lens = len(p), len(s)
start_idx = tmp_idx = -1
while s_idx < lens:
if p_idx < lenp and (p[p_idx] == '?' or p[p_idx] == s[s_idx]): # 當前字符匹配成功
s_idx += 1
p_idx += 1
elif p_idx < lenp and p[p_idx] == '*':
while p_idx + 1 < lenp and p[p_idx + 1] == '*': # 合併*
p_idx += 1
start_idx = p_idx # 記錄*出現的位置,同時初始化的時候保證*不匹配字符
tmp_idx = s_idx
p_idx += 1
elif start_idx == -1: # 沒有可以回溯的*
return False
else:
# 回溯,讓p_idx和s_idx都回溯,並且每次回溯,*多匹配一個字符
p_idx = start_idx + 1
s_idx = tmp_idx + 1
tmp_idx = s_idx
return all(x == '*' for x in p[p_idx:])
最後就是複雜度的分析了,顯然孔家複雜度是常數級別,所以是,時間複雜度呢,這個玩意的時間複雜度分析還是非常複雜的,最好情況是,就是不需要回溯的時候。一般情況下,需要回溯,平均時間複雜度是,如果想要了解具體的分析,可以看相關論文。通配符匹配平均複雜度證明過程
思想已經講的很清楚了,建議大家自己完成代碼。這個題目略微有點難,如果大家有疑惑,歡迎討論。