String

KMP

\(f[i]\)表示\(1\)\(i\)最长的后缀等于前缀的长度。容易证得每次\(f\)最多\(+1\)且保持非负,然后复杂度就是\(O(n)\)的。匹配当成拼接在原串后面继续计算f数组。

Z-Algorithm or exKMP

\(z[i]\)表示\(i\)开始的最长的等于原串前缀的长度。暴力扩展是\(O(n^2)\)的,每次记录一个最右的等于某前缀的串\([l, r]\)\(i \leq r\)就从\(\min(r - i + 1, z[i - l + 1])\)开始扩展,否则从\(0\)开始。能证每次扩展\(r\)都要向右移动,所以复杂度\(O(n)\)

AC Automaton

构建\(\text{fail}\)的基本思想是对于结点\(u\)\(c\)走到达的子节点\(v\),不断跳\(u = \text{fail}[u]\),直到存在往\(c\)走的路,然后\(\text{fail}[v] = \text{fail}[\text{ch}[u][c]]\)。然而这一过程可以简化,某个点没有往\(c\)走的边,就连到\(\text{fail}\)\(c\)走的边。这样补成\(\text{trie}\)图,就能线性时间完成计算。

AC自动机有很多好的性质。比如从\(u\)(对应串\(S\))沿着\(ch\)一直走,每次得到的串\(T\)\(S\)后缀和\(T\)前缀的最大重叠长度是递减的。

manacher

\(\text{exKMP}\)思想大致相同。先拓展使得所有回文串长度为奇数。\(f[i]\)表示\((i - f[i], i + f[i])\)。记录\(r = \max (u + f[u])\),若\(i < r\)\(\min(r - i, f[2u - i])\),否则从\(1\)开始。复杂度证明是类似的,每次r移动才会产生复杂度,复杂度\(O(n)\)

CF17E:manacher然后差分 + 前缀和统计一下。

SA

对于串\(s[1..n]\),定义后缀\(i\)\(s[i..n]\)
定义\(\text{sa}[i]\)表示排名第\(i\)的后缀,\(\text{rk}[i]\)表示后缀\(i\)的排名,\(\text{height}[i]\)表示\(\text{LCP}(sa[i], sa[i - 1])\)
实际上是倍增 + 双关键字基数排序,思想好理解。
两个后缀\(x, y(\text{rk}[x] < \text{rk}[y])\)的lcp是\(\min(\text{height}[\text{rk}[x]..\text{rk}[y] - 1])\)
然后有个性质:\(\text{height}[\text{rk}[i]] \geq \text{height}[\text{rk}[i - 1]] - 1\)

如图,\(i\)和他前面的串(记为\(j\))同时去掉第一个字符,后缀\(i\)变成后缀\(i + 1\)\(j\)变成了\(j + 1\)\(lcp(i + 1, j + 1) \geq lcp(i, j) - 1\)
所以后缀的排名上\(i + 1,j + 1\)之间的\(\text{height} \geq lcp(i, j) - 1\),因此\(\text{height}[\text{rk}[i + 1]] \geq \text{height}[\text{rk}[i]] - 1\)

