浅谈字符串

0x01 浅谈 KMP

模板题:洛谷-P3375 【模板】KMP 字符串匹配

问题简述:求字符串 s1 在给定字符串 s2 中所有的出现。

0x11 前缀数组

写在前面

本人参考多方资料,发现《算法竞赛进阶指南》、《算法竞赛入门经典》等书对于所谓的&next&数组描述并不是很清楚,容易给读者造成“应当这么设定,这么做”却不明白其原有的真谛。后本人在OI wiki上看到了他们对于前缀数组的描述,认为其讲解更易让读者理解。

当然,本文综合多方资料,下面将讲述我本人的理解。

定义与粗略算法

设字符串 \(s[0:n]\) 前缀数组 \(p[0:n]\),其中 \(p[i]\) 的含义为 \(s\) 子串 \(s[0:i]\) 中相等的真前缀与真后缀的最长长度(真前缀和真后缀即不能为字符串本身),即:\(p[i] = \max\left\{j\ \vert \ s[0:j - 1] = s[i - j + 1: j]\right\}\)

由定义,可以两层循环枚举 \(i,\ j\), 并判断前缀与后缀是否相等求出 \(p[i]\),时间复杂度显然是 \(O(n^2)\) 的。

优化1

已经求出 \(p[i]\),当枚举到 \(s[i + 1]\) 时,已知 \(s[0:p[i]-1]=s[i-p[i]+1:i]\),则显然:若\(s[\ p[i]\ ]=s[i+1]\),则\(p[i+1]=p[i]+1\)

  • 注:上面之所以是 \(s[\ p[i]\ ] = s[i+1]\),是因为字符数组下标从 \(0\) 开始计算。

优化2

在优化1不满足时,若存在某一仅次于 \(p[i]\) 的 j 满足前缀数组的性质,即:\(s[0:j - 1] = s[i - j + 1: i]\)\(j\) 的大小仅次于 \(p[i]\)。这时,若\(s[j]=s[i]\),则 \(p[i+1]=j\)

按照 \(p[i]\) 的定义,上述思想并不难理解。但是算法的关键在于如何寻找次大的 \(j\)

我个人认为 OI Wiki 上对于这一部分内容解释的相当不错,下面的内容部分引用自 OI wiki。

首先:有\(s[0:j - 1]=s[i - j + 1: i]\)\(s[0:p[i] - 1] = s[i - p[i] + 1:i]\),可以看出 \(s[0:j-1]\)\(s[0:p[i]-1]\) 的子串。不难得到以下关系:

\[s[i - j + 1: i] = s[0:j - 1] = s[p[i] - j: p[i] - 1] \]

  • 注:这边数到 \(s[p[i] - 1]\) 的原因同样是因为字符串下标从 \(0\) 开始计数,以后将不再赘述,请读者注意。

下面的示意图可以很好的帮助理解上述公式(思路来源于OI wiki):

\[\rlap{\overbrace{\phantom{s_0\ s_1\ s_2\ s_3}}^{p[i]}} \underbrace{s_0\ s_1}_j \ s_2\ s_3 \ s_4 \cdots \rlap{\overbrace{\phantom{s_{i-3}\ s_{i-2}\ s_{i-1}\ s_i}}^{p[i]}}s_{i-3}\ s_{i-2}\ \underbrace{s_{i-1}\ s_i}_j \ s_{i+1} \]

上述公式中:\(s[0:j - 1] = s[p[i] - j: p[i] - 1]\) 描述的是什么意思呢?回想 \(p[i]\) 的定义不难发现,满足 \(s[0:j - 1] = s[p[i] - j: p[i] - 1]\) 最大的 \(j\) 即为 \(p[\ p[i]-1]\)。故可得:

\[j=p[\ p[i]-1] \]

此时,只需判断是否满足 \(s[j]=s[i+1]\)。若满足,则\(p[i]=j+1\)

