您的位置:首页 > 其它

字符串匹配系列问题 & NOI OJ 6252 带通配符的字符串匹配 题解

2018-01-30 17:30 337 查看
对于带通配符的字符串AA和普通或者带通配符的字符串BB,字符串中可能有星号∗∗可以匹配0个或多个任意字符,还可能有问号??可以匹配正好一个字符,问字符串AA和BB是否相互匹配。

诸如此类的问题叫做字符串匹配问题,由它的解决方案扩展而成的正则表达式在生产开发中具有重要的意义。

这篇文章中,我们从NOI OJ 6252这道题引入,探讨解决这类问题的方法。


一个字符串带通配符的字符串匹配

请看题(NOI OJ 6252) http://noi.openjudge.cn/ch0206/6252/

题目大意就是,带通配符的字符串AA和普通的字符串BB长度都满足al≤20,bl≤20al≤20,bl≤20,AA中可能有星号∗∗可以匹配0个或多个任意字符,还可能有问号??可以匹配正好一个字符,问BB是否能匹配字符串AA。

这道题我刚拿到时是一脸懵逼的。不过研究题目后,我注意到了题目的特点:

问题可以分割为有意义的子问题。就是说,如果把字符串A​A​的前几个字符组成的子串m​m​,和B​B​中前几个字符组成的子串n​n​,把m​m​和n​n​单独挑出来替换题中的A​A​和B​B​,这个问题依然有意义。
问题没有后效性。就是说子串mm和nn是否有解,与AA和BB后半部分的字符无关。

思索再三看到了题目的分类是动态规划,我最后选择了动态规划来求解。

对于字符串AA的第ii个字符,有可能是星号、问号或者其它普通的字符。星号是最好处理的——末尾的星号意味着我们可以无视子串nn剩下的部分。所以说如果有已经确认匹配的mm和nn字符串,在mm后面加上一个星号形成m′m′,在nn后面加上任意长度的任意字符串或者不加字符串形成n′n′,必有m′m′和n′n′依然匹配。

除了星号,问号和普通字符都只可以匹配1个字符,所以把他们放在一起考虑。普通字符的匹配规则是很显然的,如果两个字符相同那就匹配,那么对于已经确认匹配的字符串mm和nn,我们在mm和nn后面分别加上一个相同的字符,使他们成为m′m′和n′n′,那么m′m′和n′n′依然匹配。问号可以匹配任意的一个字符,那么将mm加上一个问号变成m′m′,nn加上任意的一个字符成为n′n′,一定有m′m′和n′n′匹配。

综上所述,对于任意已经确认匹配的字符串mm和nn,必定有以下匹配法则:

mm加一个星号,必定和nn加上任意字符或不加字符匹配
mm加一个问号,必定和nn加上任意一个字符匹配
mm加一个普通字符,必定和nn加上相同的普通字符匹配

这时候我们就可以定义状态。如果把AA的前ii项字符组成的串记为mm,把BB的前jj项组成的串记为nn,那么就可以定义布尔数组dp[i][j]dp[i][j]记为mm和nn是否匹配。根据上面的匹配法则,可以得出这样的递推法则:

如果
A[i]
是星号,只要dp[i−1][k],1≤k≤bldp[i−1][k],1≤k≤bl中有一个为真,那么dp[i][j]dp[i][j]为真
如果
A[i]
是问号,而且dp[i−1][j−1]dp[i−1][j−1]为真,那么dp[i][j]dp[i][j]为真
如果
A[i]
是普通字符,而且dp[i−1][j−1]dp[i−1][j−1]为真,而且
A[i]
B[j]
相同,那么dp[i][j]dp[i][j]为真

对于动态规划问题,我们还需要考虑边界条件。如果A、B是两个由一个相同普通字符组成的字符串,即i=j=1i=j=1且
A[i]=B[j]
,显然dp[1][1]dp[1][1]为真,按照递推法则3,必须要先有dp[0][0]dp[0][0]为真——两个空的字符串我们不能认为他们不匹配。如果AA只包含一个星号,即i=1i=1且
A[1]
为星号,虽然我们得到了递推法则1,但我们似乎不能用赋初值的方法让dp[0][k]dp[0][k]全部符合——那就特判一下吧。

这道题还有个坑的地方——字符串A和B可以包含空格!我一开始没有发现,然后用cin读入,自信满满的提交了代码以为能AC,结果WA了两组点,修改了用gets读入才得到AC。另外,这题数据范围不大,如果长度大还能用滚动数组优化空间复杂度,这里就不讲了。

AC代码是这样的:
12345678910111213141516171819202122

