后缀树
后缀树是一种维护一个字符串所有后缀的数据结构。
一些记号
记构建后缀树的母串为
令
令
记
定义
定义字符串
容易看出后缀 trie 的优越性质:它的非根节点恰好能接受
如果令后缀 trie 中所有拥有多于一个儿子的节点和后缀节点为关键点,定义只保留关键点,将非关键点形成的链压缩成一条边形成的压缩 trie 树为 后缀树 (Suffix Tree)。如果仅令后缀 trie 中所有拥有多于一个儿子的节点和叶结点为关键点,定义只保留关键点形成的压缩 trie 树为 隐式后缀树 (Implicit Suffix Tree)。容易看出隐式后缀树为后缀树进一步压缩后得到的结果。
在后缀树和隐式后缀树中,每条边对应一个字符串;每个非根节点
下图从左至右分别为以字符串
考虑将
后缀树的建立
支持前端动态添加字符的算法
反串建 SAM 建出的 parent 树就是这个串的后缀树,所以我们将反串的字符逐个加入 SAM 即可。
参考实现
struct SuffixAutomaton {
int tot, lst;
int siz[N << 1];
int buc[N], id[N << 1];
struct Node {
int len, link;
int ch[26];
} st[N << 1];
SuffixAutomaton() : tot(1), lst(1) {}
void extend(int ch) {
int cur = ++tot, p = lst;
lst = cur;
siz[cur] = 1, st[cur].len = st[p].len + 1;
for (; p && !st[p].ch[ch]; p = st[p].link) st[p].ch[ch] = cur;
if (!p)
st[cur].link = 1;
else {
int q = st[p].ch[ch];
if (st[q].len == st[p].len + 1)
st[cur].link = q;
else {
int pp = ++tot;
st[pp] = st[q];
st[pp].len = st[p].len + 1;
st[cur].link = st[q].link = pp;
for (; p && st[p].ch[ch] == q; p = st[p].link) st[p].ch[ch] = pp;
}
}
}
} SAM;
支持后端动态添加字符的算法
Ukkonen 算法是一种增量构造算法。我们依次向树中插入串
朴素算法
首先介绍一下一种较为暴力的构建方式,我们用字符串
初始建立一个根节点,称为
首先插入字符
接下来我们插入字符
接下来,我们要再次插入一个字符
接下来我们插入另一个
注意到我们没有管
接下来我们插入
接下来,因为
构建过程结束。
该算法每次暴力从根向下寻找并插入的复杂度最坏为
后缀链接
朴素算法慢主要是因为每次 extend 都要从根找到最长隐式后缀的插入位置。所以考虑把这个位置记下来。首先,我们采用一个二元组
现在,我们只需要在
首先有引理:对隐式后缀树中任意非叶非根节点
证明。令
由该引理,我们定义
Ukkonen 算法
Ukkonen 算法的整体流程如下:
为了构建隐式后缀树,我们从前往后加入
位置已经存在 的转移。此时后缀树形态不会发生变化。由于 已经在后缀树中出现,所以对于 , 也会在后缀树中出现,此时只需将 ,不需做任何修改。 不存在 的转移。如果 恰好为树中的节点,则此节点新增一条出边 ;否则需要对节点进行分裂,在此位置新增一个节点,并在新增节处添加出边 。此时对于 ,我们并不知道 会对后缀树形态造成什么影响,所以我们还需继续考虑 。考虑怎么求出 在后缀树中的位置:如果 不为 ,可以利用后缀链接,令 ;否则,令 。最后令 ,再次重复这个过程。
每一步都只消耗常数时间,而算法在插入全部的字符后停止,所以时间复杂度为
由于 Ukkonen 算法只能处理出
参考实现
struct SuffixTree {
int ch[M + 5][RNG + 1], st[M + 5], len[M + 5], link[M + 5];
int s[N + 5];
int now{1}, rem{0}, n{0}, tot{1};
SuffixTree() { len[0] = inf; }
int new_node(int s, int le) {
++tot;
st[tot] = s;
len[tot] = le;
return tot;
}
void extend(int x) {
s[++n] = x;
++rem;
for (int lst{1}; rem;) {
while (rem > len[ch[now][s[n - rem + 1]]])
rem -= len[now = ch[now][s[n - rem + 1]]];
int &v{ch[now][s[n - rem + 1]]}, c{s[st[v] + rem - 1]};
if (!v || x == c) {
lst = link[lst] = now;
if (!v)
v = new_node(n, inf);
else
break;
} else {
int u{new_node(st[v], rem - 1)};
ch[u][c] = v;
ch[u][x] = new_node(n, inf);
st[v] += rem - 1;
len[v] -= rem - 1;
lst = link[lst] = v = u;
}
if (now == 1)
--rem;
else
now = link[now];
}
}
} Tree;
作用
后缀树上每一个节点到根的路径都是
后缀树的 DFS 序就是后缀数组。后缀树的一个子树也就对应到后缀数组上的一个区间。后缀树上两个后缀的最长公共前缀是它们对应的叶节点的 LCA,因此,后缀数组的 height 的结论可以理解为树上若干个节点的 LCA 等于 DFS 序最小的和最大的节点的 LCA。
例题
洛谷 P3804【模板】后缀自动机(SAM)
题意:
给定一个只包含小写字母的字符串
请你求出
解法
建出插入一个终止符的隐式后缀树。树上每条从根出发的路径都构成子串。一个显示后缀的出现次数即为对应节点子树内的叶子节点个数,隐式后缀不用考虑,因为一个隐式后缀的出现次数等于向下走到的第一个节点对应显示后缀的出现次数,而且一定没有该显示后缀长。所以遍历整棵树,求出每个节点子树内叶子个数和每个节点到根的路径长度。如果叶子个数
参考代码
#include <iostream>
#include <string>
using namespace std;
constexpr int N(1e6), M(2 * N), inf(1e7), RNG{26};
struct SuffixTree {
int ch[M + 5][RNG + 1], st[M + 5], len[M + 5], link[M + 5];
int s[N + 5];
int now{1}, rem{0}, n{0}, tot{1};
SuffixTree() { len[0] = inf; }
int new_node(int s, int le) {
++tot;
st[tot] = s;
len[tot] = le;
return tot;
}
void extend(int x) {
s[++n] = x;
++rem;
for (int lst{1}; rem;) {
while (rem > len[ch[now][s[n - rem + 1]]])
rem -= len[now = ch[now][s[n - rem + 1]]];
int &v{ch[now][s[n - rem + 1]]}, c{s[st[v] + rem - 1]};
if (!v || x == c) {
lst = link[lst] = now;
if (!v)
v = new_node(n, inf);
else
break;
} else {
int u{new_node(st[v], rem - 1)};
ch[u][c] = v;
ch[u][x] = new_node(n, inf);
st[v] += rem - 1;
len[v] -= rem - 1;
lst = link[lst] = v = u;
}
if (now == 1)
--rem;
else
now = link[now];
}
}
pair<long long, int> search(int u, int dep = 0) {
if (st[u] + len[u] >= n) return {0, 1};
dep += len[u];
long long ans{0};
int ys{0};
for (int i{0}; i <= RNG; ++i)
if (ch[u][i]) {
auto res = search(ch[u][i], dep);
ans = max(ans, res.first);
ys += res.second;
}
if (ys > 1) ans = max(ans, 1LL * dep * ys);
return {ans, ys};
}
} T;
string s;
int main() {
cin >> s;
for (int i = 0; i < s.size(); ++i) T.extend(s[i] - 'a' + 1);
T.extend(0);
cout << T.search(1).first << endl;
return 0;
}
CF235C Cyclical Quest
题意:给定一个小写字母主串
解法
建立插入终止符的隐式后缀树。
枚举当前在那个循环节,记录在树上能查找到多长的前缀。
重复类似 Ukkonen 算法的过程,记录当前能匹配到的位置
如果某一个次成功匹配了当前的循环节,且该循环节之前没出现过,则更新答案。
然后切换到下个循环节的时候,我们要删去当前匹配的子串开头的字符:这正好就相当于令
复杂度
参考代码
#include <cstring>
#include <iostream>
#include <string>
using namespace std;
constexpr int N(1e6);
struct SuffixTree {
static constexpr int N{2 * ::N}, RNG{26}, inf = 1e7;
int ch[N + 5][RNG + 1];
int st[N + 5], len[N + 5], link[N + 5], s[::N + 5];
int now{1}, rem{0}, tot{1}, n{0};
int cnt[N + 5], vis[N + 5];
SuffixTree() { len[0] = inf; }
void clear() {
memset(ch, 0, sizeof ch);
now = tot = 1;
rem = n = 0;
}
int new_node(int s, int le) {
++tot;
st[tot] = s;
len[tot] = le;
return tot;
}
void extend(int x) {
s[++n] = x;
++rem;
for (int lst{1}; rem;) {
while (rem > len[ch[now][s[n - rem + 1]]])
rem -= len[now = ch[now][s[n - rem + 1]]];
int &v{ch[now][s[n - rem + 1]]}, c{s[st[v] + rem - 1]};
if (!v || x == c) {
lst = link[lst] = now;
if (!v)
v = new_node(n, inf);
else
break;
} else {
int u{new_node(st[v], rem - 1)};
ch[u][c] = v;
ch[u][x] = new_node(n, inf);
st[v] += rem - 1;
len[v] -= rem - 1;
lst = link[lst] = v = u;
}
if (now == 1)
--rem;
else
now = link[now];
}
}
void init(int u) {
if (len[u] > 1e6) return cnt[u] = 1, void();
for (int i{0}; i <= RNG; ++i)
if (ch[u][i]) init(ch[u][i]), cnt[u] += cnt[ch[u][i]];
}
long long test(const char *t, int m) {
static int time{0};
++time;
int now{1}, rem{0}, o{0};
long long ans{0};
for (int i{1}; i <= m; ++i) {
while (o < i + m - 1) {
while (rem >= len[ch[now][t[o - rem + 1]]])
rem -= len[now = ch[now][t[o - rem + 1]]];
int v{ch[now][t[o - rem + 1]]}, c{s[st[v] + rem]};
if (v && c == t[o + 1]) {
++o;
++rem;
} else {
break;
}
}
if (o == i + m - 1 && vis[ch[now][t[o - rem + 1]]] != time)
ans += cnt[ch[now][t[o - rem + 1]]],
vis[ch[now][t[o - rem + 1]]] = time;
if (now == 1)
--rem;
else
now = link[now];
}
return ans;
}
} T;
string s;
int main() {
cin >> s;
for (int i = 0; i < s.size(); ++i) T.extend(s[i] - 'a' + 1);
T.extend(0);
T.init(1);
int pw;
cin >> pw;
while (pw--) {
cin >> s;
int n = s.size();
for (auto &ch : s) ch += 1 - 'a';
s = " " + s + s;
cout << T.test(s.data(), n) << "\n";
}
return 0;
}
参考文献
- 2021 国家集训队论文《后缀树的构建》代晨昕
- 炫酷后缀树魔术 - EternalAlexander 的博客
创建日期: 2018年7月11日