基于这个思想出发,若不满足,我们只需再求次于 \(j\) 的长度,不妨记为 \(j^{(2)}\),初次求出的 \(j\) 记为 \(j^{(1)}\),则只要不满足 \(s[j^{(n)}]=s[i+1]\),则迭代 \(j^{(n+1)}=p[j^{(n)}-1]\),直至 \(j=0\)。按照这个想法,其实我们可以认为 \(j^{(0)}=p[i]\)

算法实现如下:

    // 显然:p[0] = 0
	for(int i = 1; i < slen; ++i) { // slen为字符串长度
        int j = p[i - 1];
		while(j and s[i] != s[j]) j = p[j - 1];
		if(s[i] == s[j]) ++j;
		p[i] = j;
	}

发现当循环进入下一层时,\(j\) 值不变,没必要每次循环都定义并幅值,可以将算法简化:

	for(int i = 1, j = 0; i < slen; ++i) {
		while(j and s[i] != s[j]) j = p[j - 1];
		if(s[i] == s[j]) ++j;
		p[i] = j;
	}
  • 注:两段算法都是默认为 \(p[i-1]\) 已求出,现在枚举到 \(i\),读者自行变通便可以理解这与上述理论并无差异。

0x12 KMP 字符串匹配

做法1

根据前缀数组的含义,新建一个字符串 \(s\)。若把模式串 \(s_1\) 作为新串 \(s\) 前缀,文本串 \(s_2\) 作为新串 \(s\) 后缀,\(s_1\)\(s_2\) 中间用一个未出现的字符隔开。则:对于新串 \(s=s_1+'\#'+s_2\),设 \(s1\) 长度为 \(n\)\(s\) 长度为 \(slen\),对 \(s\) 进行前缀数组 \(p[0:slen-1]\) 计算,有:

字符串 \(p\)\(t\) 中出现,当且仅当 \(p[i]=n,i>n+1\)。此时,\(s_1\) 出现在 \(s_2\) 中的位置为 \(i-2n\)。(数组下标从0开始,注意 \(s\) 串中还插入了一个分割符)

这种做法有一个比较重要的前提条件,就是要已知哪些字符会在 \(s_1\)\(s_2\) 中出现,以便寻找分隔符。而此条件绝大部分题目都会给出,所以本做法具有普适性。这个做法很好的利用了前缀数组定义,也是比较好理解的做法。

  • 注:思路来源于 OI wiki。

完整代码如下:

#include <bits/stdc++.h>
#define N 2000005 // 注意 s1+s2 长度
using namespace std;

int pi[N];

void kmp(string s1, string s2) {
	string s = s1 + "*" + s2;
	int l = s1.size() + 1, r = s.size();
	int n = s1.size();
	for(int i = 1, j = 0; i < r; ++i) {
		while(j and s[i] != s[j]) j = pi[j - 1];
		if(s[i] == s[j]) ++j;
		pi[i] = j;
	}
	for(int i = l; i < r; ++i) 
		if(pi[i] == n) printf("%d\n", i - 2 * n + 1); // 题目数组编号从 1 开始
	for(int i = 0; i < n; ++i)
		printf("%d ", pi[i]);
}

int main() {
	string s1, s2; 
	cin >> s1 >> s2;
	kmp(s2, s1);
	return 0;
}

做法2

这个做法就是普遍出现在各算法竞赛资料书上的做法,即不合并 \(s_1\)\(s_2\),在求出 \(s_1\)\(p\) 数组后,继续计算剩余的 \(p\) 数组。这个解释是本人的理解,可能与各个算法书上描述有所差异,但同时笔者认为这样的描述可以避免对“模式串自己匹配自己”等想法感到玄学,而且更加便于理解。

为了区别原先的 \(p\) 数组,我们定义后续的 \('p'\) 数组为 \(f[0:m-1]\),此后的 \(i\) 为匹配到 \(s_2\) 中第 \(i\) 个字符。可见,\(f[i]\) 数组定义为:满足 \(s1[0:j-1]=s2[i-j+1:i]\) 的最大的 \(j\)

