44、通配符匹配

题目: 给定一个字符串 (s) 和一个字符模式 § ,实现一个支持 ‘?’ 和 ‘*’ 的通配符匹配。

  • ‘?’ 可以匹配任何单个字符。
  • ‘*’ 可以匹配任意字符串(包括空字符串)。
  • 两个字符串完全匹配才算匹配成功。

说明:

  • s 可能为空,且只包含从 a-z 的小写字母。
  • p 可能为空,且只包含从 a-z 的小写字母,以及字符 ? 和 *。

方法一:动态规划

  在给定的模式 p 中,只会有三种类型的字符出现,其中「小写字母」和「问号」的匹配是确定的,而「星号」的匹配是不确定的,因此我们需要枚举所有的匹配情况。为了减少重复枚举,我们可以使用动态规划来解决本题。
  我们用 dp[i][j] 表示字符串 s 的前 i 个字符和模式 p 的前 j 个字符是否能匹配。在进行状态转移时,我们可以考虑模式 p 的第 j 个字符 p(j),与之对应的是字符串 s 中的第 i 个字符 s(i):

如果 p(j)是小写字母,那么 s(i)必须也为相同的小写字母,状态转移方程为:

  • dp[i][j] = ( s (i)与 p (j)相同)∧dp[i−1][j−1]
    其中 ∧ 表示逻辑与运算。也就是说,dp[i][j]为真,当且仅当 dp[i-1][j-1]为真,并且 s (i) 与 p (j) 相同。

如果 p (j)是问号,那么对 s (i)没有任何要求,状态转移方程为:

  • dp[i][j]=dp[i−1][j−1]

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

  • dp[i][j]=dp[i][j−1]∨dp[i−1][j]
    其中 ∨ 表示逻辑或运算。如果我们不使用这个星号,那么就会从 dp[i][j-1] 转移而来;如果我们使用这个星号,那么就会从 dp[i-1][j]转移而来。

因为当 s 的前 i 个字符和匹配字符串的 j-1 个字符已经匹配的时候,若p的第 j 个字符为星号,则 p 的前 j 个字符和 s 的前 i 个字符仍然一定是匹配的,这时候星号相当于匹配了空串,所以不使用星号的时候只要dp[i][j-1] 是匹配的,则dp[i][j] 一定是匹配的。同理当 p 的第 j 个字符为星号,而此时 p 的前 j-1 个字符不能和 s 的前 i 个字符匹配的时候,就需要用星号来匹配 s 中的部分字符,这时候就判断dp[i-1][j]是否匹配,若匹配的话星号即可相当于匹配了 其原匹配值加上s 的第 i 个字符 ,因为星号可以匹配字符串(前面可能还匹配了 s 的其他字符),这样dp[i][j] 仍然是匹配的。

最终的状态转移方程如下:
在这里插入图片描述
细节
  只有确定了边界条件,才能进行动态规划。在上述的状态转移方程中,由于 dp[i][j] 对应着 s 的前 i 个字符和模式 p 的前 j 个字符,因此所有的 dp[0][j] 和 dp[i][0] 都是边界条件,因为它们涉及到空字符串或者空模式的情况,这是我们在状态转移方程中没有考虑到的:

  • dp[0][0]=True,即当字符串 s 和模式 p 均为空时,匹配成功

  • dp[i][0]=False,即空模式无法匹配非空字符串

  • dp[0][j] 需要分情况讨论:因为星号才能匹配空字符串,所以只有当模式 p 的前 j 个字符均为星号时,dp[0][j] 才为真

  我们可以发现,dp[i][0] 的值恒为假,dp[0][j] 在 j 大于模式 p 的开头出现的星号字符个数之后,值也恒为假,而 dp[i][j] 的默认值(其它情况)也为假,因此在对动态规划的数组初始化时,我们就可以将所有的状态初始化为 False,减少状态转移的代码编写难度。

  最终的答案即为 dp[m][n],其中 m 和 n 分别是字符串 s 和模式 p 的长度。需要注意的是,由于大部分语言中字符串的下标从 0 开始,因此 s (i)和 p (j)分别对应着 s[i-1]和 p[j-1]

public boolean isMatch2(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];
    }

