扩展KMP算法

算法基础

问题引入

如果要用哈希统计从 s 中每一位字符开始最多可以匹配多少位 p 中的字符,需要用到二分查找,此时时间复杂度为 O ( n l o g m + m ) O(n log m + m) O(nlogm+m) ,其中 n 表示 s 的长度,m 表示 p 的长度。

扩展KMP(也称为Z algorithm)

  • 能够以线性的时间复杂度求出一个字符串 s 和它的任意后缀
    s[i] ... s[n]最长公共前缀的长度。

  • 注意其与KMP算法求出的 next 数组的区别,一个是以字符s[i]结束,另一个是从字符s[i]开始。

  • KMP

    s[i] 结束的后缀与s的前缀匹配的最大长度

  • Z algorithm

    s[i] 开始的后缀与s的前缀匹配的最大长度

举几个例子:

因为i = 1时, 以i结尾的后缀就是s串本身, 所以不再考虑

  • s1 = “aaaaaa”

    z函数: z = [-, 5, 4, 3, 2, 1]

    对应的后缀: “aaaaa”, “aaaa”, “aaa”, “aa”, “a”

  • s2 = “aabaacd”

    z函数: z = [-, 1, 0, 2, 1, 0, 0]

    对应的后缀: “a”, “”, “aa”, “a”, “”, “”

  • s3 = “abababab”

    z函数: z = [-, 0, 6, 0, 4, 0, 2, 0]

    对应的后缀: “”, “ababab”, “”, “abab”, “”, “ab”, “”

  • s4 = “ababaaba”

    z函数: z = [-, 0, 3, 0, 1, 3, 0, 1]

    对应的后缀: “”, “aba”, “”, “a”, “aba”, “”, “a”

Z函数计算

对于满足i > 1的位置 iz[i]表示字符串 s 和后缀s[i] ... s[n]的最长公共前缀的长度。

定义z[1] = 0,然后从2n枚举i,依次计算z[i]的值。

  • 假设我们要计算第 i个位置的值 z[i],此时z[1]....z[i -1]都已经计算好了。

  • 对于 j,有s[j] ...s[j + z[j] - 1]s[1] ... s[z[j]]完全相等。

  • 为了计算 z[i],在枚举i的过程中,我们要维护R最大的区间[L,R],其中L = j (j < i), R = j + z[j] - 1。即区间[L, R]是目前R最大的s的前缀。

  • 初始时区间为空: L= 1,R = 0。

  • 如果 i ≤ R i \le R iR,则情况如下:

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xlWtt6eF-1692244743816)(字符串.assets/image-20220821222427260-1692106342180-3.png)]

    根据定义,有s[L] ... s[R] = s[1] ...s[R - L + 1]k = i - L + 1i [L,R]中的位置对应了 k 在 [1, R-L+1]中的位置,此时 s[i]...s[R]s[k].. s[R-L+1]是相等的。

    此时z[k]已知, 即有: s[1]~s[z[k]] = s[k]~s[k + z[k]], 又因为s[k]~s[z[k]]对应了s[i]~s[i+z[k]], 所以以i为后缀的子串基础长度

    取决于z[k][k, R-L+1]的长度

    可以理解为z[k]为可以提供给i的关于与前缀匹配的信息, 位置i至少能匹配的前缀长度取决于z[k]本身和[k, R-L+1]的长度

    • 如果 z [ k ] < R − i + 1 z[k] < R - i + 1 z[k]<Ri+1,说明从k开始匹配不到R - L + 1那么远,也就是说从i开始匹配不到R那么远,, 此时z[i] = z[k]
    • 如果 z [ k ] ≥ R − i + 1 z[k] \ge R - i + 1 z[k]Ri+1,说明从 i 开始至少可以匹配到那么远, 即至少有z[i] = z[k],因为s[R + 1] != s[R - L + 2], 所以不能根据z[k]本身推出z[i], 要考虑到边界影响, 但此时z[i]的初始值可确定为[k, R-L+1]的长度, 这时我们从 R+1 开始继续暴力向后匹配即可。
  • 如果i > R:

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DJXjtAlo-1692244743817)(字符串.assets/image-20220821223008127-1692106353649-6.png)]

    暴力枚举匹配即可。

    求出z[i]后用iz[i]的值更新 L 和 R, 所以我们可以知道要求R最大是为了包含更多的未确定z函数值的位置。

  • 时间复杂度

    暴力向后匹配的过程中R的值也在同步增加,如果R加了 O ( n ) O(n) O(n)次到达了字符串终点, 则此后任意位置iz[i]可立刻得知, 不需要暴力与前缀匹配, 所以R最多只会被加 O ( n ) O(n) O(n)次,因此算法的时间复杂度为 O ( n ) O(n) O(n)