考虑 \(i+1\),已知满足 \(s1[0:f[i]-1] = s2[i-f[i]+1:i\ ]\)

  • \(s1[\ f[i]\ ] = s2[i+1]\),显然有 \(f[i+1]=f[i]+1\)

  • 若不满足上述条件,寻找次大的 \(j^{(1)}\),使得 \(s1[0:j^{(1)}]=s2[i-j^{(1)}+1:i]=s1[f[i]-j^(1):f[i]-1]\),根据前缀数组的定义 \(j^{(1)}=p[f[i]-1]\)。若仍不满足 \(s1[j^{(1)}]=s2[i+1]\),则重复迭代。显然有 \(j^{(n+1)}=p[j^{(n)}-1]\),直至 \(j=0\)

  • 开始时,可设 \(j=f[i]\)

  • \(j=n\),即上一次已经求出 \(s1\)\(s2\) 中的一次出现,仍要寻找次大的 \(j\),同上述步骤。

下面的示意图可以帮助理解上述想法:

\[\rlap{\overbrace{\phantom{s_0\ s_{1_0}\ s_{1_1}\ s_{1_2}}}^{f[i]}} \underbrace{s_{1_0}\ s_{1_1}}_j \ s_{1_2}\ s_{1_3} \ s_{1_4} \cdots \ s_{1_n} \ | \ \rlap{\overbrace{\phantom{s_{2_0}\ s_{2_1}\ s_{2_2}\ s_{2_3}}}^{f[i]}}s_{2_0}\ s_{2_1}\ \underbrace{s_{2_2}\ s_{2_3}}_j \ s_{2_4}\cdots \ s_{2_m} \]

可以看出上述算法其实与第一种做法并无太大差异。

完整代码如下:

#include <bits/stdc++.h>
using namespace std;

const int N = 1e6 + 5;
char s1[N], s2[N];
int p[N], f[N];

int main() {
	scanf("%s%s", s2, s1);
	int n = strlen(s1), m = strlen(s2);
	for(int i = 1, j = 0; i < n; ++i) {
		while(j and s1[i] != s1[j]) j = p[j - 1];
		if(s1[i] == s1[j]) ++j;
		p[i] = j;
	}
	for(int i = 0, j = 0; i < m; ++i) {
		while(j and s2[i] != s1[j]) j = p[j - 1];
		if(s2[i] == s1[j]) ++j;
		f[i] = j;
		if(f[i] == n) {
			printf("%d\n", i - n + 2);
			j = p[j - 1];
		}
	}
	for(int i = 0; i < n; ++i)
		printf("%d ", p[i]);
	return 0;
}

写在后面

相对于做法2,做法1显然更好理解,并且充分体现了前缀数组的应用,笔者建议读者熟练掌握第一种方法,理解第二种方法即可。当然,读者亦可不认可笔者的说法,对于信息竞赛而言两种做法本质上并没有区别,读者掌握其一即可。

0x02 Z函数|扩展KMP

模板题:洛谷-P5410 【模板】扩展 KMP(Z 函数)

题意简述:对于长度为 \(n\) 的字符串 \(s[0:n-1]\),设 \(z[i]\) 表示 \(s\)\(s[i:n-1]\) (即 \(s\)\(s[i]\) 开头的后缀)的最长公共前缀(\(LCP\))的长度。求 \(z\)

  • 奇怪的是,很多博客(包括洛谷原题)认为 \(z[0]=n\),而 OI wiki 则写明 \(z[0]=0\)。不论如何,以题目要求为准。以大部分博客和题目而言,认为 \(z[0]=n\) 较为准确。

0x21 朴素算法

暴力比较,时间复杂度显然是 \(O(n^2)\) 的,不过多赘述。

0x22 线性算法

优化

设当前枚举到 \(i\),在此之前,我们已经计算好 \(z[0:i-1]\),设 \(j<i\) 且满足 \(j+z[j]-1\) 最大,即 \(s[0:z[j]-1]=s[j:j+z[j]-1]\)。设 \(l=j,r=j+z[j]-1\)