namespace sa {

char s[N];
int n, rk[N], sa[N], c[N], t[N];
int height[N], st[N][L], lg[N];
void build(), build_height(), build_st();
void build() {
	int m = *max_element(s + 1, s + n + 1);
	fill(c + 1, c + m + 1, 0);
	for(int i = 1; i <= n; i ++) c[rk[i] = s[i]] ++;
	for(int i = 1; i <= m; i ++) c[i] += c[i - 1];
	for(int i = 1; i <= n; i ++) sa[c[rk[i]] --] = i;
	for(int k = 1, num = 0; k <= n; k <<= 1, num = 0) {
		for(int i = n - k + 1; i <= n; i ++) t[++ num] = i;
		for(int i = 1; i <= n; i ++) if(sa[i] > k) t[++ num] = sa[i] - k;
		fill(c + 1, c + m + 1, 0);
		for(int i = 1; i <= n; i ++) c[rk[i]] ++;
		for(int i = 1; i <= m; i ++) c[i] += c[i - 1];
		for(int i = n; i >= 1; i --) sa[c[rk[t[i]]] --] = t[i];
		copy(rk + 1, rk + n + 1, t + 1); num = rk[sa[1]] = 1;
		for(int i = 2; i <= n; i ++) {
			int u = sa[i] + k <= n ? t[sa[i] + k] : 0;
			int v = sa[i - 1] + k <= n ? t[sa[i - 1] + k] : 0;
			rk[sa[i]] = t[sa[i]] == t[sa[i - 1]] && u == v ? num : ++ num;
		}
		if(num == n) break ; else m = num;
	}
	build_height();
}
void build_height() {
	for(int i = 1, j = 0; i <= n; i ++) {
		if(j) j --;
		int lim = n - max(i, sa[rk[i] - 1]) + 1;
		for(; j < lim && s[i + j] == s[sa[rk[i] - 1] + j]; j ++) ;
		height[rk[i]] = j;
	}
	build_st();
}
void build_st() {
	for(int i = 2; i <= n; i ++) { lg[i] = lg[i >> 1] + 1; st[i][0] = height[i]; }
	for(int j = 1; (1 << j) <= n; j ++)
		for(int i = 2; i + (1 << j) - 1 <= n; i ++)
			st[i][j] = min(st[i][j - 1], st[i + (1 << (j - 1))][j - 1]);
}
int lcp(int u, int v) {
	if(u == v) return n - u + 1;
	if((u = rk[u]) > (v = rk[v])) swap(u, v);
	int k = lg[v - u];
	return min(st[u + 1][k], st[v - (1 << k) + 1][k]);	
}

}

SAM

SAM的定义

\(\text{SAM}\)是有限状态自动机,类似有向无环图。结点被称为状态,点与点之间的有向边被称为转移。
\(t_0\)为初始状态,我们能从\(t_0\)到达所有状态。
一个或多个状态被称为终止状态,从\(t_0\)经过一个边达到任意终止状态将边上的字符连起来得到的字符串是原串后缀

abbb的自动机:(*表示终止状态)

SAM-abbb

结束位置endpos

考虑原串\(S\)的非空子串\(T\),定义\(endpos(T)\)\(T\)\(S\)中所有结束位置的集合。

例如\(S\)=ababb\(T\)=ab\(endpos(T) = \{ 2, 4 \}\)

引理1:若字符串\(u\)仅以\(v\)的一个后缀形式出现,\(endpos(u) = endpos(v)\)

\(endpos\)相同的串构成的集合称为\(endpos\)等价类。

引理2:考虑原串的两个非空子串\(u, v(|u| < |v|)\)\(endpos(u)\)\(endpos(v)\)要么没有交集,要么前者包含后者,即:
\(\begin{cases} endpos(v) \subseteq endpos(u)& & \text{u is a suffix of v} \\ endpos(u) \cap endpos(v) = \varnothing & & \text{otherwise} \end{cases}\)

还有一个重要的结论,把\(endpos\)等价类中的串按长度排序,相邻串长度恰好为\(1\)

对于等价类\(u\),排序后串长度一个是个区间,记为\([minlen(u), len(u)]\),即记最短串长度为\(minlen(u)\),最长串长度为\(len(u)\)

考虑自动机上的状态\(u\not = t_0\),前面说过这个状态对应一个\(endpos\)等价类。记这个类中最长的串为\(w (|w| = len(u))\),那么这个类中所有串都是\(w\)的后缀。

但是有一些\(w\)的后缀并不在该等价类里,记\(w\)的后缀中不在\(u\)等价类里的长度最大的串为\(t\),将\(u\)的后缀连接设置为\(t\)

可以得到两个性质:

  1. \(minlen(u) = len(link(u)) + 1\)

  2. \(endpos(u) \subset endpos(link(u))\)

规定\(endpos(t_0) = {0, 1, 2, ..., |S|}\)

引理3:所有后缀连接构成以\(t_0\)为根的树

引理4:我们使用\(endpos\)集合构造一棵树,使得子节点为父亲节点的子集,那么这棵树由后缀连接链接起来

下面是串abcbc的SAM及后缀连接

SAM-abcbc

