字符串匹配算法

article/2025/10/4 1:02:58

字符串匹配就是在主串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_patternhash[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的长度为mint m = pattern.length();int k = -1 ,j = 0 ;next[0] = -1;while (j < m) {if (k == -1 || pattern[j] == pattern[k])next[++j] = ++k;elsek = 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的长度为mint 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;elsek = 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;}elsej = next[j];}if (j == m)    //j走完整个pattern证明匹配成功,也会退出while循环return i - m; //返回匹配开始的位置,这里return i-j和return i-m是一样的,因为j==mreturn -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的长度为mint m = pattern.length();int j = 0, k = -1;next[0] = -1;while (j < m) {if (k == -1 || pattern[j] == pattern[k])next[++j] = ++k;elsek = 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;}elsej = next[j];}if (j == m)    //j走完整个pattern证明匹配成功,也会退出while循环return i - m; //返回匹配开始的位置,这里return i-j和return i-m是一样的,因为j==mreturn -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)。当然,大部分情况下,时间复杂度不会这么差。


http://chatgpt.dhexx.cn/article/fb0NGBiR.shtml

相关文章

字符串(字符串匹配)

一、字符串匹配问题、基础 1、假设文本是一个长度为n的数组T&#xff0c;而模式是长度为m的数组P&#xff0c;我们希望在文本T中寻找模式P 如果P出现在T中的第s个位置&#xff0c;那么我们称其有效偏移为s&#xff0c;在其他不匹配的位置称为无效偏移 2、如果字符串w是字符串…

字符串匹配

字符串匹配 1.朴素的串匹配算法(暴力解法) 1.1 分析 设t是目标串&#xff08;母串&#xff09;&#xff0c;p是模式串&#xff08;待匹配串&#xff09;&#xff0c;i , j 分别指向 模式串 和 目标串&#xff0c;m、n分别是模式串p和目标串t的长度。 从目标串的第0个字符&am…

Photoshop怎么给图片添加简介信息或者版权信息

转自&#xff1a;Photoshop怎么给摄影图片添加作者名字版权等信息? 有时我们点开一张图片的详细信息中可能可以看到各种属性信息&#xff0c;比如作者&#xff0c;时间&#xff0c;关键字&#xff0c;图片信息描述等属性&#xff0c;但是我们自己的拍摄的或者从别的地方获取的…

2022年中国版权保护中心计算机软件著作权登记最全申请步骤流程

一、前言二、实名认证1. 用户注册2. 实名认证 三、办理步骤1. 办理流程2. 填写申请表3. 提交申请文件4. 登记机构受理申请5. 审查6. 获得登记证书 四、登记申请所需文件1. 软件著作权登记申请表2. 软件&#xff08;程序、文档&#xff09;的鉴别材料3. 有关证明文件 五、申请表…

IDEA设置版权信息

File→Settings或者CtrlS快捷键。 Editor下面有个Copyright→Copyright Profiles 点击加号&#xff0c;然后输入名称。 然后修改成自己的信息&#xff1a; 其中第一个年份是本文件新建日期&#xff0c;后面的是最后一次修改年份。 中文版本&#xff1a; 版权所有(c) Jack魏 …

版权和版本信息

版权和版本信息的主要内容有&#xff1a; &#xff08;1&#xff09;版权信息&#xff1b; &#xff08;2&#xff09;文件名称、简要描述、创建日期和作者&#xff1b; &#xff08;3&#xff09;当前版本信息和说明&#xff1b; &#xff08;4&#xff09;历史版本信息和…

版权和商标权有什么关系?版权和商标的区别在哪里?

版权和商标权存在着一定的关系&#xff0c;版权和商标又有着很多区别&#xff0c;具体的关系和区别是怎样的&#xff0c;大家都知道吗&#xff1f;今天企多多就带大家来了解&#xff01; 版权和商标权的关系 版权和商标权的关系主要有以下三点&#xff1a; 1、关联性&#xf…

版权 | 收藏!哪些作品可以登记版权?

创业创新中&#xff0c;无论是公司LOGO还是IP形象或者产品手册&#xff0c;都凝结着无数的心血。当下互联网和科技的发展&#xff0c;让抄袭变得前所未有的容易&#xff0c;尤其在美术作品、文字作品和影视作品领域。如何有效地保护自己的智力成果呢&#xff1f;先从了解这些开…

Pyinstaller加入版本和版权信息

