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