前缀函数

(真)前缀/(真)后缀

前缀Prefix(S,i)Prefix(S,i)是指从串首开始到某个位置ii结束的一个子串,即Prefix(S,i)=S[0..i]Prefix(S, i)=S[0..i]真前缀指除了 SS 本身的前缀。
后缀Suffix(S,i)Suffix(S,i)是指从某个位置ii开始到字符串末尾的一个子串,即Suffix(S,i)=S[i..S1]Suffix(S, i)=S[i..|S|-1]真后缀指除了 SS 本身的后缀。

前缀函数定义

给定一个长度为nn的字符串ss,其前缀函数被定义为一个长度为nn的数组π\pi。 其中π[i]\pi [i]的定义是:

如果子串s[0...i]s[0...i]存在相等的真前缀与真后缀:s[0...k1]s[0...k-1]s[i(k1)...i]s[i-(k-1)...i],那么π[i]\pi[i]就是相等的真前缀的长度的最大值,即π[i]=max(k)\pi[i] = max(k),否则π[i]=0\pi[i] = 0
特别地,规定π[0]=0\pi[0] = 0

例如字符串abaab,其前缀函数数组为[0,0,1,1,2][0,0,1,1,2]

前缀函数计算方法

朴素的O(n3)O(n^3)解法不再赘述,这里直接介绍一种O(n)O(n)复杂度的算法,也是下面介绍的KMP算法的基础。假设目前所求为next[i]next[i]next[0..i1]next[0..i-1]已全部计算完成。令j=next[i1]j=next[i-1],如下图所示:

image-20240919104624548

尝试比较s[i]s[i]s[j]s[j]是否相同:

  • 若相同,则说明s[i]s[i]可以沿用前缀next[i1]next[i-1],即next[i]=next[i1]+1next[i] = next[i-1]+1;
  • 若不同,循环尝试是否可以沿用前缀next[j1]next[j-1],直到s[i]s[i]s[j]s[j]相同或jj为0;

该算法只需要利用s[i]s[i]左侧的π\pi值,不需要进行任何字符串的比较,可以在O(n)O(n)时间内完成。代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
// 求字符串word的前缀函数数组
public int[] getNext(String word){
int[] next = new int[n];
for(int i = 1;i < n;++i){
int j = next[i-1];
while(j != 0 && word.charAt(i) != word.charAt(j)) j = next[j-1];
if(word.charAt(i) == word.charAt(j)) ++j;
next[i] = j;
}
return next;
}

KMP算法

核心思想

KMP常用于字符串匹配任务,例如找到模式串s2s2与主串s1s1的所有匹配的下标。常规的暴力匹配算法在每次失配时,都需要将指针移动到模式串的起始位置,最终的时间复杂度为O(s1s2)O(|s1| * |s2|)。而如果先求出模式串的前缀函数,再进行匹配,时间复杂度就会大大降低。如下图所示:

image-20240919104608109

令指针i,ji,j分别指向主串和模式串,其中指针ii只可能向右,指针jj则需要在失配时回溯。
如果发生了失配,已知前后缀相同的最长长度next[j1]next[j-1],那么指针jj可以直接回溯到下标next[j1]next[j-1],也就是说跳过了模式串的前jj个字符的匹配过程。

代码模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 返回主串s1所有可行的匹配起始点
public List<Integer> strStr(String s1, String s2) {
List<Integer> ans = new ArrayList<>();
int n = s1.length(), m = s2.length();
int[] next = new int[m];
for(int i = 1;i < m;++i){
int j = next[i-1];
while(j != 0 && s2.charAt(i) != s2.charAt(j)) j = next[j-1];
if(s2.charAt(i) == s2.charAt(j)) ++j;
next[i] = j;
}
int j = 0;
for(int i = 0;i < n;++i){
while(j != 0 && s2.charAt(j) != s1.charAt(i)) j = next[j-1];
if(s2.charAt(j) == s1.charAt(i)) ++j;
if(j == m) {
ans.add(i-m+1);
j = next[j-1];
}
}
return ans;
}

题目

28. 找出字符串中第一个匹配项的下标 - 力扣(LeetCode)

3045. 统计前后缀下标对 II - 力扣(LeetCode)

Z函数(扩展KMP)

定义

对于一个长度为nn的字符串ss,定义函数z[i]z[i]表示s[0,n1]s[0,n-1]s[i,n1]s[i,n-1]的最长公共前缀的长度。特别地,z[0]=0z[0]=0

以下样例展示了不同字符串的Z函数:

  • z(aaaaa)=[0,4,3,2,1]z(aaaaa)=[0,4,3,2,1]
  • z(aaabaab)=[0,2,1,0,2,1,0]z(aaabaab)=[0,2,1,0,2,1,0]
  • z(abacaba)=[0,0,1,0,3,0,1]z(abacaba)=[0,0,1,0,3,0,1]

