【笔记】后缀自动机

这是我参与11月更文挑战的第25天,活动详情查看:2021最后一次更文挑战.


参考自 oi-wiki.org/string/sam/

定义

  1. 字符串s的后缀自动机(Suffix Automaton, SAM)是一个接受s所有后缀的最小DFA。
  2. SAM是一个有向无环图,结点称为状态,边称为转移。
  3. 存在一个源点t0称为初始状态,其它各结点均可从t0出发到达。
  4. 每个转移都有一个字母,从一个结点出发的所有转移均不同。
  5. 存在一个或多个终止状态,从t0到终止状态的每条路径等价于s的一个后缀。
  6. 在满足以上性质的自动机中,SAM的节点数是最少的。

【笔记】:虽然后缀数组并不是后缀自动机的前置知识,但是学会SA再看SAM确实很方便理解。

性质

  1. SAM包含关于s的所有子串的信息,从t0开始的所有路径和s的所有子串对应。
  2. 到达每个状态的路径可能不止一条,所以一个状态对应一些字符串的集合。

【举例】:尝试构造"","a","aa","ab","abb","abbb"的后缀自动机 【笔记】:后缀自动机必有一个主链。

理论

结束位置endpos

对于s的任意非空子串t,endpos(t)表示t在s中的所有结束位置(编号从0开始)。 【举例】:对于字符串"abcbc","bc"的endpos为{2,4}

将所有子串按endpos是否相等划分为若干个等价类,每个等价类对应SAM中的一个节点。 【笔记】:SAM的节点数 = 等价类个数+1,因为还有初始节点t0.

【定理】两个非空子串u和w的endpos相同(|u|<=|w|),当且仅当u是w的后缀。 由以上定理,如果u为w的一个后缀,且只以w后缀的形式在s中出现时,两个子串的endpos相同。

【定理】对于两个非空子串u和w(|u|<=|w|),如果u是w的后缀,那么 e n d p o s ( w ) e n d p o s ( u ) endpos(w)\subseteq endpos(u) ,否则两者endpos的交集为空。

【定理】对于一个endpos等价类,类中所有子串按长度排序后,前一个串一定为后一个串的后缀且两者长度相差为1.

后缀链接link

对于SAM中的某个非初始状态u,定义字符串w为状态v对应的字符串中的最长的一个(其它字符串都是w的后缀)。 w的前几个长后缀也在这个等价类中,其它短后缀(至少有一个空后缀)在其它等价类中。记v为最长的在其它等价类中的w的后缀的状态。

定义u的后缀链接link(u)=v,即链接到对应于w的最长后缀的另一个endpos等价类的状态。

设初始状态t0表示只含一个空字符串的等价类,规定endpos(t0)={0,...,|s|-1}.

【定理】所有后缀链接构成一棵根节点为t0的树。对于每个link(u)=v,u对应的endpos为v对应的endpos的子集。v对应的字符串一定是u对应字符串的后缀。

【例子】构建字符串"abcbc"的后缀链接树,结点处用endpos集合代替。

小结

  1. 一个结点即一个状态,对应一个endpos等价类,对应若干个子串(路径)。
  2. 对于状态v,记它对应的最长和最短的字符串为longest(v)和shortest(v),两者的长度为maxlen(v)和minlen(v)。那么状态v对应的每个字符串都是longest(v)的后缀,且长度恰好覆盖[minlen(v),maxlen(v)]中的每一个整数。
  3. 定义u的后缀链接link(u)=v,当longest(v)为shortest(u)的一个后缀,且maxlen(v)=minlen(u)-1。所有的后缀链接形成一棵以t0为根节点的一棵树。对于link(u)=v,u的endpos一定是v的endpos的子集。
  4. 从任意状态v0沿着后缀链接遍历到t0时,路上的每个结点的[minlen(v),maxlen(v)]互不相交,且并集为连续区间[0,maxlen(v0)]

