KMP算法和状态机的联系

应群里很多师弟的要求,现站出来解释解释大学时期学过的KMP算法本质是什么。以及如何理解、应用它。

首先,KMP只是一个将字符串搜索变成状态机的状态命中检测应用。然后,我要解释什么是状态机了。用一个例子解释再好不过:

假设有一台糖果自动贩卖机,拥有无限个糖果,可以进行的操作有:
1.开机
2.投币
4.退币
3.转动摇杆
4.关机

那么,看看下面的操作流程中,哪项是可以获取糖果的?( )
a.开机、转动摇杆
b.投币、退币、转动摇杆
c.退币、开机、退币、退币、退币
d.开机、投币、转动摇杆

也许d是正确答案?正确!可是怎么形式化地描述这个结果?为什么这个操作序列可以让你有糖果吃,而a、b、c则不行?

下面让我们转换到状态机模式来解释它。


根据描述,我们可以为糖果机划分几个状态:
1.closed
2.empty
3.coins
4.canddy

对于每一个状态,操作的结果是不一样的。例如,当closed状态时,关机操作没有任何效果,即系统会依旧停留在closed状态。而当系统处于coins状态时,进行摇杆操作,则会转换到canddy状态!详细的操作、状态转表如下(没有下划线优快云无法对齐,将就着看)

_____ closed__empty__coins__canddy

开机__empty____/______/_______/
投币____ /____coins____/_______/
退币____/______/_____empty____/
摇杆____/______/_____canddy___/
关机____/____closed__closed____/

那么,这又和字符串搜索有什么关系呢?让我们试一下上面的操作看成是不同的字符:
开机->a
投币->b
退币->c
摇杆->d
关机->e
并且认为字符集仅包含这些字符。

那么,字符串搜索命中的过程,实际上就可以认为是:一个糖果机操作流(即字符串)是否能最终获取糖果(canddy状态)的过程。

对于上面选择题中的4个选项,可以分别“翻译”为字符串:
ad, bcd, caccc, abd

那么,它们分别到达的最终状态为:
ad->empty
bcd->closed
caccc->empty
abd->canddy

所以,abd就是最终可以获取糖果的“模式”——即我们搜索的子字符串。

反过来,如果我们有一个待搜索的字符串str,以及模式子串pattern,该怎么理解呢?
例如《算法导论》上的例子:
str:"abcbababaabcbab"
pattern:"ababaca"

因为pattern有7个字符组成,所以我们可以认为命中模式应该有8个状态,分别为:
0.初始状态(什么都没命中)
1.命中第一个位置的a
2.命中第二个位置的b
3.命中第三个位置的a
4.命中第四个位置的b
5.命中第五个位置的a
6.命中第六个位置的c
7.命中第七个位置的a

如果源字符串(操作流)能让驱动状态值达到状态8,则表示找到了模式命中。
但请仔细考虑下面几种源串的情况:
1)aa
遇到第一个a时,显然应该进入状态1,但遇到第二个a,显然不符合预期,是否需要回到状态0?答案是不需要的。虽然未能匹配第二个b,但因为导致失配的字符是a,所以我们可以直接认为即使失败了,它也能作为新一轮搜索时,模式串“ababaca”第一个字符命中的依据。所以状态可以回到1,而不是0。
2)abaa
如刚才的描述,子串aba已经达到了3号状态,但最后的a与期望字符b不符,此时也不需要返回状态0。因为最后的那个a实际上可以认为是新的搜索开始。所以,状态转换到1号状态。
3)ababaa
同情况2),状态转换到1
4)ababab
这个比较有意思了,当命中了ababa时,当前状态为5,而随后遇到字符b,而不是期待的c,应该转换到哪个状态?答案应该是状态4。原因是,目前的ababab序列,虽然不能使状态前进到6,但后四个字符组成的子序列abab能使状态机驱动到状态4,所以完全可以从后者继续,而不需要从头开始匹配。

还有一些类似的情况,参考更详细的状态转换表,如下:
    0   1   2   3   4   5   6   7
-----------------------------------
a | 1   1   3   1   5   1   7   / 
b | 0   2   0   4   0   4   0   /
c | 0   0   0   0   0   6   0   /

有了这张状态转换表,我们就可以很轻松地用伪代码将字符串搜索过程转换为如下的模式命中过程:
state <- 0;
for each ch in str
{
drive state by ch;
if (state==7)
        break;
}
hit <- (state==7);

那么,针对上述模式的搜索程序即可硬编码为C++代码:
#include <iostream>
using namespace std;

// 状态转换表 for "ababaca"	
int statesTable[][7] = {
	{ 1, 1, 3, 1, 5, 1, 7 },
	{ 0, 2, 0, 4, 0, 4, 0 },
	{ 0, 0, 0, 0, 0, 6, 0 },
};

int main() {
	const char* str = "abababacaba";
	const int len_pattern = strlen("ababaca");

	int len = strlen(str);
	int i;
	int state = 0;
	for (i=0; i<len; ++i) {
		char ch = str[i];
		state = statesTable[ch-'a'][state];

		if (state==7)
			break;
	}

	if (state==7)
		cout<<"found at pos:"<<(i-len_pattern+1)<<endl;
	else
		cout<<"not found!"<<endl;

	return 0;
}

稍微休息一下,因为,这~只是开始。


值得注意的是,KMP算法仅仅是将上面的状态转换表再换了一种方式来表达罢了。即:当发生不匹配时,应该从pattern的哪一个字符开始重新匹配?


