就地基数排序

时间:2009-01-20 21:04:07

标签: algorithm language-agnostic sorting radix-sort in-place

这是一篇很长的文字。请多多包涵。简而言之,问题是:是否有可行的就地基数排序算法


初步

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

目前,我使用std::sort dest_groupintrosort的所有常见实现中使用STL。这非常有效。但是,我确信radix sort完全符合我的问题,并且在实践中应该更好地更多

详细

我已经用非常天真的实现测试了这个假设,对于相对较小的输入(大约10,000),这是真的(好吧,至少快两倍以上)。但是,当问题规模变大( N > 5,000,000)时,运行时会非常糟糕。

原因很明显:基数排序需要复制整个数据(实际上我的天真实现中不止一次)。这意味着我已经将~4 GiB放入我的主内存中,这显然会影响性能。即使它没有,我也承担不起使用这么多内存,因为问题规模实际上变得更大了。

用例

理想情况下,对于DNA和DNA5(允许额外的通配符“N”),甚至DNA IUPAC {{ (产生16个不同的值)。但是,我意识到所有这些情况都无法涵盖,所以我对任何提速都感到满意。代码可以动态决定要分派的算法。

研究

不幸的是,ambiguity codes没用。关于就地变体的部分是完全垃圾。 Wikipedia article on radix sort几乎不存在。有一篇名为NIST-DADS section on radix sort的有前途的论文描述了算法“MSL”。不幸的是,这篇论文也令人失望。

特别是,有以下几点。

首先,该算法包含多个错误,并留下了许多无法解释的问题。特别是,它没有详细说明递归调用(我只是假设它递增或减少一些指针来计算当前的移位和掩码值)。此外,它使用函数dest_addressdest_address而不给出定义。我没有看到如何有效地实现这些(即在O(1)中;至少{{1}}并非无足轻重)。

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

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

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

15 个答案:

答案 0 :(得分:58)

嗯,这是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,而不是一般的,但它应该很快。

编辑:

我很好奇这段代码是否真的有用,所以我在等待自己的生物信息学代码运行时测试/调试它。现在上面的版本实际上已经过测试和运行。对于每个5个碱基的1000万个序列,它比优化的内含子快3倍。

答案 1 :(得分:20)

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

<强>原因:

排序对输入数组进行线性读取,但所有写入几乎都是随机的。从某个N向上,这可以归结为每次写入的缓存未命中。此缓存未命中会降低您的算法速度。如果它到位不会改变这种效果。

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

这可以提供非常好的缓存局部性提升。然后,教科书不合适的基数排序将表现得更好。写入仍然几乎是随机的,但至少它们将聚集在相同的内存块中,因此增加了缓存命中率。

我不知道它是否在实践中有效。

顺便说一下:如果你只处理DNA字符串:你可以将一个字符串压缩成两位并将数据打包得很多。这将使内存需求减少因子4而非naiive表示。寻址变得更加复杂,但是你的CPU的ALU在所有缓存未命中期间都有很多时间花费。

答案 2 :(得分:8)

您当然可以通过以位为单位对序列进行编码来降低内存要求。 您正在考虑排列,因此,对于长度为2,“ACGT”为16状态或4位。 对于长度3,这是64个状态,可以以6位编码。因此,对于序列中的每个字母,它看起来像2位,或者像你说的那样,对于16个字符,大约是32位。

如果有办法减少有效“单词”的数量,可能会进一步压缩。

因此,对于长度为3的序列,可以创建64个桶,可能是uint32或uint64。 将它们初始化为零。 迭代你非常大的3个字符序列列表,并按上述方式对它们进行编码。 使用此作为下标,并增加该桶 重复此操作,直到处理完所有序列。

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

按顺序迭代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;
}

答案 3 :(得分: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个桶),这样就可以减少基于磁盘的缓冲区的分支。这可能会也可能不会提高性能,所以请尝试一下。

答案 4 :(得分:6)

我打算走出困境,建议你切换到堆/ heapsort实现。这个建议有一些假设:

  1. 您可以控制数据的读取
  2. 您可以在开始排序后立即对排序后的数据执行有意义的操作。
  3. 堆/堆排序的优点在于,您可以在读取数据时构建堆,并且可以在构建堆时开始获取结果。

    让我们退后一步。如果您是如此幸运,您可以异步读取数据(也就是说,您可以发布某种读取请求并在准备好某些数据时收到通知),然后您可以在等待时创建堆的一大块即将从磁盘进入的下一个数据块。通常,这种方法可以将一半排序的大部分成本埋在获取数据所花费的时间之后。

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

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

    请参阅维基百科文章:

答案 5 :(得分:4)

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

目前,您最终会触及每个字符串的每个元素,但您可以做得更好!

特别是burst sort非常适合这种情况。作为奖励,因为burstsort基于尝试,它对于DNA / RNA中使用的小字母大小非常有效,因为您不需要构建任何类型的三元搜索节点,散列或其他节点压缩方案。实施。尝试对于类似后缀数组的最终目标也许有用。

http://sourceforge.net/projects/burstsort/上的源伪造可以获得体面的burstsort通用实现 - 但它不在原地。

出于比较目的,http://www.cs.mu.oz.au/~rsinha/papers/SinhaRingZobel-2006.pdf基准测试中涵盖的C-burstsort实现比一些典型工作负载的快速排序和基数排序快4-5倍。

答案 6 :(得分:4)

你想看看Drs的Large-scale Genome Sequence Processing。笠原和森下。

由四个核苷酸字母A,C,G和T组成的字符串可以专门编码为整数,以便更多更快的处理。基数排序是本书中讨论的众多算法之一;你应该能够根据这个问题调整公认的答案,并看到一个很大的绩效改进。

答案 7 :(得分:4)

Radix sorting with no extra space”是一篇解决您问题的文章。

答案 8 :(得分:3)

您可以尝试使用trie。对数据进行排序只需迭代数据集并插入即可;结构是自然排序的,您可以将其视为类似于B树(除了进行比较,您总是使用指针间接)。

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

答案 9 :(得分:3)

我会burstsort字符串的压缩位表示。声称Burstsort比基数排序具有更好的局部性,通过爆发尝试代替经典尝试来保持额外的空间使用。原始论文有测量结果。

答案 10 :(得分:2)

Radix-Sort不是缓存意识,并不是大集合的最快排序算法。 你可以看一下:

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

答案 11 :(得分:1)

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

好的,我想了解更多有关4-nary问题的内容。你想要一个像Judy tree这样的解决方案。下一个解决方案可以处理变长字符串;对于固定长度,只需删除长度位,这实际上使它更容易。

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

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

对于每种块,您需要在LSB中存储不同的信息。由于你有可变长度的字符串,你也需要存储字符串结尾,而最后一种块只能用于最长的字符串。当你深入到结构中时,7个长度的位应该用less替换。

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

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

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

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

答案 12 :(得分:1)

dsimcha的MSB基数排序看起来很不错,但是Nils更接近问题的核心,观察到缓存局部性在大问题规模上正在扼杀你。

我建议采用一种非常简单的方法:

  1. 根据经验估算基数排序有效的最大尺寸m
  2. 一次读取m个元素的块,对它们进行基数排序,然后将它们写出来(如果你有足够的内存,则写入内存缓冲区,否则要记录),直到你耗尽输入为止。
  3. Mergesort 生成的排序块。
  4. Mergesort是我所知道的最适合缓存的排序算法:“从数组A或B中读取下一项,然后将项目写入输出缓冲区。”它在磁带驱动器上高效运行。它确实需要2n空间来排序n个项目,但我的赌注是,您将看到的大大改进的缓存区域将使这不重要 - 如果您使用的是非就地基数排序,无论如何你需要额外的空间。

    最后请注意,mergesort可以在没有递归的情况下实现,实际上这样做可以清楚地表明真正的线性内存访问模式。

答案 13 :(得分:1)

看起来你已经解决了这个问题,但是对于记录来说,似乎可行的就地基数排序的一个版本是“美国国旗排序”。它在这里描述:Engineering Radix Sort。一般的想法是对每个字符进行2次传递 - 首先计算每个字符的数量,这样就可以将输入数组细分为二进制数。然后再次检查,将每个元素交换到正确的bin中。现在递归地对下一个字符位置的每个bin进行排序。

答案 14 :(得分:0)

虽然可接受的答案完美地回答了问题的描述,但我到达这个地方却徒劳地寻求将内联数组划分为N个部分的算法。我自己写了一个,就在这里。

警告:这不是一种稳定的分区算法,因此对于多级分区,必须对每个结果分区而不是整个数组进行重新分区。优点是它是内联的。

解决所提出的问题的方法是,您可以基于字符串的字母重复对内联进行分区,然后使用您选择的算法对分区进行足够小的排序。

  function partitionInPlace(input, partitionFunction, numPartitions, startIndex=0, endIndex=-1) {
    if (endIndex===-1) endIndex=input.length;
    const starts = Array.from({ length: numPartitions + 1 }, () => 0);
    for (let i = startIndex; i < endIndex; i++) {
      const val = input[i];
      const partByte = partitionFunction(val);
      starts[partByte]++;
    }
    let prev = startIndex;
    for (let i = 0; i < numPartitions; i++) {
      const p = prev;
      prev += starts[i];
      starts[i] = p;
    }
    const indexes = [...starts];
    starts[numPartitions] = prev;
  
    let bucket = 0;
    while (bucket < numPartitions) {
      const start = starts[bucket];
      const end = starts[bucket + 1];
      if (end - start < 1) {
        bucket++;
        continue;
      }
      let index = indexes[bucket];
      if (index === end) {
        bucket++;
        continue;
      }
  
      let val = input[index];
      let destBucket = partitionFunction(val);
      if (destBucket === bucket) {
        indexes[bucket] = index + 1;
        continue;
      }
  
      let dest;
      do {
        dest = indexes[destBucket] - 1;
        let destVal;
        let destValBucket = destBucket;
        while (destValBucket === destBucket) {
          dest++;
          destVal = input[dest];
          destValBucket = partitionFunction(destVal);
        }
  
        input[dest] = val;
        indexes[destBucket] = dest + 1;
  
        val = destVal;
        destBucket = destValBucket;
      } while (dest !== index)
    }
    return starts;
  }