前缀函数、KMP算法的实现
先了解几个和这两个算法相关的概念:
- 真前缀:给定一个字符串S,除了S本身,从开始到索引i的字符串 P r e f i x ( i ) = S [ 0... i ] ( i < l e n ( S ) ) Prefix(i)=S[0...i](i < len(S)) Prefix(i)=S[0...i](i<len(S))
- 真后缀:与真前缀的定义对应,表示从索引i到字符串结束,表示为 S u f f i x ( i ) = S [ i . . . l e n ( S ) − 1 ] ( i > 0 ) Suffix(i)=S[i...len(S)-1](i > 0) Suffix(i)=S[i...len(S)−1](i>0)
本篇文章分享的前缀函数,经常应用字符串匹配的算法中,其中KMP算法就是基于前缀函数的高效字符串匹配算法。
当然还有BM算法,BM算法利用了后缀匹配来实现匹配过程中字符串的快速跳转的,Java内置的字符串匹配算法好像就是用的BM算法,本篇文章不包含BM算法的详细解释,主要是我自己没学过,只是稍微了解过一点点。
进入正题,首先明白一个概念,前缀函数是用来描述字符串中真前缀和真后缀相等的字符串的相等的一个数组,该数组
A
r
r
[
i
]
Arr[i]
Arr[i]表示到字符串S
的第i
个字符为止,S
的真前缀和真后缀字符串相等的长度,并且定义
A
r
r
[
0
]
=
0
Arr[0] = 0
Arr[0]=0
具体求前缀函数的过程,包含了动态规划的思想,自己可以想一下状态转移方程怎么写,看下面的代码(Java实现):
public static int[] prefixFunction(String s){
//字符串s的长度
int n = s.length();
//表示字符串前缀函数的数组
int[] prefix = new int[n];
for(int i = 1; i < n; i++){
//prefix[i-1]表示S[0...i-1]的前缀函数值,每次前缀函数值最多比上一个大1,不会更大,所以从此处开始比较
int j = prefix[i-1];
//如果当前的是s[i] != s[j]说明不能在前一个前缀函数值上进行比较了,这里不太好描述,可以自己仔细思考一下,将j = prefix[j-1]的原因,就是充分利用已经找到的前缀函数值的信息,找下一个前缀函数值
while(j > 0 && s.charAt(i) != s.charAt(j)){
j = prefix[j-1];
}
//比较当前字符和第j个字符是否相等,如果相等,前缀函数值加一
if(s.charAt(i) == s.charAt(j)){
j++;
}
prefix[i] = j;
}
return prefix;
}
状态转移方程:
j
(
n
)
=
p
r
e
f
i
x
[
j
(
n
−
1
)
−
1
]
j^{(n)} = prefix[j^{(n-1)}-1]
j(n)=prefix[j(n−1)−1]
其中n表示当前状态,n-1表示上一个状态
下面来到本篇文章的重点,其实理解了上述的前缀函数,KMP算法就非常简单了,看过很多博客,利用二维数组,不容易理解,其实本质上还是利用动态规划的思想求前缀函数。
KMP是Knuth-Morris-Pratt
的缩写,就是短线这三个大佬发明的,就是三个人名,听上去很高大上,但也确实厉害。
现在,考虑这样一个问题,假设我要在一段文本 t 中寻找所有出现 字符串s 的地方,该怎么实现这个功能?
怎么利用上面的前缀函数,现在将两端字符串进行拼接: s + '#' + t
, '#'表示既不在s
中出现,也不在t
中出现的字符,本质上是将该字符串的所有前缀函数值限定在
n
=
l
e
n
(
s
)
n = len(s)
n=len(s)内,所以利用前面的前缀函数的思想,我们维护这个新的字符串的前 n
个位置上的字符串的前缀函数值,然后遍历整个字符串,如果发现到索引位置 i
位置的字符串的前缀函数值为 n
,则说明该字符串出现了,当然 i
是拼接后的字符串的位置,所以需要注意一些细节。
下面用代码来实现一下:
public static List<Integer> findSubstring(String t, String s){
int ns = s.length();
int n = t.length();
//获取目标子串s的前缀函数
int[] prefix = prefixFunction(s);
List<Integer> ans = new ArrayList<Integer>();
for(int i = 0, j = 0; i < n; i++) {
while(j > 0 && t.charAt(i) != s.charAt(j)) {
j = prefix[j-1];
}
if(t.charAt(i) == s.charAt(j)) {
j++;
}
if(j == ns) {
ans.add(i - ns + 1);
j = 0;
}
}
return ans;
}
最后测试算法的正确性,这里使用org.apache.commons.lang3
这个包中的RandomStringUtils来随机生成字符串,然后调用上述函数进行匹配,函数的返回结果为一个列表,表示字符串s
出现在t
中的第一个字符的索引。
public static void main(String[] args) {
//中文环境下,不指定字符会出现乱码
//这里为了增大出现子串s的概率,只使用ab两个字符来生成随机t
String t = RandomStringUtils.random(50, "ab");
String s = RandomStringUtils.random(3, "ab");
System.out.println("t: " + t);
System.out.println("s: " + s);
System.out.println(findSubstring(t, s));
}
运行结果:
t: bbbbbabaababbabaaabbabbbbbbabaababbbbaababbbabaabb
s: baa
[6, 14, 28, 36, 45]