原文地址:https://www.douban.com/note/321870890/?type=rec
好好打下字符串算法基础。本篇通俗、透彻地解释线性时间复杂度的字符串匹配算法:KMP算法。
之前写过KMP算法,但时间久了回顾起来还是要花点儿时间,觉得需要进一步加深;现在就试图彻底吃透它。若是感兴趣豆友们能得到一点点的帮助就更好了~ 别忘了点个赞:-)KMP 算法的历史就不说了,感兴趣的去Google 下就有,大名鼎鼎的高德纳(Knuth)大神是发明人之一。初次接触KMP 算法时往往会被它的线性时间复杂度所征服,如此经典的算法值得学习。
后面的描述中,S 表示原字符串,T 表示目标串(模式串),我们要在S 中搜索T。
令 S[0..m-1] = abcabcabdabba, T[0..n-1] = abcabd,
1,Naive 算法
字符串匹配的naive 算法时O(n^2) 的:
![]() |
2,Naive 算法的问题与进化
naive 算法的最大问题是,当一轮匹配过后,模式串T 又从头开始,实际上这回造成很多不必要的比较。我们看下面的情况:
![]() |
此时,匹配失败,本轮结束;但实际上,并不需要从头开始,匹配在S[5] 和 T[5] 处失败,此时可以直接将S[5] 和 T[2] 对齐,从这里开始匹配即可。
进一步,之所以可以这样做,是由于T 的子串T[0..4] 已经与S 的子串S[0..4] 匹配成功,且"ab" 即是T[0..4] 的后缀又是它的前缀,我们直接将T[2] 移到S[5] 的位置开始下一步匹配,不会造成任何遗漏。
![]() |
3,KMP 算法思想
首先回到naive 匹配算法,我们之所以每轮过后,都非常“保守”地回溯到T[0] 和S[i+1] 的位置开始下一轮匹配,是为了避免有任何遗漏。而造成遗漏的原因是T 的子串可能存在这样的情况:它的前缀和后缀相等。我们姑且将这样的前/后缀称为“前-后缀”,注意这里这考虑“真”前缀/后缀。即对于某个子串,它本身不是我们关心的前缀/后缀。
例如,对于上面的T = abcabd,子串T[0..3] 的前-后缀是"a",子串T[0..4] 的前-后缀是"ab",T[0..5] 的前-后缀是空串""(或者说它没有前-后缀)。
如果一个子串有多个前-后缀,我们只考虑最长的那一个。例如,"aaaaa"的最长前-后缀是"aaaa"(它本身"aaaaa"不是真前/后缀)。
有了最长前-后缀的概念,我们可以开始分析KMP 算法的原理。
前面提到的例子中,当S[5] 与 T[5] 匹配失败时,我们直接将S[5] 和T[2] 对齐,正是由于T[0..4] 的最长前-后缀是"ab",它在T[0..4]中对应的前缀是T[0..1];既然T[0..4] 与S[0..4] 匹配成功,那么T[0..1] 必然可以与S[3..4] 完全匹配。
由此,我们很容易联想到,当S[i] 与T[j] (j>0) 匹配失败时,如果我们知道T[0..j-1] 的最长前-后缀在T[0..j-1]对应的前缀(比如是T[0..k]),那么我们可以直接将S[i] 与T[k+1] 对齐,开始下一次比较。原因就是T[0..k] 必然已经与S[0..i-1] 的后缀匹配成功。
这就是KMP 算法的关键思路:避免了不必要的回溯。
4,KMP 算法实现
KMP 算法的关键在于,对于模式串T,我们需要知道它的每个子串T[0..j] (j<n) 的最长前-后缀。通常采用的数据结构是,用一个标记数组NEXT[0..n-1],NEXT[j] 记录了T[0..j] 的最长前-后缀对应的前缀的下一个位置。例如,如果NEXT[j] = 2,则表示T[0..j] 的最长前-后缀是T[0..1]。
显然,当匹配在S[i], T[j] 处失败是,只需将T[NEXT[j-1] 与 S[i] 对齐,开始下一次比较。(注意:j 的位置需要改变,实际上可以先调整j = NEXT[j-1],在继续比较S[i],T[j] 即可;另外需注意边界情况的处理)。
至此,我们应该理解了KMP 算法原理。
已知S[0..m-1], T[0..n-1], 标记数据NEXT[0..n-1];(关于如何获得NEXT[0..n-1] 在后面阐述)
KMP 匹配算法:
![]() |
注意边界情况的处理:
当j == n 时,说明已经找到一个完全匹配,输出结果;
当j == 0 时匹配失败,直接从S[i+1] 处开始匹配;
否则,通过NEXT 数组获得T中新的匹配位置j = NEXT[j-1];
需要注意的一点是,在j==n (匹配成功)时,下一次需要匹配的位置也保存在NEXT[j-1] (即NEXT[n-1])中,无须特殊处理,这也是该算法的一个优美之处 :-)
这段代码的复杂度分析:i++ 最多执行m 次;i++ 没被执行时,一定有“j=NEXT[j-1]” 被执行,每次j 至少减小1,且不会小于0;而j++ “累计” 执行一定不会比 i++ 多(即不可能超过m 次),因此j 累计减少也不会超过m 次(即j=NEXT[j-1] 执行不会超过m 次)。
综上,上面代码复杂度是 O(m)。
5,标记数据NEXT[0..n-1]
还有一个问题没有回答:如何获得NEXT[0..n-1]。
实际上,NEXT 数组记录的是T 的子串T[0..j] (0<j<n) 的最长前-后缀的信息。
用传统暴力的字符串方法求NEXT数组显然很低效。这里也是KMP 算法最巧妙的地方之一:用KMP 匹配的方法求解NEXT 数组。
没错,预处理工作中,NEXT[0..n-1] 正式通过KMP 匹配方法求出的。相当于在模式串T 自身上使用KMP匹配。
方法是用两个数组下标i 和j 来扫描T。i 将T 看做是KMP 匹配中的S,j 依然将T 看做模式串。与KMP 匹配不同的是,每次i 增加前,需要赋予NEXT[i] 合适的值。此时,T[0..j] 已经与T[0..i] 的后缀相等,即T[0..i] 的前-后缀正是T[0..j],因此NEXT[i] = j+1。
匹配失败或者遇到边界情况时,处理思路与一般的KMP 匹配类似。
求NEXT[0..n-1] 的代码:
![]() |
根据前面对KMP 算法匹配过程的分析,求NEXT[0..n-1] 的复杂度显然是O(n)。
因此,整个KMP 算法的时间复杂度是:O(m+n)。