【笔记】:link树上,每个结点的子节点的endpos集合互不相交且并集为该节点的endpos集。

构造

算法

  1. 构造SAM的算法是在线算法,可以逐个加入字符串中的每个字符并逐步维护SAM。
  2. 为了维护线性的空间复杂度,只保存maxlen和link的值,以及每个状态的转移列表,不标记终止状态。

  1. 一开始只包含一个状态t0,编号为0(其余编号递增)。指定maxlen=0,link=-1。接下来的过程为添加字符c:
  2. 令last为添加c之前,整个字符串对应的状态。创建新状态cur,将maxlen(cur)赋值为maxlen(last)+1.
  3. 从last开始,如果没有转移c,就添加一个到cur的转移,然后沿着后缀链接走。如果一直到达了-1,就将link(cur)设为0然后退出。如果某个状态p已经存在转移c,就停下来。
  4. 设p通过转移c得到的状态为q,如果maxlen(p)+1=maxlen(q),就将link(cur)赋值为q,然后退出。
  5. 否则,此时需要复制状态q:创建一个新的状态clone,复制q的link和转移,并将maxlen(clone)赋值为maxlen(p)+1.然后link(cur)=link(q)=clone.并且把所有p及p在后缀链接树上的祖先到q的转移重定向到clone.
  6. cur=last,返回2.

终止状态:构造完整个SAM后,从对应于整个字符串的状态遍历后缀链接直到初始状态,将路径上的结点设置为终止节点。

因为每个字符只会创建一个或两个新状态,所以SAM只包含线性个状态。

例子

对于字符串“abcbc”,按上述算法构建后缀自动机。

  1. "a"

在这里插入图片描述 2. "ab" 在这里插入图片描述 3. "abc" 在这里插入图片描述 4. "abcb" 在这里插入图片描述 5. "abcbc" 在这里插入图片描述 6. "abcbc"的后缀链接树 在这里插入图片描述 7. 状态对应表

id link maxlen endpos string end
0 -1 0 0,1,2,3,4 "" 1
1 0 1 0 a 0
2 5 2 1 ab 0
3 7 3 2 abc 0
4 5 4 3 abcb,bcb,cb 0
5 0 1 1,3 b 0
6 7 5 4 abcbc,bcbc,cbc 1
7 0 2 2,4 bc,c 1

正确性证明

暂略

复杂度证明

设字符集大小为Σ,如果将转移用平衡树(map)存储,那么时间复杂度为O(nlogΣ),空间复杂度为O(n)。如果用数组存储,时间复杂度为O(n),空间复杂度为O(nΣ)。

  1. 自动机的状态数不会超过2n。因为一开始自动机有一个状态,第一次迭代中只会创建一个结点,之后每次迭代最多最多会创建两个状态。
  2. 自动机的转移数不会超过3n。因为连续的转移不会超过2n,不连续的转移不会超过n个,证明暂略。

关于转移的操作单次是O(1)的。 以下操作的单次时间复杂度不明显是O(1)的:

  1. 遍历状态last的所有后缀链接,添加字符c转移的过程。这个操作一定是连续的,即到达某个位置后会停下来。又由于总状态数是线性的,所以总复杂度是O(n)的。
  2. 从状态q复制状态clone时,复制从q出发的转移。因为总转移数是线性的,所以总复杂度是O(n)的。
  3. 从状态q复制状态clone时,把指向q的转移重定向到clone上的过程。暂略。

基础实现

