通配符匹配之三種解法


  這是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次,可見這個重複計算還是非常多的。
  如果遞歸重複計算,我們很輕易的加一個備忘錄,如果我們的代碼使用子串去直接遞歸,這裏加備忘錄就只能使用字典。如果使用起始索引去遞歸的話,可以考慮使用數組,具體不展開講解。備忘錄的遞歸複雜度和動態規劃應該是一致的。

動態規劃解法

  既然已經想到了帶備忘錄的遞歸,那麼動態規劃還遠嗎,如果按照遞歸的思路,去逆向思考動態規劃,動態規劃應該是從後往前計算的,但是這樣算不太方便。我們這裏直接考慮從前往後計算。
  根據上面的遞歸的討論,我們只需要定義MatchijMatch_{ij},表示從模式串前ii個字符和字符串前jj個字符是否匹配。顯然Matchij=Matchi1,j1 && (p[i]==?  p[i]==s[j])Match_{ij}=Match_{i-1,j-1}\ \&\&\ (p[i]=='?'\ ||\ p[i]==s[j]),這就是動態規劃的遞推式核心了,這個公式的意思是如果前面的部分已經匹配了,如果模式串第i個字符,字符串第j個字符是匹配的,那麼字符串到第j個字符,模式串到第i個字符也是匹配的。相反,如果前面匹配失敗,則後面一定匹配失敗。

  我們還沒有討論*的情況,如果遇到p[i]是*,並且前面i-1個字符已經和j-1個字符匹配成功了,那麼對於Matchik=truek>jMatch_{ik}=true\quad k>j

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的代碼寫出來也是很簡潔的,關鍵是隻要搞清楚遞推式即可,這裏還可以做其他優化,但是不予討論。可以看到時間複雜度是O(SP)O(SP),其中S表示字符串的長度,P表示模式串的長度。至於空間複雜度這裏其實可以優化到min(S,P)min(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:])

  最後就是複雜度的分析了,顯然孔家複雜度是常數級別,所以是O(1)O(1),時間複雜度呢,這個玩意的時間複雜度分析還是非常複雜的,最好情況是O(min(S,P))O(min(S,P)),就是不需要回溯的時候。一般情況下,需要回溯,平均時間複雜度是O(SlogP)O(SlogP),如果想要了解具體的分析,可以看相關論文。通配符匹配平均複雜度證明過程
  思想已經講的很清楚了,建議大家自己完成代碼。這個題目略微有點難,如果大家有疑惑,歡迎討論。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章