此优化的目的在于:

  • \(l≤i≤r\),则肯定有 \(s[i:r]=s[i-l:r-l]\)。回想刚刚学过的 \(KMP\),可以发现二者的思想非常像。根据 \(z\) 函数的定义,只要 \(z[l-i]<r-i+1\),则 \(z[i]=z[i-l]\)

为什么?如果 \(z[i-l]<r-i+1\),即 \(i+z[i-l]-1<r\),说明 \(s[i+z[i-l]:r]\) 这一段是不匹配的。下面的示意图可以帮助读者理解这一性质。

\[\underbrace{s_0\ s_1}_{z[i-l]}\ \rlap{\overbrace{\phantom{s_2\ s_3 \ s_4}}^{[i-l,r-l]}} \underbrace{s_2\ s_3}_{z[i-l]} \ s_4\ s_5 \cdots \rlap{\overbrace{\phantom{s_{i-2}\ s_{i-1}\ s_{i}\ s_{i+1}\ s_{i+2}}}^{[l,r]}}s_{i-2}\ s_{i-1}\ \underbrace{s_{i}\ s_{i+1}}_{z[i-l]} \ s_{i+2} \]

另外的,若 \(z[i-l]≥r-i+1\),此时:只能保证 \(s[i,r]\)\(s\) 前缀匹配,此时应当继续比较判断 \([r+1,n-1]\) 是否能和 \(s\) 的前缀继续匹配,从而确定 \(z[i]\)

  • \(i>r\),直接进行暴力比较。

  • 计算完后,更新维护 \(l,r\)

代码如下:

for(int i = 1, l = 0, r = 0; i < slen; ++i) {
	if(i <= r and z[i - l] < r - i + 1) z[i] = z[i - l];
	else {
		z[i] = max(0, r - i + 1);
		while(i + z[i] < slen and s[z[i]] == s[i + z[i]]) ++z[i];
	} 
	if(i + z[i] - 1 > r) l = i, r = i + z[i] - 1;
}

时间复杂度分析

观察内层 \(while\) 循环,每进行一次循环必将使 \(r\) 增大 \(1\),且 \(r<n\),即 \(while\) 循环最多进行 \(n\) 次,故时间复杂度 \(O(n)\)

模板题分析

\(a\) 为文本串,\(b\) 为模式串(与洛谷题目相同)。不妨设新串 \(s=b+'\#'+a\),即将 \(b\) 作为新串前缀,\(a\) 作为新串后缀。直接对新串进行 \(z\) 函数计算即可。这个思想和 \(KMP\) 的做法1非常类似,这也是笔者推荐熟练掌握这种方法的原因,其背后更重要的是思想方法。注意 \(b\)\(a\) 之间应当用一个不会出现的字符隔开。

完整代码如下:

#include <bits/stdc++.h>
#define N 40000005
using namespace std;

int z[N];

void z_func(string s1, string s2) {
	string s = s1 + '#' + s2;
	int slen = s.size(), n = s1.size();
	for(int i = 1, l = 0, r = 0; i < slen; ++i) {
		if(i <= r and z[i - l] < r - i + 1) z[i] = z[i - l];
		else {
			z[i] = max(0, r - i + 1);
			while(i + z[i] < slen and s[z[i]] == s[i + z[i]]) ++z[i];
		} 
		if(i + z[i] - 1 > r) l = i, r = i + z[i] - 1;
	}

	long long ans = 0;
	z[0] = n;
	for(int i = 0; i < n; ++i) 
		ans ^= 1LL * (i + 1) * (z[i] + 1);
	printf("%lld\n", ans);

	ans = 0;
	for(int i = n + 1; i < slen; ++i) 
		ans ^= 1LL * (i - n) * (z[i] + 1);
	printf("%lld\n", ans);
}


int main() {
	ios::sync_with_stdio(false);
	string s1, s2;
	cin >> s1 >> s2;
	z_func(s2, s1);
	return 0;
}

0x30 字典树|Trie

