并行 O(√n) 开销 LSD 基数排序
摘要
本文介绍 Radsort,一种具有 O(√n) 开销的并行 LSD 基数排序算法,该算法稳定、易于实现,并且对于大数组优于传统方法。
<p><a href="https://lobste.rs/s/9ac8ta/parallel_o_sqrt_n_overhead_lsd_radix_sort">评论</a></p>
查看缓存全文
缓存时间: 2026/08/30 22:05
# 并行 O(√n) 开销 LSD 基数排序 来源:https://arxiv.org/html/2607.05302 柏林数学应用科学研究所 (Zuse Institute Berlin),德国 [email protected] NHR-联盟研究生项目,柏林数学应用科学研究所,德国 [email protected] https://orcid.org/0000-0003-4548-788X \{CCSXML\}¡ccs2012¿ ¡concept¿ ¡concept_id¿10003752.10003809.10010031.10010033¡/concept_id¿ ¡concept_desc¿计算理论 排序与搜索¡/concept_desc¿ ¡concept_significance¿500¡/concept_significance¿ ¡/concept¿ ¡/ccs2012¿ 本文作者之一曾在卡尔斯鲁厄理工学院 (KIT) 的 Sanders 课题组进行访问研究。作者在此感谢 Marvin Williams 提供的宝贵意见。 ## 具有 O(\(\sqrt{n}\)) 开销的并行 LSD 基数排序 ###### 摘要 我们介绍了 Radsort,一种 LSD 基数排序的变体,它以 O(\(\sqrt{n}\)) 的额外空间对数据进行排序。Radsort 是稳定的,实现简单且易于并行化。对于大小超过约 2 MiB 的数组,其性能优于传统的非原地 LSD 基数排序。 ###### 分类主题 计算理论 排序与搜索 ###### 关键词 缓存局部性、基数排序、排序 ††runningauthor: R. Clausecker 和 F. Schintke ††copyright: Robert Clausecker 和 Florian Schintke ††supplement: https://github.com/clausecker/radsort ††editors: John Q. Open 和 Joan R. Access ††event-title: 第42届非常重要的主题会议 (CVIT 2016) ††event-shorttitle: CVIT 2016 ††event-acronym: CVIT ††year: 2016 ††event-date: 2016年12月24日至27日 ††event-location: Little Whinging,英国 ††series-volume: 42 ††articleno: 23 ## 1 引言 LSD(最低有效位)基数排序算法用于对一个长度为 \(n\) 的数组 \(A\) 进行排序,其中该数组包含 \(n\) 个键值的 \(n_t\) 元组 \((\textit{key}(A[i],0), \textit{key}(A[i],1), \ldots, \textit{key}(A[i],n_t-1))\),这些键值来自大小为 \(\sigma\) 的字母表 \(\Sigma\)。它通过依次根据 \(\textit{key}(A[i],n_t-1)\)、然后 \(\textit{key}(A[i],n_t-2)\),依此类推直到 \(\textit{key}(A[i],0)\) 进行排序,从而将数组排列成字典序。使用稳定排序时,相同的键会根据其后缀排序,从而得到字典序。传统的 LSD 基数排序首先对每个 \(c \in \Sigma\) 在 \(\textit{key}(A[i],t)\) 中出现的次数进行直方图统计 \(H\)。然后对 \(H\) 进行前缀和运算得到桶的起始位置 \(S\),并据此将数组 \(A\) 排序到一个新数组 \(A'\) 中(算法1 (https://arxiv.org/html/2607.05302#alg1))。 算法1:非原地 LSD 基数排序一轮 1. 过程 `radixSortStep(A', A, t)` ▷ 按键 t 对数组 A 排序,结果存入 A' 2. \(H \leftarrow 0 \ldots 0\) 3. 对于 \(i \leftarrow 0 \ldots n-1\) 执行 ▷ 统计 \(\textit{key}(A[i],t)\) 的直方图 4. \(H[\textit{key}(A[i],t)] \leftarrow H[\textit{key}(A[i],t)] + 1\) 5. 结束循环 6. \(S[0] \leftarrow 0\) 7. 对于 \(i \leftarrow 1 \ldots \sigma-1\) 执行 8. \(S[i] \leftarrow S[i-1] + H[i-1]\) ▷ 对 H 进行前缀和 9. 结束循环 10. 对于 \(i \leftarrow 0 \ldots n-1\) 执行 11. \(c \leftarrow \textit{key}(A[i],t)\) ▷ 确定桶 12. \(A'[S[c]] \leftarrow A[i]\) ▷ 将 A[i] 排序到 A' 中 13. \(S[c] \leftarrow S[c] + 1\) ▷ 桶 c 的位置前进,为新元素腾位 14. 结束循环 15. 结束过程 当输入数组 \(A\) 被消耗完时,输出被产生在一个单独的数组 \(A'\) 中。虽然在算法的任何时刻,两个数组中总共只占用 \(n\) 个元素,但仍然需要提供 \(2n\) 个元素的存储空间,即除了输入外还需要 \(n\) 个元素的额外开销。我们希望用一种新颖的 LSD 基数排序算法来解决这个缺点,该算法仅需 O(\(\sqrt{n}\)) 的额外空间就能完成工作,且性能相似。我们将此算法命名为 *Radsort*,因其具有“**根本性**开销”。 算法2:Radsort 算法 1. 过程 `radSort(A, n, n_t)` 2. `setup(A, n)` ▷ 参见算法4 (https://arxiv.org/html/2607.05302#alg4) 3. 对于 \(t \leftarrow n_t-1 \ldots 0\) 执行 4. `sortPhase(t)` ▷ 参见算法5 (https://arxiv.org/html/2607.05302#alg5) 5. `fixupPhase` ▷ 参见算法6 (https://arxiv.org/html/2607.05302#alg6) 6. 结束循环 7. `Finalize` ▷ 参见算法7 (https://arxiv.org/html/2607.05302#alg7) 8. 结束过程 ## 2 Radsort Radsort(算法2 (https://arxiv.org/html/2607.05302#alg2))将输入数组 \(A\) 视为一系列大小为 \(b\) 的*块*(关于 \(b\) 的选择见第3节 (https://arxiv.org/html/2607.05302#S3))。当一块输入被消耗后,其存储空间会被重复用于后续产生的输出,从而将存储开销减少到固定数量的*暂存块*以及一些簿记信息。 每一轮排序包含两个*阶段*。在*排序阶段*,我们首先为 \(\sigma\) 个我们用于排序的*桶*中的每一个分配一个输出块。一旦某个块被填满,就从先前消耗的输入块中取出一个新块,并在处理时覆盖输入。排序阶段的结果是 \(\sigma\) 个随机交织的输出块序列,每个序列构成一个输出桶。在每个*排序阶段*之后的*修正阶段*,这些序列被解交织,得到按桶顺序排列的数据块。解交织并不移动数据块——相反,一个置换 \(\pi\) 追踪了处理数据块的顺序,使得数据能按序出现。 与传统的 LSD 基数排序类似,这两个阶段会依次针对每个键位置重复进行。在所有 \(n_t\) 轮排序之后,一个*最终化步骤*会重新排列数据块,在数组 \(A\) 中给出排序结果。虽然 Radsort 分配空间的方式比传统的 LSD 基数排序更复杂,但它遵循相同的原则,在更低的内存开销和更好的缓存局部性下,提供了相同的稳定性保证。 ### 2.1 数据结构 除了数组 \(A\) 的 \(\lfloor n/b \rfloor\) 个块之外,还需要存储在临时数组 \(T\) 中的 \(2\sigma\) 个*暂存块*:\(\sigma\) 个*领先*块用于覆盖当前轮次各桶的初始输出块,另外 \(\sigma\) 个块用于覆盖上一轮排序的部分块。该算法将 \(T\) 和 \(A\) 的连接视为一个由 \(n_\pi\) 个块组成的长数组,每个块包含 \(b\) 个元素,通过 `blockat` 函数进行索引。前 \(2\sigma\) 个索引引用 \(T\) 中的块,其余索引引用叠加在 \(A\) 上的块。`blockat(i)` 函数返回索引 \(i\) 处块的起始地址。 \[ \textit{blockat}(i)= \begin{cases} \text{address of } T[ib] & \text{if } 0 \leq i < 2\sigma \\ \text{address of } A[(i-2\sigma)b] & \text{if } 2\sigma \leq i < n_\pi \end{cases} \quad (1) \] 然后,数组 \(A\) 末尾的 \(n \bmod b\) 个元素在算法开始时被复制到一个暂存块中。在算法结束之前,它们不会被使用,直到 \(T\) 的内容被复制回 \(A\)。数组 \(\pi\) 描述了这些 \(n_\pi\) 个块的一个置换,使得 \(\pi[i]\) 保存*逻辑索引* \(i\) 处的块索引。图1 (https://arxiv.org/html/2607.05302#S2.F1) 展示了初始化时对 \(n_t=4\) 个字符的字符串的此数据结构示例。 每个块要么是*已分配*的,要么是*未分配*的。已分配的块要么是*满的*,意味着其所有元素都持有数据;要么是*部分的*,意味着其部分元素持有数据。未分配的块不持有任何数据,可以重用。在最初的 \(\sigma\) 个逻辑索引处,存在一个由 \(\sigma\) 个未分配块组成的*领先*区域。它们后面是逻辑索引 \(\sigma \leq i < f\) 处的已分配块,最后是逻辑索引 \(f \leq i < n_\pi\) 处的未分配块。 数组 \(P\) 追踪最多 \(\sigma\) 个部分块。它按逻辑索引顺序保存部分块 \((i, l)\),其中 \(i\) 是逻辑索引,\(0 \leq l < b\) 是块长度。对于每个这样的部分块,前 \(l\) 个元素被使用,其余 \(b-l\) 个元素未被使用。为简化实现,逻辑索引 \(f-1\) 处的块始终是一个部分块。 算法3:查找下一个块的大小 1. 函数 `sizeOfNextBlock(i, iP)` 2. \((i_{i_P}, l_{i_P}) \leftarrow P[i_P]\) 3. 如果 \(\pi[i] = i_{i_P}\) 则 ▷ \(\pi[i]\) 是否是一个部分块? 4. \(i_P \leftarrow i_P + 1\) 5. 返回 \(l_{i_P}\) 6. 否则 7. 返回 \(b\) 8. 结束判断 9. 函数结束 按照 \(\pi\) 给定的顺序连接,满块和部分块的内容持有待排序的元素,其顺序由到目前为止执行的排序轮次所诱导。我们可以按逻辑顺序遍历已分配的块,同时通过遍历 \(P\) 来发现每个块的长度。这个想法被封装在函数 `sizeOfNextBlock(i, iP)` 中,它给出逻辑索引 \(i\) 处块的长度,假设下一个部分块由 \(P[i_P]\) 追踪。如果该块是部分的,则更新 \(i_P\) 以追踪下一个部分块。 总之,Radsort 算法的状态在以下变量中追踪。单个算法步骤内需要的额外变量在相应步骤中解释。 | 变量 | 描述 | 类型 | | :--- | :--- | :--- | | \(A\) | 输入数组 | 字符串数组 | | \(n\) | 输入长度 | \(n = |A|\) | | \(\sigma\) | 字母表大小 | \(\sigma = |\Sigma|\) | | \(n_t\) | 键长度 | 整数 | | \(b\) | 块大小 | 整数 | | \(n_\pi\) | 块计数 | \(n_\pi = \lfloor n/b \rfloor + 2\sigma\) | | \(T\) | 暂存缓冲区 | \(2\sigma b\) 个字符串的数组 | | \(f\) | 填充级别 | 整数 | | \(\pi\) | 置换 | \(n_\pi\) 个整数的数组 | | \(P\) | 部分块 | \(\sigma\) 对整数的数组 | **图1:** Radsort 初始化时的数据结构。一个包含 \(n_t=4\) 个字符的块,大小为 \(b\)。 [此处省略图1的ASCII图示] ### 2.2 初始化 变量初始化设置如下(参见图1 (https://arxiv.org/html/2607.05302#S2.F1)),使得数组 \(A\) 的内容由逻辑索引 \(\sigma\) 到 \(\sigma + \lceil n/b \rceil\) 的块表示。数组 \(A\) 最后的 \(n \bmod b\) 个元素被复制到块 \(\sigma\) 中。这建立了状态变量的*数据不变性*,如下所示。每一轮排序都假设此不变性在开始时成立,并在结束时重新建立。 1. \(\pi\) 是 \(0 \ldots n_\pi-1\) 的一个置换。 2. \(P\) 按逻辑索引升序追踪部分块。 3. 逻辑索引 \(f-1\) 处的块是一个部分块。 4. 逻辑索引 \(0\) 到 \(\sigma-1\) 以及 \(f\) 到 \(n_\pi-1\) 指向未分配块。 5. 逻辑索引 \(\sigma\) 到 \(f-1\) 指向已分配块,这些块中已使用元素的连接持有根据算法当前进度置换后的输入。 算法4:变量的初始化 1. 过程 `setup(A, n)` 2. \(f \leftarrow \sigma + \lfloor n/b \rfloor + 1\) 3. \(\pi[0 \ldots \sigma-1] \leftarrow 0, \ldots, \sigma-1\) ▷ 分配领先块 4. \(\pi[\sigma \ldots f-2] \leftarrow 2\sigma, \ldots, \sigma+f-2\) ▷ 分配数组 A 的内容 5. \(\pi[f-1 \ldots n_\pi-1] \leftarrow \sigma, \ldots, 2\sigma-1\) ▷ 分配剩余块 6. \(T[0 \dots 2\sigma b-1] \leftarrow 0\) ▷ 用虚拟值填充 T(可选) 7. \(\textit{blockat}(\sigma)[0 \dots (n \bmod b)-1] \leftarrow A[b \lfloor n/b \rfloor \ldots n-1]\) ▷ 将尾部数据复制到暂存块 8. \(P[0] \leftarrow (f-1, n \bmod b)\) ▷ 将输入尾部追踪为部分块 9. \(P[1 \ldots \sigma-1] \leftarrow (n_\pi, 0)\) ▷ 用虚拟值填充 P(可选) 10. 结束过程 ### 2.3 排序 每一轮排序(参见图2 (https://arxiv.org/html/2607.05302#S2.F2))根据某个键位置 \(t\) 对数组 \(A\) 的元素进行稳定排序。我们用 \(\textit{key}(A[i],t) \in \Sigma\) 表示元素 \(A[i]\) 在键位置 \(t\) 的值。首先,*排序阶段*根据键值将元素分配到桶中。然后,*修正阶段*根据排序阶段的结果计算新的 \(\pi\) 和 \(P\),恢复数据不变性。排序和修正阶段需要几个额外的变量,这些变量在之后可以丢弃: | 变量 | 描述 | 类型 | | :--- | :--- | :--- | | \(B\) | 桶分配 | \(\sigma\) 对指针的数组 | | \(C\) | 块计数 | \(\sigma\) 个整数的数组 | | \(S\) | 桶起始位置 | \(\sigma\) 个整数的数组 | | \(U\) | 块使用情况 | \(n_\pi\) 个字符的数组 | | \(\pi'\) | 新置换 | \(n_\pi\) 个整数的数组 | | \(i_{\text{in}}\) | 下一个输入块 | 整数 | | \(i_{\text{out}}\) | 下一个输出块 | 整数 | | \(i_P\) | 下一个部分块 | 整数 | #### 2.3.1 排序阶段 排序阶段在桶数组 \(B\) 中为每个桶分配一个块。该数组为每个桶 \(c\) 保存一对指针 \((p_{\text{next}}, p_{\text{end}})\),分别指向下一个空闲元素和块的末尾。然后它通过 \(i_{\text{in}}\) 按逻辑顺序遍历已分配的块,并对每个块的元素进行排序,放入正确的输出块中。如果一个输出块满了,就使用 `newBucketBlock(c)` 过程从下一个逻辑索引 \(i_{\text{out}}\) 中取出一个新块。每个输出块所用于的桶由使用数组 \(U\) 追踪,而分配给每个桶的块数则由 \(C\) 追踪。 随着排序过程在输入数组中进行,已消耗的输入块会被重新分配为输出块。关键在于,在将输入块重新分配为输出块之前,必须完全消耗这些输入块,否则元素可能在排序之前就被覆盖。这通过...
相似文章
无分支快速排序:性能超越 std::sort 和 pdqsort,提供 C 和 C++ API
一种新的无分支快速排序实现(blqsort)借助排序网络技术,在 Apple M1 和 AMD Ryzen 系统上的性能超越了 std::sort 和 pdqsort,以单头文件形式提供 C 和 C++ 库。其性能提升得益于无分支分区、中位数之中位数枢轴选择以及针对小数组的自定义排序网络。
Orasort:利用Oracle过期专利实现5倍加速的列排序算法
Orasort是Oracle的专利列排序算法,可实现5倍更快的排序速度。该专利于2024年到期后进入公有领域,使得云公司和开源数据库(如MySQL和PostgreSQL)能够集成该算法。
对370,103个单词进行排序、哈希和草图计算
一篇技术博客文章,探索在包含370,103个英文单词的数据集上的排序、哈希和草图算法,衡量时间和内存成本,重点关注二分查找、快速排序和HyperLogLog等实际实现。
你的代码很快——如果你运气好的话
本文介绍了一种使用排序网络的无分支快速排序实现,并探讨了现代编译器(特别是Clang)如何在代码以恰当风格编写时,利用无分支指令来优化循环。
15种排序算法在6分钟内 (2013) [视频]
一个2013年的视频,在6分钟内可视化和听觉化展示15种排序算法,包括选择排序、快速排序和猴子排序。