数据结构与算法之深入解析“通配符匹配”的求解思路与算法示例

Posted Serendipity·y

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据结构与算法之深入解析“通配符匹配”的求解思路与算法示例相关的知识,希望对你有一定的参考价值。

一、题目要求

  • 给定一个字符串 (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 = "*a*b"
输出: true
解释: 第一个 '*' 可以匹配空字符串, 第二个 '*' 可以匹配字符串 "dce"
  • 示例 5:
输入:
s = "acdcb"
p = "a*c?b"
输出: false

二、求解算法

① 动态规划

  • 在给定的模式 p 中,只会有三种类型的字符出现:
    • 小写字母 a−z,可以匹配对应的一个小写字母;
    • 问号 ?,可以匹配任意一个小写字母;
    • 星号 ∗,可以匹配任意字符串,可以为空,也就是匹配零或任意多个小写字母。
  • 其中「小写字母」和「问号」的匹配是确定的,而「星号」的匹配是不确定的,因此需要枚举所有的匹配情况。为了减少重复枚举,可以使用动态规划来解决本题。
  • 用 dp[i][j] 表示字符串 s 的前 i 个字符和模式 p 的前 j 个字符是否能匹配。在进行状态转移时,我们可以考虑模式 p 的第 j 个字符 pj,与之对应的是字符串 s 中的第 i 个字符 si
    • 如果 pj 是小写字母,那么 si 必须也为相同的小写字母,状态转移方程为:

    • 其中 ∧ 表示逻辑与运算,也就是说,dp[i][j] 为真,当且仅当 dp[i−1][j−1] 为真,并且 si 与 pj 相同。
    • 如果 pj 是问号,那么对 si 没有任何要求,状态转移方程为:

    • 如果 pj 是星号,那么同样对 si 没有任何要求,但是星号可以匹配零或任意多个小写字母,因此状态转移方程分为两种情况,即使用或不使用这个星号:

    • 其中 ∨ 表示逻辑或运算,如果不使用这个星号,那么就会从 dp[i][j−1] 转移而来;如果使用这个星号,那么就会从 dp[i−1][j] 转移而来。
  • 最终的状态转移方程如下:

  • 也可以将前两种转移进行归纳:

  • C++ 示例:
class Solution 
public:
    bool isMatch(string s, string p) 
        int m = s.size();
        int n = p.size();
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));
        dp[0][0] = true;
        for (int i = 1; i <= n; ++i) 
            if (p[i - 1] == '*') 
                dp[0][i] = true;
            
            else 
                break;
            
        
        for (int i = 1; i <= m; ++i) 
            for (int j = 1; j <= n; ++j) 
                if (p[j - 1] == '*') 
                    dp[i][j] = dp[i][j - 1] | dp[i - 1][j];
                
                else if (p[j - 1] == '?' || s[i - 1] == p[j - 1]) 
                    dp[i][j] = dp[i - 1][j - 1];
                
            
        
        return dp[m][n];
    
;
  • Java 示例:
class Solution 
    public boolean isMatch(String s, String p) 
        int m = s.length();
        int n = p.length();
        boolean[][] dp = new boolean[m + 1][n + 1];
        dp[0][0] = true;
        for (int i = 1; i <= n; ++i) 
            if (p.charAt(i - 1) == '*') 
                dp[0][i] = true;
             else 
                break;
            
        
        for (int i = 1; i <= m; ++i) 
            for (int j = 1; j <= n; ++j) 
                if (p.charAt(j - 1) == '*') 
                    dp[i][j] = dp[i][j - 1] || dp[i - 1][j];
                 else if (p.charAt(j - 1) == '?' || s.charAt(i - 1) == p.charAt(j - 1)) 
                    dp[i][j] = dp[i - 1][j - 1];
                
            
        
        return dp[m][n];
    

② 贪心算法

  • 方法①的瓶颈在于对星号 ∗ 的处理方式:使用动态规划枚举所有的情况。由于星号是「万能」的匹配字符,连续的多个星号和单个星号实际上是等价的,那么不连续的多个星号呢?
  • 以 p=∗ abcd ∗ 为例,p 可以匹配所有包含子串 abcd 的字符串,也就是说,只需要暴力地枚举字符串 s 中的每个位置作为起始位置,并判断对应的子串是否为 abcd 即可。这种暴力方法的时间复杂度为 O(mn),与动态规划一致,但不需要额外的空间。
  • 如果 p=∗ abcd∗efgh∗i ∗ 呢?显然,p 可以匹配所有依次出现子串 abcd、efgh、i 的字符串。此时,对于任意一个字符串 s,首先暴力找到最早出现的 abcd,随后从下一个位置开始暴力找到最早出现的 efgh,最后找出 i,就可以判断 s 是否可以与 p 匹配。这样「贪心地」找到最早出现的子串是比较直观的,因为如果 s 中多次出现了某个子串,那么我们选择最早出现的位置,可以使得后续子串能被找到的机会更大。
  • 因此,如果模式 p 的形式为 ∗ u1 ∗ u2 ∗ u3 ∗⋯∗ ux ∗,即字符串(可以为空)和星号交替出现,并且首尾字符均为星号,那么就可以设计出下面这个基于贪心的暴力匹配算法。算法的本质是:如果在字符串 s 中首先找到 u1,再找到 u2,u3,u4⋯ ux,那么 s 就可以与模式 p 匹配,伪代码如下:
// 我们用 sIndex 和 pIndex 表示当前遍历到 s 和 p 的位置
// 此时我们正在 s 中寻找某个 u_i
// 其在 s 和 p 中的起始位置为 sRecord 和 pRecord

