KMP算法
KMP算法
简介
一种由Knuth(D.E.Knuth)、Morris(J.H.Morris)和Pratt(V.R.Pratt)三人设计的线性时间字符串匹配算法。该算法主要解决的就是字符串匹配的问题。
本文参考前缀函数与KMP算法-oi.wiki
例题
LeetCode 28:找出字符串种第一个匹配项的下标
给你两个字符串 haystack 和 needle ,请你在 haystack 字符串中找出 needle 字符串的第一个匹配项的下标(下标从 0 开始)。
如果 needle 不是 haystack 的一部分,则返回 -1 。
1 <= haystack.length, needle.length <= 104
haystack 和 needle 仅由小写英文字符组成
输入:haystack = "sadbutsad", needle = "sad"
输出:0
解释:"sad" 在下标 0 和 6 处匹配。
第一个匹配项的下标是 0 ,所以返回 0 。
前置知识
- 字符串前缀
假设有一个字符串 s[0...i] (下标为0 - i)
它的前缀就定义为 s[0...j] (j <= i),当 j < i 时,就定义为该字符串的真前缀 - 字符串后缀
同理
后缀定义为 s[j...i] (j >= 0), 当 j > 0 时,就定义为该字符串的真后缀
前缀函数 next[] 数组
next数组是KMP的核心
定义
给定一个长度为 n 的字符串 s,其 前缀函数 被定义为一个长度为 n 的数组 next。 其中 next[i] 的定义是:
如果子串 s[0...i] 有一对相等的真前缀与真后缀:s[ 0...k-1 ] 和 s[ i-(k-1)...i ] ( k-1 < i-(k-1) ),那么 next[i] 的值就是这个字符串最长的真前后缀(因为相等的真前后缀可能不止一对,所以需要的是最长的真前后缀)的长度,也就是 next[i] = k;
next[i] = max(k,max) //条件:([0...k-1] == s[i-(k-1)...i])
初始化 next[0] = 0
举例
对于字符串: a b a b a b a c
next 数组:[ 0,0,1,2,1,2,3,0 ] => "","","a","ab","a","ab","aba",""
暴力求解 next 数组
给定字符串 s
暴力求解:
双指针遍历 s,设双指针 i 为当前 s[0...i] 的前缀和的下标(截至下标),j 为遍历 0 - i 的下标(遍历匹配 s[j] 和 s[i-j])
假设字符串 s[0...i] => j 遍历 比较 s[0] == s[i] ... s[1] == s[i-1] ... s[j] == s[i-j]
(需要满足要求 j < i-j )
代码如下(Java):
public static int[] prefixFunction(String str){
int[] next = new int[str.length()];
// next[0] = 0; // 可以省略,默认为 0
for(int i = 1; i < str.length(); i++){
for(int j = 0; j < i; j++){
// 截取 str[0...j] 和 str[i-j...i] 比较
if(i-j > j && str.substring(0,j+1).equals(str.substring(i-j,i+1))){
next[i] = j+1;
}
}
}
return next;
}
时间复杂度 O(n^3) 不推荐使用
优化思想
- 优化 1
对于 next[i] ,当s[i] == s[next[i-1]]
时 可以有next[i] = next[i-1] + 1
: (为什么呢😶,别急看下面)
假设字符串:a b c a b c
现在有 i = 5
已求next :[0,0,0,1,2,?]
next[5] = 2
,现在s[5] == s[2]
, 有next[5] == 3
仔细想 对于 next[i] 来说,需要求的是s[0...k] == s[i-k...i]
而已经有的 next[i-1] = m,所以已经知道的信息是s[0...m-1] == s[i-m...i-1]
,而现在又有s[m] = s[i]
,那自然能推出s[0...m] == s[i-m...i]
,所以 m + 1一定是 满足next[i] 的一个前后缀相等的一个长度,那么我们如何确定是最大的呢
假设他现在不是最大,存在一个更大的 next[i] == k + 1 ,那就有s[0...k] == s[i-k...i] (k > m )
,
那么对于 next[i-1] 而言他也能找到s[0...k-1] == s[i-k...i-1] (k > m)
那么 next[i-1] 就应该是 k 了,而不是 m ,这显然是矛盾的,所以 k 并不存在,m + 1 就是next[i] 的最大前后缀相等长度
总结if( s[i] == s[next[i-1]] ) next[i] = next[i-1] + 1
- 优化 2 (最难理解的部分来了,如果感觉看不懂可以考虑结合 oi.wiki 的一些图解增强理解)
对于 next[i] ,当s[i] != s[next[i-1]]
时, 考虑 next[i-1]的第二长前缀和长度开始 ,假设 第二长度 为 j 那么同理的 我们可以比较s[i] == s[j]
,再不行就第三长...
例如 :a b c a b d a b c a b c
此时 i == 11
next[]:[0,0,0,1,2,0,1,2,3,4,5,?]
a b c a b 是i-1最长 => s[0...4] == s[10 - 4...10]
a b 是第二长 => s[0...1] == s[10 - 1...10]
现在的问题是如何求 第二长 j (这里 j == 2) i == 11,next[i-1] == 5)
已知: next[i-1] = 5
因为有 s[0...4] == s[6...10] ,所以有 s[0...1] == s[6...7] (s[0...j-1] == s[i - next[i - 1]...i - next[i - 1] + j)
而 又因为 s[0...1] == s[10 - 1...10] == s[4 - 1...4] (s[0...j-1] == s[i-j...i-1] == s[next[i-1] - j...next[i-1]-1])
所以有 s[0...j-1] == s[next[i-1] - j ... next[i-1]-1] ,仔细观察会发现这其实就是 下标为 next[i-1] - 1 的最长前后缀相等长度
所以有 j = next[next[i-1] - 1]
同理 第三长就是 next[next[next[i-1]- 1] - 1]
优化求解 next 数组
结合前面两个优化
public static int[] prefixFunctionAdvance(String str){
int[] next = new int[str.length()];
for(int i = 1; i < next.length; i++){
int j = next[i-1];
while(j > 0 && str.charAt(i) != str.charAt(j)) j = next[j-1];
if(j < i-j && str.charAt(i) == str.charAt(j)) j++;
next[i] = j;
}
return next;
}
解例题
现在我们知道了怎么求 next 数组,想要解这个例题其实很简单
尝试将两个字符串合并,使用 '#' 字符区别开('#' 可以替换,只需要满足两个字符串中都不会出现该字符):
合并前: haystack = "sadbutsad", needle = "sad"
合并后: s = "sad#sadbutsad"
然后对 s 求 next 数组
next数组中第一次出现 next[i] == needle.lenght() 时,那么这就是 needle 第一次出现的位置
【注】:因为前缀式中有个字符'#'是不可能出现相等的,所以不可能存在前后缀最大和 > needle.length()
public int solution(String haystack,String needle){
String s = needle + "#" + haystack;
int[] ints = prefixFunctionAdvance(s);
for(int i = 0; i < ints.length; i++){
if(ints[i] == needle.length()){
// i - needle.len + 1为 next中出现重复的开始下标,需要减去 needle + "#" 的长度才是 haystack 的下标
// return i - needle.length() + 1 - (needle.length() + 1);
// 化简后
return i - needle.length() * 2;
}
}
return -1;
}