#include <cstdio>#include <iostream>#include <cstring>using namespace std;#define FOR(x,f,t) for(x=f;x<=t;++x) #define M 25char A[M], B[M];bool dp[M][M];int al,bl,i,j,k;int main () { 	gets(A+1), gets(B+1); //读入有空格!	al=strlen(A+1), bl=strlen(B+1);	dp[0][0]=1; //赋初值,上文有提到	if(A[1]=='*') FOR(j,0,bl) dp[1][j]=1; //特判,一个星号能匹配所有的字符串	FOR(i,1,al) {		if(A[i]=='*') FOR(j,1,bl) FOR(k,1,bl) dp[i][j]|=dp[i-1][k];		else FOR(j,1,bl) dp[i][j]=(dp[i-1][j-1]&&(A[i]==B[j]||A[i]=='?')); //这里把普通字符和问号合并了	}	if(dp[al][bl]) cout<<"matched";	else cout<<"not matched";	return 0;}

其实这个问题还有个升级版——


两个字符串都带通配符的字符串匹配

不要给这标题吓到了。有了解决了一个字符串的通配符问题的经验,两个字符串都含通配符的匹配问题不是很难想。

当第二个字符串也出现通配符的时候,我们经过推敲,欣喜的发现的法则都没有变化——意味着我们的核心算法不需要做大的改动。唯一需要注意的是赋予初值时的小变化——赋予初值的时候注意把AA和BB都判断一次,判断星号问号的时候把AA和BB同时判断即可。

我们只需要对原来的代码做一些小更改,就能得到解决两个字符串都带通配符的匹配问题的代码:
123456789101112131415161718192021222324

#include <cstdio>#include <iostream>#include <cstring>using namespace std;#define FOR(x,f,t) for(x=f;x<=t;++x) #define M 25char A[M], B[M];bool dp[M][M];int al,bl;int main () { 	gets(A+1), gets(B+1);	al=strlen(A+1), bl=strlen(B+1);	int i,j,k;	dp[0][0]=1;	if(A[1]=='*') FOR(j,0,bl) dp[1][j]=1;	if(B[1]=='*') FOR(i,0,al) dp[i][1]=1; //同时赋初值	FOR(i,1,al) { //这里从A的长度作为第一维,其实从A和B都是一样的		if(A[i]=='*'||B[i]=='*') FOR(j,1,bl) FOR(k,1,bl) dp[i][j]|=dp[i-1][k]; //同时判断星号		else FOR(j,1,bl) dp[i][j]=(dp[i-1][j-1]&&(A[i]==B[j]||A[i]=='?'||B[i]=='?')); //同时判断问号	}	if(dp[al][bl]) cout<<"matched";	else cout<<"not matched";	return 0;}


总结

字符串匹配问题的应用非常广。我们在生产开发过程中常常有各种各样的字符串匹配和替换需求,这时候就可以用到正则表达式(regexp或regex),这是一个比上面两个字符串匹配算法还要强大的引擎,许多编程语言都支持它,许多文本编辑器也使用它来完成查找替换。正则表达式辉煌的背后需要解决许多类似这样的字符串匹配问题,有兴趣的朋友们可以去百毒或者谷割了解一下。

最后感谢 KyleYoung神犇 的指导 :)

by lmlstarqaq


带有通配符的字符串匹配


一、Leetcode | 44 Wildcard Matching(只有一个字符串包含通配符)



题目很简单,就是说两个字符串,一个含有通配符,去匹配另一个字符串;输出两个字符串是否一致。

注意:’?’表示匹配任意一个字符,’*’表示匹配任意字符0或者多次

首先,我们想到暴力破解。如果从头到尾的破解,到第二个字符时,是否匹配成功取决于第一个字符是否匹配成功! 所以我们想到应该要用到动态规划;

既然用到动态规划,最重要的是设置初值 和找到递推式:

于是,我们开始分析初值怎么设;其实很简单,把这个匹配问题可以想象成一个矩阵dp,纵轴代表含有通配符的匹配字符串s2, 横轴代表要匹配的字符串s1。假设现在s2=”a*b”, s1=”abc” 如图:



对应空位就是截止到当前的 (i,j) 位置,两字符串是否匹配。匹配为 T(true),不匹配为 F(false),最后返回最右下角的值,就是当前两个字符串是否匹配的最终值;

现在我们要做的设置初值,所以我们大可多加一行和一列,来填充初值;s1既然是要匹配的,我们都设为 F(即dp[0][1]=F,dp[0][2]=F,dp[0][3]=F),表示当前还未开始匹配。而s2的初值,我们发现如果星号和a调换位置,星号可以匹配任意字符串,所以dp[i][0]的值取决于该位置是否为星号和上一个位置d[i-1][0]是否为T(其实就是上一个位置是否也是星号),所以我们设置dp[0][0]为 T。所以形成下图:



此时初值已经设置完毕,我们要找到递推式;经局部推算,我们发现递推式应该有两种,一种是当s2的字符是星号,另一种是s2的字符是非星号。

先看星号的情况:当要计算dp[2][1](即要匹配a*和a时),我们发现是取决于dp[1][1](即a和a是否匹配),当要计算dp[2][2] (即要匹配a*和ab时),是取决于dp[2][1] (即a*和a是否匹配)。抽象一下,星号和任意字符(0或多个)都匹配。所以字符串截止到星号匹配的情况,取决于当前位置向上和向左的情况(即可以为0个字符,也可以为多个字符)。所以此时递推式为dp[i][j]=dp[i−1][j]||dp[i][j−1] 如图:



