思想
z i z_i zi的定义是, i i i开头的后缀,和字符串本身的最长公共前缀。
和 k m p kmp kmp的类似点是,都能利用前面递推的结果,加速当前的计算,从而把 O ( n 2 ) O(n^2) O(n2)加速到 O ( n ) O(n) O(n)。当然加速的方式不太一样。
核心是,假设当前处理完一个后缀的答案,那么这个后缀对应一个 z b o x zbox zbox,也就是一个区间 [ i , i + z [ i ] − 1 ] [i,i+z[i]-1] [i,i+z[i]−1]。在递推过程中,维护一个右端点最大的区间,作为 z b o x zbox zbox。
对于一个 i i i,如果他在前面推出的 z b o x zbox zbox里,那么首先根据 z z z函数的定义, z b o x zbox zbox的区间 [ l , r ] [l,r] [l,r],和前缀 [ 0 , r − l ] [0,r-l] [0,r−l]是相等的,那么 i i i的 z z z函数,至少和 z [ i − l ] z[i-l] z[i−l]是一样的,当然,不能长度超过 r − i + 1 r-i+1 r−i+1,因为我们目前只能保证 [ i − l , r − l ] [i-l,r-l] [i−l,r−l]和 [ i , r ] [i,r] [i,r]这两段是一样的,可以复用结果,出了这个范围,则只能暴力匹配。
这哥的复杂度保证,是在于我们在当前回文不超出 r r r的时候,不会暴力匹配,而暴力匹配每次都会增加 r r r, r r r单调递增,最多增加 O ( n ) O(n) O(n)次,所以里面那个 w h i l e while while循环实际上只会 O ( n ) O(n) O(n)复杂度。
代码
两种实现,第二种是详细分类讨论。
第一种规避了分讨
c
vector<int> calc_z(const string& s) {
int n = s.size();
vector<int> z(n);
int box_l = 0, box_r = 0;
for (int i = 1; i < n; i++) {
if (i <= box_r) {
z[i] = min(z[i - box_l], box_r - i + 1);
}
while (i + z[i] < n && s[z[i]] == s[i + z[i]]) {
box_l = i;
box_r = i + z[i];
z[i]++;
}
}
z[0] = n;
return z;
}
vector<int> z_function(const string &s) {
int n = (int)s.length();
vector<int> z(n);
for (int i = 1, l = 0, r = 0; i < n; ++i) {
if (i <= r && z[i - l] < r - i + 1) {
z[i] = z[i - l];
} else {
z[i] = max(0, r - i + 1);
while (i + z[i] < n && s[z[i]] == s[i + z[i]]) ++z[i];
}
if (i + z[i] - 1 > r) l = i, r = i + z[i] - 1;
}
return z;
}
例题
z函数也能做字符串匹配,把模式串p,文本串t,拼成p#s,这样我们z函数求的,一个后缀和整个串的lcp,实际上就是求的文本串的一个后缀,和模式串的匹配长度,如果这个匹配长度为模式串长度则为找到一个匹配
c
vector<int> calc_z(const string& s) {
int n = s.size();
vector<int> z(n);
int box_l = 0, box_r = 0;
for (int i = 1; i < n; i++) {
if (i <= box_r) {
z[i] = min(z[i - box_l], box_r - i + 1);
}
while (i + z[i] < n && s[z[i]] == s[i + z[i]]) {
box_l = i;
box_r = i + z[i];
z[i]++;
}
}
z[0] = n;
return z;
}
class Solution {
public:
int strStr(string t, string p) {
string s = p + "#" + t;
vector<int> z = calc_z(s);
int n = p.size();
for (int i = 0; i + n - 1 < t.size(); i++) {
if (z[i + n + 1] == n) {
return i;
}
}
return -1;
}
};
添加到末尾的可以随意添加,我们当然可以让添加的等于初始状态的后缀。所以问题就是剩下的前缀,能否等于初始的前缀。剩下的前缀实际上是初始的一个后缀。
那么就是问一个串的后缀能否等于等长的前缀,求z函数,看一个后缀的z函数值,是否和这个后缀长度相等。
注意到这也是一个前缀和后缀的匹配,也可以kmp求最长公共前后缀实现。
求出z函数后,枚举所有长度为k的倍数的后缀,找到符合条件的,最长的后缀,就对应最少的删除次数。如果所有长度k倍数的后缀都不符合,那么只能让这个操作把整个串都移除了,然后通过添加得到初始串
c
vector<int> calc_z(const string& s) {
int n = s.size();
vector<int> z(n);
int box_l = 0, box_r = 0;
for (int i = 1; i < n; i++) {
if (i <= box_r) {
z[i] = min(z[i - box_l], box_r - i + 1);
}
while (i + z[i] < n && s[z[i]] == s[i + z[i]]) {
box_l = i;
box_r = i + z[i];
z[i]++;
}
}
z[0] = n;
return z;
}
class Solution {
public:
int minimumTimeToInitialState(string word, int k) {
int n = word.size();
vector<int> z = calc_z(word);
for (int i = k; i < n; i += k) {
if (z[i] == n - i) {
return i / k;
}
}
return (n + k - 1) / k;
}
};
kmp+字典树
给个字符串数组,问有多少对字符串,s同时是t的前缀和后缀。首先t的某个长度的前后缀相同,才可能存在s和t配对,所以先预处理t的z函数,通过z函数检查每一个后缀,是否存在一个相等的前缀,如果存在检查有多少s,等于这个前缀。
检查某个s的个数,可以字符串哈希,然后对哈希值计数。也可以trie在节点上记录每个点结束的字符串个数end_cnt。
这里如果用字典树的话,需要在字典树上搜索t的过程中,每搜索到一个前缀,就检查z函数,判断是否前后缀相等,如果相等,累加这个节点的end_cnt。
为了防止重复计数,可以采用扫描线的做法,每次计算完一个t的答案,把这个t作为s插入字典树,也就是在结束节点end_cnt++。
对于文本串中,每个长度等于模式串的子串,检查它是否是几乎相等。可以让这个子串的前缀和模式串的前缀求lcp,子串的后缀和模式串的后缀求lcp,如果长度加起来不小于m-1,也就是至多有一个不匹配的地方,则可以通过修改至多一个匹配上。
找子串的前缀和模式串的lcp,就是找文本串的一个后缀和模式串的lcp,把模式串接到文本串前面跑z函数即可,类似字符串匹配的处理。对于子段后缀和文本串后缀的lcp,把模式串和文本串都反转,再拼接求z函数即可
z函数+跳跃游戏
给一个字符串数组,用里面任意字符串的任意前缀,问拼接出目标串的最少拼接次数?
假设当前已经求了 [ 0 , i − 1 ] [0,i-1] [0,i−1]的拼接次数,接下来从 i i i开始拼接,如果能知道 i i i开始能拼接的最大长度,由于前缀的前缀仍然是前缀,肯定小于这个最大长度的拼接也存在。那么这就可以转化成跳跃游戏贪心。
现在问题就是,对于每个位置i,能拼接的最大长度?这等价于求i开头的后缀,和所有模式串的最大lcp。让目标串和每个模式串都跑一次z函数,然后取最大即可。
c
vector<int> calc_z(const string& s) {
int n = s.size();
vector<int> z(n);
int box_l = 0, box_r = 0;
for (int i = 1; i < n; i++) {
if (i <= box_r) {
z[i] = min(z[i - box_l], box_r - i + 1);
}
while (i + z[i] < n && s[z[i]] == s[i + z[i]]) {
box_l = i;
box_r = i + z[i];
z[i]++;
}
}
z[0] = n;
return z;
}
int jump(vector<int>& a) {
int ans = 0, mx = 0, lim = 0;
for (int i = 0; i < a.size(); i++) {
mx = max(mx, i + a[i]);
if (i == lim) {
if (i == mx) {
return -1;
}
ans++;
lim = mx;
}
}
return ans;
}
class Solution {
public:
int minValidStrings(vector<string>& words, string target) {
int n = target.size();
vector<int> mx(n);
for (auto& s : words) {
vector<int> z = calc_z(s + "#" + target);
int m = s.size() + 1;
for (int i = 0; i < n; i++) {
mx[i] = max(mx[i], z[i + m]);
}
}
return jump(mx);
}
};
数据允许 O ( n 2 ) O(n^2) O(n2),枚举两个切点即可,接下来检查s1是不是s2前缀,s2是不是s3前缀,可以字符串哈希,也可以z函数s1是不是s2前缀,对初始串跑一次z函数即可,因为我们这就是要把s1当模式串,正好s1就是初始串的前缀。
s2是不是s3前缀,需要把s2开头的后缀拿出来跑一次z函数,因为需要让s2当模式串,在最前面。
z函数 dp优化

每次删除一个前缀,剩下的子问题是一个更小的后缀,所以 d p dp dp定义是 f i f_i fi表示删掉 i i i开头的后缀的最大操作数,递推的话,需要从较小子问题推到较大子问题,也就需要 i i i从大到小枚举,相当于从小到大枚举后缀。
转移是,如果 [ i , i + j − 1 ] = [ i + j , i + 2 j ] [i,i+j-1]=[i+j,i+2j] [i,i+j−1]=[i+j,i+2j],那么可以把 [ i , i + j − 1 ] [i,i+j-1] [i,i+j−1]删掉, f i = f i + j + 1 f_i=f_{i+j}+1 fi=fi+j+1
判断 [ i , i + j − 1 ] = [ i + j , i + 2 j ] [i,i+j-1]=[i+j,i+2j] [i,i+j−1]=[i+j,i+2j]这里,可以字符串哈希,也可以对 i i i开头的后缀跑一次z函数。