模板题:洛谷-P8306 【模板】字典树

题意简述:给定 \(n\) 个文本串和 \(q\) 个询问,每次询问一个模式串“是否出现过”或“是否作为某个或某些文本串的前缀”等。

0x31 实现原理和方法

字典树的一个重要思想是,建立一颗树,树上的一条由根节点到叶节点的路径代表一个字符串,这个路径的每条边依次上存字符串的各个字符。

下图展示了一颗简单的 \(\text{Trie}\)

(图片为本人原创,转载请注明出处)

  • 举例而言,\(\text{Trie}\)\(0\to 1\to 5\to 6\) 这一条路径就代表了字符串 \(acm\)

理解了这一点后,在树上插入字符串便不难实现,代码如下:

void insert(char *s) {
	int size = strlen(s), p = 0;
	for(int i = 0, d; i < size; ++i) {
		d = idx(s[i]); // idx 为根据题目要求求出对应字符编号的函数,读者可自行实现
		if(!trie[p][d]) trie[p][d] = ++tot;
		p = trie[p][d];	
	}
    exist[p] = 1; 
}

查找字符串是否存在代码如下:

int find(char *s) {
	int size = strlen(s), p = 0;
	for(int i = 0, d; i < size; ++i) {
		d = idx(s[i]);
		if(!trie[p][d]) return 0;
		p = trie[p][d];
	}
	return exist[p];
}
  • 值得注意的是,根据题目要求不同。在插入和查找时维护的信息也不尽相同。这一点在洛谷的模板题就是很好的体现。

0x32 模板题解析

洛谷上的模板题要求求出模式串作为文本串前缀出现的次数,在建树时维护一个 \(\text{cnt}\) 数组,记录以以每个节点为结尾的子串个数即可。

完整代码如下:

#include <bits/stdc++.h>
using namespace std;

const int N = 3e6 + 5;
int trie[N][62], cnt[N], tot;

int idx(char c) {
	if(c >= 'a' and c <= 'z') return c - 'a';
	else if(c >= 'A' and c <= 'Z') return c - 'A' + 26;
	return c - '0' + 52;
}

void insert(char *s) {
	int size = strlen(s), p = 0;
	for(int i = 0, d; i < size; ++i) {
		d = idx(s[i]);
		if(!trie[p][d]) trie[p][d] = ++tot;
		p = trie[p][d];
		cnt[p]++;
	}
}

int find(char *s) {
	int size = strlen(s), p = 0;
	for(int i = 0, d; i < size; ++i) {
		d = idx(s[i]);
		if(!trie[p][d]) return 0;
		p = trie[p][d];
	}
	return cnt[p];
}

char s[N];
int main() {
	int T, n, q;
	scanf("%d", &T);
	while(T--) {
		scanf("%d %d", &n, &q);
		while(n--) {
			scanf(" %s", s);
			insert(s);
		}
		while(q--) {
			scanf(" %s", s);
			printf("%d\n", find(s));
		}
		for(int i = 0; i <= tot; ++i)
			for(int j = 0; j < 62; ++j)
				trie[i][j] = 0;	
		for(int i = 1; i <= tot; ++i)
			cnt[i] = 0;		
		tot = 0;
	}
	return 0;
}

0x40 AC自动机

模板题:洛谷-P5357 【模板】AC 自动机(二次加强版)

题意简述:对于若干个模式串(可能相同),求每个模式串在文本串中的出现次数。

0x41 构建失配指针

定义与构建方法

\(\text{KMP}\) 类似,对于多个模式串,建立一棵 \(\text{Trie}\),设失配数组 \(fail[0:cnt]\)\(cnt\) 为字典树节点个数),对于节点 \(i\)(不为 \(0\)),其 \(fail[i]\) 的含义为树上路径 \(0\to fail[i]\)\(0\to trie[i]\) 的最长后缀。回想 \(\text{KMP]\)\(p\) 前缀数组定义,可以发现二者具有类似之处。不同的是,\(0 \to fail[i]\) 可能不是 \(0\to trie[i]\) 字符串所对应的前缀,但总是这若干个字符串的前缀。换句话说,\(fail\) 数组与 \(p\) 数组的思想还是异曲同工的。