代码模板

char s[N];
int n, z[N];
void exkmp() {
	int l = 1, r = 0; // 空区间
	z[1] = 0; // 定义z[1] = 0, 后面直接while就行
	// 递推求z[i]
	for(int i = 2; i <= n; i++) {
		if(i > r) z[i] = 0; // z[i] = 0, 等会while循环暴力
		else { // 利用区间[l, r]更新z[i]
			int k = i - l + 1; // i 在 l~r 的对应位置
			// 只能确定 l ~ r == 1 ~ r-l+1
			// 则 i ~ r == k ~ r-l+1
			// 如果 z[k] <  r - i + 1,  则 z[i] = z[k]
			// 如果 z[k] >= r - i + 1,  则 z[i] = r - i + 1
			z[i] = min(z[k], r - i + 1);
		}
		// 暴力匹配, 两种情况都会在这暴力匹配
		while(i + z[i] <= n && s[z[i] + 1] == s[z[i] + i]) z[i]++;
		// 更新R最大区间
		if(i + z[i] - 1 > r) l = i, r = i + z[i] - 1;
	}
}

习题

字符串匹配例题

给你两个字符串 a, b,字符串均由小写字母组成,现在问你 b 在 a 中出现了几次。

输入有多组数据,第一行为数据组数 𝑇,每组数据包含两行输入,第一行为字符串 a,第二行为字符串 b。

对于每组输入需要输出两行,其中第一行为出现次数,第二行为每次出现时第一个字符在 a 中的下标(字符串首位的下标为 1)。如果找不到,输出两行 −1。

利用Z数组的含义: 以i为起点的后缀能匹配的最长前缀的长度

将模式串p放在前面, 源串s放在后面, 并用’‘#’'分割, 则在源串s位置i的z函数值z[i]等于模式串长度m, 表明模式串p在源串s中出现过, 且以位置i为起点

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 1e5+5;
const int INF = 0x3f3f3f3f;
int z[2 * N];
string a, b;
void exkmp() {
	string s = "@" + b + "#" + a;
	int n = a.size(), m = b.size();
	int L = 0, R = 1;
	z[1] = 0;
	for (int i = 2; i <= n + m + 1; i++) {
		// 获取初始z函数值
		if (i > R) z[i] = 0;
		else z[i] = min(z[i - L + 1], R - i + 1);
		// 从i+z[i]-1位置开始暴力匹配
		// i + z[i]是下一个要匹配位置
		// 当前位置合法, 并且要匹配的下一对位置字符相等
		while (i + z[i] <= n + m + 1 
			&& s[i + z[i]] == s[1 + z[i]]) {
			z[i]++;
		}
		// 更新区间[L, R]
		if (i + z[i] - 1 > R) {
			R = i + z[i] - 1, L = i;
		}
	}
	std::vector<int> ans;
	// 1 ~ m  m+1   m+2 ~ n+m+1
	//  b      #       a
	for (int i = m + 2; i <= n + m + 1; i++) {
		if (z[i] == m) {
			ans.push_back(i - (m + 2) + 1);
		}
	}

	if (ans.empty()) cout << -1 << endl << -1;
	else {
		cout << ans.size() << endl;
		for (auto v : ans) cout << v << " ";
	}
	cout << endl;
}
int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr); cout.tie(nullptr);
	int T; cin >> T;
	while (T--) {
		cin >> a >> b;
		exkmp();
	}
    return 0;
}