朴素算法

容易实现复杂度O(n2)O(n^2)的算法:

1
2
3
4
5
6
7
8
public int[] getZ(String s){
int n = s.length();
int[] z = new int[n];
for(int i = 1;i < n;++i){
while(i+z[i] < n && s.charAt(z[i]) == s.charAt(i+z[i])) ++z[i];
}
return z;
}

线性算法

从1到n-1依次计算z[i]z[i]的值(z[0]=0z[0]=0)。在计算z[i]z[i]时,需要用到已经计算完成的z[0..i1]z[0..i-1]
对于i,称区间[i,i+z[i]1][i, i+z[i]-1]ii匹配段。在算法执行过程中,维护右端点最大的匹配段[l,r][l,r]。由定义可知,s[l,r]s[l,r]与前缀s[0,rl]s[0,r-l]是相同的。初始时l=r=0l=r=0

算法流程如下,计算z[i]z[i]时:

  • i<=ri <= r,由s[l,r]=s[0,rl]s[l,r]=s[0,r-l]可得s[i,r]=s[il,rl]s[i,r]=s[i-l,r-l]。因此z[i]>=min(z[il],ri+1)z[i] >= min(z[i-l], r-i+1)
    • z[il]<ri+1z[i-l]<r-i+1,则z[i]z[i]不可能超出s[i,r]s[i,r]范围,z[i]=z[il]z[i]=z[i-l]
    • z[il]>=ri+1z[i-l]>=r-i+1,此时z[i]z[i]超出了s[i,r]s[i,r]范围,令z[i]=ri+1z[i]=r-i+1,然后暴力枚举直到z[i]z[i]无法扩展为止。
  • i>ri > r,直接按照朴素算法求出此时的z[i]z[i]
  • 求出z[i]z[i]后,若新的右边界i+z[i]1i+z[i]-1大于rr,更新[l,r][l,r],即l=i,r=i+z[i]1l=i,r=i+z[i]-1

这个过程其实有点像manacher算法,都是维护了此前可达的最右侧边界,然后利用该边界提供的信息快速跳转到某个位置。可以访问 这个网站 来看 Z 函数的模拟过程。

代码模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public int[] getZ(String s){
int n = s.length();
int[] z = new int[n];
int l = 0, r = 0;
for(int i = 1;i < n;++i){
if(i <= r){
if(z[i-l] < r-i+1) z[i] = z[i-l];
else z[i] = r-i+1;
}
while(i+z[i] < n && s.charAt(i+z[i]) == s.charAt(z[i])) ++z[i];
if(i+z[i]-1 > r){
l = i;
r = i+z[i]-1;
}
}
return z;
}

时间复杂度:O(n)O(n),由代码可知zz数组中每个位置最多被访问两次。

应用/题目

与前缀函数类似,Z函数可用于子串匹配问题,将原串s1s1和模式串s2s2拼接为s=s2++s1s=s2+*+s1,其中*为分割符。求出ss的Z函数后,对于[s2..s1][|s2|..|s|-1]范围内的ii,若z[i]=s2z[i]=|s2|,则找到了一个匹配下标ii。参考代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public List<Integer> strStr(String s1, String s2) {
List<Integer> ans = new ArrayList<>();
String s = s2 + "*" + s1;
int n = s1.length(), m = s2.length();
int[] z = new int[n+m+1];
int l = 0, r = 0;
for(int i = 1;i < n+m+1;++i){
if(i <= r){
if(z[i-l] < r-i+1) z[i] = z[i-l];
else z[i] = r-i+1;
}
while(i+z[i] < n+m+1 && s.charAt(i+z[i]) == s.charAt(z[i])) ++z[i];
if(i+z[i]-1 > r){
l = i;
r = i+z[i]-1;
}
// 判断是否匹配
if(i > m && z[i] == m) ans.add(i-m-1);
}
return ans;
}

下面是一些Z函数相关的题目,大多数情况下如果可以使用Z函数,那么也可以使用KMP。

28. 找出字符串中第一个匹配项的下标 - 力扣(LeetCode)

3045. 统计前后缀下标对 II - 力扣(LeetCode)

3036. 匹配模式数组的子数组数目 II - 力扣(LeetCode)

3008. 找出数组中的美丽下标 II - 力扣(LeetCode)

2223. 构造字符串的总得分和 - 力扣(LeetCode)

3031. 将单词恢复初始状态所需的最短时间 II - 力扣(LeetCode)

1392. 最长快乐前缀 - 力扣(LeetCode)

参考

Z 函数(扩展 KMP) - OI Wiki (oi-wiki.org)

__END__