这是一篇很长的文章。请原谅我。归结起来,问题是:是否存在可行的就地基数排序算法?


初步

我有大量固定长度的小字符串,只使用字母“a”,“C”,“G”和“T”(是的,你已经猜到了:DNA),我想对它们进行排序。

目前,我使用std::sort,它在STL的所有常见实现中使用introsort。这工作得很好。然而,我确信基数排序完全适合我的问题集,在实践中应该工作得更好。

细节

我用一个非常简单的实现测试了这个假设,对于相对较小的输入(大约10,000),这是正确的(至少快两倍多)。然而,当问题规模变大(N > 5,000,000)时,运行时间会急剧下降。

原因很明显:基数排序需要复制整个数据(实际上在我的简单实现中不止一次)。这意味着我在主存中放置了~ 4 GiB,这显然会降低性能。即使它没有,我也不能使用这么多内存,因为问题的大小实际上会变得更大。

用例

理想情况下,该算法应该适用于2到100之间的任何字符串长度,适用于DNA和DNA5(允许额外的通配符“N”),甚至适用于具有IUPAC歧义码的DNA(导致16个不同的值)。然而,我意识到所有这些情况都无法涵盖,所以我对我得到的任何速度改进都很满意。代码可以动态地决定向哪个算法分派。

研究

不幸的是,维基百科上关于基数排序的文章是无用的。关于原地变体的部分完全是垃圾。关于基数排序的NIST-DADS部分几乎不存在。有一篇听起来很有希望的论文叫做“高效自适应就地基数排序”,它描述了算法“MSL”。不幸的是,这篇论文也令人失望。

具体来说,有以下几点。

首先,该算法包含了一些错误,并留下了许多无法解释的地方。特别是,它没有详细说明递归调用(我只是假设它增加或减少一些指针来计算当前的移位和掩码值)。此外,它使用函数dest_group和dest_address,但没有给出定义。我不知道如何有效地实现这些(也就是说,在O(1);至少dest_address不是简单的)。

Last but not least, the algorithm achieves in-place-ness by swapping array indices with elements inside the input array. This obviously only works on numerical arrays. I need to use it on strings. Of course, I could just screw strong typing and go ahead assuming that the memory will tolerate my storing an index where it doesn’t belong. But this only works as long as I can squeeze my strings into 32 bits of memory (assuming 32 bit integers). That's only 16 characters (let's ignore for the moment that 16 > log(5,000,000)).

另一篇论文的作者没有给出准确的描述,但它给出了MSL的运行时是次线性的,这是完全错误的。

回顾一下:有没有希望找到一个工作的参考实现,或者至少是一个好的伪代码/描述,一个工作在DNA字符串上的就地基数排序?


当前回答

我从未见过就地基数排序,从基数排序的性质来看,只要临时数组适合内存,我怀疑它比就地排序快得多。

原因:

排序对输入数组进行线性读取,但所有写入几乎都是随机的。从特定的N开始,这可以归结为每次写入的缓存丢失。这种缓存缺失会减慢你的算法。它是否到位并不会改变这种效果。

我知道这不能直接回答您的问题,但是如果排序是一个瓶颈,那么您可能想要看看作为预处理步骤的近似排序算法(软堆上的wiki-page可以让您开始)。

这可以很好地提高缓存的局部性。课本上的错位基数排序会表现得更好。写入仍然几乎是随机的,但至少它们会聚集在相同的内存块周围,这样就增加了缓存命中率。

但我不知道这在实践中是否可行。

顺便说一句:如果你只处理DNA字符串:你可以将一个字符压缩成两个比特,并打包大量数据。这将把内存需求减少到原始表示的四倍。寻址变得更加复杂,但无论如何,CPU的ALU在所有缓存丢失期间都有大量的时间。

其他回答

首先,考虑问题的编码。去掉字符串,用二进制表示代替它们。使用第一个字节表示长度+编码。或者,在四字节边界上使用固定长度的表示。基数排序就简单多了。对于基数排序,最重要的是不要在内部循环的热点处进行异常处理。

好的,我想了一下4元的问题。你想要一个像朱迪树一样的解决方案。下一个解决方案可以处理可变长度的字符串;对于固定长度,只要去掉长度位,这实际上让它更简单。

