为避免混乱,本篇介绍不涉及后缀数组/KMP/AC自动机/SAM/广义SAM等相关知识,但需要Trie(字典树)作为前置知识。
后缀树的由来
把原串所有后缀塞入一个Trie,则形成一个后缀Trie。后缀Trie从根到任意节点x的路径与子串一一对应。对应于原串后缀的节点x称为后缀节点(叶节点必然是后缀节点)。
把所有后缀硬塞进Trie,复杂度为O(n^2),与暴力没有区别,于是考虑把后缀Trie上的非根非叶&非后缀节点&儿子不超过1的节点构成的链缩成一个点,则是后缀树(Suffix Tree),此时树边不再是字符而是字符串。
更进一步地,把非根非叶&儿子不超过1的后缀节点也缩掉,则是隐式后缀树(Implicit Suffix Tree)。
隐式后缀树的性质
下面几个性质从理解后缀树到构建后缀树到引用后缀树都非常有用,这里不证明。
性质1:记原串为S,记S(x)为从根到任意节点的路径,则节点x所代表的字符串集合为{ S(fa(x))+x入边的任意非空前缀 },记为set(x)。
性质2:每个属于任意set(x)的元素,均为原串的一个子串,且一一对应。
性质3:若s是S的一个后缀,s≠任意S(叶节点),则s是隐式后缀,且所有长度小于s的后缀也都是隐式后缀。
性质4:若x是非根非叶节点,则一定存在非叶节点y,使得S(x)去掉首字符是S(y),记为link(x)=y。
性质5:S(叶节点)与显式后缀一一对应。
性质6:若在原串后加一个从未出现过的字符,则构建出的隐式后缀树没有隐式后缀。
隐式后缀树的构建
ukkonen算法可以在线的以线性复杂度构造隐式后缀树。
我们用len[u]表示节点u的入边字符串长度,st[u]表示节点u的入边首字符在S上的序号。
我们将叶节点的len值赋为inf,这样当插入新的字符时,显式后缀会完成自动更新(显式后缀与叶节点一一对应)。
tran[u][c]≠0表示节点u有开头为c的出边到节点tran[u][c]。0同时表示根节点和空节点,令len[0]=inf可以减少一些判断。
隐式后缀,必然是某个显式后缀的前缀。
设本轮插入的字符为c,now为当前匹配节点,S(now)是当前待插入新后缀的前缀,还剩下rem长度没有匹配。
开始这轮插入时,rem++,n++,显式后缀自动更新,来看如何更新隐式后缀。
首先如果len[tran[now][S[n-rem+1]]]>rem,就跨越这条出边,直到len[tran[now][S[n-rem+1]]] ≤ rem,因为当前待插入新后缀去掉尾字符就是已插入的隐式后缀,这条边肯定能走通。
令v=tran[now][s[n-rem+1]],接下来分为3种情况:
①rem==1且v==0,此时S(now)已经是待插入后缀的最大真前缀,只需新建一个节点u,令st[u]=n,len[u]=inf,tran[now][c]=u。
②非情况①且S[st[v]+rem-1]==c,那么说明待插入后缀已经在树中了,其他更短的待插入后缀也都在了,直接退出这轮插入。
③非情况①且S[st[v]+rem-1]≠c,此时需要分裂v。新建一个z,使st[z]=n,len[z]=inf,再新建一个u,使st[u]=st[v],len[u]=rem-1,tran[now][S[n-rem+1]]=u,tran[u][S[st[v]+rem-1]]=v,tran[u][c]=z。对于原来的v,使st[v]+=rem-1,len[v]-=rem-1。
需要明确的一点是,now≠0时必然是非叶节点,因为now的入边是可以跨越的,而叶节点的len值为inf不可能跨越。当我们在①③情况下成功完成一次插入时,下一个待插入后缀就是当前插入后缀去掉首字符,于是当now≠0时,根据性质4可以直接令now=link[now],否则rem--继续从根开始匹配。
每当开始一轮插入时,令last=0。
对于①②,我们维护link[last]=now,last=now。
对于③,我们维护link[last]=u,last=u。
举例来说,假设当前最长隐式后缀为'abbc',现在插入新字符'c'。假设'abbcc'在之前没有出现过于是作为显式后缀被插入,那么S(last)='abbc',然后需要继续插入'bbcc'。假设'bbcc'在之前没出现过需要作为显式后缀被插入,那么对于情况①,S(now)='bbc',维护link[last]=now;对于情况③,S(u)='bbc',维护link[last]=u。如果'bbcc'在之前已经出现过,那么就是情况②,而'bbcc'在第一次出现时必然是作为显式后缀被插入,即'bbc'这个非根非叶节点一定存在,now也一定会走到这个节点即S(now)='bbc',维护link[last]=now。
PS:情况①③之后结束循环只有一种情况即now=0&rem=1,此时只是在增加了一个连接根节点的叶节点,无需维护link。
复杂度分析
可以看出,每完成一轮插入,rem的实际意义就是当前最长隐式后缀长度。每当执行一次①或③,则完成这轮插入时rem一定减1,而每次开始一轮插入时rem++,因此复杂度是O(n)的。
代码
用python撸了一个在文本T中统计模式串s的出现次数的方法,比较好理解。
具体是在T后面加一个从未出现过的字符,这样叶节点和T的后缀一一对应,而s的出现次数等于其在后缀中作为前缀出现的次数,于是只需统计s所在节点的子树的叶节点个数即可。
from math import inf
class ukkonen():
def __init__(self):
self.num=27
self.tot=0
self.s=[]
self.n=0
self.st=[inf]
self.L=[inf]
self.tran=[[0]*self.num]
self.link=[0]
self.now=0
self.rem=0
def new_node(self,st,L):
self.tot+=1
self.st.append(st)
self.L.append(L)
self.tran.append([0]*self.num)
self.link.append(0)
return self.tot
def extend_c(self,c):
self.s.append(c)
self.n+=1
rem=self.rem+1
n,now,tran,link,st,L,s=self.n,self.now,self.tran,self.link,self.st,self.L,self.s
last=0
while rem:
while rem>L[tran[now][s[n-rem]]]:
now=tran[now][s[n-rem]]
rem-=L[now]
v=tran[now][s[n-rem]]
if rem==1 and not v:
link[last]=now
last=now
tran[now][c]=self.new_node(n-1,inf)
elif s[st[v]+rem-1]==c:
link[last]=now
last=now
break
else:
u=self.new_node(st[v],rem-1)
tran[now][s[n-rem]]=u
tran[u][s[st[v]+rem-1]]=v
tran[u][c]=self.new_node(n-1,inf)
st[v]+=rem-1
L[v]-=rem-1
link[last]=u
last=u
if now:
now=link[now]
else:
rem-=1
self.now,self.rem=now,rem
def extend_s(self,s):
for c in s:
self.extend_c(ord(c)-97)
def match(self,s):
q=[ord(c)-97 for c in s]
m=len(s)
now,rem=0,m
n,tran,L,st=self.n,self.tran,self.L,self.st
while rem:
if not self.tran[now][q[m-rem]]:
return -1
break
else:
v=self.tran[now][q[m-rem]]
k=min(L[v],rem)
if q[m-rem:m-rem+k]==self.s[st[v]:st[v]+k]:
now=v
rem-=k
else:
return -1
break
return now
def dfs(self,u,nleaf):
if self.L[u]==inf:
return 1
for c in range(self.num):
if self.tran[u][c]:
nleaf+=self.dfs(self.tran[u][c],0)
return nleaf
def count(self,s):
node=self.match(s)
if node==-1:
return 0
else:
return self.dfs(node,0)
T='abvaabbvvababaaabbb'
tree=ukkonen()
tree.extend_s(T+'{')
s='ab'
print(tree.match(s),tree.count(s))