前缀函数
(真)前缀/(真)后缀
前缀Prefix(S,i)是指从串首开始到某个位置i结束的一个子串,即Prefix(S,i)=S[0..i]。真前缀指除了 S 本身的前缀。
后缀Suffix(S,i)是指从某个位置i开始到字符串末尾的一个子串,即Suffix(S,i)=S[i..∣S∣−1]。真后缀指除了 S 本身的后缀。
前缀函数定义
给定一个长度为n的字符串s,其前缀函数被定义为一个长度为n的数组π。 其中π[i]的定义是:
如果子串s[0...i]存在相等的真前缀与真后缀:s[0...k−1]和s[i−(k−1)...i],那么π[i]就是相等的真前缀的长度的最大值,即π[i]=max(k),否则π[i]=0;
特别地,规定π[0]=0。
例如字符串abaab,其前缀函数数组为[0,0,1,1,2]。
前缀函数计算方法
朴素的O(n3)解法不再赘述,这里直接介绍一种O(n)复杂度的算法,也是下面介绍的KMP算法的基础。假设目前所求为next[i],next[0..i−1]已全部计算完成。令j=next[i−1],如下图所示:
尝试比较s[i]与s[j]是否相同:
- 若相同,则说明s[i]可以沿用前缀next[i−1],即next[i]=next[i−1]+1;
- 若不同,循环尝试是否可以沿用前缀next[j−1],直到s[i]与s[j]相同或j为0;
该算法只需要利用s[i]左侧的π值,不需要进行任何字符串的比较,可以在O(n)时间内完成。代码如下所示:
1 2 3 4 5 6 7 8 9 10 11
| 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常用于字符串匹配任务,例如找到模式串s2与主串s1的所有匹配的下标。常规的暴力匹配算法在每次失配时,都需要将指针移动到模式串的起始位置,最终的时间复杂度为O(∣s1∣∗∣s2∣)。而如果先求出模式串的前缀函数,再进行匹配,时间复杂度就会大大降低。如下图所示:
令指针i,j分别指向主串和模式串,其中指针i只可能向右,指针j则需要在失配时回溯。
如果发生了失配,已知前后缀相同的最长长度next[j−1],那么指针j可以直接回溯到下标next[j−1],也就是说跳过了模式串的前j个字符的匹配过程。
代码模板
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| 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)
定义
对于一个长度为n的字符串s,定义函数z[i]表示s[0,n−1]和s[i,n−1]的最长公共前缀的长度。特别地,z[0]=0。
以下样例展示了不同字符串的Z函数:
- z(aaaaa)=[0,4,3,2,1]
- z(aaabaab)=[0,2,1,0,2,1,0]
- z(abacaba)=[0,0,1,0,3,0,1]
朴素算法
容易实现复杂度O(n2)的算法:
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[0]=0)。在计算z[i]时,需要用到已经计算完成的z[0..i−1]。
对于i,称区间[i,i+z[i]−1]是i的匹配段。在算法执行过程中,维护右端点最大的匹配段[l,r]。由定义可知,s[l,r]与前缀s[0,r−l]是相同的。初始时l=r=0。
算法流程如下,计算z[i]时:
- 若i<=r,由s[l,r]=s[0,r−l]可得s[i,r]=s[i−l,r−l]。因此z[i]>=min(z[i−l],r−i+1)
- 若z[i−l]<r−i+1,则z[i]不可能超出s[i,r]范围,z[i]=z[i−l]。
- 若z[i−l]>=r−i+1,此时z[i]超出了s[i,r]范围,令z[i]=r−i+1,然后暴力枚举直到z[i]无法扩展为止。
- 若i>r,直接按照朴素算法求出此时的z[i]
- 求出z[i]后,若新的右边界i+z[i]−1大于r,更新[l,r],即l=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),由代码可知z数组中每个位置最多被访问两次。
应用/题目
与前缀函数类似,Z函数可用于子串匹配问题,将原串s1和模式串s2拼接为s=s2+∗+s1,其中*为分割符。求出s的Z函数后,对于[∣s2∣..∣s∣−1]范围内的i,若z[i]=∣s2∣,则找到了一个匹配下标i。参考代码如下:
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__