就地基数排序


200

这是一个很长的文字。请多多包涵。归根结底,问题是:是否存在可行的就地基数排序算法


初步

我有很多小的固定长度字符串,它们只使用我要排序的字母“ A”,“ C”,“ G”和“ T”(是的,您猜对了:DNA)。

目前,我在STL的所有常见实现中都std::sort使用了introsort。这很好。但是,我相信,基数排序适合我的问题集完美,应该工作在实践中更好。

细节

我已经用一个非常幼稚的实现测试了这个假设,对于相对较小的输入(大约10,000个),这是正确的(至少要快两倍以上)。但是,当问题规模变大(N > 5,000,000)时,运行时间将大大降低。

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

用例

理想情况下,该算法应适用于2到100之间的任何字符串长度,适用于DNA以及DNA5(允许附加通配符“ N”),甚至适用于带有IUPAC 模糊代码的 DNA (导致16个不同的值)。但是,我意识到所有这些情况都无法解决,因此我对速度的提高感到满意。该代码可以动态决定要调度到哪个算法。

研究

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

特别是,有以下几点。

首先,该算法包含一些错误,并且有很多无法解释的地方。特别是,它没有详细介绍递归调用(我只是假设它增加或减少了一些指针来计算当前的shift和mask值)。同样,它使用这些函数dest_group并且dest_address没有给出定义。我看不到如何有效地实现这些功能(也就是说,在O(1)中;至少dest_address是不平凡的)。

最后但并非最不重要的一点是,该算法通过将数组索引与输入数组内的元素交换来实现就位。显然,这仅适用于数值数组。我需要在字符串上使用它。当然,我可以拧紧强类型,并假设内存可以容忍我存储不属于它的索引。但这仅在我可以将字符串压缩到32位内存(假设32位整数)的情况下有效。那只是16个字符(在16> log(5,000,000)的那一刻,让我们忽略它)。

一位作者的另一篇论文没有给出任何准确的描述,但它给出了MSL的运行时间为亚线性的情况,这是完全错误的。

回顾一下:是否有希望找到一个可行的参考实现,或者至少一个对DNA字符串起作用的就地基数排序的良好伪代码/描述?


65
这是一个写得很好的问题。
JustinT

1
固定长度的小弦有多小?
EvilTeach

1
@EvilTeach:我已经添加了用例。
康拉德·鲁道夫

2
@Stephan:一切都很好。但是在复制/缓存未命中的情况下,我会有所延迟。在记忆方面,我达到了物理极限。这是完全不能谈判的。所有那些将部分数据存储在磁盘上的奇特技术肯定比当前的快速排序解决方案要慢。
Konrad Rudolph,2009年

2
另一方面,对于某些输入,dsimcha的解决方案绝对比quicksort 更快。移动的次数可能很高,而缓存的位置很小,但是在现实世界中,它仍然很好。我还略微调整了解决方案,以减少需要执行的交换次数。
康拉德·鲁道夫

Answers:


61

好吧,这是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个碱基的序列,其速度比优化的introsort快3倍。


9
如果您可以使用2倍通过方法,则可以扩展到基数N:通过1 =仅计算并计算N个数字中每个数字的位数。然后,如果要对数组进行分区,则会告诉您每个数字的起始位置。通道2确实会交换到数组中的适当位置。
杰森S

(例如,对于N = 4,如果有90000 A,80000 G,100 C,100000 T,则将一个数组初始化为累积总和= [0,90000,170000,170100],该数组用于代替您的APos, CPos等作为光标,以获取每个数字的下一个元素应交换到的位置。)
Jason S,2009年

我不知道二进制表示,该字符串表示的关系将是,除了使用至少4倍的内存需要
斯蒂芬Eggermont

较长序列的速度如何?您没有足够的不同的人有5的长度
斯蒂芬Eggermont

4
该基数排序看起来是美国国旗分类的一种特例-一种众所周知的就地基数排序变体。
爱德华·KMETT 09年

21

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

原因:

排序对输入数组进行线性读取,但是所有写入几乎都是随机的。从一定的N向上可归结为每次写入的高速缓存未命中。这种高速缓存未命中会使您的算法变慢。如果它在适当的位置,则不会改变此效果。

我知道这不会直接回答您的问题,但是如果排序是一个瓶颈,那么您可能希望看一下近排序算法作为预处理步骤(软堆上的Wiki页可能会让您入门)。

这样可以大大提高缓存的局部性。这样,教科书的异地基数排序会更好。写入操作仍然几乎是随机的,但至少它们将聚集在相同的内存块周围,因此增加了缓存命中率。

我不知道它是否可以在实践中解决。

顺便说一句:如果您只处理DNA字符串:您可以将char压缩为两位并打包很多数据。这将以幼稚的形式将内存需求减少四分之一。处理变得更加复杂,但是在所有高速缓存未命中期间,CPU的ALU都有很多时间可以花。


2
有两个好处:对我来说,近分类是一个新概念,我必须阅读一下。缓存丢失是困扰我梦想的另一个考虑因素。;-)我得看一看。
康拉德·鲁道夫

对我来说,它也是新的(两个月),但是一旦您有了这个概念,就开始看到提高性能的机会。
Nils Pipenbrinck,2009年

除非基数非常大,否则写入操作几乎不会是随机的。例如,假设您一次对一个字符进行排序(基数为4的排序),则所有写入将写入4个线性增长的存储桶之一。这对缓存和预取都是友好的。当然,您可能需要使用更大的基数,并且在某些指针处您需要在缓存与预取友好度和基数大小之间进行权衡。您可以使用软件预取或为存储桶划一个区域,将收支平衡点推向更大的半径,并定期冲洗“真实”存储桶。
BeeOnRope

8

您当然可以通过按位对序列进行编码来降低内存需求。因此,您正在查看排列,对于长度2,“ ACGT”为16个状态或4位。对于长度3,这是64个状态,可以将其编码为6位。因此,序列中每个字母看起来像2位,或者16个字符大约32位,如您所说。

如果有一种方法可以减少有效“单词”的数量,则可以进行进一步压缩。

因此,对于长度为3的序列,可以创建64个存储桶,大小可能为uint32或uint64。将它们初始化为零。遍历3个char序列的非常大的列表,并按上述进行编码。使用它作为下标,并增加该存储桶。
重复此过程,直到处理完所有序列。

接下来,重新生成您的列表。

依次遍历64个存储桶,以便在该存储桶中找到计数,以生成该存储桶表示的序列的许多实例。
当所有存储桶都被迭代后,您便拥有了已排序的数组。

4的序列加2位,因此将有256个存储桶。5的序列加2位,因此将有1024个存储桶。

在某些时候,存储桶的数量将接近极限。如果您从文件中读取序列,而不是将其保留在内存中,则将有更多内存可用于存储桶。

我认为这比在原地进行分类要快,因为存储桶很可能适合您的工作环境。

这是一个展示技巧的技巧

#include <iostream>
#include <iomanip>

#include <math.h>

using namespace std;

const int width = 3;
const int bucketCount = exp(width * log(4)) + 1;
      int *bucket = NULL;

const char charMap[4] = {'A', 'C', 'G', 'T'};

void setup
(
    void
)
{
    bucket = new int[bucketCount];
    memset(bucket, '\0', bucketCount * sizeof(bucket[0]));
}

void teardown
(
    void
)
{
    delete[] bucket;
}

void show
(
    int encoded
)
{
    int z;
    int y;
    int j;
    for (z = width - 1; z >= 0; z--)
    {
        int n = 1;
        for (y = 0; y < z; y++)
            n *= 4;

        j = encoded % n;
        encoded -= j;
        encoded /= n;
        cout << charMap[encoded];
        encoded = j;
    }

    cout << endl;
}

int main(void)
{
    // Sort this sequence
    const char *testSequence = "CAGCCCAAAGGGTTTAGACTTGGTGCGCAGCAGTTAAGATTGTTT";

    size_t testSequenceLength = strlen(testSequence);

    setup();


    // load the sequences into the buckets
    size_t z;
    for (z = 0; z < testSequenceLength; z += width)
    {
        int encoding = 0;

        size_t y;
        for (y = 0; y < width; y++)
        {
            encoding *= 4;

            switch (*(testSequence + z + y))
            {
                case 'A' : encoding += 0; break;
                case 'C' : encoding += 1; break;
                case 'G' : encoding += 2; break;
                case 'T' : encoding += 3; break;
                default  : abort();
            };
        }

        bucket[encoding]++;
    }

    /* show the sorted sequences */ 
    for (z = 0; z < bucketCount; z++)
    {
        while (bucket[z] > 0)
        {
            show(z);
            bucket[z]--;
        }
    }

    teardown();

    return 0;
}

为什么要比较何时可以哈希eh?
2009年

1
该死的直。性能通常是任何DNA处理的问题。
EvilTeach

6

如果您的数据集很大,那么我认为基于磁盘的缓冲区方法将是最好的:

sort(List<string> elements, int prefix)
    if (elements.Count < THRESHOLD)
         return InMemoryRadixSort(elements, prefix)
    else
         return DiskBackedRadixSort(elements, prefix)

DiskBackedRadixSort(elements, prefix)
    DiskBackedBuffer<string>[] buckets
    foreach (element in elements)
        buckets[element.MSB(prefix)].Add(element);

    List<string> ret
    foreach (bucket in buckets)
        ret.Add(sort(bucket, prefix + 1))

    return ret

例如,如果您的字符串是:

GATTACA

第一个MSB调用将返回GATT的存储桶(总共256个存储桶),这样您就可以减少基于磁盘的缓冲区的分支。这可能会或可能不会提高性能,因此请尝试一下。


我们将内存映射文件用于某些应用程序。但是,总的来说,我们假设计算机仅提供足够的RAM来不需要显式的磁盘备份(当然,交换仍在进行)。但是我们已经在开发一种自动磁盘支持的阵列的机制
Konrad Rudolph

6

我会费力地建议您切换到堆/ heapsort实现。此建议带有一些假设:

  1. 您控制数据的读取
  2. 一旦您开始对数据进行排序,便可以对数据进行有意义的处理。

堆/堆排序的好处在于,您可以在读取数据时构建堆,并且可以在构建堆后立即开始获取结果。

让我们退后一步。如果您很幸运,可以异步读取数据(也就是说,您可以发布某种读取请求,并在某些数据准备就绪时收到通知),然后可以在等待堆时构建堆的大部分下一个要进入的数据块-甚至从磁盘。通常,这种方法可以将一半的分拣成本中的大部分费用掩藏在花费数据的时间之后。

读取数据后,第一个元素已经可用。根据发送数据的位置,这可能很棒。如果将其发送到另一个异步读取器,某种并行的“事件”模型或UI,则可以随便发送块和块。

就是说-如果您无法控制数据的读取方式,并且无法同步读取数据,并且在将数据完全写出之前您无法使用已排序的数据-请忽略所有这些内容。:(

请参阅维基百科文章:


1
好建议。但是,我已经尝试过了,在我的特殊情况下,维护堆的开销比仅将数据累加到向量中并在所有数据到达后进行排序要大。
康拉德·鲁道夫


4

在性能方面,您可能希望查看更通用的字符串比较排序算法。

目前,您可以接触到每个字符串的每个元素,但是您可以做得更好!

特别地,突发排序非常适合这种情况。另外,由于burstsort基于尝试,因此它对于DNA / RNA中使用的较小字母大小而言非常可笑,因为您无需将任何三元搜索节点,哈希或其他特里节点压缩方案构建到尝试执行。尝试也可能对类似后缀数组的最终目标很有用。

可以在http://sourceforge.net/projects/burstsort/的Source Forge上获得burstsort的一个不错的通用实现,但是它不是就地实现的。

为了进行比较,C-burstsort的实现在http://www.cs.mu.oz.au/~rsinha/papers/SinhaRingZobel-2006.pdf中进行了比较,在某些典型工作负载下,其基准速度比quicksort和radix排序快4-5倍。


我绝对必须看一下突发排序-尽管目前我还不知道如何在原地构建Trie。通常,由于后缀数组在生物信息学中几乎取代了所有后缀树(因此,人们尝试使用后缀树),因为在实际应用中具有卓越的性能特征。
康拉德·鲁道夫

4

您将要看一下Drs 的大规模基因组序列处理。asa原和森下。

由四种核苷酸字母串A,C,G和T可特殊编码成整数为更快的处理。基数排序是本书中讨论的许多算法之一。您应该能够将接受的答案调整为该问题,并看到较大的性能改进。


本书介绍的基数排序不正确,因此无法用于此目的。至于字符串压缩,我(当然)已经在做。我的(或多或少)最终解决方案(在下面发布)没有显示此内容,因为该库允许我将它们像普通字符串一样对待-但是使用的RADIX值当然可以(而且是)可以适应较大的值。
康拉德·鲁道夫

3

您可以尝试使用trie。排序数据只是简单地遍历数据集并插入;该结构是自然排序的,您可以认为它类似于B树(除了不进行比较,您始终使用指针间接寻址)。

缓存行为将有利于所有内部节点,因此您可能不会对此进行改进。但是您也可以摆弄特里的分支因子(确保每个节点都适合单个高速缓存行,将类似于堆的特里节点分配为代表级别顺序遍历的连续数组)。由于尝试也是数字结构(对于长度为k的元素,是O(k)插入/查找/删除),因此对于基数排序,您应该具有竞争力。


特里树与我的幼稚实现存在相同的问题:它需要O(n)额外的内存,这实在太多了。
康拉德·鲁道夫

3

我将对字符串的打包位表示形式进行突发排序。Burstsort具有比基数排序更好的局部性,可以通过使用突发尝试代替传统尝试来减少额外的空间使用。原始纸有尺寸。


2

Radix-Sort对缓存不敏感,也不是针对大型集合的最快排序算法。您可以看一下:

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


Bill:您能解释一下此qsort功能相对于std::sortC ++提供的功能有哪些优势吗?尤其是后者在现代图书馆中实现了高度复杂的introsort,并插入了比较操作。在大多数情况下,我不赞成它在O(n)中执行,这是因为这需要一定程度的自省,而这种自省是通常情况下无法实现的(至少不是没有很多开销)。
康拉德·鲁道夫

我没有使用c ++,但是在我的测试中,内联QSORT可以比stdlib中的qsort快3倍。ti7qsort是整数最快的排序方式(比内联QSORT更快)。您也可以使用它对固定大小的小型数据进行排序。您必须使用数据进行测试。
法案

1

dsimcha的MSB基数排序看起来不错,但是Nils观察到缓存局部性在大问题规模上使您丧命,这使问题更加接近问题的核心。

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

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

Mergesort是我所知道的最适合缓存的排序算法:“从数组A或B中读取下一项,然后将一项写入输出缓冲区。” 它在磁带机上有效运行。它确实需要2n空间来对n项目进行排序,但是我敢打赌,您将看到大大改善的缓存局部性将使其变得不重要-如果您使用的是非就地基数排序,则无论如何都需要额外的空间。

最后请注意,mergesort可以实现而无需递归,并且实际上,通过这种方式可以使真正的线性内存访问模式清晰明了。


1

看来您已经解决了问题,但为了记录在案,似乎可以使用的就地基数排序的一个版本是“美国国旗排序”。此处描述:Engineering Radix Sort。一般的想法是对每个字符进行两次传递-首先计算每个字符有多少,因此您可以将输入数组细分为bin。然后再次进行检查,将每个元素交换到正确的bin中。现在,在下一个字符位置上递归地对每个垃圾箱进行排序。


实际上,我使用的解决方案与标志排序算法密切相关。我不知道是否有任何相关的区别。
康拉德·鲁道夫

2
从来没有听说过美国国旗排序,但是很显然, 这就是我编写的代码:coliru.stacked-crooked.com/a/94eb75fbecc39066它目前表现出色std::sort,并且我确定多位数字化仪的速度可能会更快,但是我的测试套件具有内存问题(不是算法,而是测试套件本身)
Mooing Duck

@KonradRudolph:Flag排序和其他基数排序之间的最大区别是计数传递。您是对的,所有基数排序都非常紧密相关,但是我不认为您的是Flag排序。
Mooing Duck

@MooingDuck:只是从您的示例中获得了一些启发-我陷入了自己的独立实现中,而您的帮助使我重回正轨。谢谢!一种可能的优化-我在这里还不够深入,看看它是否值得:如果您要交换到的位置上的元素恰好已经在所需的位置,则您可能要跳过该位置并前进到那个位置不是。检测到这一点当然需要额外的逻辑,并可能还需要额外的存储,但是由于交换相对于比较而言是昂贵的,因此可能值得这样做。
500-内部服务器错误,

1

首先,考虑问题的编码。摆脱字符串,用二进制表示形式替换它们。使用第一个字节来表示长度+编码。或者,在四字节边界处使用固定长度的表示形式。然后,基数排序变得容易得多。对于基数排序,最重要的是在内部循环的热点不进行异常处理。

好的,我想了更多关于四进制的问题。您需要一个类似Judy树的解决方案。下一个解决方案可以处理可变长度的字符串。对于固定长度,只需删除长度位,这实际上使它更容易。

分配16个指针的块。指针的最低有效位可以重复使用,因为您的块将始终对齐。您可能需要一个特殊的存储分配器(将大存储分成较小的块)。有许多不同种类的块:

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

对于每种块,您需要在LSB中存储不同的信息。由于您具有可变长度的字符串,因此您也需要存储字符串结尾,并且最后一种块只能用于最长的字符串。随着结构的深入,应该用更少的7个长度位代替。

这为您提供了相当快速且非常内存有效的排序字符串存储。它的行为有点像特里。要使此工作正常进行,请确保构建足够的单元测试。您想要涵盖所有块转换。您只想从第二种块开始。

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

您可能想从前四个字符的256宽直接基数开始。这提供了不错的空间/时间权衡。在这种实现中,您获得的内存开销比简单的Trie少得多。它大约小三倍(我没有测量)。如果常数足够小,则O(n)没问题,正如您在与O(n log n)快速排序进行比较时所注意到的那样。

您对处理双打感兴趣吗?随着短序列,将要。调整块以处理计数是很棘手的,但是可以节省空间。


如果使用位压缩表示形式,我看不出基数排序变得多么容易。顺便说一句,我使用的框架实际上提供了使用位压缩表示的可能性,但这对于我作为界面用户来说是完全透明的。
康拉德·鲁道夫

不,当您看秒表时:)
斯蒂芬·埃格蒙特

我一定会看看朱迪树。Vanilla尝试并没有真正带来太多好处,因为它们的行为基本上像普通的MSD基数排序,元素之间的传递较少,但需要额外的存储空间。
康拉德·鲁道夫
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.