关于算法:就地基数排序

In-Place Radix Sort

这是一个长文本。请容忍我。简而言之,问题是:是否有一个可行的就地基数排序算法?

初步

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

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

细节

我已经用一个非常幼稚的实现测试了这个假设,对于相对较小的输入(大约10000个),这是正确的(好吧,至少是速度的两倍多)。但是,当问题规模变大(n>5000000)时,运行时会严重下降。

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

用例

理想情况下,对于dna和dna5(允许附加通配符"n"),或者甚至是带有iupac模糊码的dna(导致16个不同的值),该算法应适用于2到100之间的任何字符串长度。然而,我意识到所有这些情况都无法涵盖,所以我对我所获得的任何速度改进感到高兴。代码可以动态地决定要调度到哪个算法。

研究

不幸的是,维基百科关于基数排序的文章毫无用处。关于就地变量的部分完全是垃圾。关于基数排序的nist-dads部分在不存在的旁边。有一种很有前途的探测文件称为有效的自适应就地基排序,它描述了算法"MSL"。不幸的是,这篇论文也令人失望。

具体来说,有以下几点。

首先,该算法包含几个错误,并留下许多无法解释的地方。特别是,它没有详细描述递归调用(我只是假设它增加或减少一些指针来计算当前移位和掩码值)。此外,它使用函数dest_groupdest_address而不给出定义。我看不出如何有效地实现这些功能(也就是说,在O(1)中,至少dest_address不是一件小事)。

最后,该算法通过将数组索引与输入数组中的元素交换来实现到位。这显然只适用于数字阵列。我需要用在弦上。当然,我可以只进行强输入,并假设内存能够容忍我在不属于索引的地方存储索引。但是,只要我能将字符串压缩到32位内存中(假设为32位整数),这就行了。这仅仅是16个字符(让我们暂时忽略16>日志(5000000))。

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

扼要重述:有没有希望找到一个有效的引用实现,或者至少找到一个对DNA字符串有效的就地基排序的好的伪代码/描述?


这里是一个简单的DNA的msd基数排序的实现。它是用D语言写的,因为这是我使用最多的语言,因此最不可能犯愚蠢的错误,但它可以很容易地翻译成其他语言。它已就位,但需要2 * seq.length通过阵列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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倍。


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

原因:

排序在输入数组上执行线性读取,但所有写入几乎都是随机的。从某个n向上看,这归结为每次写入都会出现缓存未命中。这种缓存丢失会减慢您的算法的速度。如果它到位或不到位,不会改变这种效果。

我知道这不会直接回答您的问题,但是如果排序是一个瓶颈,那么您可能希望将接近排序算法作为预处理步骤(软堆上的wiki页面可能会帮助您开始)。

这可以提供一个非常好的缓存位置提升。一个文本书不在适当的基数排序,然后将执行更好。写操作仍然几乎是随机的,但至少它们会聚集在相同的内存块周围,从而提高缓存命中率。

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

顺便说一句:如果你只处理DNA字符串:你可以把一个字符压缩成两位,然后把你的数据打包。这将比Naive表示减少4倍的内存需求。寻址变得更加复杂,但是CPU的ALU在所有缓存未命中期间都有大量的时间。


您当然可以通过对序列进行位编码来降低内存需求。你看的是排列,所以对于长度2,用"acgt"表示16个状态,或者4位。对于长度3,这是64个状态,可以用6位编码。所以序列中的每个字母看起来都是2位,或者像您所说的16个字符大约是32位。

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

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

接下来,重新生成列表。

按顺序遍历64个存储桶,对于该存储桶中找到的计数,生成由该存储桶表示的序列的许多实例。当所有的bucket都被迭代后,就有了排序的数组。

一个4的序列,加上2位,所以会有256个桶。一个5的序列,加上2位,所以会有1024个桶。

在某些时候,桶的数量会接近你的极限。如果您从一个文件中读取序列,而不是将它们保存在内存中,那么更多的内存将可用于存储桶。

我认为这比就地分类要快,因为这些桶很可能适合你的工作环境。

这是一个显示技术的黑客

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
#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;
}