那么,考虑对于某个节点 \(x\),其父亲 \(p\) 的失配指针已经求出,其通通过字符 \(c\) 指向 \(x\)\(x=trie[p,c]\)),考虑求出 \(x\) 的失配指针,显然有 \(0\to fail[p]\) 所对应字符串是 \(0\to p\) 对应字符串的最长前缀:

  1. \(trie[fail[p], c]\) 存在,则 \(fail[x]=trie[fail[p], c]\)。显然成立,这与前缀数组的匹配思想是一致的。
  • 解释:\(0\to fail[p]\) 所代表字符串是 \(0\to p\) 所代表字符串的最长后缀,而 \(p\to x\) 这条边代表字符 \(c\)\(fail[p] \to trie[fail[p], c]\) 这条边也代表字符 \(c\)。所以 \(0\to trie[fail[p], x]\)\(0\to x\) 的最长后缀。
  1. \(trie[fail[p],x]\) 不存在,则继续考虑 \(trie[fail[faik[p]], c]\) 是否存在,重复 \(1\) 的判断直至跳到根节点。

下图展示了 \(\text{i he his she hers}\) 四个模式串组成的 \(\text{Trie}\) 以及对应的失配指针。

(图源 OI wiki,如有侵权请联系本人删除)

读者可以根据图自行理解或模拟上述构建规则。

构建优化

上述构建规则中第二条需要使用一个 \(while\) 循环一直跳到满足条件(或根节点)位置,如果多次重复访问会很消耗时间。

  • 例子是有多个位置的失配指针指向同一个节点,但是这个节点均没有(对应)子节点,则求这多个位置的失配指针时,会重复访问。

而上述构建其实基于一个很重要的条件,即深度小于 \(x\) 节点的所有 \(fail\) 指针均已求出。这种情况下,对于访问到的某一节点 \(x\) (父节点为 \(p\),代表边为 \(c\))而言,若 \(trie[x, c]\) 不存在,则不妨令 \(trie[x, c]=trie[fail[p], c]\),即记录下若要跳转,这个不存在的子节点会跳转到的节点编号。这样,只要 \(trie[p,c]\) 存在,则直接令 \(fail[x]=trie[fail[p], c]\)即可。

这是因为:若 \(trie[fail[p], c]\) 原本就存在,则符合条件 \(1\);若 \(trie[fail[p], c]\) 原先不存在,则经过刚刚的操作,我们已经记录了 \(trie[fail[fail[p]], c]\) 会跳转到的位置(\(fail[p]\) 的深度小于 \(x\);若深度不小于 \(x\),则 \(0\to trie[fail[p],c]\) 长度比 \(0\to x\) 长而显然无法成为后缀)。这样就节约了大量时间。同时,在部分改变 \(\text{Trie}\) 结构的情况下,并不改变其记录的模式串。

要实现这样的算法,根据按照深度从小到大访问,考虑 \(\text{bfs}\)。代码如下:

void getfail() {
	queue <int> q;
	for(int i = 0; i < 26; ++i)
		if(trie[0][i]) q.push(trie[0][i]);
	while(q.size()) {
		int x = q.front();
		q.pop();
		for(int i = 0; i < 26; ++i) {
			if(trie[x][i]) {
				fail[trie[x][i]] = trie[fail[x]][i];
				q.push(trie[x][i]);
			} else trie[x][i] = trie[fail[x]][i];				
		}		
	}
}
  • 并且:由于对于每一个节点, \(fail\) 指针都是唯一且指向深度小于自己的节点(根节点除外),故 \(fail\) 指针所构成了一张 \(\text{DAG}\) (有向无环图)。

0x42 查询

在插入时,记录每个字符串插入的尾节点,即可在改变 \(\text{Trie}\) 的情况下找到每一个模式串。

插入和查询代码如下:

