并行与分布式系统中的并行后缀自动机构建:基于后缀数组的构建算法
字数 4191 2025-12-19 13:29:37
并行与分布式系统中的并行后缀自动机构建:基于后缀数组的构建算法
我们来讲解一个在文本处理和大规模字符串分析中非常核心的算法——并行后缀自动机的构建。后缀自动机是一个功能强大的数据结构,能高效解决许多字符串问题,但其标准串行构造算法较为复杂。我们将重点介绍一种基于后缀数组的高效并行构建方法。这个方法将复杂的状态合并过程转化为更易于并行的步骤。
1. 算法背景与问题描述
- 后缀自动机:一个能接收给定字符串S的所有后缀的最小确定有限状态自动机。它在O(n)的空间内,隐含了S的所有子串信息,支持在线性时间内完成子串存在性查询、不同子串计数、最长公共子串查找等。
- 标准串行算法:Ukkonen算法是最著名的在线线性时间构建算法。它通过增量添加字符并维护
link指针(后缀链接)来构建。然而,其内在的依赖关系使得其直接并行化很困难。 - 并行化挑战:构建过程中,新增状态和
link的确定严重依赖于之前的状态。直接的、细粒度的并行会导致数据竞争和复杂的同步。 - 我们的解决方案:我们绕开Ukkonen的增量构建,采用“先构建后缀数组,后从后缀数组构造后缀自动机”的策略。这个策略的核心思想是,后缀数组(SA)和最长公共前缀数组(LCP)包含了构建后缀自动机所需的所有必要信息,而SA和LCP本身是高度可并行计算的。这让我们可以将问题分解为两个主要阶段,第一个阶段(SA+LCP构建)是并行友好的,第二个阶段(从SA+LCP构建SAM)虽然主要是串行处理,但也可以通过特定技术进行一定程度的并行。
给定:一个长度为n的字符串S。
目标:并行地构建字符串S的后缀自动机(SAM),一个包含状态(节点)、转移边和后缀链接(link指针)的数据结构。
2. 核心解题思路
我们不直接从字符流构建SAM,而是采取“两步走”的策略:
- 并行计算后缀数组(SA)和最长公共前缀数组(LCP)。我们可以利用高度并行的后缀数组构造算法(如并行DC3、并行SA-IS)和并行LCP计算算法。这一步是计算密集型的,并且并行性良好。
- 从SA和LCP并行地构建SAM。这一步是算法的关键创新点。其核心观察是:后缀自动机的状态对应于后缀数组中,具有特定LCP值变化模式的一组后缀集合。更具体地说,SAM的状态(代表一个或多个
endpos等价类)可以与后缀树中的节点一一对应。而后缀树可以通过SA和LCP在O(n)时间内唯一确定。因此,我们可以:- 利用SA和LCP并行地构建一棵“虚拟的”后缀树(不显式构建所有边,而是获得其节点/状态信息)。
- 从这棵“树”中推导出SAM的状态、转移和后缀链接。
3. 详细解题步骤
我们逐步拆解这个过程。
步骤1: 并行生成后缀数组(SA)和LCP数组
- 后缀数组SA:这是一个整数数组,存储了字符串S所有后缀的起始下标,并按照这些后缀的字典序升序排列。
- 并行算法:例如,并行DC3(Difference Cover modulo 3)算法。它的基本思想是递归地将后缀分为两组,并行地对它们进行排序(通过基数排序),然后并行地合并结果。由于其递归和排序的步骤是数据并行的,可以很好地映射到多核或分布式环境。
- LCP数组:
LCP[i]表示排在第i位的后缀SA[i]和排在第i-1位的后缀SA[i-1]的最长公共前缀的长度(LCP[0]通常定义为0)。- 并行计算:在得到SA后,可以利用并行Kasai算法或其变体。基本思路是,利用一个重要的性质:如果我们已知后缀
S[k...]和其在SA中相邻后缀的LCP,那么后缀S[k+1...]的LCP至少是其前一个值减一。这个计算可以分块进行,每个处理块独立计算初始值,然后再进行边界修正,从而实现并行。
- 并行计算:在得到SA后,可以利用并行Kasai算法或其变体。基本思路是,利用一个重要的性质:如果我们已知后缀
这一步结束,我们得到了两个长度为n的数组:SA 和 LCP。这是高度并行化的成果。
步骤2: 从SA和LCP构建后缀自动机
这一步是关键,目标是生成:
states: 状态列表,每个状态有一个唯一的id。next[state][char]: 转移函数,从状态state通过字符char到达的下一个状态。link[state]: 后缀链接,指向代表当前状态最长后缀的另一个状态。
构建过程可以看作隐式地构建并遍历一棵后缀树:
a. 构建“状态栈”与“虚拟树边”)
- 初始化:创建一个虚拟的根状态(
state_id = 0,len = 0,link = -1)。我们维护一个栈,栈中元素代表从根节点到当前“正在处理”的树节点路径上的状态。初始时,栈中只有根状态(0)。 - 遍历SA:我们按顺序(
i从0到n-1)处理后缀数组中的每个后缀S[SA[i]...]。设当前后缀的长度为curr_len = n - SA[i]。 - 计算
lcp_prev:lcp_prev = LCP[i](对于i=0,lcp_prev=0)。这个值代表了当前后缀与上一个后缀在树中的最近公共祖先(LCA)的深度(即从根节点到LCA的路径长度/字符串长度)。 - 栈调整(对应“沿树边上溯”):
- 当栈顶状态所代表的字符串长度(
stack.top().len)大于lcp_prev时,说明当前后缀与上一个后缀的公共前缀结束于栈中更深层的某个节点之上。我们需要“弹出”栈顶,直到栈顶状态的长度小于等于lcp_prev。这模拟了在树中从上一个叶节点回溯到LCA的过程。 - 状态创建/复用:
- 如果栈顶状态的长度等于
lcp_prev,那么这个状态正好就是LCA,我们不需要创建新状态。 - 否则(栈顶状态的长度小于
lcp_prev),我们需要在stack.top()和它原来的子节点之间插入一个新的状态,这个新状态代表长度为lcp_prev的字符串(即LCA)。创建这个新状态,并设置其后缀链接指向stack.top().link的适当位置(稍后详述)。这个新状态入栈,并成为新的“当前父节点”。
- 如果栈顶状态的长度等于
- 当栈顶状态所代表的字符串长度(
- 创建新叶状态:现在,栈顶状态(可能是原有的LCA,也可能是新创建的LCA)是当前后缀路径上深度为
lcp_prev的节点。我们需要为curr_len(>lcp_prev)的剩余部分创建新的状态(一个或多个,代表从LCA到新叶节点的路径)。- 从
l = lcp_prev + 1到curr_len,我们创建一系列新的状态。对于每个长度l:- 创建一个新状态
new_state,其len = l。 - 从当前栈顶状态(父节点)添加一条转移边:
next[stack.top().id][S[SA[i] + l - 1]] = new_state.id。注意这里使用的字符是S[SA[i] + l - 1],即构造到新状态所需的下一个字符。 - 将
new_state入栈,作为新的父节点以便处理下一个l。
- 创建一个新状态
- 最后一个创建的状态(
len = curr_len)代表了这个完整的后缀,它将是SAM中的一个“终止状态”(可以标记以便后续查询)。
- 从
- 设置后缀链接:在创建新状态链时,如何设置后缀链接
link?- 当我们在第4步因
lcp_prev而拆分边并创建新状态mid_state时,这个mid_state的后缀链接应该指向上一步弹出栈时,从栈顶弹出的那个状态(即代表更长字符串,是mid_state子节点的那个状态)。因为mid_state代表lcp_prev,它的最长真后缀(长度lcp_prev-1)恰好由这个弹出的状态代表(如果弹出状态的len恰好是lcp_prev-1)或其某个祖先代表。实际操作中,我们记录下被弹出的状态child_state,然后设置link[mid_state] = child_state.id。 - 对于第5步创建的叶状态链,除了第一个状态(
len = lcp_prev + 1)的后缀链接由前述规则设定,后续状态的后缀链接可以简单地指向前一个状态,因为它们代表连续的子串。
- 当我们在第4步因
b. 算法的并行性分析
步骤2的主要循环(遍历SA)本质上是串行的,因为后缀是按字典序处理的,LCP值决定了树结构的回溯深度。然而,我们可以从两个层面引入并行:
- 内层并行:在步骤5中,为一个后缀创建状态链(从
lcp_prev+1到curr_len)的过程,各个状态的转移边设置是独立的,可以并行填充next表的不同字符位置(尽管状态创建本身是顺序的,因为len值递增)。 - 外层并行(近似):我们可以将字符串S划分为若干块,并行地为每一块构建一个“局部后缀自动机”。但一个完整字符串的SAM状态可能跨块,所以最后需要一个合并阶段。合并多个SAM是复杂的,但可以通过在块边界处共享重叠部分的状态信息,并进行一致性处理来实现。这通常比直接串行构建要快,尤其对于超长字符串。
4. 总结与要点回顾
- 核心创新:将难以并行的增量式SAM构建,转化为“并行计算SA/LCP” + “基于SA/LCP(半)串行构建SAM”的两阶段问题。第一阶段的并行技术非常成熟。
- 关键数据结构:后缀数组(SA)提供了所有后缀的全局序,最长公共前缀数组(LCP)提供了相邻后缀的共享信息,二者结合足以唯一确定后缀树结构,从而推导出SAM。
- 构建过程的本质:算法遍历SA,并用一个栈来模拟对后缀树的深度优先遍历(DFS)。LCP值指导了栈的回溯(对应在树中向上走),而新的后缀引导了新分支的创建(对应在树中向下走)。
- 并行潜力:
- SA/LCP计算:完全可并行(如并行DC3, 并行Kasai)。
- SAM构建主循环:主要是串行,但内部转移填充可并行,且可以通过“分块构建-合并”的模式进行粗粒度任务并行。
这种基于后缀数组的并行构建方法,虽然最坏情况时间复杂度可能略高于最优的串行Ukkonen算法(取决于SA构建的复杂度),但它在实践中,尤其是面对海量文本数据时,通过充分利用多核或集群的并行计算能力,能获得显著的加速比,是处理大规模字符串问题的一种有效工程方案。