struct State
{
	int len, link; //对应字符串的最大长度,后缀链接
	map<char,int> nxt; //转移
};
State st[M*2]; //注意状态数=2n-1
int sz, lst;
void sam_init()
{
	st[0].len = 0;
	st[0].link = -1;
	sz = 1;
	lst = 0;
}
void sam_extend(char c) //增量构造
{
	int cur = sz++;
	st[cur].len = st[lst].len + 1;
	int p = lst;
	while(p!=-1 && !st[p].nxt.count(c))
	{
		st[p].nxt[c] = cur;
		p = st[p].link;
	}
	if(p==-1)
		st[cur].link = 0;
	else
	{
		int q = st[p].nxt[c];
		if(st[p].len + 1 == st[q].len)
			st[cur].link = q;
		else
		{
			int clone = sz++;
			st[clone].len = st[p].len + 1;
			st[clone].nxt = st[q].nxt;
			st[clone].link = st[q].link;
			while(p!=-1 && st[p].nxt[c]==q)
			{
				st[p].nxt[c] = clone;
				p = st[p].link;
			}
			st[q].link = st[cur].link = clone;
		}
	}
	lst = cur;
}
复制代码

应用

检查字符串是否出现

给一个文本串T和多个模式串P,检查每个P是否是T中的子串。

对T构建后缀自动机,如果后缀自动机能够接受P,说明P是T的子串。 如果不能接受,说明P不是T的子串,此时可以求出P在文本串中出现的最大前缀长度。

不同子串个数

求字符串S的不同子串个数。

后缀自动机中的每条从源点开始的路径表示一个子串,在DAG上DP就可以求出答案: 状态表示:dp[i]表示到i节点的路径数量。 状态转移:dp[v] += dp[u] 状态边界:dp[0] = 1 答案: Σ d p [ i ] \Sigma dp[i]

另外一种方法:每个节点对应的子串数量是 l e n ( i ) l e n ( l i n k ( i ) ) len(i)-len(link(i)) ,求和就是答案。

所有不同子串的总长度

给定一个字符串S,计算所有不同子串的长度之和。

还是可以在DAG上dp,求所有从源点出发路径的长度之和。 状态表示:sum[i]表示到i节点的所有路径的长度之和 状态转移:sum[v] += sum[u] + dp[u] 状态边界:sum[0] = 0 答案: Σ s u m [ i ] \Sigma sum[i] ,其中dp[i]为上个问题中求出的值。

另外一种方法:节点i对应的子串长度包括len(link(i))+1到len(i)里的所有数,按等差数列求和,即 ( b ( b + 1 ) a ( a + 1 ) ) / 2 (b*(b+1)-a*(a+1))/2 ,其中a=len(link(i)),b=len(i).

字典序第k小的子串

给定一个字符串S,找到其所有不同子串中字典序第k大的子串。

DP出每个路径的状态数后,在DAG上从根开始递归地找。

也可以使用后缀数组,因为后缀排序后,第i个后缀对应的n-i个前缀也相当于排序了。二分可以找到答案。

最小表示法

给定一个字符串S,找出字典序最小的循环移位。

即对于S+S,找到字典序最小的长为|S|的路径,每步贪心访问最小的字符即可得到答案。

出现次数

给定一个文本串T和若干个模式串P,求每个模式串在文本串中的出现次数。

预处理出每个节点的endpos集大小。在自动机上查找P对应的节点,如果存在答案就是集合大小。如果不存在,答案就是0.

第一次出现的位置

给定一个文本串T和若干个模式串P,求每个模式串P在T中第一次出现的位置。

构建SAM时,对每个状态额外维护一个变量firstpos,即endpos集中的第一个元素。 创建新状态时,firstpos(cur)=len(cur)-1. 从q复制状态clone时,firstpos(clone)=firstpos(q)

查询时,首先找到P对应的状态t,然后答案就是firstpos(t)-|P|+1.

所有出现的位置

给定一个文本串T和若干个模式串P,求每个模式串P在T中出现的所有位置。

和上题类似,计算所有firstpos后,找到P对应的状态t。 现在需要知道t的endpos集,显然firstpos(t)是一个P出现的位置。 然后因为在link树中t节点的每一个子节点的endpos集正好是t的endpos集的一个划分,所以只要在以t为根的子树中遍历一次,把所有的firstpos组合起来就是t的endpos集。

需要维护link树。


本文也发表于我的 CSDN 博客中。

Guess you like

Origin juejin.im/post/7035279199078350879