字符串算法小结

AC 自动机:

介绍:

用 Trie 维护模式串的前缀。

自动机快速的原因就在于失配指针 \(\mathrm{fail}_i\),它指向 \(i\) 的最长后缀。

对于构建 \(\mathrm{fail}_i\),找到其父节点 \(\mathrm{fa}\),一直跳 \(\mathrm{fail}\),直到跳到根节点或某点有连过和 \(i\) 相同的字符。

例子。

代码:

const int N = 1e6 + 10;

inline ll Read()
{
	ll x = 0, f = 1;
	char c = getchar();
	while (c != '-' && (c < '0' || c > '9')) c = getchar();
	if (c == '-') f = -f, c = getchar();
	while (c >= '0' && c <= '9') x = (x << 3) + (x << 1) + c - '0', c = getchar();
	return x * f;
}

int n; 

namespace AC
{
	int t[N][30], e[N], fail[N];
	int tot;
	void Insert(char *s)
	{
		int p = 0, len = strlen(s);
		for (int i = 0; i < len; i++)
		{
			int ch = s[i] - 'a';
			if (!t[p][ch]) t[p][ch] = ++tot;
			p = t[p][ch];
		}
		e[p]++;
	}
	queue <int> q;
	void Build()
	{
		while(!q.empty()) q.pop();
		for (int i = 0; i < 26; i++)
			if (t[0][i]) q.push(t[0][i]);
		while (!q.empty())
		{
			int p = q.front(); q.pop();
			for (int i = 0; i < 26; i++)
				if (t[p][i])
					fail[t[p][i]] = t[fail[p]][i], q.push(t[p][i]);
				else
					t[p][i] = t[fail[p]][i];
		}
	}
	int Query(char *s)
	{
		int p = 0, ans = 0, len = strlen(s);
		for (int i = 0; i < len; i++)
		{
			p = t[p][s[i] - 'a'];
			for(int j = p; j && e[j] != -1; j = fail[j])
				ans += e[j], e[j] = -1;
		}
		return ans;
	}
}

char s[N];

int main()
{
	n = Read();
	for (int i = 1; i <= n; i++)
		scanf ("%s", s), AC::Insert(s);
	AC::Build();
	scanf("%s", s);
	printf ("%d\n", AC::Query(s));
	return 0;
}

Manacher 算法:

Manacher 算法,经常被称作马拉车,可以以 \(\mathcal{O}(n)\) 的时间复杂度求出字符串关于回文子串一类的问题。

介绍:

首先举个例子,设字符串 \(s=\texttt{bbdkd}\)

在这里面,有偶回文子串 \(\texttt{bb}\)、奇回文子串 \(\texttt{dkd}\),在计算的过程中还要对奇偶问题进行讨论太过于麻烦,所以可以在每个字符之间加入特殊字符(首尾特殊字符可以作边界):

