【剑指Offer-19】正则表达式匹配
问题
请实现一个函数用来匹配包含'. '和'*'的正则表达式。模式中的字符'.'表示任意一个字符,而'*'表示它前面的字符可以出现任意次(含0次)。在本题中,匹配是指字符串的所有字符匹配整个模式。例如,字符串"aaa"与模式"a.a"和"ab*ac*a"匹配,但与"aa.a"和"ab*a"均不匹配。
示例
输入:
s = "aa"
p = "a"
输出: false
解释: "a" 无法匹配 "aa" 整个字符串。
输入:
s = "ab"
p = ".*"
输出: true
解释: ".*" 表示可匹配零个或多个('*')任意字符('.')。
输入:
s = "aab"
p = "cab"
输出: true
解释: 因为 '*' 表示零个或多个,这里 'c' 为 0 个, 'a' 被重复一次。因此可以匹配字符串 "aab"。
解答1:动态规划
class Solution {
public:
bool isMatch(string s, string p) {
int m = s.size(), n = p.size();
bool dp[m + 1][n + 1]; memset(dp, 0, sizeof(dp));
dp[0][0] = true;
auto matches = [&](int i, int j) { // lambda函数
if (i == 0) return false;
if (p[j - 1] == '.') return true;
return s[i - 1] == p[j - 1];
};
for (int i = 0; i <= m; i++)
for (int j = 1; j <= n; j++) {
if (p[j - 1] != '*') { // 情况1、2
if (matches(i, j))
dp[i][j] = dp[i - 1][j - 1];
} else { // 情况3
dp[i][j] = dp[i][j - 2]; // 情况3中s[i]与p[j-1]相同的第二种情况,以及情况3中s[i]与p[j-1]不相同的情况
if (matches(i, j - 1))
dp[i][j] |= dp[i - 1][j]; // 情况3中s[i]与p[j-1]相同的第一种情况,|=表示或运算
}
}
return dp[m][n];
}
};
重点思路
二维数组dp[i][j]表示待匹配字符串s的前i个字符,与正则匹配字符串p的前j个字符是否匹配。针对本问题的状态转移方程,需要考虑以下3种情况:
- 如果p[j]为小写字母,则s[i]必须为相同的小写字母才能匹配成功;
- 如果p[j]为
.
,则必定匹配成功; - 如果p[j]为
*
,如果p[j-1]与s[i]不相同,则只能把*
理解为前一个字符出现0次;如果相同,其匹配过程本质只有两种情况,以下详细讨论。
对于上述第3点,这两种情况为:
- 匹配s末尾的一个字符,将该字符扔掉,而该组合还可以继续进行匹配;
- 不匹配字符,将该组合扔掉,不再进行匹配。
综上所述,我们可以总结出以下状态转移方程:
其中or
对应的就是第3点中的两种情况,只需要满足任意一个即可,在代码中体现在|=
运算符中。
需要特别注意的是本题的动态规划数组的初始化。s与p的空与非空对应以下4种情况:
- s空p空,依照题意,匹配成功,返回true;
- s非空p空,匹配失败,返回false;
- s空p非空,只有当p是
.*
这样的形式才返回true,否则返回false; - s非空p非空,没啥好说的。
第1种情况在代码第6行被单独定义;第2种情况中,注意第13行的循环,j是从1开始的,则这种情况不会进入循环体,输出dp[m][0]
为false;第3种情况,在执行循环时,会进入matches
函数,而该函数内定义了i=0
的情况,不会出现后面坐标i-1
越界的问题(说到越界问题,再提一嘴j-2
也不会越界,因为本题默认p的第1个字符不可能是*
)。当p为.*
这种形式时,最终dp[0][m]
会退化到dp[0][0]
,返回true。
还需要注意的是,dp两个维度都比对应字符串的长度大1(因为要单独定义为空的情况),所以涉及到字符串上的角标取值时,都需要减去1,如第10行代码。
解答2:递归
class Solution {
public:
bool isMatch(string s, string p) {
if (p.empty()) return s.empty();
bool matchFirst = !s.empty() && (p[0] == s[0] || p[0] == '.'); // s和p的首字母匹配
if (p[1] == '*') return isMatch(s, p.substr(2)) || (matchFirst && isMatch(s.substr(1), p));
return !s.empty() && (matchFirst && isMatch(s.substr(1), p.substr(1)));
}
};
重点思路
- 特判,同时也是递归出口,如果
p
是空串,返回s
是否为空串。如果p
不为空,保证一定存在p[1]
(可能是字符串结尾\0
); - 假如
p[1] == *
的话,可以尝试两种情况:情况一是递归比较s
和p.substr(2)
;情况二是当s[0]
可以匹配p[0]
时, 尝试递归比较s.substr(1)
和p
,这里没有必要比较s.substr(1)
和p.substr(2)
,因为这种情况已经包含在递归比较s.substr(1)
和p
当中了; - 假如
p[1] != *
,如果p[0]
不匹配s[0]
,返回false,否则递归判断s.substr(1)
和p.substr(1)
。
解答3:非确定性有穷自动机(NFA)
class Nfa {
public:
void build(string re) { // 建图
this->re = re;
n = re.size();
for (int i = 0; i < n - 1; i++) {
if (re[i + 1] == '*') {
graph[i].push_back(i + 1);
graph[i + 1].push_back(i + 2);
graph[i + 1].push_back(i);
}
}
}
bool isMatch(string target) {
unordered_set<int> marked; // 存储有向图上当前节点能达到的所有下一节点
dfs(0, marked); // 初始化marked(初始节点能到达的节点)
for (int i = 0; i < target.size(); i++) { // 遍历待匹配字符串
vector<int> matched; // matched存储经过匹配后marked中节点能到达的所有下一节点
for (auto v : marked) {
if (v == n) continue; // 已到达正则串最后的'\0'节点,但待匹配字符串还没匹配完,丢弃该状态
if (re[v] == target[i] || re[v] == '.') // 当前节点与待匹配字符相同或者为'.'时,将当前节点的后一个节点添加进matched
matched.push_back(v + 1);
}
marked.clear();
for (auto v : matched) // 以每个matched节点为起点,生成marked
dfs(v, marked);
}
for (auto v : marked) // 待匹配字符串遍历结束后,matched节点含正则串的末尾'\0'时,匹配成功
if (v == n) return true;
return false;
}
private:
int n; // 正则串的size
string re;
unordered_map<int, vector<int>> graph;
void dfs(int cur, unordered_set<int>& marked) {
marked.insert(cur);
for (auto v : graph[cur]) {
if (marked.count(v)) continue;
dfs(v, marked);
}
}
};
class Solution {
public:
bool isMatch(string s, string p) {
Nfa nfa;
nfa.build(p);
return nfa.isMatch(s);
}
};
重点思路
具体见代码注释。打算先了解正则表达式、NFA、DFA之间的关系和转换方法,今后有时间打算单独整理一篇文章。上述实现的NFA包括有向图的构建和有向图的深度优先搜索。