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

哈喽,各位,我是前端小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状态转移严谨性的完美体现。

咱们下期再见!

相关推荐
九皇叔叔2 天前
Linux Shell 正则表达式中的 POSIX 字符集:用法与实战
linux·运维·正则表达式
m0_64880493_江哥3 天前
用正则方法从中英文本提取英文的python示例
python·mysql·正则表达式
九皇叔叔3 天前
Linux Shell 正则表达式:从入门到实战,玩转文本匹配与处理
linux·mysql·正则表达式
一百天成为python专家4 天前
python爬虫入门(小白五分钟从入门到精通)
开发语言·爬虫·python·opencv·yolo·计算机视觉·正则表达式
蓝桉~MLGT5 天前
Python学习历程——字符串相关操作及正则表达式
python·学习·正则表达式
一晌小贪欢5 天前
Python爬虫第5课:正则表达式与数据清洗技术
爬虫·python·正则表达式·网络爬虫·python爬虫·python3·网页爬虫
MANONGMN6 天前
Linux 通配符与正则表达式(含实战案例+避坑指南)
linux·运维·正则表达式
带土16 天前
18 .shell编程-正则表达式
linux·正则表达式
2025年一定要上岸6 天前
【日常学习】10-15 学习re
学习·算法·正则表达式