字符串匹配就是在主串A中查找模式串B,例如在主串abababc中查找模式串abc是否存在,记主串A的长度为n,模式串B的长度为m,n>=m。
BF算法
BF(Brute Force)算法,又叫暴力匹配算法或者朴素匹配算法,思路很简单:在主串中取前下标为[0,m-1]这m个字符的子串和模式串逐个字符逐个字符比较,如果完全一样就结束并返回下标;如果有不一样的,那么主串中的子串后移一位,主串中[1,m]这个子串和模式串继续比较,… ,主串中[n-m,n-1]这个子串和模式串继续比较。主串中长度为m的子串有n-m+1个。
int BF(std::string &s,std::string &pattern) {
int n = s.length(), m = pattern.length();
for (int i = 0; i < n-m+1; i++) {
int j = 0;
for (; j < m; j++) {
if (s[i + j] != pattern[j])
break;
}
if(j==m)
return i; //匹配到了,返回主串中的下标
}
return -1; //匹配不到
}
最坏的情况下,在第一个for循环里,i 从0到n-m走满共n-m+1次,第二个for循环里,j 从0到m-1走满共m次,因此最坏的情况下时间复杂度为O(n*m),举个例子,在bbbbbbf中查找bf,所有n-m+1个子串都要走完,并且每次和模式串比较都要比较m次,总共比较n-m+1次。
BF算法最大的优点就是简单,代码不容易出错,在主串和模式串的长度都不大的时候还是比较实用的。
RK算法
RK(Rabin-Karp)算法,是用两个发明者的名字命名的。思路也比较简单:对主串中n-m+1个子串求哈希值,模式串也求哈希值,然后比较子串的哈希值和模式串的哈希值,如果不相等证明不匹配,如果相等就匹配(在没有哈希冲突的情况,冲突的情况后面会讲)。
这个算法对哈希函数的设计要求会高一点,当然最好就不存在哈希冲突,就会比较简单,相等就匹配,不相等就不匹配。
来看看这样一个设计:假设主串和模式串只有a-j这10个字母,我们可以直接将字符串映射成整数(a-j对应十进制0-9),例如bcd我们可以直接映射成bcd=1*10*10+2*10+3=123,这样就不存在哈希冲突了。那如果现在是a-z这26个字母,我们可以用同样的思路,但是使用26进制,a-z对应0-25:例如bcd=1*26*26+2*26+3=731。
这个哈希函数是有规律的,当前子串的哈希值hash[i]是可以根据上一个子串的哈希值hash[i-1]计算得到,来看看下面这个例子:
上面的子串aba的起始坐标为i-1,哈希值为hash[i-1],下面子串bab的起始坐标为i,哈希值为hash[i],我们知道两个子串中都有ba,但是下面的ba映射成的值要比上面的ba要大26倍,因为所在的位置不一样,下面的ba在最高位和第二位,下面的ba在第二位和第三位,那么我们将hash[i-1]乘26,上下子串ba的hash值都相同了,然后再减去上面子串的最高位a(注意此时的a也是乘多了个26的),最后加上下面子串的末尾的b即可。最终的计算结果就同上图中的计算一样,注意蓝色框框的m即可,是m而不是m-1,因为hash[i-1]乘了26。
//假设哈希值不会溢出int的范围
int RK(std::string& s, std::string& pattern) {
int n = s.length(), m = pattern.length();
int p = pow(26, m); //26的m次方
int* hash = new int[n - m + 1];
hash[0] = s[0] - 'a';
int hash_pattern = pattern[0] - 'a';
for (int i = 1; i < m; i++) { //求第一个子串的哈希值hash[0]和模式串的哈希值hash_pattern
hash[0] = hash[0] * 26 + s[i] - 'a';
hash_pattern = hash_pattern * 26 + pattern[i] - 'a';
}
for (int i = 1; i < n - m + 1; i++) //根据前一个子串的哈希值求所有的子串的哈希值,代入上图的公式
hash[i] = 26 * hash[i - 1] - (s[i - 1] - 'a') * p + (s[i + m - 1] - 'a');
for (int i = 0; i < n - m + 1; i++) //比较模式串的哈希值hash_pattern和每个子串的哈希值
if (hash[i] == hash_pattern) //相同即匹配
return i;
return -1; //不匹配
}
int main() {
std::string a = "abababc";
std::string p = "abc";
std::cout << RK(a, p) << std::endl;
}
遍历一次主串就可以求出所有子串的哈希值了,求一个子串的哈希值时间复杂度可以看做O(1),求所有子串的哈希值的时间复杂度为O(n)。模式串和子串的哈希值比较时间复杂度为O(1),共有n-m+1个子串即比较n-m+1次,所以时间复杂度为O(n)。所以RK算法的时间复杂度为O(n)。
如果模式串的长度m太大,字符不止26个字母,我们上面设计的算法可能会溢出整型数据的范围,我们也可以设计别的哈希函数:例如每种字符对应一个小质数,然后将所有字符对应的质数相加得到哈希值,这样算出来的哈希值会比较小,基本不会溢出,但是可能会造成哈希冲突。
存在哈希冲突的情况下,我们对比子串的哈希值和模式串的哈希值,如果不相等还是证明两个串不匹配;如果相等,由于存在哈希冲突的情况,我们还不能判定子串和模式串相等,还需要逐个字符进行比较,如果每个字符都相同,才能证明匹配。
在极端的情况下,存在大量哈希冲突,RK算法会退化BF算法,时间复杂度为O(n*m)。
KMP算法
KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,也是由这三位作者的名字进行命名的,用i 遍历主串,j 遍历模式串,主要的思想就是主串的 i 不回溯,模式串的 j 回溯到某个位置k,使得模式串的[0,k-1]的位置和主串[i-k,i-1]的位置直接匹配。来看看下面的例子:
这个例子中,我们一直比较s[i]和pattern[j]的值,当i=j=4的时候也就是上图中的红色框框的时候,s[i]!=pattern[j],此时如果按照BF算法,i 回溯到1,j 回溯到0继续比对,但其实是不必要的,因为i 回溯到1,s[i]=‘b’,而j 回溯到0,pattern[j]=‘a’,很明显是不匹配的;来看看KMP算法的做法:主串不回溯也就是 i 不动,j 回溯到 k的位置,使得模式串的[0,k-1]的位置和主串[i-k,i-1]的位置直接匹配(下图中 j 前的a和 i 前的a匹配,即蓝色框框的部分直接匹配):
那现在问题就变成了位置k的确定。前面说到k的位置要满足:模式串的[0,k-1]的位置和主串[i-k,i-1]的位置直接匹配,也就是第二幅图中蓝色框框的部分匹配。那再来看看第一幅图中的两个绿色框框,可以发现模式串的[j-k,j-1]和主串的[i-k,i-1]部分相等,整理得到:
pattern[0]~pattern[k-1] = s[i-k]~s[i-1]
pattern[j-k]~pattern[j-1] = s[i-k]~s[i-1]
两个等式右边部分都相等,所以我们可以得到两个等式左边部分相等:
pattern[0]~pattern[k-1] =pattern[j-k]~pattern[j-1]
其实就是上面这幅图中3个绿色的框框,整个道理也很简单:就是因为i 和 j前的a相等,而j 前的a 和 k前的a 相等,所以我们下次回溯的时候,就用k 前的a 对准 i 前的a,有机会可以匹配成功,而省略了BF算法中的多次不必要的回溯和比较。
我们现在可以知道k的位置是由模式串本身来确定的,和主串无关:pattern[0]~pattern[k-1] =pattern[j-k]~pattern[j-1],也就是说模式串中每一个字符pattern[j]都对应着一个k值,这个k值只与模式串有关,我们用next[j]表示pattern[j]对应的k值,next数组的求解就是KMP算法的关键。
next[j]就是模式串[0,j-1]的子串中真前缀和真后缀的最长可匹配长度,下面举一个例子(模式串为ababc):
j=0时,next[0]=-1表示不存在,因为当j=0的时候,j-1=-1是非法下标。
j=1时,[0,j-1]的子串只有一个a,没有真前缀和真后缀(因为真前缀真后缀不能包含自身),此时next[1]=0。
j=2时,[0,j-1]的子串为ab,此时【真前缀为a,真后缀为b】,可最长匹配长度为0,所以next[2]=0;
j=3时,[0,j-1]的子串为aba,此时【真前缀为a,真后缀为a】,还有一种组合【真前缀为ab,真后缀为ba】,第一个组合的匹配长度为1,第二个组合的匹配长度为0,最长匹配长度为1,所以next[3]=1。
j=4时,[0,j-1]的子串为abab,此时的组合有:【真前缀为a,真后缀为b】,【真前缀为ab,真后缀为ab】,【真前缀为aba,真后缀为bab】,匹配长度分别为0,2,0,最长匹配长度为2,所以next[4]=2。
上面这种方法只是我们用人脑计算真前缀和真后缀的最长可匹配长度,那要是让计算机来如何计算呢?我们贴出代码,再结合代码进行解释:
void getnext(std::string &pattern, int next[]) { //next的长度为m
int m = pattern.length();
int k = -1 ,j = 0 ;
next[0] = -1;
while (j < m) {
if (k == -1 || pattern[j] == pattern[k])
next[++j] = ++k;
else
k = next[k];
}
}
前三行代码应该没啥问题,k和j相当于两个快慢指针,k用来找真前缀,j用来找真后缀。next[j]=k要满足的关系也正如我们前面提到的:
pattern[0]~pattern[k-1] =pattern[j-k]~pattern[j-1]
接下来是while循环,j的另一个作用是用来遍历模式串的,所以循环的条件是j<m。
先让我们看下if分支,先忽略k==-1这个判定条件,pattern[j] == pattern[k]的时候next[++j] = ++k。这个应该比较好理解,其实就和上面这条等式差不多,上面这条等式是next[j]=k要满足的要求,而代码这里是next[j+1]=k+1要满足的要求。
再来看看为什么要判断k==-1,首先k初始化为-1,因为j是从0开始的,j是快指针,k是慢指针,所以k要从-1开始。if分支里要访问pattern[k]的值,k为-1的时候是非法下标;其次在else语句中,k = next[k]导致了k是会往前走的(也就是k会变小),有可能k回退到next[0]的时候又会变为-1了,所以在访问pattern[k]的时候又会是非法下标。综上所述,判断k==-1是为了保证访问pattern[k]时数组下标合法。
整段代码的关键就在于else分支的k = next[k](也就是k!=-1且pattern[j] != pattern[k]),也是最难懂的地方,让我们来看个例子:
如图,现在已知next[13]=6,求next[14],所以两个橙色框框内长度为6的真前缀和真后缀是相同的(也就是0和7相同,1和8相同,2和9相同以此类推…最后5和12相同);假如现在走if分支:pattern[k]==pattern[j]即pattern[6]==pattern[13]的话,那么执行next[++j] = ++k即next[14]=7,这个应该没啥问题。
那如果走的else分支呢?pattern[k]!=pattern[j]即pattern[6]!=pattern[13]:k = next[k]
现在假设next[k]=i,如图所示,那么代表(0和1的绿框的两个真前缀和4和5的蓝框的后前缀相同),但是前面说过两个橙色框相同,可以推出两个绿框相同,两个篮筐也相同,最终推导出两个篮筐和两个绿框总共4个框框里面的字符串都相同(即0、4,、7、11相同;1、5、8、12相同);我们的目的主要是为了证明(0和1的绿框 与 11和12的蓝框相同),那么现在只需比较pattern[j]是否等于pattern[i],如果相等next[++j]=++i也即next[14]=3。而我们为了逻辑统一,将 i 赋值给k,即k=next[k],那么又可以回到if的分支和else的分支进行判断了。
如果还不明白为什么k要初始化为1,代码也可以这样写:k初始化为0,j初始化为1,如果pattern[j] == pattern[k]那么next[2]=1这里应该没问题,因为k指向真前缀,j指向真后缀。但是仍要判断k==-1,因为next[0]=-1,k回退时有可能还是会出现-1的情况。其次 next[1]=0这里可能会溢出,因为m=1的时候next数组的长度也只有1。我们可以将next[1]也并入下面的逻辑,也就是k从-1开始,j从0开始,同样的if else逻辑也可以计算出next[1]=0,所以我们不采取下面这种写法,而采用上面的写法。
void getnext(std::string &pattern, int next[]) { //next的长度为m
int m = pattern.length();
int j = 1, k = 0;
next[0] = -1;
next[1] = 0; //模式串的长度m可能只有1,所以这里有可能会溢出
while (j < m) {
if (k == -1 || pattern[j] == pattern[k]) //next[0]=-1,k回退时有可能还是会出现-1的情况
next[++j] = ++k;
else
k = next[k];
}
}
在构建完next数组后,剩下的部分就简单了:
int KMP(std::string& s, std::string& pattern) {
int n = s.length(), m = pattern.length(), j = 0, i = 0;
int* next = new int[m];
getnext(pattern, next);
while (i < n && j < m) {
if (j == -1 || s[i] == pattern[j]) {
++i;
++j;
}
else
j = next[j];
}
if (j == m) //j走完整个pattern证明匹配成功,也会退出while循环
return i - m; //返回匹配开始的位置,这里return i-j和return i-m是一样的,因为j==m
return -1;
}
前面说过,kmp算法主串不回溯,也就是i是不回溯的,一直往上加遍历主串,如果j==-1或者s[i] == pattern[j]时,i和j分别自加,准备下一个字符的比较,否则j回溯到next[j]的位置,回溯后pattern[0]~pattern[j-1] = s[i-j]~s[i-1],也就是说j前面的字符串和主串是已经匹配的了,只需要继续比较pattern[j]和s[i]。
while循环结束的情况有:i==n或者j==m或者(i==n且j==m),只要j==m就证明匹配成功,因为j走完了整个pattern,说明每个pattern的字符在主串中都能匹配上。完整代码:
void getnext(std::string &pattern, int next[]) { //next的长度为m
int m = pattern.length();
int j = 0, k = -1;
next[0] = -1;
while (j < m) {
if (k == -1 || pattern[j] == pattern[k])
next[++j] = ++k;
else
k = next[k];
}
}
int KMP(std::string& s, std::string& pattern) {
int n = s.length(), m = pattern.length(), j = 0, i = 0;
int* next = new int[m];
getnext(pattern, next);
while (i < n && j < m) {
if (j == -1 || s[i] == pattern[j]) {
++i;
++j;
}
else
j = next[j];
}
if (j == m) //j走完整个pattern证明匹配成功,也会退出while循环
return i - m; //返回匹配开始的位置,这里return i-j和return i-m是一样的,因为j==m
return -1;
}
int main() {
std::string a = "abababc";
std::string p = "abc";
std::cout << KMP(a, p) << std::endl;
}
KMP算法使用了额外的next数组,长度是pattern的长度m,所以空间复杂度是O(m)。
getnext函数的时间复杂度为O(m),KMP函数中的while循环时间复杂度为O(n),因此KMP算法的时间复杂度为O(m+n)。
BM算法
我们在文本编辑器中,我们经常用到查找及替换功能。比如说,在Word文件中,通过查找及替换功能,可以把某一个单词统一替换成另一个单词。对于文本编辑器这种软件来说,查找及替换是其核心功能,我们希望使用的字符串匹配算法尽可能地高效。之前讨论过RK算法,时间复杂度为O(n),其实已经很高效了,现在来介绍一个新的字符串匹配算法,BM(Boyer-Moore)算法。
模式串和主串的匹配过程可以看成模式串在主串中不停的向后滑动。当遇到不匹配的字符时,BF算法和RK算法是将模式串往后滑动一位,然后从模式串的第一个字符开始重新匹配。
主串中的字符c在模式串中没有对应的字符,就肯定无法匹配。因此,我们可以一次性把模式串往后多滑动几位,把模式串移动到字符c的后面,如下图。
其实,BM算法本质上就是在寻找某种规律,借助这种规律,在模式串与主串匹配的时候,当模式串与主串中的某个字符不匹配时,能够跳过一些肯定不会匹配的情况,将模式串往后多滑动几位。字符串匹配的效率因此就高了。
4.2 BM算法的原理分析
BM算法的具体实现原理包含两个部分:坏字符规则(bad character rule)和好后缀规则(good suffix rule)。
1.坏字符规则
我们知道在BF算法中的模式串和主串之间的匹配是按照下标从小到大的顺序进行的,而BM算法的匹配顺序却是比较特殊,他是按照模式串下标从大到小的顺序倒叙进行的。如下图。
从模式串的末尾往前倒着匹配,当发现某个字符无法匹配的时候,我们就把这个无法匹配的字符称为“坏”字符。注意,坏字符指的是主串中的字符,而不是模式串中的字符。如下图。
我们用坏字符c在模式串中查找,发现模式串中并不存在这个字符,也就是说,字符c与模式串中的任何字符都不可能匹配。这个时候,我们就可以将模式串直接滑动到字符c的后面,再重新从模式串的末尾字符开始比较。
我们将模式串滑动到c之后,就会发现,模式串中的最后一个字符d,还是无法与主串中的字符a相互匹配。此时,我们是否能将模式串滑动到主串中坏字符a(主串中第三个a)的后面?
其实是不可以的,因为坏字符a在模式串中存在,也就是下标为0的位置存储的就是字符a。因此,我们可以将模式串往后滑动两位,让模式串中的a与主串中的第三个a上下对齐,然后再从模式串的末尾字符开始匹配。
现在大家可能会发现一个问题,那就是,第一次我们移动模式串的时候,是移动了三位,但是第二次的时候就是移动了两位,那么对于移动的具体次数,有没有规律呢?
当模式串与主串不匹配时,我们把坏字符对应的模式串中的字符在模式串中的下标记作为si。如果坏字符在模式串中存在,那么我们把坏字符在模式串中的下标记作xi,如果坏字符在模式串中不存在,那么我们把xi记作-1。那么,模式串往后滑动的位数就等于si-xi。
这里还要说明的是,如果坏字符在模式串中出现多次,那么在计算xi的时候,我们选择模式串中最靠后的哪个坏字符的下标作为xi的值。这样就不会因为模式窜滑动过多,而导致本来可能匹配的情况被忽略。
利用坏字符规则,BM算法在最好情况下的时间复杂度非常底,是O(n/m)。例如,主串是aaabaaabaaabaaab,模式串是aaaa,每当模式串与主串不匹配时(坏字符是b),我们就可以将模式串直接往后滑动4位,因此,匹配具有类似特点的主串与模式串的时候,BM算法是高效的。
不过,单纯使用坏字符规则还不够,因为根据si-xi计算出来的滑动位数又可能是负数,如主串是aaaaaaaaaaaa,模式串为baaa。针对这种情况,就还需要另外一种规则,好后缀规则。
2.好后缀规则
好后缀规则与坏字符规则非常类似,当模式串滑动到如下图所示的位置时,模式串与主串有两个字符是匹配的,倒数第三个字符不匹配。
先来看看好后缀规则怎么工作的。
我们把已经匹配的"bc"称为好后缀,记作{u}.我们用他在模式串中进行查找,如果找到另一个与好后缀{u}匹配的子串{u*},那么我们就将模式串滑动到子串{u*}与好后缀{u}上下对齐的位置。如下图。
如果在模式串中找不到好后缀{u}匹配的另外的子串,就直接将模式串滑动到好后缀{u}的后面。
不过大家想没想过这个问题,当模式串中不存在与好后缀{u}匹配的子串时,我们直接将模式串滑动到好后缀{u}后面,是不是有点过头,看下图一个例子,其中"bc"是好后缀,尽管在模式串中没有另外一个与好后缀匹配的子串,但是我们如果模式串移动到好后缀的后面,就会错过模式串和主串可以匹配的情况。
如果好后缀在模式串中不存在可匹配的子串,那在我们一步一步往后滑动模式串的过程中, 只要主串中的{u}与模式串有重合,那肯定就无法完全匹配。但是当模式串滑动到前缀与主 串中{u}的后缀有部分重合的时候,并且重合的部分相等的时候,就有可能会存在完全匹配 的情况。
所以,针对这种情况,我们不仅要看好后缀在模式串中,是否有另一个匹配的子串,我们还
要考察好后缀的后缀子串,是否存在跟模式串的前缀子串匹配的。
所谓某个字符串 s 的后缀子串,就是最后一个字符跟 s 对齐的子串,比如 abc 的后缀子串 就包括 c, bc。所谓前缀子串,就是起始字符跟 s 对齐的子串,比如 abc 的前缀子串有 a, ab。我们从好后缀的后缀子串中,找一个最长的并且能跟模式串的前缀子串匹配的,假设 BM是{v},然后将模式串滑动到如图所示的位置。
当模式串和主串中的 某个字符不匹配的时候,如何选择用好后缀规则还是坏字符规则,来计算模式串往后滑动的 位数?
我们可以分别计算好后缀和坏字符往后滑动的位数,然后取两个数中最大的,作为模式串往 后滑动的位数。这种处理方法还可以避免我们前面提到的,根据坏字符规则,计算得到的往 后滑动的位数,有可能是负数的情况。
BM算法的代码实现
“坏字符规则”本身不难理解。当遇到坏字符时,要计算往后移动的位数 si-xi,其中 xi 的 计算是重点,我们如何求得 xi 呢?或者说,如何查找坏字符在模式串中出现的位置呢?如果我们拿坏符在模式串中顺序遍历查找,这样就会比较低效,势必影响这个算法的性 能。有没有更加高效的方式呢?我们之前学的散列表,这里可以派上用场了。我们可以将模 式串中的每个字符及其下标都存到散列表中。这样就可以快速找到坏字符在模式串的位置下 标了。
关于这个散列表,我们只实现一种最简单的情况,假设字符串的字符集不是很大,每个字符 长度是 1 字节,我们用大小为 256 的数组,来记录每个字符在模式串中出现的位置。数组 的下标对应字符的 ASCII 码值,数组中存储这个字符在模式串中出现的位置。
哈希表的代码构建过程
private static final int SIZE = 256;//全局变量或成员变量
//b为模式串,m为模式串长度,bc为哈希表
pivate void generateBC(char[] b,int m,int[] bc){
for(int i = 0;i < SIZE;++i){
bc[i] = -1; //初始化bc
}
for(int i = 0;i < m;++i){
int ascii = (int)b[i];//计算b[i]的ASCII值
bc[ascii] = i;
}
}
掌握了坏字符规则之后,我们先把 BM 算法代码的大框架写好,先不考虑好后缀规则,仅
用坏字符规则,并且不考虑 si-xi 计算得到的移动位数可能会出现负数的情况。
public int bm(char[] a,int n,char[] b,int m){
int[] bc = new int[SIZE];//记录模式串中每个字符出现的位置
generateBC(b,m,bc);//构建坏字符哈希表
int i = 0;//i表示主串与模式串上下对齐的第一个字符
while(i < n-m){
int j;
for(j = m - 1;j >= 0;--j){ //模式串从后往前匹配
if(a[i+j] != b[j]) break; //坏字符对应的模式串中的下标是j
}
if(j < 0){
return i; //匹配成功,返回主串与模式串第一个匹配的字符的位置
}
//这里将等同于模式串往后滑动j-bc[(int)a[i+j]]位
i = i + (j - bc[(int)a[i+j]]);
}
return -1;
}
BM算法代码实现中的关键变量。
大家载坚持一下看完好后缀规则的实现
我们先简单回顾一下,前面讲过好后缀的处理规则中最核心的内容:
在模式串中,查找跟好后缀匹配的另一个子串;
在好后缀的后缀子串中,查找最长的、能跟模式串前缀子串匹配的后缀子串;
在不考虑效率的情况下,这两个操作都可以用很“暴力”的匹配查找方式解决。但是,如果 想要 BM 算法的效率很高,这部分就不能太低效。如何来做呢?
因为好后缀也是模式串本身的后缀子串,所以,我们可以在模式串和主串正式匹配之前,通 过预处理模式串,预先计算好模式串的每个后缀子串,对应的另一个可匹配子串的位置。这 个预处理过程比较有技巧,很不好懂,应该是这节最难懂的内容了,你要认真多读几遍。 我们先来看,如何表示模式串中不同的后缀子串呢? 因为后缀子串的最后一个字符的位置是 固定的,下标为 m-1,我们只需要记录长度就可以了。通过长度,我们可以确定一个唯一 的后缀子串
现在,我们要 引入最关键的变量 suffix 数组 。suffix 数组的下标 k,表示后缀子串的长 度,下标对应的数组值存储的是,在模式串中跟好后缀{u}相匹配的子串{u*}的起始下标值。这句话不好理解,我举一个例子。
但是,如果模式串中有多个(大于 1 个)子串跟后缀子串{u}匹配,那 suffix 数组中该存储 哪一个子串的起始位置呢?为了避免模式串往后滑动得过头了,我们肯定要存储模式串中最 靠后的那个子串的起始位置,也就是下标最大的那个子串的起始位置。不过,这样处理就足 够了吗?
实际上,仅仅是选最靠后的子串片段来存储是不够的。我们再回忆一下好后缀规则。 我们不仅要在模式串中,查找跟好后缀匹配的另一个子串,还要在好后缀的后缀子串中,查 找最长的能跟模式串前缀子串匹配的后缀子串。
如果我们只记录刚刚定义的 suffix,实际上,只能处理规则的前半部分,也就是,在模式串 中,查找跟好后缀匹配的另一个子串。所以,除了 suffix 数组之外,我们还需要另外一个 boolean 类型的 prefix 数组,来记录模式串的后缀子串是否能匹配模式串的前缀子串。
现在,我们来看下, 如何来计算并填充这两个数组的值 ?这个计算过程非常巧妙。
我们拿下标从 0 到 i 的子串(i 可以是 0 到 m-2)与整个模式串,求公共后缀子串。如果
公共后缀子串的长度是 k,那我们就记录 suffix[k]=j(j 表示公共后缀子串的起始下标)。
如果 j 等于 0,也就是说,公共后缀子串也是模式串的前缀子串,我们就记录
prefix[k]=true。
我们把 suffix 数组和 prefix 数组的计算过程,用代码实现出来,就是下面这个样子:
void generateGS(char[] b,int m,int[] suffix,boolean[] prefix){
for(int i = 0;i < m;++i){
//初始化suffix,prefix数组
suffix[i] = -1;
prefix[i] = false;
}
for(int i = 0;i < m - 1;++i){
//循环处理b[0,i]
int j = i;
intk = 0;//公共后缀子串的长度
while(j >= 0 && b[j] == b[m-1-k]){
//与b[0,m-1]求公共后缀子串
--j;
++k;
suffix[k] = j+1;//j+1表示公共后缀子串在b[0,i]中的起始下标
}
if(j == -1) prefix[k] = true; //公共后缀子串也是模式串的前缀子串
}
}
有了这两个数组之后,我们现在来看, 在模式串跟主串匹配的过程中,遇到不能匹配的字符
时,如何根据好后缀规则,计算模式串往后滑动的位数?
假设好后缀的长度是 k。我们先拿好后缀,在 suffix 数组中查找其匹配的子串。如果
suffix[k] 不等于 -1(-1 表示不存在匹配的子串),那我们就将模式串往后移动 j-suffix[k]+1 位(j 表示坏字符对应的模式串中的字符下标)。如果 suffix[k] 等于 -1,表示 模式串中不存在另一个跟好后缀匹配的子串片段。我们可以用下面这条规则来处理。
好后缀的后缀子串 b[r, m-1](其中,r 取值从 j+2 到 m-1)的长度 k=m-r,如果 prefix[k] 等于 true,表示长度为 k 的后缀子串,有可匹配的前缀子串,这样我们可以把模 式串后移 r 位。
如果两条规则都没有找到可以匹配好后缀及其后缀子串的子串,我们就将整个模式串后移
m 位。
至此,好后缀规则的代码实现我们也讲完了。我们把好后缀规则加到前面的代码框架里,就
可以得到 BM 算法的完整版代码实现。
//a和b分别表示主串和模式串,n和m分别表示主串和模式串的长度
public int bm(char[] a,int n,char[] b,int m){
int[] bc = new int[SIZE];//记录模式串中每个字符最后出现的位置
generateBC(b,m,bc);//构建坏字符哈希表
int[] suffix = new int[m];
boolean[] prefix = new boolean[m];
generateGS(b,m,suffix,prefix);
int i = 0;//表示主串与模式串匹配的第一个字符
while(i <= n - m){
int j;
for(j = m - 1;j >= 0;--j){
//模式串从后往前匹配
if(a[i+j] != b[j]) break; //坏字符对应模式串中的下标是j
}
if(j < 0){
return i; //匹配成功,返回主串与模式串第一个匹配的字符的位置
}
int x = j - bc[(int)a[i+j]];
int y = 0;
if(j < m-1){
y = moveByGS(j,m,suffix,prefix);
}
i = i + Math.max(x,y);
}
return -1;
}
//j表示坏字符对应的模式串中的字符下标,m表示模式串长度
private int moveByGs(int j,int m,int[] suffix,boolean[] prefix){
int k = m - 1 - j;//好后缀的长度
if(suffix[k] != -1) return j - suffix[k] + 1;
for(int r = j+2;r <= m-1;++r){
if(prefix[m-r] == true){
return r;
}
}
return m;
}
BM 算法的性能分析及优化
我们先来分析 BM 算法的内存消耗。
整个算法用到了额外的 3 个数组,其中 bc 数组的大 小跟字符集大小有关,suffix 数组和 prefix 数组的大小跟模式串长度 m 有关。 如果我们处理字符集很大的字符串匹配问题,bc 数组对内存的消耗就会比较多。因为好后 缀和坏字符规则是独立的,如果我们运行的环境对内存要求苛刻,可以只使用好后缀规则,不使用坏字符规则,这样就可以避免 bc 数组过多的内存消耗。不过,单纯使用好后缀规则 的 BM 算法效率就会下降一些了。
对于执行效率来说,我们可以先从时间复杂度的角度来分析。
基于我目前讨论的这个BM算法,在极端情况下,预处理计算 suffix 数组、prefix 数组 的性能会比较差。
比如模式串是 aaaaaaa 这种包含很多重复的字符的模式串,预处理的时间复杂度就是 O(m^2)。当然,大部分情况下,时间复杂度不会这么差。