Password

给你一个字符串 s,由小写字母组成,你需要求出其中最长的一个子串 p,满足 p 既是 s 的前缀,又是 s 的后缀,且在 s 中以非前缀后缀的形式出现过。如果找不到输出 Just a legend

输入一行一个字符串 s,输出一行一个字符串 p。

可以用KMP和扩展KMP解决

判断子串既是前缀又是后缀

  • 对于位置i, z[i]表示以i开始的子串能与前缀匹配的最大长度, 如果该长度可以到达整个字符串的终点, 则说明该子串是一个既是前缀又是后缀

判断是否以非前缀后缀形式出现过

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lYYUABYk-1692244743818)(字符串.assets/image-20230816120223575.png)]

  • 如果s[i] ~ s[n]既是前缀也是后缀, 则有s[i] ~ s[n] = s[1] ~ s[z[i]]

    如果此时位置i前面有位置j (j < i)使得z[j] > z[i], 将两个位置起始的子串对于到其前缀有j起始子串匹配的前缀包含了i起始的子串匹配的前缀, 即s[1] ~ s[z[i]] 包含在 s[1] ~ s[z[j]]

    因为s[1] ~ s[z[j]] = s[j]~s[j + z[j] - 1],所以有s[1] ~ s[z[i]] = s[j] ~ s[j + z[i] - 1]

    即该前后缀s[i]~s[n] = s[1]~s[z[i]] = s[j] ~ s[j + z[i]]存在于中间位置

综上, 只需要遍历每个位置, 判断该子串是否是前后缀, 再判断该位置前的最大z函数是多少, 如果大于该位置的z函数, 则存在非前后缀的中间串与前后缀相等, 最后取最长的z函数值即可。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 1e6+5;
const int INF = 0x3f3f3f3f;
int z[2 * N];
string s;
void exkmp() {
	s = "@" + s;
	int n = s.size() - 1;
	int L = 0, R = 1;
	z[1] = 0;
	for (int i = 2; i <= n; i++) {
		// 获取初始z函数值
		if (i > R) z[i] = 0;
		else z[i] = min(z[i - L + 1], R - i + 1);
		while (i + z[i] <= n 
			&& s[i + z[i]] == s[1 + z[i]]) {
			z[i]++;
		}
		// 更新区间[L, R]
		if (i + z[i] - 1 > R) {
			R = i + z[i] - 1, L = i;
		}
	}
	int preMaxZ = 0, maxZ = 0;
	for (int i = 1; i <= n; i++) {
		// 要求此等于前缀后缀右端点为字符串右端点
		if (z[i] == n - i + 1) {
			// 如果前面存在大于z[i]的, 说明中间存在一个等于前缀的后缀
			if (preMaxZ >= z[i]) {
				maxZ = max(maxZ, z[i]);
			}
		}
		preMaxZ = max(preMaxZ, z[i]);
	}
	if (!maxZ) cout << "Just a legend" << endl;
	else cout << s.substr(1, maxZ) << endl;
	
}
int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr); cout.tie(nullptr);
    cin >> s;
    exkmp();
    return 0;
}

Prefixes and Suffixes

您的任务是,对于与字符串s的后缀匹配的字符串s的任何前缀,打印它的长度和作为子字符串在字符串s中出现的次数。

与上一题类似, 我们判断出后缀等于前缀的位置后, 如果前面位置存在大于该位置的z函数值的位置, 则存在于该后缀相等的子串, 本体中要求求这样的后缀子串个数, 则问题变为求某个位置前面有多少个位置的z函数值大于等于该位置的z函数值

可以用树状数组维护到位置i之前的位置的z函数值域的树状数组, 但是z函数值可以取0, 所以0不能直接用树状数组存

