目录

题目描述

44. 通配符匹配

题意分析

给定字符串 s 和模式串 p,判断 p 能否匹配整个 sp 中除小写字母外还有两个特殊符号:? 匹配任意一个字符(必须有一个,不能是零个),* 匹配任意长度的字符序列,包括空序列。

「匹配整个 s」这一点必须强调。不是找子串,也不是前缀匹配,而是 p 从头到尾用完的同时 s 也恰好用完。很多错误实现能通过前半段却在这里翻车。

约束是两个串的长度都在 2000 以内。这意味着 $O(mn)$ 的四百万级运算完全可行,不需要费力去凑贪心的线性解法;同时它也排除了对 * 的匹配长度做显式枚举的三重循环写法,那会是 $O(mn^2)$,在两千长度下就有 80 亿次,必然超时。

边界情况有三类值得先想清楚。第一,s 可以为空,此时只有 p 全由 * 组成(或 p 也为空)才匹配。第二,p 可以为空,此时只有 s 也为空才匹配。第三,p 里可能出现连续多个 *,比如 "**a*",它们在语义上等价于一个 *,实现上必须能自然地处理而不出错。

解法:一维动态规划匹配前缀

核心思路

* 可以匹配任意长度,若直接枚举它吞掉多少字符,会产生大量重复状态。动态规划只记录两个前缀是否匹配,把长度枚举压成常数次转移。

先定义二维状态:f[i][j] 表示模式串前 i 个字符能否匹配字符串前 j 个字符。

  • 普通字符或 ? 必须恰好消耗一个字符:
    f[i][j] = f[i-1][j-1] && (p[i-1] == '?' || p[i-1] == s[j-1])
  • * 有两种选择:
    f[i][j] = f[i-1][j] || f[i][j-1]。前者表示 * 匹配空串,后者表示它在已经匹配前 j-1 个字符的基础上再吞一个字符。

边界是 f[0][0] = true;空模式不能匹配非空字符串;模式前缀只有全部为 * 时才能匹配空串。每个状态都完整覆盖了当前模式字符的所有选择,因此转移不重不漏。

由于当前行只依赖上一行和本行左侧,可以压成一维 dp[j]。这里的遍历方向由依赖决定:普通字符需要上一行的 dp[j-1],必须从右向左;* 需要本行刚更新的 dp[j-1],必须从左向右。这是实现的核心。

解题步骤

  • 建立长度为 n + 1 的布尔数组,令 dp[0] = true,表示空模式匹配空串。
  • 从左到右遍历模式串。
  • 当前字符是 * 时,令 j 从 1 到 n,更新 dp[j] = dp[j] || dp[j-1]dp[0] 保持不变,因为 * 可以匹配空串。
  • 当前字符不是 * 时,令 jn 到 1,按字符是否相等或为 ? 更新;完成后将 dp[0] 置为 false。
  • 返回 dp[n],它表示两个完整字符串是否匹配。

s="adceb", p="*a*b" 为例,处理各模式前缀后的可达长度依次为:* 可达 0..5*a 只可达 1,*a* 可达 1..5,最后的 b 让长度 5 可达,因此返回 true。

代码实现

class Solution {
    public boolean isMatch(String s, String p) {
        int n = s.length();
        boolean[] dp = new boolean[n + 1];
        dp[0] = true;

        for (int i = 0; i < p.length(); i++) {
            char pattern = p.charAt(i);
            if (pattern == '*') {
                for (int j = 1; j <= n; j++) {
                    dp[j] = dp[j] || dp[j - 1];
                }
            } else {
                for (int j = n; j >= 1; j--) {
                    dp[j] = dp[j - 1]
                            && (pattern == '?' || pattern == s.charAt(j - 1));
                }
                dp[0] = false;
            }
        }
        return dp[n];
    }
}
func isMatch(s string, p string) bool {
    n := len(s)
    dp := make([]bool, n+1)
    dp[0] = true

    for i := 0; i < len(p); i++ {
        pattern := p[i]
        if pattern == '*' {
            for j := 1; j <= n; j++ {
                dp[j] = dp[j] || dp[j-1]
            }
        } else {
            for j := n; j >= 1; j-- {
                dp[j] = dp[j-1] && (pattern == '?' || pattern == s[j-1])
            }
            dp[0] = false
        }
    }
    return dp[n]
}

复杂度分析

  • 时间复杂度:$O(mn)$,其中 mn 分别是 ps 的长度;每个前缀状态只转移一次。
  • 空间复杂度:$O(n)$,二维状态被压缩成一行。

关键点总结

  • * 的“匹配任意长度”可拆成“匹配空串”或“在当前状态上再吞一个字符”。
  • 一维 DP 的更新方向取决于数据依赖:读上一行旧值就逆序,读本行新值就顺序。
  • dp[0] 表示模式前缀能否匹配空串:遇到 * 保留,遇到其他字符清零。
  • 本题的 * 是独立通配符;正则匹配第 10 题的 * 依附前一个字符,两者转移不同。

易错点总结

  • 普通字符从左向右更新会覆盖上一行状态。例如 s="ab", p="ab" 会因状态串行污染而误判;必须逆序。
  • * 从右向左更新时,true 无法沿本行传播。例如 s="abc", p="*" 会误判为 false;必须顺序。
  • * 分支忘记清空 dp[0],会让模式错误地跳过必须消耗字符的部分,例如 s="a", p="ba"
  • dp[0] = false 不能放在普通字符的内层循环之前,否则 s="a", p="a" 读取不到上一行的 f[0][0]
  • ? 必须匹配恰好一个字符;只有 * 能匹配空串。

相似题目

题目 难度 考察点
10. 正则表达式匹配 困难 * 依附前一字符表示重复次数,需成对处理并向前看一位
剑指 Offer 19. 正则表达式匹配 困难 与 10 题同题,可用来对照两种 * 语义在转移式上的差异
72. 编辑距离 中等 同为两串前缀 DP,但求最小操作数而非判定,转移取三支最小值
139. 单词拆分 中等 同样是布尔可达性 DP,但枚举的是切分点而非模式字符