目录

题目描述

10. 正则表达式匹配

image-20230322150033416

题意分析

输入两个串:待匹配的字符串 s(只含小写字母)和模式串 p(含小写字母、.*)。要回答的是一个判定问题:p 能否匹配 s,返回布尔值。

模式的语义要逐条读清楚:

  • 普通小写字母只匹配与自己相同的那一个字符。
  • . 匹配任意一个单字符,但必须匹配掉一个,不能匹配空。
  • * 本身不是一个独立的模式单元,它依附于紧挨在它前面的那个字符(字母或 .),表示「该字符重复出现零次或多次」。因此 p 中的 * 一定不会出现在首位,也不会紧跟另一个 *a*.* 这样的两字符组合才是真正的基本单位。
  • 关键在于「零次」也是合法的:a* 可以什么都不匹配,c*a* 能匹配空串。

最容易被忽略的一条是:题目要求 完全匹配,即整个 s 必须被整个 p 覆盖,而不是在 s 中找到某个匹配的子串。所以 s = "aa"p = "a" 返回 false,尽管 p 匹配了 s 的一个前缀。

边界上,s 可以为空串,p 也可以为空串;两串长度都不超过 20,规模很小,说明可以接受平方级甚至更高的算法。

解法:二维动态规划处理星号转移

核心思路

问题关键:题目要求整个字符串与整个模式完全匹配。普通字符和 . 都固定消费一个字符,只有 x* 会产生“使用零次”或“使用一次以上”的分支;直接回溯会重复计算相同的字符串、模式位置。

为什么选二维动态规划:递归子问题只由两个前缀长度决定。定义 dp[i][j] 表示 s 的前 i 个字符能否与 p 的前 j 个字符完全匹配,最终答案是 dp[m][n]

根据模式前缀的最后一个单元转移:

  • p[j - 1] 不是 *,它必须与 s[i - 1] 相容(字符相等或模式字符为 .),再看 dp[i - 1][j - 1]
  • p[j - 1] == '*',把 p[j - 2]* 看成整体。用零次时跳过 x*,取 dp[i][j - 2];用一次以上时,当前字符必须与 x 相容,消费一个字符串字符但保留模式,取 dp[i - 1][j]

初始 dp[0][0] = true。循环让 i 从 0 开始,这样 a*c*a* 可通过“用零次”递推出与空串匹配,无需单独初始化首行。

正确性:非星号结尾只能一一匹配最后两个字符;星号结尾必然来自 x* 使用零次或至少一次,前者跳过 x*,后者消费一个字符并保留模式。两种来源可能同时成立,取或即可,并且它们覆盖全部情况。每次转移都去掉至少一个已确定的字符或模式单元,因此按前缀长度归纳,所有 dp 状态都正确。

解题步骤

  1. 建立 (m + 1) x (n + 1) 的布尔表,设置 dp[0][0] = true
  2. 枚举 i = 0..mj = 1..n,第 0 行用于处理可匹配空串的连续 x*
  3. *:仅当 i > 0 且末字符相容时,从 dp[i - 1][j - 1] 转移。
  4. *:先取 dp[i][j - 2] 表示零次;若 i > 0 且前导字符相容,再或上 dp[i - 1][j] 表示继续重复。
  5. 返回 dp[m][n],不能在中途看到某个真值就提前返回。

面试口述示例s = "aab", p = "c*a*b"c* 用零次,a* 先通过 dp[0][4] 匹配空串,再连续消费两个 a 而保持模式列不动,最后 bb 一一匹配,所以答案为 true

边界反例:空串与 a* 匹配;aaa 不完全匹配;ab.* 匹配。三者分别检验星号零次、完全匹配和“任意字符重复”的语义。

代码实现

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 = 0; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                if (p.charAt(j - 1) == '*') {
                    dp[i][j] = dp[i][j - 2];
                    if (i > 0 && matches(s, p, i, j - 1)) {
                        dp[i][j] |= dp[i - 1][j];
                    }
                } else if (i > 0 && matches(s, p, i, j)) {
                    dp[i][j] = dp[i - 1][j - 1];
                }
            }
        }
        return dp[m][n];
    }

    private boolean matches(String s, String p, int i, int j) {
        return p.charAt(j - 1) == '.'
                || s.charAt(i - 1) == p.charAt(j - 1);
    }
}
func isMatch(s string, p string) bool {
    m, n := len(s), len(p)
    dp := make([][]bool, m+1)
    for i := range dp {
        dp[i] = make([]bool, n+1)
    }
    dp[0][0] = true

    for i := 0; i <= m; i++ {
        for j := 1; j <= n; j++ {
            if p[j-1] == '*' {
                dp[i][j] = dp[i][j-2]
                if i > 0 && regexMatches(s, p, i, j-1) {
                    dp[i][j] = dp[i][j] || dp[i-1][j]
                }
            } else if i > 0 && regexMatches(s, p, i, j) {
                dp[i][j] = dp[i-1][j-1]
            }
        }
    }
    return dp[m][n]
}

func regexMatches(s string, p string, i int, j int) bool {
    return p[j-1] == '.' || s[i-1] == p[j-1]
}

复杂度分析

  • 时间复杂度:$O(mn)$,共有 $(m + 1)(n + 1)$ 个状态,每个状态只做常数次判断。
  • 空间复杂度:$O(mn)$,保存完整状态表。转移只依赖当前行和上一行,可压成 $O(n)$,但二维表更适合面试推导和排查边界。

关键点总结

  • 状态必须表达“两个前缀完全匹配”,这样 dp[m][n] 才直接对应题意。
  • x* 是一个模式单元:零次跳两列,多次时字符串退一格、模式列不动。
  • 题目保证模式合法,因此遇到 * 时必有前导元素,访问 j - 2 不会越界。
  • . 只匹配一个字符;.* 能匹配任意长度,是 .* 两种语义的组合。
  • 从空字符串这一行开始递推,才能保留 a*c*a* 等模式匹配空串的能力。

易错点总结

  • 星号零次写成 dp[i][j - 1]:只丢掉 * 却留下 x,正确转移必须跳过两列。
  • 星号多次写成 dp[i - 1][j - 2]:模式被提前丢弃,a* 无法继续匹配 aaa
  • 只按前导字符是否匹配在“零次”和“多次”间二选一:s = "a", p = "aa*" 虽然末字符相同,却必须让末尾 a* 使用零次;应对两个完整状态取或。
  • 忽略 i > 0 就比较字符:空字符串场景会访问 s[-1]
  • 外层从 i = 1 开始:dp[0][2] 无法由 dp[0][0] 推出,空串与 a* 会被误判。
  • 把本题当子串匹配:s = "aa", p = "a" 必须为 false,答案只能取完整前缀状态 dp[m][n]

相似题目

题目 难度 考察点
44. 通配符匹配 困难 * 独立通配任意串,另有贪心 $O(1)$ 空间解
剑指 Offer 19. 正则表达式匹配 困难 与本题同题,可用来复习星号双支转移
72. 编辑距离 中等 双序列 DP 求最小操作数而非布尔判定
97. 交错字符串 中等 双前缀布尔 DP,转移来自两个串各退一格
115. 不同的子序列 困难 双序列 DP 统计方案数,含「用与不用」两支
1143. 最长公共子序列 中等 双序列 DP 求最优长度的入门模板