void insert(char *s) {
	int size = strlen(s), p = 0;
	for(int i = 0, c; i < size; ++i) {
		c = s[i] - 'a';
		if(!trie[p][c]) trie[p][c] = ++cnt;
		p = trie[p][c];
	}
	End[p]++;
}

int query(char *s) {
	int size = strlen(s), ans = 0, p = 0;
	for(int i = 0; i < size; ++i) {
		p = trie[p][s[i] - 'a'];
		for(int j = p; j and ~End[j]; j = fail[j])
			ans += End[j], End[j] = -1;
	}
	return ans;
}

对于查询代码的解释:

对于某一出现在文本串中模式串,其后缀也一定出现在文本串中。而顺序匹配时,是前缀匹配,忽略了后缀的计算。此时使用 \(fail\) 指针跳转至每一后缀,此后缀可能是完整的模式串也可能只是模式串的一部分。若是完整的模式串, \(End\) 不为 \(0\),否则为 \(0\)

这样就是实现查询。

0x43 优化

注意到:查询时仍可能出现构造失配指针时“重复跳转”的情况,其最坏时间复杂度相当高,在部分题目会被卡掉。

但同时注意到,\(fail\) 数组构成一张 \(\text{DAG}\),对于此 \(\text{DAG}\) 上一边 \(x\to y\),显然 \(y\) 节点对于答案的贡献是其所有前驱节点对答案贡献之和(字典树上 \(0\to y\)\(0\to x\) 的后缀),可以使用拓扑排序进行优化。查询时只需记录每个“前驱节点”的贡献即可,并在构建 \(fail\) 图时记录每个节点的入度。

模板题完整代码如下:

#include <bits/stdc++.h>
using namespace std;

const int N = 2e5 + 10, M = 2e6 + 10;
int trie[N][26], End[N], fail[N], id[N], cnt;

void insert(char *s, int num) {
	int size = strlen(s), p = 0;
	for(int i = 0; i < size; ++i) {
		int c = s[i] - 'a';
		if(!trie[p][c]) trie[p][c] = ++cnt;
		p = trie[p][c];
	}
	if(!End[p]) End[p] = num;
	id[num] = End[p]; // 可能有重复字符串
}

int vis[N], ans[N], in[N];

void getfail() {
	queue <int> q;
	for(int i = 0; i < 26; ++i)
		if(trie[0][i]) q.push(trie[0][i]);
	while(q.size()) {
		int x = q.front();
		q.pop();
		for(int i = 0; i < 26; ++i) {
			if(trie[x][i]) {
				fail[trie[x][i]] = trie[fail[x]][i];
				in[trie[fail[x]][i]]++; // 记录入度
				q.push(trie[x][i]);			
			} else trie[x][i] = trie[fail[x]][i];
		}	
	}
}

void query(char *s) {
	int size = strlen(s), p = 0;
	for(int i = 0; i < size; ++i) {
		p = trie[p][s[i] - 'a'];
		vis[p]++; // 记录前驱节点贡献,其所有后缀在 fail 树上都是它的子节点(后继)
	}
}

void topo() {
	queue <int> q;
	for(int i = 1; i <= cnt; ++i)
		if(!in[i]) q.push(i);
	while(q.size()) {
		int x = q.front(), y;
		q.pop();
		ans[End[x]] = vis[x]; // 只有完整的模式串才有答案,否则都是对 ans[0] 赋值
		y = fail[x];
		vis[y] += vis[x]; // 计算贡献
		if(!--in[y]) q.push(y);
	}
}
char s[M];
int main() {
	int n;
	scanf("%d", &n);
	for(int i = 1; i <= n; ++i) 
		scanf(" %s", s), insert(s, i);
	getfail();
	scanf(" %s", s);
	query(s);
	topo();
	for(int i = 1; i <= n; ++i) printf("%d\n", ans[id[i]]);
	return 0;
}
posted @ 2023-09-08 19:07  Ning-H  阅读(16)  评论(0编辑  收藏  举报