通配符匹配之三种解法


  这是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次,可见这个重复计算还是非常多的。
  如果递归重复计算,我们很轻易的加一个备忘录,如果我们的代码使用子串去直接递归,这里加备忘录就只能使用字典。如果使用起始索引去递归的话,可以考虑使用数组,具体不展开讲解。备忘录的递归复杂度和动态规划应该是一致的。

动态规划解法

  既然已经想到了带备忘录的递归,那么动态规划还远吗,如果按照递归的思路,去逆向思考动态规划,动态规划应该是从后往前计算的,但是这样算不太方便。我们这里直接考虑从前往后计算。
  根据上面的递归的讨论,我们只需要定义 M a t c h i j Match_{ij} ,表示从模式串前 i i 个字符和字符串前 j j 个字符是否匹配。显然 M a t c h i j = M a t c h i 1 , j 1   & &   ( 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个字符匹配成功了,那么对于 M a t c h i k = t r u e k > j Match_{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 ( S P ) O(SP) ,其中S表示字符串的长度,P表示模式串的长度。至于空间复杂度这里其实可以优化到 m i n ( 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 ( m i n ( S , P ) ) O(min(S,P)) ,就是不需要回溯的时候。一般情况下,需要回溯,平均时间复杂度是 O ( S l o g P ) O(SlogP) ,如果想要了解具体的分析,可以看相关论文。通配符匹配平均复杂度证明过程
  思想已经讲的很清楚了,建议大家自己完成代码。这个题目略微有点难,如果大家有疑惑,欢迎讨论。

原创文章 47 获赞 41 访问量 97万+

猜你喜欢

转载自blog.csdn.net/m0_38065572/article/details/104972176
今日推荐