线性构造SAM

  1. 每个结点保存link和len,表示后缀连接和endpos类中最长串长度

  2. 初始情况下结点\(0\)表示\(t_0\), 该结点\(link = -1, len = 0\)

  3. 变量\(last\)为整个串的状态结点,初始\(last = 0\)

  4. SAM是在线的,每次加入一个字符\(c\),创建一个新的状态\(cur\)\(len(cur) = len(last) + 1\)

  5. 运行一个循环把\(cur\)加入自动机

    • 刚开始处于\(last\)结点
    • 不断跳\(link\)
    • 如果当前结点\(p\)没有\(c\)转移,添加一个到\(cur\)\(c\)转移
    • 否则当前结点\(p\)\(c\)转移,设\(p\)经过\(c\)到达结点\(q\),执行以下过程然后退出循环:
      • \(len(p) + 1 = len(q)\)\(link(q) = -1\)
      • 否则\(len(p) + 1 < len(q)\),新建一个\(q'\)\(q\)相同,但\(len(q') = len(p) + 1\)\(link(cur) = link(q) = q'\),然后把\(p\)\(link\)祖先中所有存在转移\(c\)\(q\)的都改为\(q'\)
    • 如果跳到了\(t_0\)\(link(cur) = 0\)并退出循环
#include <algorithm>
#include <cstdio>
#include <map>
using namespace std;

const int N = 1e6 + 10;

namespace sam {

struct state {
	int link, len, next[26];
	void clr() { fill(next, next + 26, -1); }
} st[N * 2];
int last, sz, slen, w[N * 2];
void init() {
	last = 0; sz = 1; slen = 0;
	st[0].link = -1; st[0].len = 0; st[0].clr();
}
void extend(char c) {
	int p = last, cur = sz ++; st[cur].len = st[p].len + 1; st[cur].clr();
	for(; ~ p && -1 == st[p].next[c]; p = st[p].link)
		st[p].next[c] = cur;
	if(p == -1) st[cur].link = 0;
	else {
		int q = st[p].next[c];
		if(st[q].len == st[p].len + 1) st[cur].link = q;
		else {
			int nq = sz ++;
			st[nq] = (state) {st[q].link, st[p].len + 1};
			copy(st[q].next, st[q].next + 26, st[nq].next);
			for(; ~ p && st[p].next[c] == q; p = st[p].link)
				st[p].next[c] = nq;
			st[q].link = st[cur].link = nq;
		}
	}
	last = cur; slen ++; w[cur] = 1;
}
void solve() {
	long long ans = 0;
	static int cnt[N], a[N * 2];
	for(int i = 0; i < sz; i ++) cnt[st[i].len] ++;
	for(int i = 1; i <= slen; i ++) cnt[i] += cnt[i - 1];
	for(int i = 0; i < sz; i ++) a[-- cnt[st[i].len]] = i;
	for(int i = sz - 1; i >= 1; i --) {
		int u = a[i]; //printf("st[%d].link = %d, len = %d\n", u, st[u].link, st[u].len);
		w[st[u].link] += w[u];
		if(w[u] != 1) ans = max(ans, 1ll * w[u] * st[u].len);
	}
	printf("%lld\n", ans);
}

}

int main() {
	static char s[N];
	scanf("%s", s); sam::init();
	for(int i = 0; s[i]; i ++) sam::extend(s[i] - 'a');
	sam::solve();
	return 0;
}

SAM的时空复杂度证明

挖坑。

SAM基础用法和一些题目:

一、本质不同字串数:

后缀自动机上每个结点表示的本质不同字串数加起来。

\(\sum_{u} \text{len}(u) - \text{len}(\text{link}(u))\)

二、两个串求\(\text{LCS}\)(SPOJ-LCS):对第一个串建SAM,第二个串在SAM上走。能转移就转移,没有转移跳\(\text{link}\)直到可以转移或者到了结点\(0\)。类似AC自动机的匹配。

int match(char *s) {
   int ans = 0, u = 0, len = 0;
   while(*s) {
   	int c = *s - 'a';
   	while(u && -1 == st[u].nxt[c]) {
   		u = st[u].link;
   		len = st[u].len;
   	}
   	if(~ st[u].nxt[c]) {
   		u = st[u].nxt[c]; len ++;
   		ans = max(ans, len);
   	}
   	s ++;
   }
   return ans;
}