// sIndex 和 sRecord 的初始值为 0
// 即我们从字符串 s 的首位开始匹配
sIndex = sRecord = 0

// pIndex 和 pRecord 的初始值为 1
// 这是因为模式 p 的首位是星号,那么 u_1 的起始位置为 1
pIndex = pRecord = 1

while sIndex < s.length and pIndex < p.length do
    if p[pIndex] == '*' then
        // 如果遇到星号,说明找到了 u_i,开始寻找 u_i+1
        pIndex += 1
        // 记录下起始位置
        sRecord = sIndex
        pRecord = pIndex
    else if match(s[sIndex], p[pIndex]) then
        // 如果两个字符可以匹配,就继续寻找 u_i 的下一个字符
        sIndex += 1
        pIndex += 1
    else if sRecord + 1 < s.length then
        // 如果两个字符不匹配,那么需要重新寻找 u_i
        // 枚举下一个 s 中的起始位置
        sRecord += 1
        sIndex = sRecord
        pIndex = pRecord
    else
        // 如果不匹配并且下一个起始位置不存在,那么匹配失败
        return False
    end if
end while

// 由于 p 的最后一个字符是星号,那么 s 未匹配完,那么没有关系
// 但如果 p 没有匹配完,那么 p 剩余的字符必须都是星号
return all(p[pIndex] ~ p[p.length - 1] == '*')
  • 然而模式 p 并不一定是 ∗ u1 ∗ u2 ∗ u3 ∗⋯∗ ux ∗ 的形式:
    • 模式 p 的开头字符不是星号;
    • 模式 p 的结尾字符不是星号。
  • 第二种情况处理起来并不复杂,如果模式 p 的结尾字符不是星号,那么就必须与字符串 s 的结尾字符匹配。那么不断地匹配 s 和 p 的结尾字符,直到 p 为空或者 p 的结尾字符是星号为止。在这个过程中,如果匹配失败,或者最后 p 为空但 s 不为空,那么需要返回 False。
  • 第一种情况的处理也很类似,我们可以不断地匹配 s 和 p 的开头字符。下面的代码中给出了另一种处理方法,即修改 sRecord 和 tRecord 的初始值为 −1,表示模式 p 的开头字符不是星号,并且在匹配失败时进行判断,如果它们的值仍然为 −1,说明没有「反悔」重新进行匹配的机会。
  • C++ 示例:
class Solution 
public:
    bool isMatch(string s, string p) 
        auto allStars = [](const string& str, int left, int right) 
            for (int i = left; i < right; ++i) 
                if (str[i] != '*') 
                    return false;
                
            
            return true;
        ;
        auto charMatch = [](char u, char v) 
            return u == v || v == '?';
        ;

        while (s.size() && p.size() && p.back() != '*') 
            if (charMatch(s.back(), p.back())) 
                s.pop_back();
                p.pop_back();
            
            else 
                return false;
            
        
        if (p.empty()) 
            return s.empty();
        

        int sIndex = 0, pIndex = 0;
        int sRecord = -1, pRecord = -1;
        while (sIndex < s.size() && pIndex < p.size()) 
            if (p[pIndex] == '*') 
                ++pIndex;
                sRecord = sIndex;
                pRecord = pIndex;
            
            else if (charMatch(s[sIndex], p[pIndex])) 
                ++sIndex;
                ++pIndex;
            
            else if (sRecord != -1 && sRecord + 1 < s.size()) 
                ++sRecord;
                sIndex = sRecord;
                pIndex = pRecord;
            
            else 
                return false;
            
        
        return allStars(p, pIndex, p.size());
    
;
  • Java 示例:
class Solution 
    public boolean isMatch(String s, String p) 
        int sRight = s.length(), pRight = p.length();
        while (sRight > 0 && pRight > 0 && p.charAt(pRight - 1) != '*') 
            if (charMatch(s.charAt(sRight - 1), p.charAt(pRight - 1))) 
                --sRight;
                --pRight;
             else 
                return false;
            
        

        if (pRight == 0) 
            return sRight == 0;
        

        int sIndex = 0, pIndex = 0;
        int sRecord = -1, pRecord = -1;
        
        while (sIndex < sRight && pIndex < pRight) 
            if (p.charAt(pIndex) == '*') 
                ++pIndex;
                sRecord = sIndex;
                pRecord = pIndex;
             else if (charMatch(s.charAt(sIndex), p.charAt(pIndex))) 
                ++sIndex;
                ++pIndex;
             else if (sRecord != -1 && sRecord + 1 < sRight) 
                ++sRecord;
                sIndex = sRecord;
                pIndex = pRecord;
             else 
                return false;
            
        

        return allStars(p, pIndex, pRight);
    

    public boolean allStars(String str, int left, int right) 
        for (int i = left; i < right; ++i) 
            if (str.charAt(i) != '*') 
                return false;
            
        
        return true;
    

    public boolean charMatch(char u, char v) 
        return u == v || v == '?';
    

以上是关于数据结构与算法之深入解析“通配符匹配”的求解思路与算法示例的主要内容,如果未能解决你的问题,请参考以下文章

数据结构与算法之深入解析“股票的最大利润”的求解思路与算法示例

数据结构与算法之深入解析“安装栅栏”的求解思路与算法示例

数据结构与算法之深入解析“最长连续序列”的求解思路与算法示例

数据结构与算法之深入解析“路径总和”的求解思路与算法示例

数据结构与算法之深入解析“斐波那契数”的求解思路与算法示例

数据结构与算法之深入解析“连续整数求和”的求解思路与算法示例