树状数组可以查询位置i之前的前缀权值和, 即查询有多少位置的z函数值小于等于z[i], 因为位置i前面有i-1个位置, 前面的权值要么比z[i]大, 要么比z[i]小, 要么等于0, 所以位置i前面有(i - 1) - query(z[i] - 1) - zero个位置的z函数值大于等于z[i],

最后, 位置i开始的后缀也不要忘记

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 1e5+5;
const int INF = 0x3f3f3f3f;
ll c[N];
int n, z[2 * N];
string s;
ll query(int x) {
    ll s = 0;
    for (; x; x -= x & (-x)) s += c[x];
    return s;
}
void add(int x, ll s) {
    for (; x <= N; x += x & (-x)) {
        c[x] += s;
    }
}
void exkmp() {
	s = "$" + s; 
	n = s.size() - 1;
	int L = 0, R = 1;
	z[1] = 0;
	for (int i = 2; i <= n; i++) {
		if (i > R) z[i] = 0;
		else z[i] = min(z[i - L + 1], R - i + 1);
		while (i + z[i] <= n 
			&& s[i + z[i]] == s[1 + z[i]]) {
			z[i]++;
		}
		if (i + z[i] - 1 > R) {
			R = i + z[i] - 1, L = i;
		}
	}
	z[1] = n;
	std::vector<pair<int, int>> ans;
	int zero = 0;
	for (int i = 1; i <= n; i++) {
		if (z[i] == n - i + 1) {
			int cnt = (i - 1) - zero - (query(z[i] - 1)) + 1;
			ans.push_back({z[i], cnt});
		}
		if (!z[i]) zero++;
		else add(z[i], 1);
	}
	sort(ans.begin(), ans.end());
	cout << ans.size() << endl;
	for (auto v : ans) cout << v.first << " " << v.second << endl;

}
int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr); cout.tie(nullptr);
	cin >> s;
	exkmp();
    return 0;
}

Periodic Strings

如果字符串可以通过连接另一个长度为k的字符串的一次或多次重复来形成,则该字符串具有周期k。例如,字符串“abcabcabc”具有周期3,因为它是由字符串“abc”的4次重复组成的。它也有周期6(“abcabc”重复两次)和12(“abcabcabc”重复一次)。

扩展kmp求最小循环节

z[i] = n - i + 1时, 表示从i开始的后缀等于等长的前缀: s[i] ~ s[n] = s[1] ~ s[z[i]]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MV758kFe-1692244743818)(字符串.assets/image-20230816211325028.png)]

所以s[1]~s[i-1] = s[i]~s[i+i-2], 以此类推后缀s[i]~s[n]可被前缀s[1]~s[i-1], 如果后缀s[i]~s[n]的长度z[i]能被该前缀的长度i-1整除, 则i-1就是该字符串的最小循环节, 否则出现只使用部分s[1]~s[i-1]覆盖调字符串, 则s[1]~s[i-1]不是最小循环节

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 100+5;
const int INF = 0x3f3f3f3f;
int z[2 * N];
string a;
void exkmp() {
	string s = "@" + a;
	int n = s.size() - 1;
	int L = 0, R = 1;
	z[1] = 0;
	for (int i = 2; i <= n; i++) {
		if (i > R) z[i] = 0;
		else z[i] = min(z[i - L + 1], R - i + 1);
		while (i + z[i] <= n && s[i + z[i]] == s[1 + z[i]]) {
			z[i]++;
		}
		if (i + z[i] - 1 > R) {
			R = i + z[i] - 1, L = i;
		}
	}
	// 1 ~ m  m+1   m+2 ~ n+m+1
    // 	b      #       a
	for (int i = 2; i <= n; i++) {
		if (z[i] % (i - 1) == 0 
            && z[i] == n - i + 1) {
			cout << (i - 1) << endl;
			return;
		}
	}
	cout << n << endl;
}
// 
int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr); cout.tie(nullptr);
	int T; cin >> T;
    while (T--) {
    	cin >> a;
    	exkmp();
    	if (T) cout << endl; 
    }
    return 0;
}
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值