目录 参考链接 前言 一. 获取版本信息 1. 拖过来个有版本和版权信息的exe文件 2. 放置一个txt文件 我们接着放置一个txt文件叫file_version_info.txt。这名字不能改&#xff0c;Pyinstaller自动就把版权信息放在这里。 3.开始获取 二. 修改 三. 打包 参考链接 pyinsta…

版权信息的生成方法

网页底部添加网站的版权信息&#xff0c;将版权信息封装到JavaBean中&#xff0c;可重复利用 新建JavaBean类 public class StringUtil3 {private String copyrightStr"xxxxxxxxxxx xxxxxxxxxxxxx--xxxxxxxxxxxxxxxxxxxx-xxx";public String getCopyrightStr() {ret…

C++虚函数表

一、背景知识&#xff08;一些基本概念&#xff09; 虚函数&#xff08;Virtual Function&#xff09;&#xff1a;在基类中声明为 virtual 并在一个或多个派生类中被重新定义的成员函数。 纯虚函数&#xff08;Pure Virtual Function&#xff09;&#xff1a;基类中没有实现体…

虚函数和虚函数表

多态是由虚函数实现的&#xff0c;而虚函数主要是通过虚函数表&#xff08;V-Table&#xff09;来实现的。 如果一个类中包含虚函数&#xff08;virtual修饰的函数&#xff09;&#xff0c;那么这个类就会包含一张虚函数表&#xff0c;虚函数表存储的每一项是一个虚函数的地址…

C++ 虚函数表 vfptr

前言 大家都应该知道C的精髓是虚函数吧? 虚函数带来的好处就是: 可以定义一个基类的指针, 其指向一个继承类, 当通过基类的指针去调用函数时, 可以在运行时决定该调用基类的函数还是继承类的函数. 虚函数是实现多态(动态绑定)/接口函数的基础. 可以说: 没有虚函数, C将变得一…

c++ 虚函数及虚函数表

多态”的关键在于通过基类指针或引用调用一个虚函数时&#xff0c;编译时不确定到底调用的是基类还是派生类的函数&#xff0c;运行时才确定。 #include <iostream> using namespace std; class A { public:int i;virtual void func() {}virtual void func2() {} }; cla…

虚函数表结构

虚函数表 所谓虚函数表就是存放着当前类中所有虚函数地址的表。在实例化一个具有虚函数的类时&#xff0c;这个表也被分配到这个实例对象的内存中&#xff0c;通过虚函数表可以指明所要调用的函数的位置。在C编译器中虚函数表的地址存放在对象的最前面&#xff0c;这是为了即使…

关于虚函数与虚函数表

首先&#xff0c;我们知道&#xff0c;C的动态多态是基于虚函数实现的 。 C能够在运行时确定调用的函数是因为引入了虚函数&#xff0c;在类中引入虚函数后,在程序编译期间就会创建虚函数表&#xff0c;表中每一项数据都是虚函数的入口地址。 然而&#xff0c;怎么才能访问到虚…

C++中的虚函数表

引言&#xff1a; 多态对于C这种面向对象的语言来讲&#xff0c;其重要性是不言而喻的&#xff0c;用了足足半天的时间来把我所理解的多态表达出来&#xff0c;其中还有很多细节需要以后补充。&#xff08;一个字一个字写&#xff0c;还要画图&#xff0c;太累了&#xff09; …

虚函数原理与虚函数表

目录 一、 虚函数 二、虚函数原理与虚函数表 一、 虚函数 虚函数&#xff1a; 使用 virtual 关键字声明的函数&#xff0c;是动态多态实现的基础。 非类的成员函数不能定义为虚函数。 类的静态成员函数不能定义为虚函数。 构造函数不能定义为虚函数&#xff0c;但可以将析构函…

c++虚函数和虚函数表

什么是虚函数? 用virtual 修饰的成员函数叫虚函数 没有虚构造函数 不写虚函数&#xff0c;没有默认的虚函数 虚函数对于类的影响&#xff1a;增加一个指针的内存 虚函数的存储&#xff1a;虚函数表(了解内容&#xff1a;就是一个指针存储所有虚函数的首地址[函数指…

虚函数表存储位置

前言 先说结论&#xff1a;虚函数表存储在只读数据段&#xff08;.rodata&#xff09;、虚函数存储在代码段&#xff08;.text&#xff09;、虚表指针的存储的位置与对象存储的位置相同&#xff0c;可能在栈、也可能在堆或数据段等。 相信大家知道虚表指针和虚函数存储的位置…