大规模并行后缀数组构建
1. 引言
在处理文本 T 时,我们可以利用文本搜索技术来获取其统计信息。其中一种方法是先为文本 T 计算一个索引,以便后续回答各种查询。这个索引需要在构建时间、内存使用和存储空间方面都具有高效性。
在字符串算法领域,后缀树是一种优雅的数据结构,它能为给定输入文本提供索引和统计资源。对于长度为 n 的文本 T,其后缀树是 T 所有(唯一)后缀的压缩字典树。虽然后缀树可以在线性顺序时间和空间内构建,但 O(n) 空间要求背后隐藏的常数使得该数据结构在许多实际应用中不切实际。因此,近年来人们设计了一些算法,在空间和查询时间之间进行权衡。
另一种为给定文本提供索引的替代数据结构是后缀数组。长度为 n 的文本 T 的后缀数组是一个大小为 n 的数组,其中第 i 个条目是根据字符串字典序排序后的第 i 小后缀。后缀数组代表了文本 T 有序后缀树的叶子节点。为了在后缀数组中进行搜索,还需要两个额外的数组(或 2n - 4 个值),这些数组包含最长公共前缀信息,代表了等效后缀树中的内部(分支)节点。虽然后缀数组的构建时间为 O(n log n),但它所需的空间不到等效后缀树的一半。存储空间的减少意味着查询后缀数组也非常高效,因为所需的(外部)内存引用更少。
在并行计算中,有一个 PRAM 算法用于构建后缀树,该算法需要 O(n log n) 的工作量和 O(log n) 的时间,并且需要多项式空间(即 n^(1 + ε),其中 0 < ε < 1)。我们借鉴为并行字符串算法开发的基本工具,提出了一种实用的 PRAM 算法来构建后缀数组。后缀数组的构建需要排序技术,我们使用公告板技术来实现确定性命名方案,同时保持子字符串之间的字典序。公告板在并行字符串算法中经常使用,我们的实现展示了它在实践中的应用。然而,对于实际输入大小,公告板的多项式空间要求很快会超过计算机主内存提供的空间。在这种情况下,我们使用并行基数排序继续后缀数组的构建。
2. 预备知识和 MasPar
- 字符串相关定义 :字符串 x 是字符的有限序列 x[1::n],每个 x[i] 来自字母表 Σ。x 的长度为 n,记为 |x|。子字符串 x[i::j] 满足 1 ≤ i ≤ j ≤ n,前缀是 x[1::j](1 ≤ j ≤ n),后缀是 x[i::n](1 ≤ i ≤ n)。
-
后缀数组定义
:给定输入文本 T = x[1::n] 和有序字母表 Σ,T 的后缀集为 {s1, s2, …, sn},其中 si = x[i::n]。T 的后缀数组是一个字典或索引数据结构,由以下组件组成:
- 一个大小为 n 的整数数组 sa,范围在 1::n 之间,表示 T 按字典序排序的后缀。
- 两个额外的数组 left - lcp 和 right - lcp,大小均为 n - 2,包含范围在 0::n - 1 的整数,用于在查询过程中引导搜索。
例如,对于字符串 T = abbaabaaababbb,其 sa 数组为 sa[14] = [7, 4, 8, 5, 9, 1, 11, 14, 6, 3, 10, 13, 2, 12],其中 s7 是字典序最小的后缀,s12 是最大的后缀。对应的 lcp 数组为 lcp[13] = [2, 4, 1, 3, 2, 3, 0, 1, 3, 2, 1, 2, 2]。可以使用一个计算给定区间最小值的最优算法,从 lcp 数组计算 left - lcp 和 right - lcp。
2.1 MasPar 架构
MasPar MP - 2 采用大规模并行方式,通过合适的互连网络将数千个简单处理元素(PE)连接成特定拓扑结构来提高性能。在 MasPar MP - 2 中,16384 个 PE 连接成 128×128 的二维网格,称为 PE 数组。每个 PE 有 64K 的本地内存,总分布式内存为 1GB。此外,PE 还可以访问 512K 的共享内存。
PE 之间的通信有两种方式:
- XNet 互连提供快速本地通信,每个 PE 可以与 8 个最近的邻居(水平、垂直和对角相邻)进行寄存器到寄存器的通信。
- 全局路由器使用多级互连网络实现,用于更任意的通信。每 16 个 PE 集群有一个源路由器端口和一个目标端口,路由器通信时间是常数,与通信 PE 的位置无关。
我们使用 MasPar 并行应用语言(MPL)实现算法,该语言允许程序员对数据分布和处理器间通信进行特定控制。
3. 数据结构和技术
3.1 子字符串命名
在收集字符串 x 的统计信息时,子字符串命名技术常用于将 x 中所有相等的子字符串分组,并为每个组关联一个唯一的整数。子字符串命名和递归加倍技术在并行字典计算中广泛使用。
更正式地,对于输入文本 T 中长度为 ’ = j - i + 1 的子字符串 x[i::j](1 ≤ i < j ≤ n),确定性子字符串命名函数接受这样的子字符串作为参数,并返回一个整数值 name。我们用 ‘-name 表示长度为 ’ 的子字符串的名称,’-name(i) 表示从位置 i 开始的长度为 ’ 的子字符串的名称。长度为 n 的字符串的名称可以通过多次应用命名函数 f(name1, name2) = newname 来计算,其中 (name1, name2) 是整数元组,每个整数是长度为 ’ 的子字符串的名称,newname 是由与 name1 和 name2 关联的子字符串连接而成的长度为 2’ 的子字符串的 2’-name。
3.2 使用公告板命名
公告板 bb[1::n][1::n] 是一个二维数组数据结构,常用于 CRCW PRAM 字符串算法。它允许处理器在常数时间内更新特定并行变量的值:所有希望写入位置 bb[r, c] 的处理器都会尝试写入,根据使用的 CRCW 模型,使用写冲突解决机制确定一个获胜处理器进行写入。
对于我们的算法,命名函数通过将 bb 的行 (r) 和列 (c) 分别与 name1 和 name2 值关联来计算。对于所有与 T 中位置相关的处理器,当 name1 = r 且 name2 = c 时,获胜处理器在位置 bb[r, c] 写入 1,该位置变为活动状态。然后通过计算所有活动位置的前缀和,为每个活动位置关联一个唯一值,这个值就是变量 newname 的值。所有尝试写入 bb[r, c] 的处理器现在都可以从该位置读取相同的 newname 值。
公告板实现为一组分布在 PE 数组内存中的二维数组。对于大小为 n×n 的公告板和 p 个处理元素,简单的分布方式是将处理器 pi(i = 0::p - 1)分配到公告板位置 [i div n + 1, i mod n + 1]。当 n² > p 时,该数据结构被虚拟化。限制数组边界为 2 的幂,我们发现使用 1GB 的 PE 内存可以实现大小为 4096×4096 的公告板,以及相关的最长公共前缀数据结构。每个 PE 关联一个由 32×32 = 1024 个 bb 位置组成的子块。
初始时,输入字母表 Σ 分布到所有 PE,所需的第一个公告板大小为 |Σ| + 1。我们移除 Σ 中不在输入文本 T 中的所有字符,并为剩余字符分配名称,因为它们代表文本中长度为 1 的所有子字符串。输入 T 从外部内存以连续块的形式读取,每个块包含 p 个连续的文本字符,每个 PE 分配一个文本位置。由于公告板非常小,最初可以将文本字符分层堆叠到 PE 上。因此,每个排序阶段的所有公告板更新都需要 O(n/p) 次外部内存读取(和写入)。
3.3 最长公共前缀计算
设 lcp[i; j]k 表示输入文本 T 中从位置 i 和 j 开始的长度为 k 的子字符串之间的最长公共前缀的长度。对于 1 ≤ h ≤ log |T|,有以下公式:
[
lcp[i; j]^h =
\begin{cases}
2^{h - 1} + lcp[i + 2^{h - 1}; j + 2^{h - 1}]^{h - 1}, & \text{if } lcp[i; j]^{h - 1} = 2^{h - 1} \
lcp[i; j]^{h - 1}, & \text{otherwise}
\end{cases}
]
在命名的每个阶段,使用以下数组更新每个后缀与其右邻居(即当前下一个最大的后缀)之间的最长公共前缀值:
1. 一个 q×q 的数组 new lcp 用于存储当前阶段计算的每对新名称之间的最长公共前缀值(q 是当前阶段分配的最大名称)。
2. 一个 w×w 的数组 old lcp 用于存储上一阶段计算的每对新名称之间的最长公共前缀值(w 是上一阶段分配的最大名称)。
这些数组分布在 PE 内存中,我们利用架构在 PE 数组的行和列内高效广播值的能力。
4. 公告板和 LCP 表:性能结果
算法 LCP 用于维护 lcp 值,该算法以数组 old lcp 为输入,输出数组 new lcp。“活动”处理器是与公告板上标记为 1 的位置相关联的处理器。
以下是算法 LCP 的步骤:
1. 初始化 new lcp 的第一行 r0 和第一列 c0:每个活动处理器将其 name1 值写入行 r0 中处理器 pδ 的 α1 和列 c0 中处理器 pδ 的 α2,然后将其 name2 值写入行 r0 中处理器 pδ 的 β1 和列 c0 中处理器 pδ 的 β2。
2. 初始化 new lcp 的所有位置:行 r0 中的处理器 pδ 将 α1 和 β1 广播到同一列的所有处理器,列 c0 中的每个处理器 pδ 将 α2 和 β2 广播到同一行的所有处理器。
3. new lcp 中的每个位置现在接收 4 个值,分为两对:γ = (α1, α2) 和 η = (β1, β2)。
4. new lcp 表中的所有位置考虑其 γ 和 η 对:如果 α1 ≠ α2,则根据公式和引理 1,最长公共前缀值不变;否则,最长公共前缀值等于 |α1| + old lcp[β1, β2]。
数据类型 | n | 迭代次数 | 最大实现公告板大小 | 下一个所需公告板大小 | 时间(秒) |
---|---|---|---|---|---|
DNA | 8k | 11 | 260 | 6818 | 88.07 |
DNA | 16k | 3 | 260 | 12002 | 3.41 |
DNA | 32k | 3 | 260 | 21946 | 4.62 |
DNA | 64k | 3 | 260 | 34767 | 7.83 |
DNA | 128k | 3 | 260 | 47664 | 14.59 |
英文文本 | 8k | 4 | 4036 | 4138 | 31.52 |
英文文本 | 16k | 2 | 450 | 7222 | 77.34 |
英文文本 | 32k | 2 | 487 | 12389 | 3.09 |
英文文本 | 64k | 2 | 507 | 15150 | 4.99 |
英文文本 | 128k | 2 | 539 | 22611 | 9.09 |
表 1 显示了随着英文文本和 DNA 长度的增加,公告板大小增长的速度。结果表明,DNA 和英文文本输入的公告板大小增长非常迅速,对于大于 16384 的输入大小,DNA 和英文文本分别只能进行 3 次和 2 次命名迭代,后缀根据长度为 8 的统一前缀进行排序。实际使用的最大公告板大小分别为 DNA 的 2602 和英文文本的 5392。
下面是算法 LCP 的流程图:
graph TD;
A[开始] --> B[初始化 new lcp 的 r0 和 c0];
B --> C[广播 α1、β1、α2、β2];
C --> D[接收 4 个值并分组];
D --> E{α1 ≠ α2?};
E -- 是 --> F[最长公共前缀值不变];
E -- 否 --> G[最长公共前缀值 = |α1| + old lcp[β1, β2]];
F --> H[结束];
G --> H[结束];
大规模并行后缀数组构建
5. 基于归并排序的基数排序
在第 4 部分中我们发现,由于内存限制,对于大输入规模,公告板无法用于构建后缀数组 sa。当达到内存限制(例如在重命名的第 j 次迭代时),输入文本 T 可以用一个范围在 1::q 的无序整数数组表示,其中 q 是命名阶段最后一次迭代计算出的最大新名称。通过对这些整数(每个整数都标记了它所代表的后缀的索引)进行排序,可以将后缀索引重新排列成当前的字典序。在我们的实现中,使用并行归并排序来完成这个任务。
这样,所有具有至少长度为 ’ 的公共前缀的后缀都会被放置在部分完成的后缀数组 part sa 的连续位置中。此外,每个后缀现在可以用一个新的、更短的字符串进行编码:设 si 是 T 的一个后缀,我们将 si 的 q - 字符串定义为 (k1, k2, …, kt),其中:
1. 对于 1 ≤ j ≤ t,kj 是从位置 i + ‘(j - 1) 开始的长度为 ’ 的子字符串的名称。
2. 对于 1 ≤ j ≤ t,0 ≤ kj ≤ q,用值 0 表示出现在输入中位置 n 之后的 k - 分量。
在这个阶段,使用基数排序(以 q + 1 为基数)完成数组 sa 的构建,并且基数排序的每一轮都是一次并行归并排序。
(注:这里的图片链接需替换为实际的图片链接,原文档中未给出具体图片,可根据实际情况补充)
5.1 并行归并排序
传统 PRAM 归并排序算法的底层计算结构是一棵完全二叉树。我们的归并过程是对相关算法的一种改进。为了避免某些并行排名过程带来的通信开销,我们使用简单的二分查找来实现排名,这将归并的(理论)时间复杂度增加到 O(log n log log n)。
我们使用一个 2×n 的数组 sort 来实现二叉树计算。归并排序过程使用 n 个处理器,需要 O(log² n log log n) 的时间。对于 p < n 个处理器,使用类似的切割和堆叠数据映射来虚拟化这个数据结构,确保没有两个连续的输入元素被分配到同一个处理器。
以下是并行归并排序的主要步骤:
1.
数据准备
:将输入的无序整数数组(标记了后缀索引)分配到各个处理器。
2.
并行归并
:
- 每个处理器对自己负责的部分数据进行局部排序。
- 相邻处理器之间进行归并操作,逐步扩大有序子数组的规模。
- 在归并过程中,使用二分查找进行排名。
3.
重复归并
:不断重复步骤 2,直到整个数组有序。
下面是并行归并排序的流程图:
graph TD;
A[开始] --> B[数据分配到处理器];
B --> C[局部排序];
C --> D[相邻处理器归并];
D --> E{是否整个数组有序?};
E -- 否 --> C;
E -- 是 --> F[结束];
总结
本文介绍了一种在 MasPar MP - 2 架构上构建后缀数组的实用方法。通过结合公告板技术、子字符串命名、最长公共前缀计算和并行排序算法,我们能够高效地处理大规模文本数据。
虽然公告板技术在处理小输入规模时非常有效,但随着输入规模的增加,其空间需求增长迅速,因此需要结合基数排序来完成后缀数组的构建。并行归并排序为大输入规模的排序提供了一种可行的解决方案。
总体而言,这种方法在空间和时间效率之间取得了较好的平衡,能够满足实际应用中对大规模文本数据处理的需求。
以下是本文涉及的主要技术和数据结构的总结表格:
| 技术/数据结构 | 描述 | 应用场景 |
| ---- | ---- | ---- |
| 子字符串命名 | 将相等子字符串分组并关联唯一整数 | 并行字典计算、后缀数组构建 |
| 公告板 | 二维数组,用于更新并行变量值 | 子字符串命名、确定新名称 |
| 最长公共前缀计算 | 通过数组更新后缀间的最长公共前缀值 | 后缀数组查询引导 |
| 并行归并排序 | 基于完全二叉树结构的排序算法 | 大输入规模的后缀排序 |
| 基数排序 | 以 q + 1 为基数,每轮为并行归并排序 | 完成后缀数组构建 |