多个串求LCS:(SPOJ-LCS2)

对第一个串建SAM,然后剩下的串用上面的方法在SAM上走。SAM上每个结点保存一个信息ans,表示所以串走到该结点时,最大匹配长度的最小值。
具体实现时,当一个串在SAM上走时,到每个点都记录最大匹配值tmp。然后在link树上自下向上更新ans,因为子结点能匹配父结点也能匹配。最后的答案就是\(\max(ans[])\)

int a[N], cur[N], ans[N];
void tpo() {
	static int cnt[N];
	int n = st[la].len;
	for(int i = 1; i <= id; i ++) cnt[st[i].len] ++;
	for(int i = 1; i <= n; i ++) cnt[i] += cnt[i - 1];
	for(int i = 1; i <= id; i ++) a[cnt[st[i].len] --] = i;
}
int lcs() {
	fill(ans + 1, ans + id + 1, N);
	while(~ scanf("%s", s)) {
		fill(cur + 1, cur + id + 1, 0);
		int u = 0, len = 0;
		for(int i = 0; s[i]; i ++) {
			int c = s[i] - 'a';
			while(u && st[u].nxt[c] == -1) {
				u = st[u].link;
				len = st[u].len;
			}
			if(~ st[u].nxt[c]) {
				u = st[u].nxt[c]; len ++;
				cur[u] = max(cur[u], len);
			}
		}
		for(int i = id; i >= 1; i --) {
			int u = a[i], f = st[u].link;
			cur[f] = max(cur[f], min(st[f].len, cur[u]));
			ans[u] = min(ans[u], cur[u]);
		}
	}
	int res = 0;
	for(int i = 1; i <= id; i ++)
		if(ans[i] < N) res = max(res, ans[i]);
	return res;
}

三、CF316G3:所有串有分割符拼一下做SAM,然后用\(c[u][i]\)表示SAM上结点\(u\),endpos集合多少元素在第\(i\)个串(s标为0串)。然后给前缀结点标记,自底向上更新即可。

四、BZOJ1396 识别子串:分析性质可以发现只需要考虑出现endpos大小为1结点。然后就是个两端区间覆盖,前一端是用右端点更新,后一端用长度更新。直接用vector+set代替线段树做区间取max。

五、[HAOI2016]找相同字符:SAM模板题,用分隔符隔开建SAM,然后统计。

六、CF802I:SAM的模板题。有SA + 单调栈做法:

考虑出现超过一次的字串是一定是某两个后缀\(\text{LCP}\)的前缀。假设后缀排好序得到SA,对于\(k\),极大区间\([l, r]\)满足\(\text{LCP}(\text{SA}[l ... r]) = \text{height}[k]\)\(l < k \leq r\),就说明有\(\text{height[k]}\)个字串出现了\(r - l + 1\)次。

只出现了一次的字串单独统计一下即可。

七、CF873F:同样有SAM和SA + 单调栈两个做法,SA是把原串翻转变开头禁止。其他和上题十分类似,不再赘述。

八、[TJOI2017]DNA:在SAM上dfs,保证修改代价\(\leq 3\)。看成分层图可知复杂度是线性。

九、CF452E:又是SAM板子题,用SA的话把\(\text{height}\)从大到小排序用并查集合并。每轮合并得到一个区间满足\(\text{LCP}\geq i\),先减去两个区间的答案,再加入新区间的答案。

十、CF235C:把询问串倍长,在文本串的SAM上走(类似求\(\text{LCS}\)),相当于找所有长度为\(n\)的子串在SAM上出现的位置。

走一步字符转移时如果\(\text{len}(\text{link(u)}) \geq n\)就不断跳link,当然统计答案时要避免重复。

十一、第\(k\)小字串(TJOI2015 弦论):DAG上统计每个点出发能到达能所有点的权值和(本质不同字串算\(1\)个时权值为\(1\),否则为\(|endpos(u)|\)),然后在DAG上走。

广义SAM

对Trie建SAM。注意转移数是\(O(n |\Sigma|)\)的。

建立的时间复杂度:bfs 是trie结点数 * 字符集大小,dfs 是trie结点数 * 字符集大小 + 叶子深度和

