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