再看非星号的情况:当要计算dp[3][2] (即要匹配a*b和ab时),则取决于dp[2][1]和a[3][2] (即a*和a是否匹配,同时b和b是否匹配);所以可以得到递推式 dp[i][j] = dp[i-1][j-1]&&a[i][j]。如图:



最后我们得到了初值和两个递推式,就可以上代码了;
//isMatch: s1无通配符,s2有通配符, '?'表示匹配任意一个字符,'*'表示匹配任意字符0或者多次
public static boolean isMatch(String s1, String s2) {
int countXing = 0;
for(char c : s2.toCharArray())
countXing++;
if(s2.length() - countXing > s1.length() ) //说明s2去掉通配符,长度也长于s1
return false;

//动态规划设置初值
boolean[][] dp = new boolean[s2.length()+1][s1.length()+1];
dp[0][0] = true;

for(int i=1; i<=s2.length(); i++) {
char s2_char = s2.charAt(i-1);
dp[i][0] = dp[i-1][0] && s2_char=='*'; //设置每次循环的初值,即当星号不出现在首位时,匹配字符串的初值都为false
for(int j=1; j<=s1.length(); j++) {
char s1_char = s1.charAt(j-1);
if(s2_char == '*')
dp[i][j] = dp[i-1][j] || dp[i][j-1]; //动态规划递推式(星号) 表示星号可以匹配0个(决定于上次外循环的结果)或者多个(决定于刚才内循环的结果)
else
dp[i][j] = dp[i-1][j-1] && (s2_char=='?' || s1_char == s2_char); //动态规划递推式(非星号) 表示dp值取决于上次的状态和当前状态
}
}
return dp[s2.length()][s1.length()];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[/code]


PS: 两个字符串都包含通配符的解法

通过上面那个列子,其实就这个问题就很容易想了。

首先就是初值的设置,两个字符串都按上题中的包含通配符的字符串设置初值的方法,根据是否为星号和上一个的状态。

其次就是递推式,它不用变,只是需要同时判断两个字符串中是否都包含通配符。

直接上代码:
public static boolean isMatchByBoth(String s1, String s2) {

//动态规划设置初值
boolean[][] dp = new boolean[s2.length()+1][s1.length()+1];
dp[0][0] = true;

for(int i=1; i<=s2.length(); i++) {
char s2_char = s2.charAt(i-1);
dp[i][0] = dp[i-1][0] && s2_char=='*'; //设置每次循环的初值,即当星号不出现在首位时,匹配字符串的初值都为false
for(int j=1; j<=s1.length(); j++) {
char s1_char = s1.charAt(j-1);
dp[0][j] = dp[0][j-1] && s1.charAt(j-1)=='*';
if(s2_char == '*' || s1_char == '*') {
dp[i][j] = dp[i-1][j] || dp[i][j-1]; //动态规划递推式(星号) 表示星号可以匹配0个(决定于上次外循环的结果)或者多个(决定于刚才内循环的结果)
} else {
dp[i][j] = dp[i-1][j-1] && (s1_char=='?' || s2_char=='?' || s1_char == s2_char);
}
}
}
return dp[s2.length()][s1.length()];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[/code]


二、Leetcode | 10 Regular Expression Matching(正则通配符)

这道题是把*的概念变了,它代表匹配星号之前元素的0个或多个。即 c* 带便0个或者多个c。

所以具体代码和思路写到代码注释里了。大家可以对照上面的题看看。
public class Solution {
public boolean isMatch(String s, String p) {
//有两个假设,一个是不会出现c**的格式;第二个打头的一定是字母
//dp[i, j] means matching status between s.Substring(0, j) and p.Substring(0, i)
boolean[][] dp = new boolean[p.length()+1][s.length()+1];
dp[0][0] = true;

for(int i=1; i<=p.length(); i++) {
char pchar = p.charAt(i-1);
//dp[i, 0] means if patter.Substring(0, i) matches empty string
if(i > 1 && pchar=='*') dp[i][0] = dp[i-2][0];

for(int j=1; j<=s.length(); j++) {
char schar = s.charAt(j-1);
if(i > 1 && pchar == '*') {
//p可以匹配多个或0个pchar元素,所以检查上一个,是是否匹配多个,检查上上一个,是匹配了0个,检查上上一个匹配元素的状态(这是竖着的)
dp[i][j] = dp[i-2][j] || dp[i-1][j];
if(j > 1 && match(schar, p.charAt(i-2))) //从第二列(p的第二个字符开始),是否有连续匹配
dp[i][j] = dp[i][j] || dp[i][j-1];
} else {
dp[i][j] = match(schar, pchar) && dp[i-1][j-1];
}

}
}
return dp[p.length()][s.length()];
}

boolean match(char c, char p) {
if (p == '.') return true;
else return c == p;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
[/code]

以上只是自己的理解,希望大家多多交流!
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: