拓冰建站拓冰建站
首页 / 资讯中心 / 正文

KMP与后缀自动机结合:解决复杂字符串匹配与子串统计问题

1. 从一道国赛模拟题说起当字符串匹配遇上数据结构最近在复盘一些算法竞赛的题目特别是国赛级别的模拟题总能遇到一些将经典算法与数据结构进行巧妙结合的案例。今天想和大家深入聊聊的就是一道名为“match”的题目。光看标题“后缀字典树、KMP”可能很多朋友会想这不就是两个独立的字符串处理工具吗一个用于多模式串匹配和前缀查询另一个用于单模式串的快速匹配它们俩能擦出什么火花这道题的精妙之处恰恰在于它打破了我们对这些经典工具的刻板印象。它并不是让你简单地调用一下KMP或者建一棵Trie树而是要求你深刻理解KMP算法中“最长公共前后缀”这一核心思想的本质并将其转化为一种可以高效进行批量查询和动态维护的数据结构问题。简单来说题目通常会给你一个非常长的文本串S以及一系列查询。每个查询可能会问对于给定的一个模式串PS中有多少个子串其某个后缀与P的某个前缀的匹配情况满足特定条件或者需要你处理S的动态更新增加字符并持续回答这类查询。如果暴力求解对每个查询都扫描S的所有子串并与P做KMP复杂度是灾难性的。而“后缀字典树”通常指对S的所有后缀构建的Trie树即后缀树或更常用的后缀自动机的某种简化理解提供了对S所有子串信息的浓缩索引。KMP的next数组则描述了模式串P自身的结构。这道题的核心就是教你如何将P的next数组所蕴含的“自相似性”信息映射到S的后缀字典树这个“子串宇宙”中从而实现快速查询。这不仅仅是套模板而是对字符串匹配本质的一次深度挖掘对于想提升算法设计能力尤其是将问题抽象并转化为高效数据结构的同学来说是一个非常棒的训练。2. 拆解核心武器KMP的next数组与后缀字典树要攻克这道题我们必须先抛开“使用KMP函数”的惯性思维而是聚焦于其灵魂——next数组并理解后缀字典树到底承载了什么信息。2.1 KMP的next数组不仅仅是失配指针我们都知道对于模式串P假设长度为mKMP算法会预处理出一个next数组有时也称fail数组或prefix函数。next[i]0-indexed通常表示[0...i]这个子串的最长公共真前后缀的长度。真前后缀意味着不能是自身。例如对于模式串P “ababa”next[0] 0单个字符无真前后缀next[1] 0“ab” 前缀“a”和后缀“b”不同next[2] 1“aba” 前缀“a”和后缀“a”相同长度为1next[3] 2“abab” 前缀“ab”和后缀“ab”相同长度为2next[4] 3“ababa” 前缀“aba”和后缀“aba”相同长度为3这个数组的精髓在于它刻画了模式串P内部强大的自相似结构。next[i]的值意味着位置i结束的子串它的一个后缀长度next[i]恰好也是它的一个前缀。这个性质是递归的next[next[i]]给出了次长的匹配长度。在“match”这类问题中我们关心的往往不是如何在S中找P而是P自身的这种前后缀匹配关系。查询可能问的是在S的所有子串中有多少个的子串T满足T的某个后缀的长度恰好等于P的某个前缀的长度并且这个后缀和那个前缀是匹配的这时next数组就成为了描述P的“匹配需求”的蓝图。2.2 后缀字典树文本串S的子串全目录后缀字典树更严谨地说这里通常指的是后缀树或者为了方便实现与理解我们常用后缀自动机来等价地获取所有子串信息。但题目名称中的“字典树”提示我们可以从最直观的“对所有后缀建Trie”开始思考。假设文本串S “ababa”。它的所有后缀是“ababa”,“baba”,“aba”,“ba”,“a”。将这些后缀依次插入一棵字典树就得到了S的后缀字典树实际是压缩后的后缀树但概念相通。这棵树的伟大之处在于从根节点到任意节点的路径都对应着S的一个子串。每个子串在树中出现的次数即有多少个后缀以其为前缀可以通过统计子树中叶节点数量等方式得到。它以一种树形结构无损地压缩存储了S的所有子串信息并且支持快速查找、计数。所以后缀字典树就是S的“子串宇宙”的导航图。我们的任务就是把描述P的“匹配需求”的蓝图next数组拿到S的“子串宇宙导航图”里快速统计出符合需求的子串有哪些、有多少。2.3 关键结合点在树上进行“失败转移”暴力做法是枚举S的每个子串对应后缀树中的每个节点代表的串然后和P做一次匹配检查。这太慢了。高效的思路是利用KMP的“在匹配过程中利用next数组进行状态跳转”的思想。我们把对P的匹配过程看作一个自动机实际上就是KMP自动机。这个自动机的每个状态表示当前已成功匹配了P的前多少位状态0表示未匹配任何字符。现在我们不是用这个自动机去跑S这个线性序列而是去跑后缀字典树这个树形结构。我们从树根空串开始尝试用自动机去“吃”树上每条边代表的字符。当我们沿着树向下走在S的某个子串后添加字符自动机状态就根据KMP转移函数进行更新。如果我们走到的树节点对应的子串其长度即从根到该节点的深度恰好等于自动机的某个状态并且这个状态满足题目查询的条件比如是P的某个前缀长度那么我们就找到了一个符合条件的S的子串。这本质上是一种在树形结构上的DP动态规划或DFS遍历。dp[node][state]可以表示走到后缀树的node节点时对应的KMP自动机状态为state这样的路径有多少条或者是否存在。通过遍历整棵树我们就能一次性得到所有子串的匹配信息。注意实际编码中我们很少显式构建完整的后缀树太占内存而是使用后缀自动机。后缀自动机的每个节点即状态也代表了一组endpos等价类子串并且其link树后缀链接树与后缀树有紧密联系。在这种做法中我们是在后缀自动机的DAG有向无环图或它的link树上进行DP思想是相通的——都是在表示所有子串的结构上进行状态转移。3. 实战推演以一道典型问题为例为了让思路更具体我们不妨设定一个简化但核心的题目模型问题描述有一个初始为空的文本串S支持两种操作add(c): 在S的末尾添加一个字符c。query(P): 给定一个模式串P询问当前S中有多少个子串T满足T的某个后缀是P的一个前缀并且这个后缀的长度至少为LL是题目给定的参数或者是P的某个函数例如|P|/2。挑战操作可能多达10^5次必须支持在线添加和查询。3.1 暴力思路及其瓶颈最直接的想法是每次query(P)时获取当前S的所有后缀。对每个后缀用KMP或直接扫描找出它与P的最长公共前缀长度。如果这个长度 L则这个后缀的所有长度 L的前缀对应的子串都符合条件。但还要去重不同后缀可能产生相同子串复杂度极高。显然每次查询都O(|S|*|P|)是不可接受的。我们需要利用数据结构将S的信息预先组织好使得查询时间主要与|P|相关而与|S|无关或关系很小。3.2 高效解法框架动态后缀自动机 KMP自动机DP这才是本题的“标准答案”思路。步骤一维护动态的后缀自动机我们使用增量构造法构建后缀自动机。每次add(c)时在线性时间内更新SAM。SAM的每个节点状态存储len: 该状态能表示的最长子串长度。link: 后缀链接指向一个更短子串的状态。next[]: 状态转移函数。cnt: 该状态对应的子串集合在整个S中出现的次数即endpos集合大小。这个值可以在构建时通过拓扑排序按len从大到小累加得到st[link].cnt st[u].cnt。这样在任意时刻我们的SAM都完整记录了当前字符串S的所有子串信息及其出现次数。步骤二对查询串P构建KMP自动机对于每次查询的P我们预先计算出它的next数组。然后我们可以构建一个KMP自动机aut。这是一个二维数组aut[state][char]state范围是0到mm |P|表示当前已匹配了P的前state个字符。char是字符集。aut[state][char]的值表示当前匹配状态为state下一个输入字符是char时转移到的下一个状态。这个自动机的构建规则是如果state m且P[state] char则aut[state][char] state 1。否则aut[state][char] aut[next[state]][char]这里需要递归计算通常用DP迭代完成。步骤三在后缀自动机上进行DP或记忆化搜索这是最核心的一步。我们需要计算在SAM这个包含S所有子串的DAG上从初始状态空串出发所有路径对应所有子串中有多少条路径其对应的KMP自动机状态最大值或最终值 L。我们可以定义dp[sam_node][kmp_state]表示从SAM的初始状态走到sam_node状态并且对应的KMP自动机状态为kmp_state这样的转移路径有多少条。注意这里“路径条数”对应的是不同的子串而每个子串的出现次数由SAM节点的cnt决定所以最终答案需要加权。但由于SAM的DAG可能很大直接二维DP开销大。一个更巧妙的做法是进行记忆化搜索并利用SAM的性质进行剪枝。搜索函数dfs(sam_node, kmp_state)如果kmp_state m完全匹配了P或者kmp_state L满足题目长度条件那么从当前SAM节点往后走的所有子串即该节点能表示的所有子串的后缀扩展都至少能满足条件吗不完全是。我们需要记录从这个(sam_node, kmp_state)状态出发能产生的符合条件的子串总出现次数。为了避免重复计算我们记忆化memo[sam_node][kmp_state]。对于SAM节点sam_node的每一条转移边(c, next_sam_node)计算下一个KMP状态next_kmp_state aut[kmp_state][c]。递归计算dfs(next_sam_node, next_kmp_state)。当前状态的答案是其所有后继状态答案之和。关键点如果当前kmp_state L那么sam_node这个状态所代表的所有子串出现次数为st[sam_node].cnt都符合条件吗是的因为我们已经匹配了至少L长度的P的前缀而当前SAM节点代表的子串本身作为T它的一个后缀就是我们刚刚匹配上的部分就是P的一个长度L的前缀。因此sam_node.cnt需要被加入答案。最终dfs(初始状态, 0)的返回值就是满足条件的子串总出现次数。步骤四处理动态添加每次add(c)我们只更新SAM。查询时我们基于当前的SAM和当前的查询串P重新进行步骤二和三的DP/搜索。因为P不同KMP自动机不同DP表也不同所以查询时需要重新计算。但由于SAM节点数规模是O(|S|)的而|P|通常不会极大否则查询本身也慢这个DP是可接受的。3.3 一个具体的计算示例假设当前S “abab”查询P “aba”,L 2。SAM会包含代表子串 “a”, “b”, “ab”, “ba”, “aba”, “bab”, “abab” 的节点及其关联。P的next数组: [0, 0, 1]。KMP自动机autaut[0][‘a’] 1,aut[0][‘b’] 0aut[1][‘a’] 1?等等这里要小心状态1表示匹配了”a”下一个字符是’a’P[1]’b’不匹配所以aut[1][‘a’] aut[next[1]0][‘a’] 1。aut[1][‘b’] 2。aut[2][‘a’] aut[next[2]1][‘a’] 1,aut[2][‘b’] 0。我们从SAM初始状态0和KMP状态0开始DFS。假设走到SAM节点代表子串”a”时KMP状态变为1。此时kmp_state1 L2继续。走到SAM节点代表子串”ab”时KMP状态可能是这取决于路径”a”(KMP1) - ’b’ - KMPaut[1][‘b’]2。此时kmp_state2 L那么这个节点代表的子串”ab”就符合条件其出现次数假设为2次计入答案。继续探索走到代表”aba”的节点时KMP状态会变成aut[2][‘a’]1注意不是3因为匹配”ab”后接’a’P[2]’a’匹配但状态2接’a’在KMP自动机里我们算的是aut[2][‘a’]根据上面计算是1。此时状态1不符合条件但”aba”这个子串本身它的后缀”aba”是P的前缀长度32所以也应该被计入。这就是为什么在DFS中当kmp_state L时需要将当前SAM节点的所有子串贡献加入而不是只看路径的终点状态。这个例子展示了DP过程如何通过状态转移在后缀自动机中“并行地”检验所有子串并利用KMP自动机高效计算匹配长度。4. 实现细节与避坑指南理论很美但实现起来陷阱不少。下面分享一些我在编码和调试这类题目时积累的经验。4.1 后缀自动机的正确增量构造与cnt维护这是整个算法的基石。一定要确保SAM的构建代码百分百正确。常见坑点clone节点时的数据复制当需要克隆节点时除了len,link,next千万不要忘记复制其他你可能用到的信息比如是否作为终止状态的标记。但在本题的DP中我们通常不直接需要终止标记而是通过len和link来推算子串集合。cnt的维护cnt表示该状态包含的所有子串在整个字符串中的出现次数。初始化时只有每次add操作创建的新状态cur和克隆状态clone的cnt设为1代表这个新后缀的结束位置。在构建完成后必须按照len从大到小的顺序将每个节点的cnt加到其link父节点上st[st[i].link].cnt st[i].cnt。这个拓扑序可以通过桶排序基于len高效获得。// 伪代码示例SAM构建后计算cnt vectorint order; // 存储状态id for(int i 1; i sz; i) order.push_back(i); sort(order.begin(), order.end(), [](int a, int b) { return st[a].len st[b].len; }); for(int u : order) { if(st[u].link 0) { st[st[u].link].cnt st[u].cnt; } }4.2 KMP自动机的构建与状态转移优化构建aut[state][char]时如果字符集很大比如26个小写字母对每个state都遍历字符集计算是O(m*|Σ|)。对于本题|Σ|通常不大可以接受。但要注意避免递归计算导致的重复开销。标准写法是// 假设 next 数组已经求出 (next[0]0) vectorarrayint, 26 aut(m1); for(int state 0; state m; state) { for(char c a; c z; c) { if(state m P[state] c) { aut[state][c-a] state 1; } else { aut[state][c-a] (state 0) ? 0 : aut[next[state]][c-a]; } } }这里有一个优化aut[0][c]在P[0]!c时就是0所以循环里的(state 0) ? 0 : ...判断可以简化逻辑。上面写法更清晰。4.3 记忆化搜索DP的复杂度与优化最坏情况下我们需要计算dp[sam_node][kmp_state]其中sam_node数量是O(|S|)kmp_state数量是O(|P|)。总状态数O(|S|*|P|)。如果|S|和|P|都达到10^5状态数爆炸。优化策略1状态压缩与剪枝实际上很多(sam_node, kmp_state)状态是访问不到的。因为SAM的转移边是有限的每个节点最多|Σ|条而KMP自动机的转移是确定的。我们是在做两个自动机的同步乘积。实际访问的状态数大约是O(SAM的边数 * 平均转移)这通常远小于理论最大值。使用记忆化搜索unordered_map或map存储pair可以只存储实际访问的状态。优化策略2利用SAM的树形结构Link Tree有时题目查询的条件只与匹配长度是否达到某个阈值L有关而不关心具体的KMP状态值。我们可以换一种角度对于SAM上的每个节点它代表了一组长度在[len[link]1, len[node]]之间的子串。如果我们能知道以这些子串作为后缀时能匹配P的最长前缀长度是多少那么问题就转化为统计匹配长度L的节点权值和。如何求这个“最长匹配长度”我们可以将P放在SAM上运行记录到达每个SAM节点时的匹配长度match_len。但P在SAM上运行只能得到P的每个前缀与S匹配的最长后缀我们需要的是S的每个子串对应SAM节点与P匹配的最长前缀。这并不直接对称。一个经典技巧是建立SAM后把P放在SAM上跑同时维护当前匹配长度cur_len。当P的字符c在SAM上能转移时cur_len不能时沿着link回跳直到能转移或回到根cur_len更新为跳转后节点的len。在这个过程中对于SAM的每个状态我们记录下cur_len能达到的最大值**。这个最大值可以理解为以该状态所表示子串的某个结束位置为结尾能匹配P的前缀的最大长度。但这并不是该状态所有子串都能达到的匹配长度只是某个特定结束位置能达到的。为了得到更准确的信息我们有时需要在SAM的后缀链接树上做树形DP将从某个节点得到的最长匹配信息通过link边传递给它的父节点代表更短的子串因为如果长串能匹配某个长度那么它的后缀对应父节点至少也能匹配这个长度可能更短。这个过程需要仔细设计。优化策略3离线处理与根号分治如果题目允许离线或者查询的P长度有特点可以考虑根号分治。设一个阈值B。对于短串P|P| B我们使用上述的DP或Link Tree上DP的方法复杂度可以接受。对于长串P|P| B这样的串数量不会太多因为总字符数有限制。我们可以考虑对每个长串P暴力地枚举S的所有后缀或所有SAM节点用KMP快速计算每个后缀与P的最长公共前缀长度。由于长串P数量少且|S|变化时SAM结构已维护好枚举后缀可以通过遍历SAM节点来实现每个节点代表一组结束位置相同的子串可以批量计算。4.4 空间与时间的权衡记忆化存储使用unordered_mappairint, int, long long来存储DP结果。注意哈希函数的选择或者直接使用map虽然慢一点但更稳定。清空DP缓存每次查询的P不同DP缓存必须清空。避免使用全局大数组而是每次查询动态创建缓存容器。SAM节点存储使用数组或vector存储节点用指针或下标引用。next转移可以用mapchar, int字符集大时或arrayint, 26字符集小时。5. 举一反三相关变种与扩展思考“match”这道题打开了一扇门字符串匹配问题不仅可以问“P在S中出现在哪”还可以问“S的子串与P的前后缀关系如何”。这种思想可以扩展到许多变种问题。变种1统计匹配长度恰好为k的子串数量查询条件从“至少L”变为“等于k”。在DP时我们不仅需要知道当前KMP状态是否k还需要知道是否恰好达到过状态k。这需要在DP状态中增加一维记录是否“达标”或者用两个DP数组分别记录“当前匹配长度”和“历史是否达到过k”。变种2带权值的子串计数每个子串对应SAM节点可能带有权值如出现次数、所在位置权重等。最终答案不是计数而是求满足条件的子串的权值和。这在上述DP框架中很容易修改将计数累加改为权值累加即可。变种3多个模式串的查询如果查询是给一个模式串集合{P1, P2, ... Pk}问S的子串能匹配其中至少一个模式串的前缀。这需要构建AC自动机Aho-Corasick可以看作是KMP自动机在多模式串上的扩展来代替KMP自动机。然后在后缀自动机上与AC自动机进行同步DP。状态变成了(sam_node, ac_state)原理完全相通但状态空间更大需要更精细的优化。变种4动态S与可持久化数据结构如果题目不仅支持在S末尾添加还支持在开头添加或任意位置修改那么SAM的在线构造就失效了。可能需要用到双向SAM或可持久化数据结构来维护字符串的区间信息难度会大大增加。这时“后缀字典树”可能更倾向于指可持久化线段树合并维护endpos集合或者后缀平衡树这类高级数据结构。回过头看“后缀字典树、KMP”这个标题精准地概括了解决此类问题的两大支柱一个用于高效索引文本串S的所有子串信息后缀字典树/SAM另一个用于描述模式串P的内部匹配结构KMP自动机。两者的结合通过动态规划在有限状态自动机上的穿梭实现了对海量子串匹配关系的快速统计算。掌握这个思路不仅能解决这道具体的题目更能提升你解决复杂字符串匹配与计数问题的能力。在实现时耐心处理好SAM的构建、cnt的维护、DP状态的设计与优化就能从理论走向AC。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门