一、题目背景
我们要实现一个简化版的正则表达式匹配函数 isMatch(s, p),其中:
-
s:文本串(普通字符串) -
p:模式串,只包含:-
普通字符(例如
'a','b'等) -
特殊字符
'.':匹配任意单个字符 -
特殊组合
'*':匹配 零个或多个 前一个字符(例如a*,b*,.*)
-
要求:
必须匹配整个字符串 s,不能只匹配前半段或中间一段。
二、暴力递归思路(会超时)
最直接的想法是:
用递归从左往右匹配 s 和 p,每次只看当前的第一个字符,处理两种情况:
-
当前首字符是否匹配(firstMatch)
cppbool fristVail = (!s.empty() && (s[0] == p[0] || p[0] == '.'));也就是:
-
s不能为空 -
并且
s[0]与p[0]相等,或者p[0] == '.'
-
-
第二个字符是否是
'*'-
如果
p[1] == '*',说明当前模式是X*,可以有两种选择:-
把
X*当成「出现 0 次」:直接跳过p的前两个字符 →isMatch(s, p.substr(2)) -
把
X*当成「吃掉一个字符」:前提是首字符匹配 →fristVail && isMatch(s.substr(1), p)
-
-
否则就是普通情况:
首字符必须匹配,然后同时往后挪一格:
cppfristVail && isMatch(s.substr(1), p.substr(1));
-
这个写法逻辑是对的,但会有大量重复计算 ,在一些构造出来的极端 case 下会 超时。
三、优化方向:记忆化搜索(递归 + 缓存)
暴力递归会 TLE 的根本原因是:
同一个子问题(同一段
s[i:]和p[j:])会被重复计算很多次。
所以我们引入一个二维数组 memo 来做记忆化:
-
memo[i][j]表示:
s[i:]与p[j:]是否匹配的结果 -
取值约定:
-
-1:还没计算过 -
0:不匹配(false) -
1:匹配(true)
-
这样,每当我们递归到 dfs(s, p, i, j) 时:
-
先看
memo[i][j]里有没有记录 -
有的话直接返回,不再重复递归
-
没有的话正常计算一次,并把结果存入
memo
这样,整个算法的时间复杂度就从「指数级」降到了 O(|s| × |p|)。
四、核心函数:dfs 的含义与逻辑
1. 函数定义
cpp
bool dfs(const string& s, const string& p, int i, int j)
表示:
当前我们要判断:从
s的第i个字符开始的后缀s[i:],是否能匹配模式串从第j个字符开始的后缀p[j:]。
也就是把原来的 isMatch(s, p)
变成了带下标版本的 dfs(s, p, i, j)。
2. 记忆化剪枝
cpp
if (memo[i][j] != -1) return memo[i][j];
如果这个状态之前计算过,直接返回结果。
3. 递归边界:模式串 p 用完了
cpp
if (j == p.size()) { // p 用完了
ans = (i == s.size());
}
-
如果
p也用完了,那么只有在s也用完的情况下才是匹配成功 -
如果
s没用完,那就说明还有残留字符,匹配失败
这其实对应原来暴力版里的:
cpp
if (p.empty()) return s.empty();
只是这里改成了「用下标表示空串」:
-
j == p.size()表示p剩余部分为空 -
i == s.size()表示s剩余部分为空
4. 一般情况:模式串还没用完
如果 p 还没用完,就进入 else 分支:
cpp
bool firstMatch = (i < s.size() &&
(s[i] == p[j] || p[j] == '.'));
firstMatch 表示当前这一个字符位置上是否能匹配:
-
i < s.size():s 不能越界 -
s[i] == p[j]:同字符 -
或者
p[j] == '.':点号可以匹配任意一个字符
5. 处理 '*' 的情况
如果 p[j+1] 存在且为 '*':
cpp
if (j + 1 < p.size() && p[j + 1] == '*') {
ans = dfs(s, p, i, j + 2) ||
(firstMatch && dfs(s, p, i + 1, j));
}
结合含义解释:
-
p[j]是某个字符X -
p[j+1]是'*' -
整体
p[j] p[j+1]组成X*
X* 有两种用法:
① 把 X* 当成「匹配 0 次」
直接跳过 X*,即从 p[j+2] 开始继续匹配:
cpp
dfs(s, p, i, j + 2);
此时 s 不动,因为我们选择让 X* 不吃任何字符。
② 把 X* 当成「匹配 ≥1 次」
前提是 firstMatch == true,也就是当前字符可以被 X 匹配。
那么:
-
s往后移动一格(被X吃掉了一个字符) -
p仍停在j,因为X*可能继续多次匹配
cpp
firstMatch && dfs(s, p, i + 1, j);
这两种情况只要有一种为真,整体就为真,所以用 || 连接。
6. 普通情况(不含 '*')
如果 p[j+1] 不是 '*',那当前就只能是普通字符或 '.' 匹配:
cpp
ans = firstMatch && dfs(s, p, i + 1, j + 1);
解读:
-
当前字符必须先能匹配
-
然后递归匹配后面的子串:
s[i+1:]和p[j+1:]
7. 把结果写入 memo
cpp
memo[i][j] = ans;
return ans;
避免以后再遇到同样的 (i, j) 时重复计算。
五、对外接口:isMatch
isMatch 只是一个包装函数,负责:
-
初始化
memo数组 -
从
(0, 0)开始匹配
cpp
bool isMatch(string s, string p) {
memo.assign(s.size() + 1, vector<int>(p.size() + 1, -1));
return dfs(s, p, 0, 0);
}
这里的 s.size() + 1 / p.size() + 1 是因为:
-
i的取值范围是[0, s.size()](包含s.size(),表示空后缀) -
j的取值范围是[0, p.size()](同理)
所以必须多开一格来存 i == s.size() 或 j == p.size() 这些"空串状态"。
六、时间复杂度与优势
有了记忆化之后,每个 (i, j) 状态至多计算一次:
-
状态数量:
(s.size() + 1) * (p.size() + 1) -
每个状态的转移是 O(1)
-
整体时间复杂度:O(|s| × |p|)
-
空间复杂度同样是 O(|s| × |p|)
相比较原始的暴力递归(指数级),这个解法在 LeetCode 上是完全可以通过的,不会超时。
七、小结
-
暴力版本
isMatch(s, p)核心思想没错,但存在大量重复子问题 → 超时。 -
优化思路是:把函数改写成带下标的
dfs(s, p, i, j),并用memo[i][j]记忆结果。 -
关键点在于正确处理:
-
p 用完:
j == p.size() -
X*两种分支:-
跳过
X*:dfs(s, p, i, j + 2) -
用
X*吃掉一个字符:firstMatch && dfs(s, p, i + 1, j)
-
-
-
外部接口
isMatch只负责初始化memo并从(0, 0)开始搜索。