注意广义SAM上没有拓扑序性质。

[ZJOI 2015]诸神眷顾的幻想乡:广义SAM模板题。

PAM

资料:oi-wiki-pam;《回文树及其应用》翁文涛

回文树(或者回文自动机),接受字符串的所有回文子串。

结构

\(\text{PAM}\)是一个森林,由两个树组成,分别记录长度为奇数的回文串和长度为偶数的回文串。一个根叫\(\text{odd}\),一个根叫\(\text{even}\),每个结点只表示一个回文串。

父亲可以转移到儿子,假设结点\(u\)父亲结点\(fa\)表示\(s\)\(fa\)通过\(c\)转移到\(u\),则\(u\)表示\(csc\)这个回文串。

为了方便,规定\(\text{odd}\)表示一个不存在的长度为\(-1\)的串,\(\text{even}\)表示一个长度为\(0\)的空串。

每个结点还有一个\(\text{fail}\)指针,指向该结点表示的字符串最长回文后缀所对应的结点。特别地\(\text{fail}(\text{odd})\)\(\text{fail}(\text{even})\)指向odd。定义\(\text{fail}\)
为从一个点出发不断跳\(\text{fail}\)指针直到\(\text{odd}\)经过的结点集合。

构建

增量法。考虑串\(s\)后面加字符\(c\),假设\([l_1, |s| + 1],[l_2,|s| + 1|,...,[l_k, |s| + 1]\)是所有回文后缀(\(l_1 < l_2 < ... < l_k\)),那么对于\(2\)\(k\)这些回文后缀一定在\([l_1, |s|]\)中出现过,不需要考虑,因此实际上每次最多增加一个没有出现过回文串,而且容易发现这个串是\(s\)的最长回文后缀。由此可以说明PAM状态数和转移数是线性。

问题转化为快速找到\(sc\)的最长回文后缀,并安排其\(\text{fail}\)指针。直接使用一种暴力的思想,从s的最长回文后缀结点的\(\text{fail}\)链上找长度最长的点\(u\)满足\(s[|s| - \text{len}[u]] = c\),则\([|s| - \text{len}[u]], |s|+1]\)就是\(sc\)最长回文后缀。如果\(u\)已经存在\(c\)转移就退出(说明该串已经能被表示),否则新建结点\(x\),然后还要安排其\(\text{fail}\)指针,只要在\(\text{fail}[u]\)链上找最长的\(v\)存在使得\(s[|s| - \text{len}[v]] = c\),然后令\(\text{fail}[x] = v\)即可。

我们可以使用势能分析法来证明这一构建过程总复杂度是线性的(视\(\Sigma\)为常数):

只需分析两个跳\(\text{fail}\)的过程,设势能函数\(\Phi(s)\)\(s\)最长回文后缀的半径,则\(\Phi(sc) \leq \Phi(s) + 1\)。每次跳\(\text{fail}\)都会使得\(\Phi\)至少减\(1\)(对于第二个过程,\(\Phi\)不会立刻\(-1\),但是只要新建结点再跳一次\(\text{fail}\)这些\(-1\)全部实现)。而\(\Phi \geq 0\),所以得证。建议画出回文树结构,复杂度显而易见。

前端插入操作

类似前端插入,同样记录一个\(\text{fail}'\)表示一个结点的最长回文前缀。然后发现实际上因为结点是回文串,\(\text{fail} = \text{fail'}\)。所以只要对\(s\)记录一下最长回文前缀和最长回文后缀。只需注意一点,更新左端时如果整个串变成回文串要更新最大回文后缀,右端同理。其他都是常规操作。

其他高深知识:

(挖坑)

一种不基于势能分析的增量法,对Trie树建PAM,可持久化PAM。

一些简单的PAM题

一、Luogu P5496 【模板】回文自动机(PAM)

实际上每个点可以多存一个int表示nxt是否存在,常数会小一些。

