在大屏或电脑上体验更佳——动画和操作区域更宽敞。
输入

后缀数组

后缀数组按字典序存储每个后缀的起始位置。文本固定、查询模式很多时,可以建立一次索引,再为每次查询使用二分搜索。本课用前缀倍增构造,时间为 O(n log² n)。

索引保存位置而非后缀副本。例如 BANANA 中位置 3 代表后缀 ANA,数组只存六个起点,字符仍从原文本读取。位置索引占 O(n),复制全部后缀则占 O(n²)。

后缀数组如何工作

长度 n 的文本中,后缀 i 为 text[i:]。BANANA 的排序结果是:

SA 槽位起点后缀
05A
13ANA
21ANANA
30BANANA
44NA
52NANA

直接比较完整后缀容易实现,但一次比较可能检查 O(n) 个字符。前缀倍增把比较对象变成整数对。

先按字符赋秩,同一首字符的后缀得到相同秩。假设已有秩能区分前 k 个字符,那么后缀 i 的前 2k 个字符可表示为 (rank[i], rank[i + k]):前一项表示首个 k 字符块,后一项表示接下来的块。若第二块起点超过文本末尾,使用 -1,使短后缀排在共享相同前缀的长后缀之前,例如 A 排在 ANA 前面。

排序这些整数对,相同对赋相同新秩,相邻对改变时秩加一。新秩就能区分前 2k 字符。将 k 翻倍继续,所有秩唯一时后缀顺序确定。

查询用二分下界。比较模式与中间后缀的模式长度前缀:前缀较小就右移 lo,相等或较大就左移 hi。相等也向左,是因为要找匹配范围的左边界。区间闭合后再用一次 startswith 验证候选。

后缀数组逐步演示

默认先为 text = "BANANA" 建索引,再查 pattern = "ANA"。

  1. 字符顺序 A < B < N,秩为 0、1、2。按文本位置写回 rank = [1, 0, 2, 0, 2, 0]。
  2. k = 1:起点 0 的键为 (1, 0);1、3 为 (0, 2);2、4 为 (2, 0);5 为 (0, -1),因为单个 A 后已无字符。
  3. 排序得到 [5, 1, 3, 0, 2, 4]。1、3 仍同秩,2、4 也同秩。按原起点写回的新秩为 [2, 1, 3, 1, 3, 0]。
  4. 倍增到 k = 2。前一轮已区分两个字符。起点 1 的原始块为 AN | AN,两个 AN 的秩都是 1,所以 (1, 1) 表示四字符前缀。全部键为:5 的 (0, -1)、1 的 (1, 1)、3 的 (1, 0)、0 的 (2, 3)、2 的 (3, 3)、4 的 (3, -1)。
  5. 再排序得到 [5, 3, 1, 0, 4, 2]。全部新秩唯一,构造停止。对应 A, ANA, ANANA, BANANA, NA, NANA,无需另存这些字符串。
  6. 查询区间 [0, 6),mid = 3 指向文本起点 0,取前缀 BAN。BAN > ANA,令 hi = 3。
  7. mid = 1 指向起点 3,前缀为 ANA。相等也令 hi = 1,继续寻找左边界。
  8. mid = 0 指向起点 5,前缀只有 A。A < ANA,令 lo = 1,此时 lo == hi。
  9. SA[1] = 3,BANANA 在索引 3 处确实以 ANA 开头,find_one 返回 3。

ANA 也在文本索引 1 出现。返回 3 仍正确:函数返回的是后缀数组顺序中第一个匹配,不保证文本中的最小索引。要列出全部出现位置,可再求匹配前缀范围的右边界,收集区间内起点。

时间与空间复杂度

设文本长 n、模式长 m。

操作时间辅助空间
本课前缀倍增构造O(n log² n)O(n)
单次子串查询O(m log n)O(m) 临时切片
保存后缀数组—O(n)

共有 O(log n) 轮,每轮构造 n 个常数大小的秩对,再用 O(n log n) 比较排序,因此总时间 O(n log² n)。改用整数秩的基数排序或计数排序可以达到 O(n log n),但展示代码不是那个实现。

二分查询有 O(log n) 次探测,每次最多比较 m 个字符。教学代码切出前缀,会产生 O(m) 临时字符;直接按索引比较可省掉分配,不改变时间上界。

一个文本回答多次查询时,索引成本可由查询分摊。单次查询通常用 text.find(pattern);同一模式用于多个持续到来的文本时,KMP 预处理的是模式这一侧。

边界情况

  • 单字符文本只有一个后缀,初始秩已确定顺序,不进入倍增循环。
  • AAAAAAAA 在 k = 1, 2, 4 保留同秩组,是画布八字符限制下最长的构造轨迹。
  • 后缀在第二块中结束:末尾哨兵 -1 及前一轮块秩共同保证短后缀在前,不能用真实秩 0 代替越界哨兵。
  • 模式比文本长:二分正常闭合,最终验证失败并返回 -1。
  • 多次出现:find_one 返回后缀数组顺序中的一个位置,不一定是最小文本索引。
  • 纯构造函数处理空文本,返回 SA = [];UI 要求文本和模式非空,因为空模式返回约定可能不同。

常见错误

  • 存储所有 text[i:] 副本,尚未构造就花掉 O(n²) 空间。后缀数组应存起点。
  • 用 0 表示末尾。0 是实际字符类别的合法秩,哨兵必须小于所有真实秩。
  • 计算新秩时原地修改 rank。同轮所有键必须读取相同旧数组,应写入 next_rank 后统一替换。
  • 只比较 rank[i],没有第二块就无法让已知前缀长度翻倍。
  • 二分遇到相等就停止。可以找到一个匹配,但得不到下界;需要左边界时应保留 hi = mid。
  • 将当前构造写成 O(n log n)。每轮使用比较排序,因此是 O(n log² n),更快上界需要更换排序子程序。