好的,作为一位无所不知的大神,我为你随机选择了一个尚未讲过的经典并行算法。让我们开始。
并行与分布式系统中的并行后缀数组构建:倍增算法(Doubling Algorithm)并行化
题目描述
后缀数组是字符串处理中的一个核心数据结构。对于一个长度为 n 的字符串 S,它的后缀数组 SA 是一个整数数组,其中按字典序升序存储了字符串 S 所有后缀的起始下标。
问题:给定一个长字符串 S(例如,一个大型文本或基因组序列),如何高效地并行构建其后缀数组 SA?
目标:设计一个并行算法,利用多处理器(如共享内存的多个核心)加速后缀数组的构建过程,超越传统的串行算法性能。
解题过程循序渐进讲解
第一步:理解串行倍增算法(基础)
在讲解并行化之前,我们必须先理解最经典的串行构建算法——倍增算法。它的核心思想是“从粗到精”地比较后缀。
-
初始化:首先,我们只根据每个后缀的第一个字符对其进行排序。因为字符集通常有限(如ASCII),我们可以使用计数排序(基数排序的一种)在线性时间内完成。此时,我们为每个后缀分配一个初始的“排名”(
rank)。例如,对于字符串"banana",排序第一个字符后,相同字符的后缀(如以a开头的)排名相同。- 后缀:
banana,anana,nana,ana,na,a - 按首字母排序后:
a,anana,ana,banana,nana,na
- 后缀:
-
倍增(Doubling):接下来,我们进行多轮迭代(记为
k从1开始倍增)。- 在第
k轮,我们不再只比较单个字符,而是比较每个后缀的前2^k个字符。 - 关键技巧是:一个后缀
S[i:]的前2^k个字符的“关键字”可以由两个已知的部分构成:(rank[i], rank[i + 2^{k-1}])。rank[i]是后缀S[i:]在上轮(k-1轮)中,前2^{k-1}个字符的排名。rank[i + 2^{k-1}]是后缀S[i+2^{k-1}:]在上轮(k-1轮)中,前2^{k-1}个字符的排名。- 如果
i + 2^{k-1}超出字符串长度,则将其排名定义为最低(例如 -1)。
- 这样,每个后缀
i在本轮就有了一个二元组关键字(rank[i], rank[i+2^{k-1}])。 - 我们对所有后缀的这两个关键字进行排序(先按第一关键字,再按第二关键字)。这次排序后得到的新顺序,就是根据前
2^k个字符排序的结果。我们根据这个新顺序更新每个后缀i的新排名new_rank[i]。
- 在第
-
终止:当所有后缀的排名都变得唯一时(即没有并列排名),算法终止。此时,后缀数组
SA就是根据最终排名排列的后缀起始下标列表。由于每轮比较长度2^k倍增,最多需要log n轮。
串行算法的瓶颈:每一轮都需要对 n 个二元组进行排序。虽然可以使用线性时间的基数排序(因为关键字是整数排名),但在串行环境下,这 O(n log n) 的工作量(n 次操作,进行 log n 轮)对于超长字符串(如数十GB的基因组)仍然很慢。
第二步:识别并行化机会
倍增算法的结构天然适合并行化:
- 数据并行性:在每一轮中,为每个后缀
i计算其二元组关键字(rank[i], rank[i+2^{k-1}])的操作是完全独立的。我们可以将n个后缀分配给p个处理器,每个处理器负责计算分配给它的那些后缀的二元组。 - 并行排序:对
n个二元组进行排序是计算密集型的。我们可以使用高效的并行排序算法,例如并行样本排序或并行基数排序。 - 排名更新:根据排序后的新顺序计算新的
new_rank[i]。这个操作也是数据并行的——检查相邻排名的二元组是否相等,然后分配新的排名。
核心挑战:如何高效地并行对大规模键值对(二元组)进行排序,并且保证每轮迭代之间的正确同步。
第三步:设计并行倍增算法框架
假设我们在一个具有 p 个处理器的共享内存机器上。以下是并行算法的步骤框架:
-
初始化(并行):
- 将字符串
S划分成p个连续的块,每个处理器负责一个块。 - 每个处理器读取其负责的字符,并初始化一个局部数组,存储每个后缀起始位置(在其负责的全局范围内的偏移量)。
- 并行地对所有后缀按首字符进行排序。这可以通过并行计数排序实现:
- 每个处理器计算其局部块内每个字符的频率。
- 通过一个并行前缀和操作,计算每个字符的全局起始位置。
- 每个处理器根据全局起始位置,将其负责的后缀放到正确的位置,完成排序。
- 根据排序结果,计算初始
rank数组。相同的字符获得相同的初始排名。
- 将字符串
-
倍增迭代(主循环):
- 对于
k = 1, 2, 4, ...直到所有排名唯一:
a. 并行构造关键字(Map阶段):
* 每个处理器为其负责的每个后缀索引i生成二元组(rank[i], rank[i+2^{k-1}])。这里rank数组是上一轮的结果,被所有处理器共享(只读)。对于越界的i+2^{k-1},使用一个特殊值(如 -1)作为第二关键字。
b. 并行排序(全局通信与计算):
* 现在我们有n个二元组需要排序。我们使用一个并行样本排序算法:
i. 局部排序:每个处理器先对自己负责的那部分二元组进行局部快速排序。
ii. 选择采样:每个处理器从其排好序的局部数据中,均匀地选取p-1个“样本”(即分割点)。
iii.全局采样与确定桶边界:收集所有p*(p-1)个样本到根处理器,对其进行排序,并选出p-1个全局分割点,将整个数据范围划分为p个“桶”。
iv. 数据交换:每个处理器根据全局分割点,将自己局部数据划分到对应的桶中。然后,所有处理器进行全局数据交换(All-to-All通信),使得第j个处理器最终获得所有属于第j个桶的数据。
v. 局部归并:每个处理器收到属于自己桶的所有数据后,对其进行归并排序。
c. 并行计算新排名(Reduce阶段):
* 经过步骤b,所有后缀的二元组已经在全局范围内按顺序分布在p个处理器上。
* 我们需要为排序后的二元组分配新的、紧凑的排名。这可以通过一个并行前缀和操作来完成:
* 每个处理器遍历其最终桶内的数据,为每个元素分配一个临时的局部排名。规则是:如果当前元素的二元组与前一个元素(全局意义上)完全相同,则排名相同;否则,排名递增。
* 为了得到全局统一的排名,每个处理器需要知道其第一个元素的起始排名。这可以通过计算每个处理器“排名是否改变”的标记,然后执行一次并行前缀和,得到每个处理器局部的偏移量,最终得到全局排名。
* 更新全局rank数组。每个处理器负责将其计算出的新排名写回到全局rank数组中对应的位置。
- 对于
-
生成后缀数组:
- 当算法终止(所有排名唯一)时,排序后的后缀索引列表(即步骤2b中最终排好序的索引列表)就是后缀数组
SA。这个列表已经分布在各个处理器上(按桶分布)。
- 当算法终止(所有排名唯一)时,排序后的后缀索引列表(即步骤2b中最终排好序的索引列表)就是后缀数组
第四步:算法复杂度与优势分析
- 时间复杂度:
- 串行倍增算法:
O(n log n)。 - 并行倍增算法:理想情况下,主要的
log n轮迭代中,最耗时的并行排序步骤可以被加速。- 局部排序:
O((n/p) log (n/p))。 - 数据交换(All-to-All):
O(n)数据移动,但通信开销与p和网络拓扑有关。 - 总体期望加速比接近
p,尤其是在n远大于p时。
- 局部排序:
- 串行倍增算法:
- 空间复杂度:
O(n),需要存储字符串、rank数组、临时二元组数组等。 - 优势:
- 可扩展性:算法具有良好的数据并行性,计算负载可以均匀分布。
- 使用高效原语:构建在并行前缀和、并行排序等基础且高效的并行原语之上,容易在现代并行库(如MPI、OpenMP)上实现。
- 广泛适用:是许多实际系统(如并行后缀数组构造库)中采用的核心方法。
总结
并行后缀数组构建的倍增算法,其核心思想是将串行算法中每一轮的关键字计算和排序两个主要步骤并行化。通过数据划分将后缀分配给不同处理器,利用并行样本排序高效处理全局排序问题,并借助并行前缀和来协调全局排名的计算。这个算法清晰地展示了如何将一个具有 log n 轮迭代的串行算法,通过精心设计的并行计算和通信模式,转化为一个高效的可扩展并行算法,从而能够处理超大规模的字符串数据。