给定字符串s和模式p,实现支持‘.’和‘*’的正则表达式匹配。
‘.’ 匹配任意单个字符.
‘*’ 匹配任意个前一字符元素.
字符串应完全匹配才可(不是部分匹配)。
注意:
s和p都可能为空,并且只包含小写字母(p还可能包含’.‘或’*’)
来源:LeetCode
链接:https://leetcode.com/problems/regular-expression-matching
题目要求中的一些典型的需要注意的情况:
题目被标记为“困难”,但如果想清楚了匹配的特殊情况和细节,能够熟练运用常见的算法(如回溯、动态规划),其实解题的思路也很简单。
解法一:(失败)
(之所以还把失败的解法放在这里,是因为虽然该解法未能解出该道题,但是其思路还是比较清晰直接的,且对于某些匹配而言是可以通过的,更重要的原因是,通过对其失败原因的分析,能够对这道题的解题思路和本质有更深的理解)
刚开始上手的时候,先尝试了预处理两个字符串,将相邻的相同字符累计存储,试图分别遍历一遍就匹配完成。为节省空间,采取边处理边匹配的方式。但这样的思路是不完全正确的,先见代码:
class Solution {
public:
bool isMatch(string s, string p) {
int s_len = s.length();
int p_len = p.length();
if (s_len == 0 && p_len == 0)
return true;
else if (s_len == 0 || p_len == 0)
return false; //这里实际上是错误的,考虑‘.*’和空字符串
int sc[2][2]; //第一维是字符串内的字符元素,第二维是对应的字符个数,每次总预取2个字符
int pc[2][2];
memset(sc, 0, sizeof(sc));
memset(pc, 0, sizeof(pc));
bool b[2] = {false, false}; //指明对应的字符是否被‘*’跟着
sc[0][0] = s[0];
sc[1][0] = 1;
int i = 1;
int j = 1;
while (i < s_len && s[i] == sc[0][0]){
sc[1][0] ++;
i++;
}
pc[0][0] = p[0];
if (1 == p_len || p[1] != '*')
pc[1][0] = 1;
else{
b[0] = true;
j++;
}
while(j < p_len && p[j] == pc[0][0]){
if (j == p_len - 1 || p[j + 1] != '*')
pc[1][0] ++;
else{
b[0] = true;
j++;
}
j++;
}
bool t1 = true; //字符串s需要往后处理
bool t2 = true; //模式p需要往后处理
bool spe = false; //模式p需要往后处理两个字符
bool may = false; //s和p匹配到了最后一个字符,p由于其个数的任意性随时可能结束
int pt1[2] = {i, -1}; //记录处理到的字符在原串中的下标
int pt2[2] = {j, -1};
while(1){
if(t1 && i < s_len){
sc[0][1] = s[i];
sc[1][1] = 1;
i++;
while (i < s_len && s[i] == sc[0][1]){
sc[1][1] ++;
i++;
}
pt1[1] = i;
}
else if(t1 && i >= s_len)
pt1[1] = -1; //无字符可处理了则标记-1
if(t2 && j < p_len){
pc[0][1] = p[j];
if (j == p_len - 1 || p[j + 1] != '*')
pc[1][1] = 1;
else{
b[1] = true;
j++;
}
j++;
while(j < p_len && p[j] == pc[0][1]){
if (j == p_len - 1 || p[j + 1] != '*')
pc[1][1] ++;
else{
b[1] = true;
j++;
}
j++;
}
pt2[1] = j;
}
else if(t2 && j >= p_len)
pt2[1] = -1; //无字符可处理了则标记-1
t1 = false;
t2 = false;
spe = false;
may = false;
if (sc[0][0] != pc[0][0] && pc[0][0] != '.' && !(pc[1][0] == 0 && b[0]))
return false; //无法匹配
else if (sc[0][0] == pc[0][0]) { //case1:字符相同
if (!b[0]) { //字符在p中未被‘*’修饰
if (sc[1][0] < pc[1][0])
return false;
else if (sc[1][0] == pc[1][0]) {
t1 = true;
t2 = true;
}
else {
sc[1][0] -= pc[1][0];
t2 = true;
}
}
else { //字符在p中被‘*’修饰
if (sc[1][0] < pc[1][0])
return false;
//!!!下面的处理考虑不周,因为无法处理情况3
t1 = true;
t2 = true;
}
}
else if(pc[0][0] != '.' && pc[1][0] == 0 && b[0]){ //case2:不同的字符,但p的对应可为0个
t2 = true;
}
else { //case3:p中对应是‘.’
if (!b[0]) { //‘.’在p中未被‘*’修饰
if (sc[1][0] < pc[1][0]) {
pc[1][0] -= sc[1][0];
t1 = true;
}
else if (sc[1][0] == pc[1][0]) {
t1 = true;
t2 = true;
}
else {
sc[1][0] -= pc[1][0];
t2 = true;
}
}
else { //‘.’在p中被‘*’修饰
if (sc[1][0] < pc[1][0]) {
pc[1][0] -= sc[1][0];
t1 = true;
}
else {
if (pt2[1] == -1 || pc[0][1] != sc[0][0]) { //p中下一个字符(或没有)与s当前字符不同
pc[1][0] = 0;
t1 = true;
may = true;
}
else {
if (pt2[1] == -1 || sc[1][0] < pc[1][1] + pc[1][0])
return false;
t1 = true;
t2 = true;
spe = true;
}
}
}
}
bool tmp1 = t1 && (pt1[1] == -1); //判断s是否结束
bool tmp2 = (t2 && (pt2[1] == -1 || (spe && j >= p_len))) || (may && pt2[1] == -1 && tmp1); //判断p是否结束
if (tmp1 ^ tmp2)
return false;
else if (tmp1 && tmp2)
return true;
if(t1){
sc[0][0] = sc[0][1];
sc[1][0] = sc[1][1];
pt1[0] = pt1[1];
sc[1][1] = 0;
}
if(t2){
pc[0][0] = pc[0][1];
pc[1][0] = pc[1][1];
b[0] = b[1];
pt2[0] = pt2[1];
b[1] = false;
pc[1][1] = 0;
}
if(spe){
pc[0][0] = p[j];
if (j == p_len - 1 || p[j + 1] != '*')
pc[1][0] = 1;
else{
pc[1][0] = 0;
b[0] = true;
j++;
}
j++;
while(j < p_len && p[j] == pc[0][0]){
if (j == p_len - 1 || p[j + 1] != '*')
pc[1][0] ++;
else{
b[0] = true;
j++;
}
j++;
}
pt2[0] = j;
}
}
}
};
用这段代码跑,可以过447个case中的402个,但是过不了上述分析中所说的情况3,也即多个相同字符(如’a’)在模式中被拆开,中间插入其它字符(可取0),在这种情况下,这种遍历一遍的思路没有办法确定模式中当前带‘*’的‘a’应该取多少个,因为尽管模式中下一个字符不是‘a’,但有可能下下个就又是了。代码永远只预处理两个字符的累计,无法实现对个数的枚举猜测。因此可以确定,必须使用回溯的思路。
解法二:递归(回溯)
回溯和递归在我的理解中是不同的:回溯是指在一棵搜索树上搜索,根据某种约束条件往下搜索或者回退;而递归是利用问题的子结构,或者说是问题和其子问题的相似性,来重复调用解题函数,将原问题不断化归成易于解决的子问题,最终利用子问题的解来得出原问题的解。
对于这道题而言,实际上是由“回溯”思想作为切入点——在匹配的过程中,需要一定的枚举试探,一旦某一步无法匹配,则回退上一步更改枚举值再继续——而仔细观察这道题可以发现,它是具有最优子结构的,“最优”可以理解为“能匹配”,显然,如果题目给出的字符串对是能匹配的,那么必然存在其某一个子串对是能匹配的,这是使用递归(或者动态规划)的一个必要条件,因此,可以通过备忘录的方式,在遍试的过程中充分利用之前的计算结果,最终得出答案。
使用递归解题的代码如下:
class Solution {
public:
enum res{False, True, NuLL}; //枚举值,增加NuLL的原因见代码后的注释
res **memo; //备忘录
int s_len, p_len;
bool isMatch(string s, string p) {
s_len = s.length();
p_len = p.length();
//这里用不着对两个字符串的长度是否为0进行特判,后续的算法直接处理
memo = new res*[s_len + 1];
for(int i = 0; i < s_len + 1; ++i){
memo[i] = new res[p_len + 1];
for(int j = 0; j < p_len + 1; ++j){
memo[i][j] = res::NuLL;
}
}
bool ans = dp(0, 0, s, p);
for(int i = 0; i < s_len + 1; ++i) //回收内存
delete[] memo[i];
delete[] memo;
return ans;
}
//递归函数dp
bool dp(int i, int j, string s, string p){
if(memo[i][j] == res::True || memo[i][j] == res::False)
return memo[i][j] == res::True;
bool ans;
if(j == p_len) //模式串已经结束,判断s是否结束(这里不单独判断s是否结束,因为可能出现最开始分析中的情况1)
ans = i == s_len;
else{
bool firstMatch = (i < s_len && (s[i] == p[j] || p[j] == '.')); //判断两个子字符串中的第一个字符是否可以匹配
if(j + 1 < p_len && p[j+1] == '*')
ans = dp(i, j+2, s, p) || (firstMatch && dp(i+1, j, s, p)); //这里就在处理前一种解法处理不了的情况3
else
ans = firstMatch && dp(i+1, j+1, s, p);
}
memo[i][j] = ans? res::True : res::False;
return ans;
}
};
注释:代码比较直观,在写的过程中遇到一个小问题,因为使用枚举值作为备忘录的值,所以枚举值不能只有True和False,还要记住添加一个“无记录”值(NuLL),以表明当前值还未被计算过。
(写到这儿发现自己对枚举类型变量所占空间的大小不太清楚,去查了一下,枚举类型的变量因为只能取众多常数中的一个,而常数默认为int类型,所以所占空间即为4个字节)
该解法的运行情况如下:
Runtime: 12 ms
Memory Usage: 22.1 MB
在时间复杂度和空间复杂度上,由于递归的栈开销,还有改进的空间。
解法三:动态规划
具有最优子结构的递归一般都可以转化成动态规划,动态规划还分为自顶向下和自底向上两种搭建方式,下面的代码使用的是自顶向下。代码结构和递归函数内部基本一致。
代码如下:
class Solution {
public:
bool isMatch(string s, string p) {
bool **memo;
int s_len, p_len;
s_len = s.length();
p_len = p.length();
memo = new bool*[s_len + 1];
for(int i = 0; i < s_len + 1; ++i){
memo[i] = new bool[p_len + 1];
for(int j = 0; j < p_len + 1; ++j)
memo[i][j] = false;
}
//注意边界的赋值
memo[s_len][p_len] = true;
for(int i = s_len; i >= 0; --i){
for(int j = p_len-1; j >= 0; --j){
bool firstMatch = (i < s_len && (s[i] == p[j] || p[j] == '.'));
if(j + 1 < p_len && p[j+1] == '*')
memo[i][j] = memo[i][j+2] || (firstMatch && memo[i+1][j]);
else
memo[i][j] = firstMatch && memo[i+1][j+1];
}
}
bool ans = memo[0][0];
//回不回收内存在平台上似乎没有什么影响,但是自己编程时一定要养成习惯
for(int i = 0; i < s_len + 1; ++i)
delete[] memo[i];
delete[] memo;
return ans;
}
};
代码运行效果:
Runtime: 4 ms
Memory Usage: 8.6 MB
都有较大的提升。
如有错误及不足,欢迎交流指正~