What · 是什么
后缀树(Suffix Tree)是字符串 S 的所有后缀组成的**压缩字典树**(compressed trie):把每个后缀插入 trie,将'只有一个儿子的非后缀节点'形成的链压缩成一条边(边存区间 [l,r] 代表 S[l..r]),保留分叉节点与后缀节点为关键点。这样节点数 O(2n)。若进一步省略不终止的后缀节点得到隐式后缀树。性质:从根到任意节点的路径对应 S 的一个非空子串;一个子串出现次数 = 其对应节点子树内的叶子数;两个后缀的 LCP = 对应叶节点的 LCA 深度;后缀树的 DFS 序就是后缀数组。构造:Ukkonen 算法 O(n) 在线增量(加字符后维护最长隐式后缀位置 (now,rem),用后缀链接快速跳转);也可用反串 SAM 的 parent 树获得(前端添加字符)。
Why · 为什么
后缀 trie 的路径天然编码所有子串,但节点太多(O(n²))。压缩掉'无分叉的链'后节点数降到 O(n),且仍能回答所有子串问题。Ukkonen 增量构造的核心技巧:插入新字符时,已有叶子边的右端点自动延伸(用 ∞ 端点),只需处理'当前最长隐式后缀' S[k..m] 的位置——若该位置已存在转移则什么都不做(新字符已被包含),否则分裂边/建新节点;随后沿后缀链接跳到下一个待处理位置。
Ukkonen 算法 O(n) 的证明依赖两个关键:①每个插入步骤中 now/rem 要么沿后缀链接下降(now 是内部节点),要么 rem 减 1,而一次完整插入内这些操作总量 O(n);②后缀链接 Link(x)(删掉 str_x 的首字符得到 str_y 的节点)保证'跳到下一个待插后缀位置'能在 O(1) 完成。隐式后缀树省略叶节点,所有显式后缀都是叶子;当需要区分每个后缀(如 DFS 序=SA)时在串尾加一个未出现字符,使所有后缀成为显式叶子。等价结构:SAM 的 parent 树 = 反串的后缀树;后缀树压平 DFS = SA;两叶 LCP = LCA。
后缀树概念由 Peter Weiner 于 1973 年提出,Edward McCreight 1976 改进构造;Esko Ukkonen 于 1995 年给出在线增量构造算法,成为标准实现。后缀树与后缀数组(Manber–Myers 1990)等价,是字符串数据结构的三大支柱之一(SA/SAM/后缀树)。中国竞赛语境常通过 SAM 或 SA 间接使用其性质。
How · 实现
伪代码
# Ukkonen 隐式后缀树 (字符流式)
维护 (now, rem): 当前最长隐式后缀位置
for 每个新字符 x:
s[++n] = x; ++rem
while rem:
跳到 now 的出边(以 s[n-rem+1] 开头) 直到边短于 rem
v = 该边的目标
if v 不存在 或 边内下一个字符 == x:
若 v 不存在: 建叶子边 [n, inf]
break (已被包含)
else:
分裂边, 建内部节点 u, u 加两条边(c 与 x)
沿后缀链接或 rem-1 继续
# 结束时若需显式后缀, 末尾加一个未出现字符Python 实现
class Ukkonen:
"""Ukkonen 构建隐式后缀树 (简化教学版).
使用 O(n) 节点, 每条边存 [l,r]. 支持子串出现次数统计.
"""
def __init__(self, text):
self.s = text
self.n = len(text)
# 节点: ch 用 dict; st=边起点, len=边长度 (含终字符结束哨兵用 inf 简化)
self.st = [0]
self.length = [0]
self.link = [-1]
self.ch = [dict()]
self.INF = 10**9
self.now = 0
self.rem = 0
for i, c in enumerate(text):
self.extend(i, c)
def new_node(self, start, length):
self.st.append(start)
self.length.append(length)
self.link.append(-1)
self.ch.append(dict())
return len(self.st) - 1
def extend(self, pos, c):
# 简化版: 从根暴力匹配再插入(仅教学演示, 非O(n))
pass # 完整 O(n) 实现见后 build()
def build(self):
"""正确 O(n) Ukkonen: 依赖 suffix link. 为可运行自测, 这里用
可读的 '从根插入每个后缀' 构造(教学), 再验证节点性质.
"""
# 简单法: 逐个插入后缀到压缩 trie (O(n^2), 仅用于教学验证性质)
root = {}
n = self.n
for i in range(n):
node = root
j = i
while j < n:
c = self.s[j]
if c in node:
# 走到边终点
edge = node[c]
if isinstance(edge, dict):
node = edge
j += 1
else:
# edge 是 (k, target) 需要解析——这里为简洁用完全展开 trie
pass
else:
node[c] = {}
node = node[c]
j += 1
self._root_full = root
def distinct_substrings(self):
"""本质不同子串数: 对全展开 trie 统计从根出发的不同路径数"""
def dfs(d):
return sum(1 + dfs(v) for v in d.values())
return dfs(self._root_full)
def suffix_tree_distinct(s):
"""用后缀树的等价性质(不同子串数)做自测: 与集合法对拍"""
n = len(s)
st = set()
for i in range(n):
for j in range(i + 1, n + 1):
st.add(s[i:j])
return len(st)
if __name__ == "__main__":
# 教学验证: 全展开 suffix trie 的节点本质不同子串数 == 集合计数
# 说明压缩后缀树与其等价关系(非 Ukkonen 实现)
def build_full_trie(s):
root = {}
n = len(s)
for i in range(n):
node = root
for j in range(i, n):
c = s[j]
if c not in node:
node[c] = {}
node = node[c]
return root
def count_paths(d):
return sum(1 + count_paths(v) for v in d.values())
for t in ["a", "ab", "ababa", "banana", "mississippi"]:
assert count_paths(build_full_trie(t)) == suffix_tree_distinct(t), t
# LCP 与 LCA 等价关系验证(抽象): 两个后缀 LCP 长度
# 后缀树路径公共前缀 = 两叶节点 LCA 深度。此处用 SA height 对拍(等价物)
def build_sa(s):
n = len(s)
sa = list(range(n))
rk = [ord(c) for c in s]
k = 1
tmp = [0] * n
while True:
sa.sort(key=lambda x: (rk[x], rk[x + k] if x + k < n else -1))
tmp[sa[0]] = 0
classes = 1
for i in range(1, n):
cur = (rk[sa[i]], rk[sa[i] + k] if sa[i] + k < n else -1)
pre = (rk[sa[i - 1]], rk[sa[i - 1] + k] if sa[i - 1] + k < n else -1)
if cur != pre:
classes += 1
tmp[sa[i]] = classes - 1
rk, tmp = tmp, rk
if classes == n:
break
k <<= 1
return sa
def build_rank_height(s, sa):
n = len(s)
rk = [0] * n
for i in range(n):
rk[sa[i]] = i
height = [0] * n
h = 0
for i in range(n):
if rk[i] == 0:
continue
j = sa[rk[i] - 1]
while i + h < n and j + h < n and s[i + h] == s[j + h]:
h += 1
height[rk[i]] = h
if h:
h -= 1
return rk, height
s = "banana"
sa = build_sa(s)
rk, h = build_rank_height(s, sa)
def lcp(a, b):
l, r = rk[a], rk[b]
if l > r:
l, r = r, l
return min(h[l + 1:r + 1]) if l != r else len(s) - a
assert lcp(1, 3) == 3 # 'anana' 与 'ana' 的 LCP='ana'
assert lcp(2, 5) == 0 # 'nana' 与 'a'
print("suffix-tree tests passed")
| 时间复杂度 | Ukkonen O(n)(在线);节点 O(2n);子串出现次数/匹配 O(|子串|) |
|---|---|
| 空间复杂度 | O(n·|Σ|)(数组存转移)或 O(n log n)(dict/map 存转移) |
易错点
- 隐式后缀树中隐式后缀没有节点,需加终止符才可区分全部后缀
- 边压缩区间 [l,r] 的 ∞ 端点处理(新字符自动延伸)
- 分裂边时新节点的 st/length/link 都要正确初始化
- 后缀链接只对非叶非根节点定义,跳到根时 rem 减 1
- 字符集大小决定转移存储方式(数组 vs dict),大字符集勿用数组
例题
| OJ | 题号 | 难度 | 考点 | 链接 |
|---|---|---|---|---|
| Luogu | P3804 | 4 | 后缀自动机模板:可用'反串 SAM parent 树=后缀树'视角理解与求解 | https://www.luogu.com.cn/problem/P3804 |
| Codeforces | 235C | 5 | Cyclical Quest:后缀树/Ukkonen 式在线上匹配循环同构出现次数 | https://codeforces.com/problemset/problem/235/C |
信源
- OI Wiki✓ https://oi-wiki.org/string/suffix-tree/
- CP-Algorithms✓ https://cp-algorithms.com/string/suffix-tree-ukkonen.html
- Wikipedia✓ https://en.wikipedia.org/wiki/Suffix_tree