分配16个指针的块。指针中最不重要的部分可以被重用,因为你的块总是对齐的。您可能需要为它使用一个特殊的存储分配器(将大的存储分解为较小的块)。有许多不同类型的积木:

用可变长度字符串的7个长度位进行编码。当它们填满时,你用: 位置编码接下来的两个字符,你有16个指针指向下一个块,以: 字符串最后三个字符的位图编码。

对于每种类型的块,您需要在lsb中存储不同的信息。当你有可变长度的字符串时,你也需要存储字符串的结尾,最后一种块只能用于最长的字符串。随着结构的深入,长度为7的位应该被更少的位所取代。

这为您提供了一个合理快速和非常有效的内存存储排序字符串。它会表现得有点像一个trie。要让它工作,请确保构建足够的单元测试。您希望覆盖所有块转换。你只想从第二种积木开始。

为了获得更好的性能,您可能需要添加不同的块类型和更大的块大小。如果块总是相同的大小和足够大,您可以为指针使用更少的位。块大小为16个指针,在32位地址空间中已经有一个字节空闲。查看Judy树文档,了解有趣的块类型。基本上,您添加代码和工程时间以进行空间(和运行时)权衡

您可能希望从头四个字符的256宽直接基数开始。这提供了一个不错的空间/时间权衡。在这个实现中,你得到的内存开销比简单的trie少得多;它大约小了三倍(我还没有测量过)。如果常数足够低,O(n)不是问题,就像你在与O(n log n)快速排序比较时注意到的那样。

你对处理双数感兴趣吗?对于短序列,会有。调整块来处理计数是很棘手的,但它可以非常节省空间。

dsimcha的MSB基数排序看起来不错,但是Nils更接近问题的核心,他观察到缓存的局部性是在大问题规模下造成问题的原因。

我建议一个非常简单的方法:

根据经验估计基数排序有效的最大大小m。 一次读取m个元素块,对它们进行基数排序,然后将它们写入(如果有足够的内存,则写入内存缓冲区,否则写入文件),直到耗尽所有输入。 对结果排序块进行归并排序。

归并排序是我所知道的对缓存最友好的排序算法:“从数组A或B中读取下一项,然后将一项写入输出缓冲区。”它在磁带驱动器上有效地运行。它确实需要2n个空间来排序n个项目,但我敢打赌,您将看到的大大改进的缓存位置将使这一点变得不重要——如果您使用的是非就地基数排序,无论如何您都需要额外的空间。

最后请注意,归并排序可以在没有递归的情况下实现,事实上,这样做可以明确真正的线性内存访问模式。

基数排序不是缓存意识,也不是对大集最快的排序算法。 你可以看看:

ti7qsort。Ti7qsort是对整数最快的排序(可用于固定大小的小字符串)。 内联QSORT 字符串排序

您还可以使用压缩并将DNA的每个字母编码为2位,然后存储到排序数组中。

我从未见过就地基数排序,从基数排序的性质来看,只要临时数组适合内存,我怀疑它比就地排序快得多。

原因:

排序对输入数组进行线性读取,但所有写入几乎都是随机的。从特定的N开始,这可以归结为每次写入的缓存丢失。这种缓存缺失会减慢你的算法。它是否到位并不会改变这种效果。

我知道这不能直接回答您的问题,但是如果排序是一个瓶颈,那么您可能想要看看作为预处理步骤的近似排序算法(软堆上的wiki-page可以让您开始)。

这可以很好地提高缓存的局部性。课本上的错位基数排序会表现得更好。写入仍然几乎是随机的,但至少它们会聚集在相同的内存块周围,这样就增加了缓存命中率。

但我不知道这在实践中是否可行。

顺便说一句:如果你只处理DNA字符串:你可以将一个字符压缩成两个比特,并打包大量数据。这将把内存需求减少到原始表示的四倍。寻址变得更加复杂,但无论如何,CPU的ALU在所有缓存丢失期间都有大量的时间。

我将对字符串的打包位表示进行burst排序。突发排序被认为比基数排序有更好的局部性,用突发尝试代替经典尝试减少了额外的空间使用。原始论文有测量。