我打算冒险建议您切换到堆/堆排序实现。这个建议有一些假设:

  • 控制数据的读取
  • 一旦开始对排序的数据进行排序,就可以对其进行一些有意义的操作。
  • 堆/堆排序的好处在于,您可以在读取数据的同时构建堆,并且在构建堆之后就可以开始获取结果。

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

    一旦读取了数据,第一个元素就已经可用了。根据您发送数据的位置,这可能非常好。如果您要将它发送到另一个异步读卡器,或者某个并行的"事件"模型,或者UI,那么您可以在发送时发送块和块。

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

    参见维基百科文章:

    • 堆排序
    • 二元堆


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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    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

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

    1
    GATTACA

    第一个msb调用将返回gatt的bucket(总共256个bucket),这样可以减少基于磁盘的缓冲区的分支。这可能会提高性能,也可能不会提高性能,因此请尝试使用它。


    从性能角度看,您可能希望了解更通用的字符串比较排序算法。

    现在你接触到了每根绳子的每一个元素,但是你可以做得更好!

    特别是,突发排序非常适合这种情况。另外,由于burstsort是基于tries的,因此它对于dna/rna中使用的小字母大小非常有效,因为您不需要在trie实现中构建任何类型的三元搜索节点、哈希或其他trie节点压缩方案。这些尝试对后缀数组(比如final goal)也很有用。

    BurstSort的一个像样的通用实现可以在源代码伪造上找到,网址为http://sourceforge.net/projects/burstsort/,但还没有到位。

    为了进行比较,C-BurstSort实现在http://www.cs.mu.oz.au/~rsinha/papers/sinharingzobel-2006.pdf中介绍,对于某些典型工作负载,基准测试比快速排序和基数排序快4-5倍。


    "没有额外空间的基数排序"是一篇解决您问题的论文。


    你会想看看Kasahara博士和Morishita博士的大规模基因组序列处理。

    由四个核苷酸字母a、c、g和t组成的字符串可以专门编码成整数,以便更快地处理。基数排序是本书中讨论的许多算法之一;您应该能够使接受的答案适应这个问题,并看到一个巨大的性能改进。


    我将对字符串的压缩位表示法进行分类。据称Burstsort比基排序具有更好的局部性,它可以通过突发尝试来减少额外的空间使用,而不是用经典尝试。原纸有尺寸。


    你可以尝试使用trie。排序数据就是简单地遍历数据集并插入数据集;结构是自然排序的,您可以将其视为类似于B树(除了进行比较之外,您总是使用指针间接寻址)。

    缓存行为将有利于所有内部节点,因此您可能不会改进这一点;但是您也可以修改trie的分支因子(确保每个节点都适合于单个缓存线,将类似堆的trie节点分配为表示级别顺序遍历的连续数组)。由于尝试也是数字结构(长度为k的元素的o(k)insert/find/delete),所以您应该具有与基数排序相竞争的性能。


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

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

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


    看起来你已经解决了这个问题,但就记录而言,一个可行的就地基数排序的版本是"美国国旗排序"。这里描述的是:工程基排序。一般的想法是对每个字符进行2次传递-首先计算每个字符有多少个,这样您就可以将输入数组细分为容器。然后再次检查,将每个元素交换到正确的容器中。现在递归地在下一个字符位置对每个bin排序。


    dsimcha的msb基数排序看起来不错,但nils更接近问题的核心,因为有人观察到缓存位置在大问题规模下会让您陷入困境。

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

  • 根据经验估计基数排序有效的最大尺寸m
  • 一次读取m元素块,对其进行基数排序,然后将其写出(如果有足够的内存,则写入内存缓冲区,否则则写入文件),直到耗尽输入。
  • 合并排序结果排序块。
  • MergeSort是我所知道的最适合缓存的排序算法:"从数组A或B中读取下一个项,然后将一个项写入输出缓冲区。"它在磁带驱动器上高效运行。它确实需要2n空间来对n项进行排序,但我敢打赌,如果您使用的是非就地基数排序,则无论如何都需要额外的空间。

    最后请注意,MergeSort可以不使用递归实现,事实上,这样做可以清楚地说明真正的线性内存访问模式。


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

    好吧,我想了一点关于四线制的问题。你需要一个像朱迪树这样的解决方案。下一个解决方案可以处理可变长度的字符串;对于固定长度,只需删除长度位,这实际上更容易处理。

    分配16个指针的块。指针的最低有效位可以重用,因为块总是对齐的。您可能需要一个专门的存储分配器(将大存储拆分为较小的块)。有许多不同类型的块:

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

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

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

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

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

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