动态规划:驯服正则表达式的*号魔王

哈喽,各位,我是前端小L。

上一篇 文章中,我们刚刚驯服了通配符匹配中的 *,它拥有"凭空消失"和"无限吞噬"两种神力。我们当时以为,这已经是匹配问题的极限了。

然而,今天,我们将面对一个更古老、更强大、也更狡猾的对手------正则表达式中的 *。它不再是那个可以独立行动的"大魔王",它的所有力量,都必须依附于它前面的那个字符 。这个看似微小的规则变化,将彻底颠覆我们的状态转移逻辑,要求我们进行一次前所未有的"向前看两位"的精密操作。

这,就是"正则表达式匹配"。

力扣 10. 正则表达式匹配

https://leetcode.cn/problems/regular-expression-matching/

题目分析 & 核心区别: 给定一个字符串 s 和一个模式 p,实现正则表达式匹配。

  • .:可以匹配任何单个 字符。(等同于上一题的 ?

  • *:匹配零个或多个前面的那个元素。

划重点!* 的意义完全变了!

  • 在"通配符匹配"中,* 是独立的,匹配任意序列。

  • 在"正则表达式"中,* 必须和它前面的字符 c 组成 c*,作为一个整体,表示 c 可以出现0次、1次、或任意多次。* 永远不会单独出现。

这个"捆绑"关系,是解开本题所有谜题的唯一钥匙。

DP棋盘的再构建:在捆绑中寻找出路

我们依然使用二维DP棋盘作为战场。

1. DP状态定义: dp[i][j] 表示:字符串 s 的前 i 个字符 (s[0...i-1]) 是否能与模式 p 的前 j 个字符 (p[0...j-1]) 完全匹配

2. 状态转移的"终极"对决: 当我们计算 dp[i][j] 时,我们依然聚焦于模式的最后一个字符 p[j-1]

  • Case 1: p[j-1] 不是 * (是普通字母或 .) 这是简单模式。要匹配成功,s[i-1] 必须能与 p[j-1] 匹配(要么相等,要么 p[j-1].),并且它们的前缀也必须已经匹配成功。 dp[i][j] = match(s[i-1], p[j-1]) && dp[i-1][j-1] (其中 match 是一个辅助判断函数)

  • Case 2: p[j-1]* (Boss登场!) * 必须和它前面的 p[j-2] 捆绑在一起,形成 p[j-2]* 这个组合。这个组合有两种决策:

    • 决策A:"零次"------让 p[j-2]* 这个组合直接作废 思路 : 我们让 p[j-2]* 匹配一个空序列,把它当成"空气"。 后果 : 那么 s 的前 i 个字符能否匹配 p 的前 j 个字符,就完全取决于它能否匹配 p 的前 j-2 个字符(即跳过 p[j-2]* 这个组合)。 公式 : dp[i][j-2]

    • 决策B:"一次或多次"------让 p[j-2]* 至少匹配一个字符 思路 : 我们要用 p[j-2]* 这个组合,去"吞噬"字符串末尾的 s[i-1]前提 : s[i-1] 必须能和 p[j-2] 匹配上(要么相等,要么 p[j-2].)。如果连这都做不到,这个决策就直接失败。 后果 : 如果匹配上了,说明 s[i-1] 被成功"消化"掉了。现在的问题,变成了 s 的前 i-1 个字符,能否与 p 的前 j 个字符继续匹配。 为什么还是 p 的前 j 个字符? 因为 * 的魔力还在!p[j-2]* 这个组合在吞噬掉 s[i-1] 之后,依然可以继续去吞噬 s[i-2] 等等。 公式 : match(s[i-1], p[j-2]) && dp[i-1][j]

    最终,dp[i][j] 只要满足这两种决策中的任意一种 ,就算成功。 dp[i][j] = dp[i][j-2] || (match(s[i-1], p[j-2]) && dp[i-1][j])

3. Base Cases

  • dp[0][0] = true:空对空,匹配。

  • dp[i][0] (i > 0):非空字符串对空模式,不匹配。

  • dp[0][j] (j > 0):空字符串对非空模式。只有当模式是 a*b*c*... 这种形式时才可能匹配。 if (p[j-1] == '*') { dp[0][j] = dp[0][j-2]; }

代码实现

复制代码
class Solution {
public:
    bool isMatch(string s, string p) {
        int m = s.length();
        int n = p.length();

        auto matches = [&](int i, int j) {
            if (i == 0) return false;
            if (p[j - 1] == '.') return true;
            return s[i - 1] == p[j - 1];
        };

        vector<vector<bool>> dp(m + 1, vector<bool>(n + 1, false));

        // Base Cases
        dp[0][0] = true;
        for (int j = 1; j <= n; ++j) {
            if (p[j - 1] == '*') {
                dp[0][j] = dp[0][j - 2];
            }
        }

        // 状态转移
        for (int i = 1; i <= m; ++i) {
            for (int j = 1; j <= n; ++j) {
                if (p[j - 1] != '*') {
                    if (matches(i, j)) {
                        dp[i][j] = dp[i - 1][j - 1];
                    }
                } else { // p[j-1] is '*'
                    // 决策A: 匹配0次
                    dp[i][j] = dp[i][j - 2];
                    // 决策B: 匹配1次或多次
                    if (matches(i, j - 1)) {
                        dp[i][j] = dp[i][j] || dp[i - 1][j];
                    }
                }
            }
        }
        return dp[m][n];
    }
};

注:为了处理 i=0 的情况,将 match 逻辑封装成辅助函数,代码更清晰。

总结:二维序列DP的巅峰对决

对比项 通配符匹配 (LC 44) 正则表达式匹配 (LC 10)
* 的含义 独立的,匹配任意序列 依附的,匹配前一个字符的0次或多次
* 的状态来源 dp[i-1][j] (吞噬) dp[i][j-1] (消失) dp[i-1][j] (吞噬) dp[i][j-2] (消失)
核心区别 * 的"消失"是看j-1 * 的"消失"是看j-2,需要跳过char*组合
思维复杂度 很高 极高 ,需要理解*的依附性

今天,我们终于攻克了二维序列DP中,逻辑最复杂、细节最魔鬼的终极Boss。它教会我们,动态规划的建模,本质上是将一个复杂问题的规则,不重不漏、无歧义地翻译成数学语言(状态转移方程)的过程。

* 依附于前一个字符的这个简单规则,导致其"消失"的行为从 dp[i][j-1] 变成了 dp[i][j-2],这正是DP状态转移严谨性的完美体现。

咱们下期再见!

相关推荐
Patrick在香港10 小时前
Python正则表达式实战:解析和验证香港身份证、车牌、电话格式
开发语言·python·正则表达式
吴声子夜歌2 天前
正则指引——Ruby
开发语言·正则表达式·ruby
吴声子夜歌2 天前
正则指引——Golang
开发语言·golang·正则表达式
吴声子夜歌3 天前
正则指引——PHP
开发语言·正则表达式·php
吴声子夜歌5 天前
正则指引——断言
正则表达式
吴声子夜歌5 天前
正则指引——匹配原理
java·正则表达式
吴声子夜歌7 天前
正则表达式——环视
开发语言·正则表达式
吴声子夜歌7 天前
正则表达式——边界
正则表达式
营养充电站8 天前
C++23 新特性在 CLion 中的实战体验
macos·docker·jupyter·正则表达式