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


初步

我有大量固定长度的小字符串,只使用字母“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字符串上的就地基数排序?


当前回答

你可以试着用trie。对数据排序就是简单地遍历数据集并插入数据集;结构是自然排序的,你可以把它想象成类似于b -树(除了不做比较,你总是使用指针指向)。

缓存行为将有利于所有内部节点,因此您可能无法在此基础上进行改进;但是您也可以修改树的分支因子(确保每个节点都适合单个缓存线,将树节点分配为类似于堆的节点,作为表示级别顺序遍历的连续数组)。由于try也是数字结构(对于长度为k的元素,O(k)插入/查找/删除),因此您的性能应该优于基数排序。

其他回答

你可以试着用trie。对数据排序就是简单地遍历数据集并插入数据集;结构是自然排序的,你可以把它想象成类似于b -树(除了不做比较,你总是使用指针指向)。

缓存行为将有利于所有内部节点,因此您可能无法在此基础上进行改进;但是您也可以修改树的分支因子(确保每个节点都适合单个缓存线,将树节点分配为类似于堆的节点,作为表示级别顺序遍历的连续数组)。由于try也是数字结构(对于长度为k的元素,O(k)插入/查找/删除),因此您的性能应该优于基数排序。

这是DNA的MSD基排序的一个简单实现。它是用D语言写的,因为这是我最常用的语言,因此最不可能犯愚蠢的错误,但它可以很容易地翻译成其他语言。它是到位的,但需要2 * seq。Length通过数组。

void radixSort(string[] seqs, size_t base = 0) {
    if(seqs.length == 0)
        return;

    size_t TPos = seqs.length, APos = 0;
    size_t i = 0;
    while(i < TPos) {
        if(seqs[i][base] == 'A') {
             swap(seqs[i], seqs[APos++]);
             i++;
        }
        else if(seqs[i][base] == 'T') {
            swap(seqs[i], seqs[--TPos]);
        } else i++;
    }

    i = APos;
    size_t CPos = APos;
    while(i < TPos) {
        if(seqs[i][base] == 'C') {
            swap(seqs[i], seqs[CPos++]);
        }
        i++;
    }
    if(base < seqs[0].length - 1) {
        radixSort(seqs[0..APos], base + 1);
        radixSort(seqs[APos..CPos], base + 1);
        radixSort(seqs[CPos..TPos], base + 1);
        radixSort(seqs[TPos..seqs.length], base + 1);
   }
}

显然,这是特定于DNA的,而不是普遍的,但它应该很快。

编辑:

我很好奇这个代码是否真的有效,所以在等待我自己的生物信息学代码运行时,我对它进行了测试/调试。上面的版本现在已经经过测试并且可以工作。对于1000万个每个5个碱基的序列,它比优化的引入排序快大约3倍。

“没有多余空间的基数排序”就是一篇针对你的问题的论文。

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

我将冒个险,建议您切换到堆/堆排序实现。这个建议伴随着一些假设:

您可以控制数据的读取 你可以做一些有意义的排序数据,只要你“开始”得到它排序。

堆/堆排序的美妙之处在于,您可以在读取数据时构建堆,并且可以在构建堆的那一刻开始获得结果。

让我们后退一步。如果您非常幸运,可以异步读取数据(也就是说,您可以发布某种类型的读请求,并在某些数据准备就绪时收到通知),那么您可以在等待下一个数据块进入(甚至来自磁盘)时构建堆的一块。通常,这种方法可以将一半排序的成本隐藏在获取数据所花费的时间之后。

读取数据之后,第一个元素就已经可用了。这取决于您将数据发送到哪里,这可能很棒。如果你要把它发送给另一个异步阅读器,或者一些并行的“事件”模型,或者UI,你可以不断地发送数据块。

也就是说,如果您无法控制如何读取数据,并且它是同步读取的,并且在完全写入之前对排序的数据没有任何用处,那么请忽略所有这些。:(

参见维基百科的文章:

堆排序 二叉堆