对于模式"ababaca",让我们逐个说明,如果:
1.第一个a都无法匹配,当然之后需要重头开始了。
2.从第二个b无法匹配,必须从头开始
3.从第三个a无法匹配,必须从头开始
4.从第四个b无法匹配,这时就有点意思了,因为当前命中了aba,而后面的a可以有效地做出提示:虽然不能继续匹配,但最起码已命中的串中,包含了一个a,那么我们只需要从pattern的第二个b开始匹配,而不需要从头开始。
5.从第五个a无法匹配,此时已经匹配了abab,所以下一个匹配可以从pattern的第三个a开始。
6.从第六个c无法匹配,此时已经匹配了ababa,所以下一个匹配可以从pattern的第四个b开始。
7.从第七个a无法匹配,此时已经匹配了ababac,我们可以选择利用已命中的a,ab模式,调整pattern的位置,然后继续匹配。如下:
  最后未匹配的a:
    ababacbabac
    ababaca

  =>ababacbabac
      ababaca
        ababaca

但这么做是多余的。因为既然最后还命中了一个c,则可以肯定的是:无论这两种调整中的哪一种,最终都必然会因为已经遇到的c而不匹配。所以,需要从头开始

可以从上面得出的规律既是:当发生失配时,对于pattern中已经命中的部分来说,找出该部分尽可能相同的前缀和后缀相同的部分的长度,那么下一个该匹配的位置应该从这个相等部分的下一个字符开始匹配。

可形式化描述为:
next[i] = 最大的k, 使得pattern[0...k-1]==pattern[i-k, i-1];

那么,这和前面所说的状态机之间怎么联系起来?再看一次状态转换表,可以这么理解:
    a   b   a   b   a   c   a          <=   pattern
    -1  0   0   1   2   3   0          <=   next值
    0   1   2   3   4   5   6   7      <=   状态值
-----------------------------------
a | 1   1   3   1   5   1   7   / 
b | 0   2   0   4   0   4   0   /
c | 0   0   0   0   0   6   0   /


除了next[0] = -1外,next[i] 的值,应该是pattern的子pattern[0...i-1]所能包含的所有可能后缀
    pattern[1...i-1], pattern[2...i-1],pattern[3...i-1] ……
这些子串中,能将状态驱动得到的最大值,即为next[i]的值。

例如,next[4],对应的是:
    ababaca  的子pattern[0...3],即
    abab     所包含的所有可能后缀
           b     <= pattern[3]
         ab     <= pattern[2...3] 
       bab     <= pattern[1...3]
分别用这3个后缀字符串,从状态0开始,能到达的最大状态值分别为:
    b   -> 0
    ab  -> 2
    bab -> 2
所以,next[4] = 2

C++测试代码如下:
#include <iostream>
using namespace std;
// 状态转换表 for "ababaca"	
int statesTable[][7] = {
	{ 1, 1, 3, 1, 5, 1, 7 },
	{ 0, 2, 0, 4, 0, 4, 0 },
	{ 0, 0, 0, 0, 0, 6, 0 },
};

int driveState(const char* str, int n) {
	int state = 0;
	int i=0;
	while (n>0) {
		state = statesTable[str[i]-'a'][state];
		++i;
		--n;
	}

	return state;
}

int main() {
	const char* pattern = "ababaca";
	int next[7] = {};
	next[0] = -1;
	int len = strlen(pattern);
	for (int i=1; i<len; ++i) {
		int state = 0;
		const char* beg = pattern + 1;
		const char* end = pattern + i;
		while(beg<end) {
			if (driveState(beg, end-beg)>state)
				state = driveState(beg,end-beg);

			++beg;
		}
		next[i] = state;
	}

	for (int i=0; i<len; ++i) 
		cout<<next[i]<<' ';

	return 0;
}


KMP算法包括有穷状态自动机的搜索算法,所能带来的主要利益有两点:

1.不需要对源数据的回退访问。

    曾经,我参与的一个项目需要对海量数据进行搜索。处理过程中,需要将源数据进行分块(例如10K),在其中进行搜索后,再搜索下一块。但这样的处理方式会导致遗漏,如图

     |________第一块_________|________第二块__________|

     |_________abc_________abc_________________abc__|

如果在这两块数据中搜索"abc",应该结果有3个命中,但实际上,由于第二个"abc"处于两块数据交汇处,所以导致搜索遗失。补救办法是对前一块的结尾取一小段数据,再加上后一块前面的一小段数据,进行二次搜索。如

     |__________________[___abc___]___________________|


但,这种打补丁式的处理方式又会导致另一种bug:重复命中!这取决于被搜索的模式串长度,以及这个被重复搜索区域的大小。做个一般性假设:补救办法中,为了准确,而将重叠区取的很大,那很可能包含了前面的abc。

     |________[_abc_________abc_________]_____________|

这时,为了避免重复的命中,我们还需要维护一个命中表,用来进行“去重”的操作。这显然是在补丁上打补丁的做法。

最终,这个问题的解决,需要用到KMP或状态机搜索算法。前面的算法分析中,一个非常有意义的细节是:我们一直没有对源数据进行回退访问!对状态机来说,每一个源字符都会驱动状态的变化,如果产生失配,则下一个状态也应该由源的下一个字符来决定,而不需要用之前的源数据。对于KMP来说,情况类似。

这种性质对于海量数据的搜索,以及一些不方便回退数据的搜索(如磁带机)都是非常有帮助的。

2.算法效率提升

    这个好处是受限的,因为无论是计算状态转换表,还是next数组,都需要一定的时间,但如果源数据量非常大,那么这种前期处理时间将被摊分,直到可以认为其对复杂度的影响为零。


另外简单提到一点,这种算法还是可扩展的,对于多个模式串的搜索,如“abcd”,“abde”。状态转换表中,只需要添加两个状态即可支持,并且源数据只需要遍历一次。而朴素的搜索方法,需要针对每一个模式串都遍历一次源数据。所以,状态机搜索一般也应用于多模式搜索的情境中



评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值