后缀自动机是什么?
作为一个初学者,你跟我说如何构建自动机有什么用!?
我们来举一个例子:aabbabd。(借用一下网上的图,侵删)
观察这幅图,我们可以得到看到后缀自动机的性质:
- 仅看实线边,这是一幅有向无环图。
- 每个点有且仅有一个蓝色边,连向的点不妨叫做“父亲结点(parent)”。特别的,我们让根结点(图中标记为S)的父亲结点为
NULL
(空)。 - 对于一个点来说,连出去的边没有相同的两个字母。
- 对于原串aabbabd任意的连续子串(下面说的子串都是指连续子串),从源点开始走,一定不会无路可走。如果一个串不是原串的子串,那么走了一段路之后一定走不动了。
- 每一个点有一个到它的最长距离(length),图中用红色数字表示,我们不妨把这条路径表示字符串叫做这个点的代表的最长字符串。
- ……
最关键的:对于一个点u来说,它所代表的字符串(不止一个)是从源点走到它的所有路径,不妨设这些字符串组成集合S(u),一定满足:
- 定义(len(s))为字符串s的长度。
- 对于任意(s, t in S(u)),如果(len(s) < len(t)),那么s一定是t的后缀。
- 设该点的length为right,该点父亲的length为left,那么对于任意整数(x in (left, right]),一定有(s in S(u))使得(len(s) = x)。并且S(u)的大小为(right-left)。
再设T(u)表示u点代表的所有后缀,设u的父亲为v,那么定义(T(u) = T(v) + S(u)),特别地,(T(root)={""})。几乎和上面相同,我们有:
- 对于任意(s, t in T(u)),如果(len(s) < len(t)),那么s一定是t的后缀。
- (x in [0, length]),一定有(s in T(u))使得(len(s) = x)。并且T(u)的大小为(length + 1)。这里的length就是u的length。
需要满足的条件好像很复杂,那么如何构建?
不严谨地说,我们可以利用递归的思想:假设对于字符串s的自动机已经构造好,如何构造s+c(c是一个字符)的自动机。只要做到这个,其他都不是问题。
我们找到当前自动机length最长的那个点,记为last,注意该点只有一个,且T(last)恰好就是s的所有后缀组成的集合。
首先新建一个点u,我们只要让T(u)恰好是s+c的所有后缀组成的集合就可以继续保持上面一系列的性质。
定义(T(u) + c = { a + c | a in T(u) }。)
最基本的想法就是沿last的父亲一直找下去,然后向点u连一条字符为c的边,正确性是因为(T(u) = T(last) + c):
current = last
while current != NULL
current->child[c] = u
current = current->parent
u->parent = root
然而,事情没有那么简单。问题出在第3行,因为对于一个点来说,连出去的边没有相同的两个字母。所以如果当前current已经有一个标号为c的边了,直接这样连接,就会破坏一些性质。
如果发现current->child[c]
已经有了,那么我们分两种情况处理:
情况一
current->child[c]->length
等于current->length + 1
。
设current->child[c]
为点v。
这个简单。我们不是要通过往我们的T'(u)加入一些字符串,使得T'(u)最后成为T(u)吗?此时我们的T'(u)缺少的恰恰就是T(v)!就是说(T'(u)+ ( T(current) +c) = T(u))(如果集合A+集合B,表示的结果是(Acup B),且说明(A cap B = emptyset)),而此时(T(current) + c = T(v))。
这样的话,我们把u->parent
设为current->child[c]
就可以啦。
current = last
while current != NULL
v = current->child[c]
if v != NULL
if v->length = current->length + 1
u->parent = v
return
else
...
else
current->child[c] = u
current = current->parent
u->parent = root
情况二
那当然就是:current->child[c]->length
不等于current->length + 1
。
此时我们所需要的(T(current) + c = T(v) - P),也就是说(T(v))多了一点我们不需要的字符串,不妨设它们组成集合P。如果简单地将u->parent
设为current->child[c]
,将违背前面所说的T的性质。
我们要做的就是把T(v)分成两半,即(T(v) -P)和P。因为(S(current)subseteq S(v)),更进一步说,就是将S(v)分成两半,即(S(v) -P)(不妨设为Q)和P。
设(par(u, x))表示u->parent->parent->...->parent
,x个parent。
容易发现:
(Q = (S(par(current, 0)) +c)+(S(par(current, 0)) +1) +... +(S(par(current, k)) +c)),k的大小为使得(par(current,k) +csubseteq S(v))的最大值,所以这里任意par(current,i)
(0 leq i leq k)都有一条连向v的c字符的边。
那么我们可以新建一个点w,表示Q,原来的v表示P。首先,显然的,将w的parent改为v的parent,v的parent改为w(注意这两个是有前后顺序的)。然后我们将par(current, 0)
..par(current, k)
这些点的连向v的c字符的边,全部改为连向w。
这样我们就完成了“将S(v)分成两半”这一工作了。
然后同情况一,将把u->parent
设为current->child[c]
(此时为点w)就可以啦。
current = last
while current != NULL
v = current->child[c]
if v != NULL
if v->length = current->length + 1
u->parent = v
return
else
w = v
v->parent = w
current2 = current
while current2 != NULL and current2->child[c] = v
current2->child[c] = w
current2 = current2->parent
if current
assert current2->child[c] = w->parent
u->parent = w
return
else
current->child[c] = u
current = current->parent
u->parent = root
空间复杂度?
显然,点数为O(n),因为每次插入最多添加两个点。
边数我们可以这样算。对于一条u连向v的边,如果u->length
+1= v->length
,那么我们把这样的边分为第一类,其余的为第二类。
对于第一类,对于每个点来说,连向它的第一类边只能有一条,所以第一类边的数量为O(n)。
对于第二类,这里有一个性质:所有的没有出度的结点u,必然存在i,使得(par(last, i)=u),也就是说,所有的(S(u))的并集就是(T(last))。
对于一条u连向v的边,我们通过下面的路径,让它对应一个字符串(一条路径代表一个字符串):
- 从root到u的最长路
- 从u到v
- 从v到最近的没有出度的点
这样,第二类的每一条都对应一个字符串,且有这样的性质:
- 每个字符串都不相同
- 每个字符串都是原串的后缀(看前面的那个性质)
由于原串只有O(n)个后缀,所以第二类边的边数也是O(n)的。我们可以开(2n)的点数和(3n)的边数即可,如果字符集比较大的话。
时间复杂度?
除了下面这个操作,其他都与添加边有关。因为空间复杂度是O(n),所以不算下面这个操作的时间复杂度也是O(n)。
然后我们将
par(current, 0)
..par(current, k)
这些点的连向v的c字符的边,全部改为连向w。
我们观察这样一个k值((par(last,k)=root)),每一次插入一个字符c,k的值都加一。每次我们执行上述操作,假设“连向v的c字符的边”数量为x,那么k就要减少x。因为减少的一定小于增加的,所以这个时间复杂都也是O(n)的。