字符串匹配之KMP算法
一、字符串匹配的应用
字符串匹配在很多场景下都有应用:
- 邮箱的服务器和客户端的垃圾邮件过滤器,通过检查邮件标题、发件人以及内容是否特定字序来评价是否属于垃圾邮件。
- 使用编辑器以及字处理系统,查找单词或者句子,或者是在程序里找拼写错误的标识符
- 搜索软件查找、网页内容查找以及各种检索需求
- 各种防病毒软件就是在各种文件里检索表征病毒的片段,也是串匹配
- 分子生物学的DNA串匹配
实际应用中模式匹配的规模(n和m)可能非常大并且有严苛的时间要求。具体应用的场景也有许多变化。比如:
- 需要检索的文本可能很大,需要经常用一个模式串在其中反复搜索
- 防病毒软件需要在合理时间内处理数以十万记文件并且需要处理一大批病毒特征串
- 网络搜索需要处理数以亿计的网页,用于处理世界各地的发生频率极高的千奇百怪的搜索需求
- 服务器上的邮件过滤程序
- 生物工程的应用,需要大量的DNA模式与DNA样本匹配
这些都是对字符串匹配的高效匹配有极大的要求,总结来说,字符串匹配是在理论和实际中都非常重要的计算问题
二、传统的匹配串算法
最简单的算法采用的就是直观可行的策略,①从左到右逐个字符匹配;②发现不匹配时,转去考虑目标串里的下一个位置是否与模式串匹配,如下状态
(0) fdsvfgffbdfbgdbgnnzdfbd fgfdfd (1) fdsvfgffbdfbgdbgnnzdfbd fgfdfd (2) fdsvfgffbdfbgdbgnnzdfbd fgfdfd (3) fdsvfgffbdfbgdbgnnzdfbd fgfdfdy
由此可以得出一个匹配算法的实现
def naive_matching_algorithm(target_str, pattern_str): target_len, pattern_len = len(target_str), len(pattern_str) i, j = 0, 0 while i < pattern_len and j < target_len: if pattern_str[i] == target_str[j]: i, j = i + 1, j + 1 else: i, j = 0, j - i + 1 if i == pattern_len: return j - i return -1
实现很容易,也不需要做更多的解释
上述匹配算法简单,也容易理解,但是效率很低,其主要原因就是执行过程中会出现回溯:当匹配到不同的字符时模式串p右移一个字符位置,随后匹配回到模式串的开始(重置j=0),再重新开始匹配,该算法效率很低,最坏的情况是每一趟比较都是在模式串的最后一个字符不同,总共需要做n-m+1趟比较,总的比较次数为m*(n-m+1),所以其时间复杂度为O(m*n)。实例如下
目标串:aaaaaaaaaaaaaaaaaaaaaaaaab
模式串:aaaaab
其算法效率低的根源在于把字符的每次比较都当做完全独立的操作,没有利用字符串本身的特点,也没有尽可能的利用前面已经做过比较中得到的信息。
上面的算法从数学上看,是相当于认为目标串和模式串里的字符都是完全随机的量,且有无穷多种可能取值,实际情况并不是这样,字符串中的字符取值来自于一个有穷集合且每个字符串都有确定的有穷长度,特别是模式串,通常不太长,且在匹配中被反复使用。各种改进算法也是利用了字符串的这些特点开发出来的。
三、无回溯串匹配算法
KMP算法是一个高效的串匹配算法。由D.E.Knuth和V.R.Pratt提出,J.H.Morris也是几乎同时独立发现了这个算法,因此被称为KMP算法。该算法与朴素算法相比,效率有着本质的提高。
先基于朴素匹配算法的缺陷
设目标串是ababcabcacbab 模式串是abcac 朴素匹配的算法执行情况以及状态如下:
0:ababcabcacbab abcac 1:ababcabcacbab abcac 2:ababcabcacbab abcac 3:ababcabcacbab abcac 4:ababcabcacbab abcac 5:ababcabcacbab abcac
状态0的匹配进行到模式串的字符c时失败,此前有两次成功,从中可知目标串前两个字符与模式串的前两个字符相同,由于模式串中的前两个字符不同,与b匹配的目标串字符不可能与a匹配,所以状态1的匹配一定失败,朴素匹配算法没有利用这种信息,做了无用功,再看状态2,这里前四个字符都匹配,最后匹配c时失败,由于模式串中的第一个a与其后面的两个字符(bc)不同,用a去匹配目标字符串的b、c也一定失败,跳过这两个位置不会丢掉匹配点,另一方面,模式串中下标为3的字符也是a,它在状态2匹配成功,首字符a不必重做这一匹配,朴素匹配算法并没有考虑这些信息,总是一步步移位并从头比较。
如果先对模式串做一些分析,记录得到的有用信息(如其中哪些位置的字符相同或者不同),就有可能避免一些不必要的匹配,提高匹配效率,这种做法事实际匹配前的静态预处理,只需要做一次。记录下来的信息就可以在匹配中反复使用。
KMP的算法的精髓就是开发了一套分析和记录模式串信息的机制(和算法),而后借助得到的信息加速匹配,对上面的实例,用KMP算法陪陪过程如下所示:
0:ababcabcacbab abcac 1:ababcabcacbab abcac 0:ababcabcacbab abcac
状态0匹配到第一个c失败时,由于已知前两个字符不同,KMP算法直接把模式串移动两个位置,模式串开头的a移动到c匹配失败的位置,达到状态1,这次匹配知道模式串最后的c处失败,由于已知模式串c之前是a,首字符也是a,而且这两个字符之间的字符与它们不同,不可能有匹配,KMP算法直接把模式串中的b移动到刚才匹配c失败的位置(前两个字符a肯定匹配,不必再试),达到状态2,接下去从模式串的b继续匹配,找到了一个成功匹配。
KMP中额基本算法是匹配中不回溯。如果匹配中用模式串里的pi匹配某个字符串tj时失败(遇到了pi≠tj的情况),就找到了某个特定的ki(0≤ki<i),下一步用模式串中字符pki与目标字符串里的tj比较。也就是说,在匹配失败的时候,把模式字符串前移若干位置,用模式串里的匹配失败字符之前的某个字符与目标串中的失败匹配的字符作比较。
KMP算法设计中的关键认识是:在匹配pi失败时,所有的pk(0<k<i)都已经匹配成功,也即,在目标串tj之前的i个字符串也就是模式串p的前i个字符串。说明,原本私户应该根据目标串t中tj之前已匹配的一段来决定模式串的前移位置,实际上只需要根据模式串本身的情况就可以决定了,这说明,完全可在实际的与任何目标串匹配之前,通过对模式串本身的分析,就可以解决匹配失败时应该怎样前移的问题(这个很重要,一定记得后面都是针对模式串本身展开介绍的)。
从上面的分析可以得出结论:对P中的每一个i,都有与之对应的下标ki,与被匹配的目标串无关。有可能通过对模式串p的预分析得到每个i对应的ki(为每个p找到与之对应的pki)。假设模式字符串p的长度为m,现在需要对每个i(0≤i<m)计算出对应的ki并将其保存起来,以便在匹配中使用,为此可以考虑用一个长为m的表pnext,用表元素pnext[i]记录于i对应的位置ki值。
还有一种特殊情况,在一些pi匹配失败的时候,有可能发现在用pi匹配之前做过的所有模式串字符与目标串字符的比较都没有实际利用价值。在这种情况下,下一步就应该从头开始,用p0与tj+1比较。如果遇到这种特殊情况时,就在pnext[i]里存入-1.对任意一个模式,都有pnext[0] = -1.
所以就有了如下算法实现
def kmp_matching(target_str, pattern_str, pnext): j, i = 0, 0 target_len, pattern_len = len(target_str), len(pattern_str) while j < target_len and i < pattern_len: # i == pattern_len 说明找到匹配 if i == -1: j, i = j + 1, i + 1 # 往后匹配 elif target_str[j] == pattern_str[i]: # 当前字符相等,继续往后匹配 j, i = j + 1, i + 1 else: i = pnext[i] # 从pnext中取得p的下一个字符的位置 if i == pattern_len: return i - 1 return -1 # 没找到 返回-1
前面两个分支可以合并,简化后如下
def kmp_matching(target_str, pattern_str, pnext): j, i = 0, 0 target_len, pattern_len = len(target_str), len(pattern_str) while j < target_len and i < pattern_len: # i == pattern_len 说明找到匹配 if i == -1 or target_str[j] == pattern_str[i]: j, i = j + 1, i + 1 # 往后匹配 else: i = pnext[i] # 从pnext中取得p的下一个字符的位置 if i == pattern_len: return i - 1 return -1 # 没找到 返回-1
至此,KMP的算法代码完成一半,先分析一下复杂度,关键是其中的循环执行次数。注意,在整个循环中j是递增的,但其加一的总次数不会多于len(target_str),而且,j递增时i的值也是递增。而在if的另一分支,语句i = pnext[i]总使i值减少。但是if条件又保证变量i的值不小于-1,因此pnext[i]的执行次数不会多于i值递增的次数。由这些情况可知,循环次数不会多于O(n),因此这个算法的复杂性是O(n),n为目标串的长度。
关于pnext表的构建,我们下一篇文章里展开分析。