参考文章
知乎:如何更好的理解和掌握 KMP 算法?
从头到尾彻底理解KMP
KMP 算法(1):如何理解 KMP
(原创)详解KMP算法
字符串匹配——BMH算法
1. 引言
真的是遇事不决问知乎啊,知乎上对于KMP的理解提供了很多文章,July这篇文章比较通俗易懂,需要的可以去看原文,我想按照原文的逻辑顺序梳理一下我原来不懂的地方。
2. 暴力匹配算法
简单来说就是一个个字的比较,让谁来写都是这个样。重点是这句话
- 如果当前字符匹配成功(即S[i] == P[j]),则i++,j++,继续匹配下一个字符
- 如果失配(即S[i]! = P[j]),令i = i - (j - 1),j = 0。相当于每次匹配失败时,i 回溯,j 被置为0。
这就相当于说要回溯m次,每次最多比较n回,时间复杂度就是O(mn),KMP就是通过少回溯来使得时间复杂度变为O(m+n)。
3. KMP算法
3.1 定义
一个基本事实是,当空格与 D 不匹配时,你其实是已经知道前面六个字符是 "ABCDAB"。KMP 算法的想法是,设法利用这个已知信息,不要把 "搜索位置" 移回已经比较过的位置,而是继续把它向后移,这样就提高了效率。
3.2 步骤
next数组的其实就是在 前缀后缀最长公共元素的基础上得出来的,这个数组代表了当这一位失配后,模式串的指针应该调到哪一位(即next[i]中的值),当这个值是-1的时候,表示目标串指针要向后移动,模式串的指针回溯到0。
3.3 通过代码递推计算next 数组
void GetNext(char* p,int next[])
{
int pLen = strlen(p);
next[0] = -1;
int k = -1;
int j = 0;
while (j < pLen - 1)
{
//p[k]表示前缀,p[j]表示后缀
if (k == -1 || p[j] == p[k])
{
++k;
++j;
next[j] = k;
}
else
{
k = next[k];
}
}
}
问题的关键应该是如何用代码求得next数组,显然这里用到了类似于统计归纳法的证明方法,f(n)可以通过f(n-1)推导出来,关键是这句话
- 若p[i] == p[j],则next[i + 1] = next [i] + 1 = j + 1;
- 若p[i] ≠ p[j],如果此时p[ next[j] ] == p[i],则next[ i + 1 ] = next[j] + 1,否则继续递归前缀索引 j = next[j],而后重复此过程。
这里还是拿图比较明白
next[i] = j
得,也就是对于位置 i 来说,区段 [0, i - 1] 的最长相同真前后缀分别是 [0, j - 1] 和[i - j, i - 1],即这两区段内容相同。按照算法流程,if (P[i] == P[j]),则i++; j++; next[i] = j;相当于得到了next[i+1]的值。
若不等,则j = next[j],见下图1/2/3:
4. KMP算法的优化
这部分优化优化的是p[j] = p[ next[j] ]的问题。当p[j] != s[i] 时,下次匹配必然是p[ next [j]] 跟s[i]匹配,如果p[j] = p[ next[j] ],必然导致后一步匹配失败。如果出现了p[j] = p[ next[j] ]则需要再次递归,即令next[j] = next[ next[j] ],从而得到了完整的KMP算法。
//优化过后的next 数组求法
void GetNextval(char* p, int next[])
{
int pLen = strlen(p);
next[0] = -1;
int k = -1;
int j = 0;
while (j < pLen - 1)
{
//p[k]表示前缀,p[j]表示后缀
if (k == -1 || p[j] == p[k])
{
++j;
++k;
//较之前next数组求法,改动在下面4行
if (p[j] != p[k])
next[j] = k; //之前只有这一行
else
//因为不能出现p[j] = p[ next[j ]],所以当出现时需要继续递归,k = next[k] = next[next[k]]
next[j] = next[k];
}
else
{
k = next[k];
}
}
}
5. BM算法
KMP的匹配是从模式串的开头开始匹配的,而1977年,德克萨斯大学的Robert S. Boyer教授和J Strother Moore教授发明了一种新的字符串匹配算法:Boyer-Moore算法,简称BM算法。该算法从模式串的尾部开始匹配,且拥有在最坏情况下O(N)的时间复杂度。在实践中,BM算法不仅效率高,而且构思巧妙,容易理解。
BM算法定义了两个规则:
坏字符规则:当文本串中的某个字符跟模式串的某个字符不匹配时,我们称文本串中的这个失配字符为坏字符,此时模式串需要向右移动,移动的位数 = 坏字符在模式串中的位置 - 坏字符在模式串中最右出现的位置。此外,如果"坏字符"不包含在模式串之中,则最右出现位置为-1。
好后缀规则:当字符失配时,后移位数 = 好后缀在模式串中的位置 - 好后缀在模式串上一次出现的位置,且如果好后缀在模式串中没有再次出现,则为-1。
每次后移这两个规则之中的较大值。这两个规则的移动位数,只与模式串有关,与原文本串无关。
下面举例说明BM算法。例如,给定文本串“HERE IS A SIMPLE EXAMPLE”,和模式串“EXAMPLE”,现要查找模式串是否在文本串中,如果存在,返回模式串在文本串中的位置。
-
首先,"文本串"与"模式串"头部对齐,从尾部开始比较。"S"与"E"不匹配。这时,"S"就被称为"坏字符"(bad character),即不匹配的字符,它对应着模式串的第6位。且"S"不包含在模式串"EXAMPLE"之中(相当于最右出现位置是-1),这意味着可以把模式串后移6-(-1)=7位,从而直接移到"S"的后一位。
-
依然从尾部开始比较,发现"P"与"E"不匹配,所以"P"是"坏字符"。但是,"P"包含在模式串"EXAMPLE"之中。因为“P”这个“坏字符”对应着模式串的第6位(从0开始编号),且在模式串中的最右出现位置为4,所以,将模式串后移6-4=2位,两个"P"对齐。
-
依次比较,得到 “MPLE”匹配,称为"好后缀"(good suffix),即所有尾部匹配的字符串。注意,"MPLE"、"PLE"、"LE"、"E"都是好后缀。
-
发现“I”与“A”不匹配:“I”是坏字符。如果是根据坏字符规则,此时模式串应该后移2-(-1)=3位。问题是,有没有更优的移法?
-
更优的移法是利用好后缀规则:当字符失配时,后移位数 = 好后缀在模式串中的位置 - 好后缀在模式串中上一次出现的位置,且如果好后缀在模式串中没有再次出现,则为-1。所有的“好后缀”(MPLE、PLE、LE、E)之中,只有“E”在“EXAMPLE”的头部出现,所以后移6-0=6位。可以看出,“坏字符规则”只能移3位,“好后缀规则”可以移6位。每次后移这两个规则之中的较大值。这两个规则的移动位数,只与模式串有关,与原文本串无关。
-
继续从尾部开始比较,“P”与“E”不匹配,因此“P”是“坏字符”,根据“坏字符规则”,后移 6 - 4 = 2位。因为是最后一位就失配,尚未获得好后缀。
(彩蛋)BMH算法——省去好后缀表的优化算法
BMH它不再像BM算法一样关注失配的字符(好后缀),它的关注的焦点在于这个坏字符上,根据这个字符在模式串P中出现的最后的位置算出偏移长度,否则偏移模式串的长度。
研究表明,坏字符表在匹配过程中占主导作用的概率为94.03%(其实脑子里想想就知道了),远高于好后缀表的使用。同时相对来说好后缀表的计算过程相对复杂。BMH算法从减少比较次数来提高BM算法的性能。
- 字符X不在模板P中,则跳跃的步数为模板P的长度
-
字符X在模板P中,跳跃的步数为字符X距离离尾部最近的字符X的距离(不包括最后一个字符)
const int maxNum = 1005;
int shift[maxNum];
int BMH(const string& T, const string& P) {
int n = T.length();
int m = P.length();
// 默认值,主串左移m位
for(int i = 0; i < maxNum; i++) {
shift[i] = m;
}
// 模式串P中每个字母出现的最后的下标,最后一个字母除外
// 主串从不匹配最后一个字符,所需要左移的位数
for(int i = 0; i < m - 1; i++) {
shift[P[i]] = m - i - 1;
}
// 模式串开始位置在主串的哪里
int s = 0;
// 从后往前匹配的变量
int j;
while(s <= n - m) {
j = m - 1;
// 从模式串尾部开始匹配
while(T[s + j] == P[j]) {
j--;
// 匹配成功
if(j < 0) {
return s;
}
}
// 找到坏字符(当前跟模式串匹配的最后一个字符)
// 在模式串中出现最后的位置(最后一位除外)
// 所需要从模式串末尾移动到该位置的步数
s = s + shift[T[s + m - 1]];
}
return -1;
}
6. Sunday算法(直接复制过来的)
上文中,我们已经介绍了KMP算法和BM算法,这两个算法在最坏情况下均具有线性的查找时间。但实际上,KMP算法并不比最简单的c库函数strstr()快多少,而BM算法虽然通常比KMP算法快,但BM算法也还不是现有字符串查找算法中最快的算法,本文最后再介绍一种比BM算法更快的查找算法即Sunday算法。
Sunday算法由Daniel M.Sunday在1990年提出,它的思想跟BM算法很相似:
只不过Sunday算法是从前往后匹配,在匹配失败时关注的是文本串中参加匹配的最末位字符的下一位字符。
如果该字符没有在模式串中出现则直接跳过,即移动位数 = 匹配串长度 + 1;
否则,其移动位数 = 模式串中最右端的该字符到末尾的距离+1。
下面举个例子说明下Sunday算法。假定现在要在文本串"substring searching algorithm"中查找模式串"search"。
- 刚开始时,把模式串与文本串左边对齐:
s u b s t r ing searching algorithm
s e a r c h - 结果发现在第2个字符处发现不匹配,不匹配时关注文本串中参加匹配的最末位字符的下一位字符,即标粗的字符 i,因为模式串search中并不存在i,所以模式串直接跳过一大片,向右移动位数 = 匹配串长度 + 1 = 6 + 1 = 7,从 i 之后的那个字符(即字符n)开始下一步的匹配,如下图:
substri n g _ s e a rching algorithm
s e a r c h
结果第一个字符就不匹配,再看文本串中参加匹配的最末位字符的下一位字符,是'r',它出现在模式串中的倒数第3位,于是把模式串向右移动3位(r 到模式串末尾的距离 + 1 = 2 + 1 =3),使两个'r'对齐,如下:
substring s e a r c h ing algorithm
s e a r c h匹配成功。
回顾整个过程,我们只移动了两次模式串就找到了匹配位置,缘于Sunday算法每一步的移动量都比较大,效率很高。完。