时间复杂度:O(mn),其中 m 和 n 分别是字符串 s 和模式 p 的长度。
空间复杂度:O(mn),即为存储所有 (m+1)(n+1) 个状态需要的空间。此外,在状态转移方程中,由于 dp[i][j]只会从 dp[i][…]以及 dp[i - 1][…]转移而来,因此我们可以使用滚动数组对空间进行优化,即用两个长度为 n+1的一维数组代替整个二维数组进行状态转移,空间复杂度为 O(n)

例:p=“a*b?b” , s=“adcbeb”
代码执行后dp的状态
在这里插入图片描述

方法二:贪心算法

  方法一的瓶颈在于对星号 ∗ 的处理方式:使用动态规划枚举所有的情况。由于星号是「万能」的匹配字符,连续的多个星号和单个星号实际上是等价的,那么不连续的多个星号呢?

  我们以 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,⋯,Ux,那么 s 就可以与模式 p 匹配。

然而模式 p 并不一定是 * U1* U2* U3* …* Ux* 的形式:

  • 模式 p 的开头字符不是星号;

  • 模式 p 的结尾字符不是星号。

  第二种情况处理起来并不复杂。如果模式 p 的结尾字符不是星号,那么就必须与字符串 s 的结尾字符匹配。那么我们不断地匹配 s 和 p 的结尾字符,直到 p 为空或者 p 的结尾字符是星号为止。在这个过程中,如果匹配失败,或者最后 p 为空但 s 不为空,False。

  第一种情况的处理也很类似,我们可以不断地匹配 s 和 p 的开头字符。下面的代码中给出了另一种处理方法,即修改 sRecord 和 pRecord 的初始值为 -1,表示模式 p 的开头字符不是星号,并且在匹配失败时进行判断,如果它们的值仍然为 −1,说明没有「反悔」重新进行匹配的机会。

//方法二:贪心算法
    public boolean isMatch(String s, String p) {
        int sRight = s.length(), pRight = p.length();
        //模式p的结尾不是*号,为?或字符,则与s的结尾一个个判断是否匹配
        while (sRight > 0 && pRight > 0 && p.charAt(pRight - 1) != '*') {
            if (charMatch(s.charAt(sRight - 1), p.charAt(pRight - 1))) {
                --sRight;
                --pRight;
            } else {
                //只要结尾有一个非*字符不能匹配s对应位置字符就返回false
                return false;
            }
        }
        //p为空模式
        if (pRight == 0) {
            //s为空串则匹配,否则不匹配
            return sRight == 0;
        }

        int sIndex = 0, pIndex = 0;
        int sRecord = -1, pRecord = -1;

        while (sIndex < sRight && pIndex < pRight) {
        	//出现 * 假设其不匹配任何字符,继续判断剩下的 s 和 p 是否匹配
        	//并记录该 * 号下一个字符的下标,和对应的 s 的下标,便于(后悔)即不匹配的时候,返回来让 * 匹配部分 s 中的字符看剩下的是否匹配
            if (p.charAt(pIndex) == '*') {
                ++pIndex;
                sRecord = sIndex;
                pRecord = pIndex;
                //s[sIndex]和p[pIndex]匹配,则指针后移,判断剩下的是否匹配
            } else if (charMatch(s.charAt(sIndex), p.charAt(pIndex))) {
                ++sIndex;
                ++pIndex;
                // 出现了 * 号,且 * 不起作用时(即不匹配任何字符串时),s 和 p 不匹配则倒退回来(后悔),
                // 让 * 代表部分 s 的字符串,继续判断剩下的是否匹配
            } else if (sRecord != -1 && sRecord + 1 < sRight) {
                ++sRecord;
                sIndex = sRecord;
                pIndex = pRecord;
            } else {
                return false;
            }
        }

        return allStars(p, pIndex, pRight);
    }
    //若 p 的左边一部分已经完全匹配了 s ,则此时 pIndex < pRight, p 剩下的p(pIndex)到p(pRight) 部分必须全为*,才匹配
    //不然 s 不存在对应的字符串片段与 p 该段的字符相匹配,所以返回false
	//如果最后pIndex==pRight则返回true,是匹配的
    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 == '?';

    }

时间复杂度:

  • 渐进复杂度:O(mn),其中 m 和 n 分别是字符串 s 和模式 p 的长度。从代码中可以看出,s[sIndex] 和 p[pIndex] 至多只会被匹配(判断)一次,因此渐进时间复杂度为 O(mn)。
  • 平均复杂度:O(mlogn)。

空间复杂度:O(1)。

参考:
Leetcode官方题解

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值