#include <algorithm>
#include <cstdio>
using namespace std;
const int N = 5e5 + 10;
struct pam {
	int fail, len, d, nxt[26];
} t[N];
char s[N];
int n, id, la;
void clr(int u) { fill(t[u].nxt, t[u].nxt + 26, -1); }
void init() {
	scanf("%s", s + 1); s[0] = -1;
	n = 0; id = 1; la = 0;
	t[0] = (pam) {0, -1, 0};
	t[1] = (pam) {0, 0, 0};
	clr(0); clr(1);
}
int find(int u) {
	while(s[n - t[u].len] != s[n + 1]) u = t[u].fail;
	return u;
}
void insert(int c) {
	int u = find(la);
	if(~ t[u].nxt[c]) la = t[u].nxt[c];
	else {
		t[u].nxt[c] = la = ++ id; clr(la);
		t[la].len = t[u].len + 2;
		int v = u ? t[find(t[u].fail)].nxt[c] : 1;
		t[la].fail = v;
		t[la].d = t[t[la].fail].d + 1;
	}
	n ++;
}
int main() {
	init(); int ans = 0;
	for(int i = 1; s[i]; i ++) {
		insert(s[i] = (s[i] - 'a' + ans) % 26);
		printf("%d ", ans = t[la].d);
	}
	return 0;
}

二、[JSOI2013]快乐的 JYY / Aizu - 2292:两个串用两种不同字符拼起来,这样到第二个串时最长回文后缀一定不过分隔符。每次插入得到的最长回文后缀结点,加上落在A串或落在B串的标记。最后倒序从下往上更新(相当于树上查分),类似ac自动机。

三、Luogu P5555 秩序魔咒:练手题,和上题没有太大区别。

四、Victor and String HDU - 5421:前端插入操作练习题。

#include <algorithm>
#include <cstdio>
using namespace std;
const int N = 1e5 + 20;
struct pam {
	int fail, len, st, d, nxt[26];
} t[N];
int id, nl, nr, ml, mr;
char str[N + N], *s = str + N;
long long ans;
void init() {
	t[0] = (pam) {0, -1, 0, 0}; t[1] = (pam) {0, 0, 0, 0};
	id = 1; nl = 1; nr = ml = mr = 0; s[0] = s[1] = -1;
}
int findl(int u) {
	while(nl + t[u].len > nr || s[nl + t[u].len] != s[nl - 1])
		u = t[u].fail;
	return u;
}
int findr(int u) {
	while(nr - t[u].len < nl || s[nr - t[u].len] != s[nr + 1])
		u = t[u].fail;
	return u;
}
void pushback(int c) {
	s[nr + 1] = c;
	int u = findr(mr);
	if(t[u].st >> c & 1) mr = t[u].nxt[c];
	else {
		t[u].st ^= 1 << c;
		t[u].nxt[c] = mr = ++ id; t[mr].st = 0;
		t[mr].len = t[u].len + 2;
		int v = u ? t[findr(t[u].fail)].nxt[c] : 1;
		t[mr].fail = v; t[mr].d = t[v].d + 1;
	}
	nr ++; ans += t[mr].d;
	if(nr - nl + 1 == t[mr].len) ml = mr;
}
void pushfront(int c) {
	s[nl - 1] = c;
	int u = findl(ml);
	if(t[u].st >> c & 1) ml = t[u].nxt[c];
	else {
		t[u].st ^= 1 << c;
		t[u].nxt[c] = ml = ++ id; t[ml].st = 0;
		t[ml].len = t[u].len + 2;
		int v = u ? t[findl(t[u].fail)].nxt[c] : 1;
		t[ml].fail = v; t[ml].d = t[v].d + 1;
	}
	nl --; ans += t[ml].d;
	if(nr - nl + 1 == t[ml].len) mr = ml;
}
int main() {
	for(int q; ~ scanf("%d", &q); ) {
		init(); char s[5]; ans = 0;
		for(int op, i = 1; i <= q; i ++) {
			scanf("%d", &op);
			if(op == 1) scanf("%s", s), pushfront(*s - 'a');
			if(op == 2) scanf("%s", s), pushback(*s - 'a');
			if(op == 3) printf("%d\n", id - 1);
			if(op == 4) printf("%lld\n", ans);
		}
	}
	return 0;
}

SQAM

序列自动机(seq-automaton),SQAM是我取的名字

https://oi-wiki.org/string/seq-automaton/

posted @ 2019-12-15 08:19  怀  阅读(72)  评论(0)    收藏  举报