\[s'=\texttt{\$#b#b#d#k#d#@} \]

再设 \(R_i\) 表示以第 \(i\) 位字符为中心的最长回文的半径。对应上面的 \(s'\) 可以得到:

\[R=\{1,2,3,2,1,2,1,4,1,2,1\} \]

可以发现,以 \(i\) 为中心的最长回文子串长度就是 \(R_i-1\)

我们在求解 \(R\) 数组的过程中再设 \(p,mx\) 分别表示当前回文子串中心和回文子串的右端点。若正在计算 \(R_i\),令 \(i\) 关于 \(p\) 的对称点 \(j=2p-i\)。然后为了先给 \(R_i\) 定下界,进行分类讨论(结合图片思考):

  1. \(mx\leq i\),因为恒有 \(R_i\geq 1\),所以 \(R_i\) 下界是 \(1\)
  2. \(mx-i>R_j\)\(j\) 的左端点没有包裹住 \(p\) 的左端点(即以 \(j\) 为中心的回文子串被包含与 \(p\) 的)。因为是对称点,所以以 \(i\) 为中心的回文子串也一定被包含与 \(p\) 的,所以 \(R_i\) 下界为 \(R_j\)
  3. \(mx-i\leq R_j\)\(j\) 的左端点包裹住了 \(p\) 的左端点(即以 \(j\) 为中心的回文子串不被包含与 \(p\) 的)。因为不被包含,所以不能保证在 \(mx'\) 以前的字符和 \(i\) 的对应,所以下界是 \(mx-i\)

简单来说,定下界就是取 \(\min(mx-i,R_j)\)

定完下界后,再往两边枚举检测一遍。

由于向左右拓展成功必然会导致 \(mx\) 向右移,而 \(mx\) 向右移不超过 \(n\) 次,故向左右拓展操作的总时间复杂度是 \(\mathcal{O}(n)\)。枚举 \(i\) 也是\(\mathcal{O}(n)\) 的,那么 Manacher 算法时间复杂度是 \(\mathcal{O}(n)\)

代码:

int p = 1, mx = 1;
for (int i = 1; i <= n; i++)
{
	R[i] = min (mx - i, R[2 * p - i]);
	while (s[i - R[i]] == s[i + R[i]]) ++R[i];
	if (i + R[i] > mx)
		mx = i + R[i], p = i;
}

例题:

【YBTOJ】不交回文串

回文自动机:

回文自动机,又称回文树,也可以处理部分回文类问题。

介绍:

回文树是一棵由两棵分别为奇、偶的树构成的森林。

其中,奇树用于存储长度为奇数的回文串;偶树用于存储长度为偶数的回文串。

每个节点可以维护该节点字符串长度、该节点出边连接到的节点、失配指针。

如图:

image

构造方法 - 基础插入方法:

有定理:以新加入的字符 \(c\) 为结尾的,未在 \(s\) 中出现过的回文子串最多只有 \(1\) 个,且必为 \(sc\) 的最长回文后缀。那么构造时,求出 \(sc\) 的最长回文后缀即可。于是现在要在 \(s\) 的最长回文后缀对应的失配链中找到长度最大的一个节点 \(t\),使得 \(s_{|s|-\mathrm{len}(t)}=c\),则 \(sc\) 的最长回文后缀即为 \(ctc\)

也因此,新建节点时的失配指针要连向最长的 \(t\) 满足 \(s_{|s|-\mathrm{len}(t)}=c\) 的节点,若 \(\mathrm{len}(t)=-1\) 则连偶树根。

代码:

const int N = 5e5 + 10;

inline ll Read()
{
	ll x = 0, f = 1;
	char c = getchar();
	while (c != '-' && (c < '0' || c > '9')) c = getchar();
	if (c == '-') f = -f, c = getchar();
	while (c >= '0' && c <= '9') x = (x << 3) + (x << 1) + c - '0', c = getchar();
	return x * f;
}

char s[N];

namespace PAM
{
	int len[N], fail[N], s[N], t[N][27];
	int lst, tot = -1, n = 0;
	int sum[N];
	
	int New(int x)
	{
		len[++tot] = x;
		return tot;
	}
	
	void Build()
	{
		New(0), New(-1);
		fail[0] = 1;
		s[0] = -1;
	}
	
	int Find(int x)
	{
		while (s[n - 1 - len[x]] != s[n]) 
		x = fail[x];
		return x;
	}
	
	void Insert(int x)
	{
		s[++n] = x;
		int cur = Find(lst);
		if (!t[cur][x])
		{
			int now = New(len[cur] + 2),
			tmp = Find(fail[cur]);
			fail[now] = t[tmp][x];
			sum[now] = sum[fail[now]] + 1;
			t[cur][x] = now;
		}
		lst = t[cur][x];
	}
}

int main()
{
//	freopen(".in", "r", stdin);
//	freopen(".out", "w", stdout);
	scanf ("%s", s + 1);
	int len = strlen(s + 1);
	PAM::Build();
	for (int i = 1, lst = 0; i <= len; i++)
	{
		PAM::Insert((s[i] - 97 + lst) % 26 + 97 - 'a');
		printf ("%d ", lst = PAM::sum[PAM::lst]);
	}
	return 0;
}

后缀数组:

倍增:

注意:本文第 \(i\) 个后缀是指关于原字符串 \(S\) 的子串 \(S_{i\cdots\text{len}}\)

后缀数组 \(\mathrm{SA}\) 就是以此存储将 \(s\)\(n\) 个后缀从小到大排序后的数组。即:排名为 \(i\) 的后缀是哪一个。

名次数组 \(\mathrm{Rank}\)\(\mathrm{SA}\) 的互逆,它储存的是第 \(i\) 个后缀的排名。


求出后缀数组 \(\mathrm{SA}\),一般使用倍增的方法:用倍增对每个字符开始的长度 \(2^k\) 的子字符串进行排序,得到 \(\mathrm{Rank}\)\(k\)\(0\) 开始,每次加一,当 \(2^k\) 大于 \(n\) 就一定能比较出大小。每次排序利用上一次长度为 \(2^{k-1}\) 的两个字符串排名作为两个部分,然后基数排序,就能得到长度为 \(2^k\) 的字符串的排名。如图举例子:

其中 \(x,y\) 就分别是前半部分和后半部分。这两个部分正好在基数排序中看作是两位。然后假设后半部分已经有序,而且前半部分排名相同的必然在倍增后排名依然为连续一段,所以我们可以求出前半部分每个排名倍增后所在的区间。接着,倒序枚举后半部分,并依次在所在区间末尾插入即可。

也可以从上图得知倍增是 \(i\) 的前半部分其实就是倍增前 \(i\) 的排名,其后半部分就是就是将要合并的第二部分的起始位置。

还有一点:

如图红圈,有的位置在合并过程中已经没有了后半部分,我们把它当作是该前半部分最小的,这里有点难理解,由于两个部分在基数排序中看作是两位数,换句话说这里的意思就是个位当作是 \(0\),就成了该十位中最小的数。

倍增代码:

int n, m;
int sa[N], c[N], x[N], y[N];
char s[N];

void Solve()
{
	for (int i = 1; i <= m; i++) c[i] = 0;
	for (int i = 1; i <= n; i++) c[x[i] = s[i]]++;
	for (int i = 2; i <= m; i++) c[i] += c[i - 1];
	for (int i = n; i; i--) sa[c[x[i]]--] = i;
	
	for (int k = 1; k <= n; k <<= 1)
	{
		int num = 0;
		for (int i = n - k + 1; i <= n; i++) y[++num] = i; //The numer has no the latter part
		for (int i = 1; i <= n; i++) 
			if(sa[i] > k) y[++num] = sa[i] - k;
		
		for (int i = 1; i <= m; i++) c[i] = 0;
		for (int i = 1; i <= n; i++) c[x[i]]++;
		for (int i = 2; i <= m; i++) c[i] += c[i - 1];
		for (int i = n; i; i--) sa[c[x[y[i]]]--] = y[i], y[i] = 0;
		
		swap(x, y);
		x[sa[1]] = 1, num = 1;
		for (int i = 2; i <= n; i++)
			x[sa[i]] = (y[sa[i]] == y[sa[i - 1]] && y[sa[i] + k] == y[sa[i - 1] + k])? num: ++num;
		m = num;
		if (n == m) break;
	}
	return ;
}

int main()
{
//	freopen(".in", "r", stdin);
//	freopen(".out", "w", stdout);
	scanf ("%s", s + 1);
	n = strlen(s + 1), m = 122;
	Solve();
	for (int i = 1; i <= n; i++)
		printf ("%d ", sa[i]);
    return 0;
}

height 数组:

定义 \(\mathrm{height}_i\) 是第 \(\mathrm{SA}_i\) 个后缀和第 \(\mathrm{SA}_{i-1}\) 个后缀的最长公共前缀的长度。那么第 \(j\) 个后缀与第 \(k\) 个后缀(\(\mathrm{Rand}_j<\mathrm{Rand}_k\))的最长公共前缀为:

\[\min_{i=\mathrm{Rank}_j+1}^{\mathrm{Rank}_k}\{\mathrm{height}_i\} \]

那么在实际应用时,我们就可以通过这个数组得到 LCP 了。

既然这样,那如何快速求出它呢?定义 \(h_i\) 表示第 \(i\) 个后缀与其排名减一的后缀的 LCP 长度,即 \(h_i=\mathrm{height}_{\mathrm{Rand}_i}\)\(h_i\) 有一个性质,即 \(h_i\geq h_{i-1}-1\)

证明性质:

设第 \(j\) 个后缀时排在第 \(i-1\) 个后缀前一名的后缀,其 LCP 长度为 \(h_{i-1}\),那么第 \(j+1\) 个后缀时排在第 \(i\) 个后缀前面,且其 LCP 长度为 \(h_{i-1}-1\),所以 \(h_{i}\) 至少是 \(h_i\)

证毕。

求解代码:

int sa[N], Rank[N], height[N];
char s[N];

void calHeight()
{
	for (int i = 1; i <= n; i++) Rank[sa[i]] = i;
	int h = 0, j;
	for (int i = 1; i <= n; height[Rank[i++]] = h)
		for (h? h--: 0, j = sa[Rank[i] - 1]; s[i + h] == s[j + h]; h++);
	return;
}

后缀自动机:

一个很强大的字符串处理的算法。

介绍:

一个后缀自动机,实际上是一个字符串的压缩的后缀字典树。

如图,这是 \(\texttt{ababc}\) 串的后缀字典树。

image

后缀字典树是 \(\mathcal{O}(n^2)\) 级的,考虑压缩。

可以发现,子串 \(\texttt{a}\underline{\texttt{babc}}\) 的状态和 \(\texttt{babc}\) 的状态是完全相同的,那么就可以压缩成:

image

最终变成:

image

posted @ 2021-02-15 10:16  Jayun  阅读(105)  评论(0编辑  收藏  举报