从竞赛题看后缀数组与后缀自动机的实战应用与选型

发布时间:2026/8/28 8:34:04
从竞赛题看后缀数组与后缀自动机的实战应用与选型 1. 从一道竞赛题看后缀数据结构的实战价值最近在整理一些算法竞赛的经典题目时又翻到了这道“Sasha and Swag Strings”。这道题本身是一个典型的字符串处理问题但它的背景和考察点却直指算法领域里一个既经典又充满魅力的工具家族——后缀数据结构。很多朋友在初次接触后缀数组、后缀自动机这些概念时会觉得它们抽象、复杂似乎只在少数“神仙打架”的场合才会用到。但我想说这种看法可能低估了它们的威力。这道模拟赛题恰恰是一个绝佳的窗口让我们能从一个具体的、有挑战性的问题出发去理解这些数据结构究竟解决了什么痛点以及它们是如何优雅地将看似复杂的字符串匹配、统计问题转化为对树或数组的高效遍历。简单来说后缀数据结构是一套专门为了高效处理字符串的“所有后缀”而设计的工具。为什么是后缀因为一个字符串的任意子串都可以表示为该字符串某个后缀的某个前缀。抓住了这个特性我们就能把许多关于子串的查询、统计、匹配问题统一到对后缀集合的操作上来。这道“Sasha and Swag Strings”题目我理解其核心就是要求我们处理一系列字符串并高效地回答关于它们子串的某种复杂统计或匹配查询其数据规模和查询复杂度必然使得朴素的遍历算法无法承受从而必须依赖后缀数组或后缀自动机这类“重型武器”来优化。所以这篇内容我们不打算枯燥地罗列模板代码而是想借着这道题的由头深入聊聊后缀数组和后缀自动机这两种核心的后缀数据结构。我会结合自己打比赛和做项目时踩过的坑重点剖析它们的设计思想、构建过程、典型应用场景以及最关键的一环在类似“Sasha and Swag Strings”这样的问题中我们该如何选择、如何建模、如何将问题转化到这些数据结构上求解。无论你是正在备赛的选手还是对高性能字符串处理感兴趣的开发者相信这些从实战中沉淀下来的思路和细节都能给你带来一些不一样的启发。2. 后缀数组排序的艺术与倍增的智慧当我们面对一个长字符串需要频繁查询其不同子串的信息时最朴素的想法可能是枚举所有子串但这在长度上万时就不现实了。后缀数组提供了一个极其紧凑且强大的表示它将一个字符串的所有后缀按照字典序排序并将排序后的后缀的起始下标存入一个数组。这个数组本身以及伴随它产生的另一个数组——高度数组共同构成了解决大量字符串问题的基石。2.1 核心构造倍增算法与基数排序的珠联璧合理解后缀数组首先要理解它的经典构造算法——倍增法。为什么叫“倍增”它的核心思想是我们并不直接比较整个后缀那样太慢而是通过多次迭代每次比较的长度翻倍逐步逼近最终的排序结果。假设我们有一个字符串S “aabaaaab”其长度为 n8。我们目标是得到所有后缀S[i:]的排序。第一步初始化k1我们只根据每个后缀的第一个字符进行排序。这很简单直接比较单个字符即可。排序后我们得到每个后缀基于第一个字符的排名rank。注意排名可能相同字符相同。第二步倍增k2现在我们不再只看第一个字符而是看每个后缀的前两个字符组成的“二元组”。关键技巧来了对于起始位置为i的后缀它的前两个字符的“键值”可以表示为(rank[i], rank[i1])。这里rank[i]是上一步中后缀S[i:]的排名rank[i1]是后缀S[i1:]的排名。如果i1超出字符串范围我们将其排名视为0最小。这样我们就把比较两个长度为2的字符串转化为了比较两个整数二元组。后续步骤k4, 8, …重复这个过程。在 k4 时我们比较的键值变为(rank[i], rank[i2])其中rank[i]是基于前2个字符的排名。以此类推每次比较的长度 k 倍增直到 k n。此时每个后缀的“键值”都唯一确定了它的字典序排序也就完成了。这个过程的高效实现依赖于基数排序。因为我们的键值是整数对基数排序可以在 O(n) 时间内完成对整数对的排序。整个倍增过程进行 O(log n) 轮因此总时间复杂度是 O(n log n)。这是后缀数组构造的经典时间复杂度在实际竞赛和工程中n在百万级别都完全够用。这里有一个我踩过的坑在实现基数排序时一定要注意计数数组的清零和前缀和计算顺序。一个常见的优化是使用“双关键字基数排序”先按第二关键字排序再按第一关键字排序。这个过程如果写错会导致排名计算错误进而使整个数组乱掉。调试这类错误非常痛苦因为中间状态很难直观检查。我的经验是在实现时为每一轮排序后的sa后缀数组和rank数组都加上详细的输出打印用小字符串比如“ababa”手动模拟确保每一步都和纸上推导一致。2.2 高度数组连接相邻后缀的桥梁仅有后缀数组sa我们知道了后缀的次序但还不知道它们之间的关系。高度数组height应运而生。height[i]定义为排名第 i 的后缀和排名第 i-1 的后缀的最长公共前缀的长度。它的意义极其重大。有了height数组我们可以在 O(1) 时间内配合RMQ预处理查询任意两个后缀的最长公共前缀。为什么因为两个后缀的 LCP 长度等于它们在后缀数组中排名之间的所有相邻后缀的height值的最小值。这是一个非常优美的性质将子串的比较问题转化为了区间最值查询问题。height数组有一个 O(n) 的线性构造算法其核心是利用一个性质height[rank[i]] height[rank[i-1]] - 1。这个性质允许我们以“爬坡”的方式在比较字符时避免回退从而实现线性复杂度。在实现时务必注意下标与排名的转换关系height数组的下标是排名1到n而计算过程中我们使用原串下标i。我强烈建议在代码中统一使用从1开始的下标并在s[n1]处放置一个小于所有字符的哨兵如0这样可以避免很多边界判断让代码更清晰。2.3 后缀数组的典型应用场景拆解回到“Sasha and Swag Strings”这类题目后缀数组能做什么我们可以通过几个经典模型来感知。场景一不同子串的计数一个字符串的所有不同子串数量是多少朴素计算是 O(n²)。利用后缀数组和高度数组我们可以巧妙求解总子串数为 n*(n1)/2而重复的子串数恰恰是所有后缀与它前一名后缀的公共前缀部分也就是所有height[i]的和。因为这部分公共前缀在前一个后缀中已经作为子串被统计过了。所以不同子串数 总子串数 - Σheight[i]。这个思路可以扩展到求排名第k小的不同子串等问题这在很多竞赛题中都是标准解法。场景二多字符串的公共子串问题如果“Sasha and Swag Strings”涉及多个字符串比如求多个字符串的最长公共子串后缀数组同样擅长。经典做法是将所有字符串用互不相同的分隔符连接起来如‘#‘, ‘$‘等构建这个大字符串的后缀数组和高度数组。然后我们使用一个滑动窗口确保窗口内包含来自所有原字符串的后缀那么窗口内所有后缀的 LCP即窗口内height的最小值就是一个公共子串长度。我们只需要找到满足条件的所有窗口中这个 LCP 的最大值即可。这个滑动窗口配合单调队列或并查集维护最小值是解决多字符串公共子串问题的利器。场景三循环同构与最小表示法虽然不一定是本题直接考点但后缀数组可以轻松解决字符串的最小表示问题即求一个循环字符串的最小字典序表示。方法是将原字符串复制一份接在后面形成长度为 2n 的字符串构建其后缀数组然后在前 n 个后缀中找到排名最小的那个其起始位置就是最小表示的起点。这比传统的双指针算法更通用尤其是在需要处理更复杂比较规则时。在思考如何用后缀数组解题时我的经验是先问自己问题是否可以转化为对后缀的排序、比较或公共前缀的查询如果答案是肯定的那么后缀数组很可能就是那把钥匙。接下来要做的就是精心设计如何将问题参数比如字符串集合、查询限制映射到sa和height数组上并通过适当的预处理如RMQ、二分答案来高效回答查询。3. 后缀自动机状态压缩与拓扑的神奇自动机如果说后缀数组像一本精心编排的字典那么后缀自动机就像一台为特定字符串量身定制的精密识别机器。它的设计更加抽象但功能也更加强大尤其擅长处理与子串出现位置、次数、状态转移相关的问题。3.1 SAM的核心思想等价类与最简状态为什么需要后缀自动机后缀数组给出了所有后缀的排序但对于“所有子串”的集合它是以一种间接的方式通过后缀的前缀来组织的。后缀自动机则直接瞄准了“所有子串”这个集合并试图用一个最小的确定性有限状态自动机来识别它们。SAM 的核心在于“结束位置集合”的等价类。对于字符串 S 的任意子串 t我们考虑它在 S 中所有出现位置的结束下标集合记为endpos(t)。SAM 将具有相同endpos集合的子串归为同一个状态。这是一个非常深刻的洞见。例如在字符串“abcbc”中子串“bc”出现在位置 2 和 4结束“c”出现在位置 3 和 5。它们的endpos集合不同所以属于不同状态。而子串“bc”和“c”在位置4的结束是“bc”独有的。一个状态等价类中包含的所有子串其长度是连续的并且较短者是较长者的后缀。每个状态我们记录两个关键信息len该状态能表示的最长子串长度和link后缀链接指向一个状态该状态所表示的子串是当前状态子串的后缀。这个link指针构成了一个树形结构我们称之为parent 树或后缀链接树。这棵树是 SAM 强大功能的源泉它反映了子串之间的后缀关系。构建 SAM 的算法是在线算法逐个添加字符时间复杂度 O(n)空间复杂度 O(n)。算法细节涉及状态的克隆与转移边的调整是 SAM 学习中最难啃的部分。我建议的学习路径是先理解endpos等价类和link树的概念然后找一段可靠的模板代码用几个短字符串如“abab”在纸上一步步画出每一步添加字符后的状态图、len、link和转移边感受状态的创建与克隆过程。这个过程虽然繁琐但一旦走通你对 SAM 的理解会深刻得多。3.2 Parent树连接子串统计的脉络SAM 的 parent 树是其进行子串统计的基石。在这棵树上父节点状态所代表的子串集合是其子节点状态子串集合的后缀。并且一个子串在所有出现位置上的信息可以通过在 parent 树上的子树聚合来得到。一个至关重要的性质对于某个状态p它代表的所有子串在原串中的出现次数是相同的。而这个出现次数等于在 parent 树中以p为根的子树中包含了多少个原串前缀对应的状态。因为每个前缀对应 SAM 上的一条路径终点是一个状态。所有以后缀关系链接到状态p的前缀其对应的子串都包含了p所代表的子串。因此计算每个状态即每个等价类子串的出现次数就变成了一个简单的树形 DP我们首先标记出每个前缀到达的状态然后从叶子节点向根节点做累加。cnt[p] sum(cnt[son])。得到的cnt[p]就是状态p所代表的所有子串的出现次数。这个能力是后缀数组难以直接提供的。后缀数组可以求一个子串出现的不同位置数通过二分在sa中查找但计算所有子串的出现次数SAM 的 parent 树结构提供了天然的 O(n) 解决方案。3.3 后缀自动机的实战应用与选型思考那么在“Sasha and Swag Strings”可能涉及的场景中SAM 的优势在哪里场景一求出现次数至少为K次的最长子串这是 SAM 的招牌问题。我们构建主串的 SAM并按照上述方法计算出每个状态的出现次数cnt。然后我们知道每个状态p对应了一组长度连续的子串其长度范围是[len[link[p]]1, len[p]]且这些子串的出现次数都是cnt[p]。如果cnt[p] K那么该状态能贡献的最长子串长度就是len[p]。我们遍历所有状态取满足条件的len[p]的最大值即可。如果要求第K小子串也可以结合 parent 树和 DP 来计算从每个状态出发能走出多少条不同路径。场景二多字符串的复杂匹配与统计对于涉及多个字符串的问题SAM 有两种主要思路。一是对其中一个主串建 SAM然后用其他串在 SAM 上运行记录每个状态能匹配到的最长长度再结合 parent 树向上更新。这种方法常用于求多个字符串的公共子串。二是建立广义后缀自动机。广义 SAM 可以看作是多个字符串的 Trie 树所构成字符串的后缀自动机。构建时需要特别注意处理多个串相同转移的情况通常有“在线”和“离线”两种构建方法。广义 SAM 可以统一处理多个字符串的所有子串是解决复杂多串问题的终极武器之一。场景三动态字符串问题由于 SAM 支持在线构建它天然适合处理字符串动态追加的场景。比如有一个初始字符串随后在末尾添加字符并需要随时查询当前字符串的某些子串信息。用 SAM 可以轻松维护而后缀数组则需要重新构建代价高昂。选型心得后缀数组 vs. 后缀自动机面对一个问题如何选择后缀数组更“几何直观”。它给出了所有后缀的明确排序适合需要基于字典序比较、二分查找、区间最值查询的问题。代码相对固定调试直观sa和height数组可以打印出来看。在只需要处理一个字符串且查询模式偏向“区间”和“排序”时我通常会先考虑后缀数组。后缀自动机更“代数抽象”。它聚焦于子串的等价类和状态转移特别适合需要统计子串出现次数、处理多个字符串的子串关系、以及涉及状态和转移的问题。它的 parent 树提供了强大的树形结构支持。当问题中出现“至少K次”、“所有不同子串的出现次数”、“多个字符串的公共子串”等字眼时SAM 往往是更优解。在实战中尤其是竞赛中两者都必须要掌握。有时一道题甚至有多种解法分别用 SAM 或 SA 配合不同的技巧。我的习惯是先仔细分析问题的本质需求是重“次序”还是重“状态/次数”再决定使用哪种工具。当然最理想的情况是你对两者都足够熟悉能在脑中快速评估两者的建模复杂度选择代码更简洁、更不易出错的一种。4. 回归问题如何拆解“Sasha and Swag Strings”类题型虽然我们无法看到原题但结合“后缀数据结构”这个核心标签以及“Sasha and Swag Strings”这个标题的暗示可能涉及多个字符串或某种“炫酷”的字符串操作我们可以尝试推演一类典型题目的解题框架。这类题目通常不会直接让你套模板而是需要你将问题巧妙转化。4.1 问题建模的通用思路第一步永远是理解题意抽象模型。我们需要明确输入是什么是单个长字符串还是多个字符串的集合查询或要计算的目标是什么是某种子串的个数、最大/最小长度、还是满足特定条件的子串集合条件是什么“Swag”可能暗示某种限制比如子串必须出现在所有字符串中、出现次数有特定范围、或者子串需要满足某种数值条件如元音辅音比例。第二步是选择数据结构。根据第一步的抽象如果涉及多个字符串的公共性或交叉性广义后缀自动机是强有力的候选。我们可以将所有字符串插入广义SAM每个状态会记录它来自哪些字符串可以通过位掩码或集合。这样查询“在所有字符串中都出现的子串”就转化为检查状态的来源标记是否包含所有字符串。如果目标是统计满足复杂条件的子串数量并且条件可以转化为对子串出现位置或出现次数的约束后缀自动机的 parent 树配合树形DP是很好的选择。例如求出现次数在[L, R]区间内的不同子串个数我们可以计算每个状态p的出现次数cnt[p]若cnt[p]在区间内则该状态贡献的子串数量为len[p] - len[link[p]]。如果问题更侧重于子串的字典序、快速比较两个子串或基于排名的查询后缀数组可能更直接。例如求所有不同子串中字典序第K大的子串可以通过后缀数组上二分答案并利用height数组计算不同子串数量的前缀和来定位。第三步是设计算法流程。选定数据结构后需要设计具体的计算步骤。这可能包括预处理构建SAM/SA计算辅助数组height/cnt。信息聚合在SAM的parent树或SA的height数组上进行DP、二分、滑动窗口等操作计算出中间信息如每个状态的出现次数、来源、对应子串数等。回答查询利用预处理的信息高效回答原问题。可能是O(1)或O(log n)的查询也可能是离线处理所有询问。4.2 一个可能的例题分析与实现草图假设“Sasha and Swag Strings”是这样一个问题给定n个字符串定义一个子串是“Swag”的当且仅当它在至少K个不同的原串中出现。求所有“Swag”子串中长度最长的那个。如果有多个输出字典序最小的。分析多字符串子串出现性统计 - 优先考虑广义后缀自动机。需要知道每个子串状态在多少个不同原串中出现 - 在广义SAM构建时每个状态需要维护一个来源集合bitset或int掩码。但需要注意一个字符串可能通过多个前缀到达同一个状态我们需要去重。需要最长长度 - 每个状态本身有len属性。需要字典序最小 - SAM本身不直接维护字典序。但我们可以通过遍历SAM的状态来生成子串。对于长度相同的子串我们需要比较字典序。一个可行的方法是在得到最大长度L后我们找出所有满足条件来源数K且len[p] L的状态。这些状态中长度恰好为L的子串其具体字符串需要被生成并比较。但更高效的做法可能是结合后缀数组来求字典序。这提示我们有时SAM和SA需要结合使用。简化版实现思路仅求最长长度构建n个字符串的广义SAM。在构建过程中对于每个插入的字符它到达的状态需要标记其来自当前字符串。注意在克隆状态时来源信息也需要复制。在parent树上进行树形DP合并子节点的来源信息到父节点。这样每个状态p的来源集合mask[p]就代表了有多少个不同的原串包含该状态所代表的子串。遍历所有状态如果popcount(mask[p]) K则用len[p]更新答案maxLen。求字典序最小的挑战如果要求字典序最小问题复杂度上升。我们可以在得到maxLen后找出所有“候选状态”即popcount(mask[p]) K且len[p] maxLen的状态p。对于每个候选状态它贡献的长度为maxLen的子串是其代表的最长串longest(p)的某个后缀。我们需要枚举这些子串。一个比较“暴力”但可行的做法是从这些候选状态出发在SAM上按照字典序即转移边a-z进行DFS一旦路径长度达到maxLen就得到一个候选子串与当前最优解比较。由于SAM状态数是O(N)的且maxLen不会太大这在某些数据范围内是可接受的。更优雅的做法可能需要结合后缀数组来对所有候选子串进行排序。这个例子展示了面对复杂问题我们可能需要对多种后缀数据结构进行组合与变通。核心在于深刻理解每种工具的能力边界并灵活地将问题分解到这些工具可以处理的模块上。5. 避坑指南与性能优化实战经验纸上得来终觉浅绝知此事要躬行。理论再完美实现时总会遇到各种“坑”。这里分享一些我在使用后缀数据结构的实战中积累的经验和教训。5.1 后缀数组的实现陷阱陷阱一基数排序的细节倍增算法中的基数排序是易错点。我强烈建议使用一个清晰的三步实现// 假设 sa 为待排序后缀数组 x为第一关键字 y为第二关键字排序结果 c为计数数组 // 第一步按第二关键字 y 排序结果放入 sa memset(c, 0, sizeof(c)); for (i0; in; i) c[x[y[i]]]; for (i1; im; i) c[i] c[i-1]; for (in-1; i0; --i) sa[--c[x[y[i]]]] y[i]; // 第二步按第一关键字 x 排序结果放回 y swap(x, y); // 此时y存的是旧x // ... 类似第一步但按y[sa[i]]作为键值 // 第三步计算新的排名数组 x关键在于每一轮结束后需要根据sa和旧的rank即代码中的x或y来计算新的唯一排名。如果相邻两个后缀的双关键字都相同则它们的新排名相同。陷阱二高度数组的构造height数组的线性构造算法for (i1, k0; in; i)循环中k的维护是关键。k表示当前后缀i与上一后缀的 LCP 长度在计算下一个时k要先减一因为从下一个字符开始比较。循环边界和数组大小通常开n5要处理好防止越界。一个有效的调试方法是构造一个随机短串同时用O(n²)的朴素算法计算height与你的线性算法结果对比。陷阱三字符集与哨兵如果字符串包含非小写字母字符如数字、大写字母、中文需要离散化或扩大字符集大小m。哨兵字符通常放在s[n]的值必须小于所有真实字符的编码以确保排序正确。对于整数数组求后缀数组同样需要保证值域范围可控以便进行基数排序。5.2 后缀自动机的实现与调试陷阱一指针与数组的选择SAM 的实现有指针版和数组版。对于竞赛我推荐使用数组预分配空间的静态写法因为更稳定不易出现指针错误。next数组转移边通常用map或int[26]数组。如果字符集小且固定如小写字母用数组访问更快如果字符集大或不确定用map更省空间。link和len数组用一维数组即可。陷阱二克隆状态的正确性在线算法中当发现转移冲突时需要克隆状态。这是 SAM 最难理解的部分。务必记住克隆状态的步骤创建新节点clone复制原节点q的所有转移边。设置len[clone] len[p] 1这里的p是cur的link。将clone的link指向q原来的link。将q和cur的link都指向clone。最后沿着p的link向上将所有指向q的转移重定向到clone。 这个过程必须严格按照顺序否则会导致状态机识别错误。用一个简单字符串“abab”在纸上画图跟踪是理解这个过程的最佳方式。陷阱三Parent树上的DP在 parent 树上进行 DP如计算出现次数cnt时需要按len从大到小对状态排序后进行遍历因为len更大的状态在 parent 树上是更深的叶子或靠近叶子。这保证了在更新父节点时所有子节点都已更新完毕。我们可以用一个桶计数排序来实现这个排序// 假设 maxlen 是最大的 len 值 for (int i1; isz; i) buc[len[i]]; for (int i1; imaxlen; i) buc[i] buc[i-1]; for (int i1; isz; i) order[buc[len[i]]--] i; // order 是排序后的状态索引 // 然后逆序遍历 order从后往前即从长到短进行DP for (int isz; i1; i--) { int p order[i]; cnt[link[p]] cnt[p]; }5.3 性能优化与代码风格空间与时间的权衡后缀数组通常需要sa[n],rank[n],height[n], 以及基数排序的辅助数组c[max(m, n)]。如果内存紧张可以复用数组例如用sa同时作为rank的辅助。但代码清晰度会下降。后缀自动机状态数最多2n-1转移边数最多3n-4。使用map存储转移边空间是 O(n) 的但常数较大。在字符集固定时使用二维数组next[2*n][26]速度更快但可能浪费空间。根据问题规模和数据特点选择。代码模块化将后缀数组的构建、高度数组计算、RMQ预处理分别写成函数。将后缀自动机的扩展节点、克隆节点也封装成函数。这样主逻辑清晰调试方便。对于广义SAM可以封装一个insert函数每次插入新串前将last指针重置为根节点1。测试策略使用小规模数据n10进行暴力对拍是检验算法正确性的黄金法则。生成随机字符串用你的 SA/SAM 算法和 O(n²) 或 O(n³) 的朴素算法同时计算结果如不同子串数、出现次数、LCP等比对是否一致。对于边界情况如空串、单字符串、所有字符相同的串也要进行测试。最后也是最重要的经验理解远比背模板重要。当你真正理解了height数组为什么能求 LCP理解了 SAM 的link指针为何构成一棵树你就能在遇到变形题时灵活地修改和运用这些结构而不是生搬硬套。后缀数据结构是工具而解题的钥匙永远是你对问题本质的洞察和对工具原理的掌握。