web-dev-qa-db-ja.com

インプレース基数ソート

これは長いテキストです。どうか我慢してください。要約すると、質問は次のとおりです:実行可能なインプレース基数ソートアルゴリズムはありますか


予備

文字「A」、「C」、「G」、「T」のみを使用する膨大な数の[小さな固定長文字列を持っています(はい、あなたはそれを推測しました: [〜#〜] dna [〜#〜] )並べ替えたいもの。

現時点では、std::sortは、 [〜#〜] stl [〜#〜] のすべての一般的な実装で introsort を使用します。これは非常にうまく機能します。ただし、 radix sort は問題セットに完全に適合し、実際にはmuchより適切に機能するはずだと確信しています。

詳細

私は非常に単純な実装でこの仮定をテストしましたが、比較的小さな入力(10,000程度)でこれは事実でした(少なくとも、少なくとも2倍以上高速です)。ただし、問題のサイズが大きくなると([〜#〜] n [〜#〜]> 5,000,000)、ランタイムは大幅に低下します。

その理由は明らかです。基数ソートでは、データ全体をコピーする必要があります(実際には、単純な実装では複数回)。これは、メインメモリに〜4 GiBを入れたことでパフォーマンスが明らかに低下することを意味します。そうしなかったとしても、実際には問題のサイズが大きくなるため、さらに大きくなります。

ユースケース

理想的には、このアルゴリズムは、DNAおよびDNA5(追加のワイルドカード文字「N」を許可)、または [〜#〜] iupac [〜#〜 ]曖昧性コード (16の異なる値になります)。ただし、これらのすべてのケースをカバーすることはできないので、速度の改善に満足しています。コードは、ディスパッチするアルゴリズムを動的に決定できます。

研究

残念ながら、 基数ソートに関するウィキペディアの記事 は役に立たない。インプレースバリアントに関するセクションは完全なゴミです。 基数ソートに関するNIST-DADSセクション は、存在しないものの隣にあります。アルゴリズム「MSL」について説明している Efficient Adaptive In-Place Radix Sorting と呼ばれる有望な論文があります。残念ながら、この論文も残念です。

特に、次のことがあります。

まず、アルゴリズムにはいくつかの間違いが含まれており、多くの原因が不明です。特に、再帰呼び出しの詳細は示していません(現在のシフト値とマスク値を計算するために、ポインターを増分または減分すると仮定しています)。また、関数dest_groupおよびdest_address定義を与えずに。これらを効率的に実装する方法がわかりません(つまり、O(1);少なくともdest_addressは簡単ではありません)。

最後になりましたが、このアルゴリズムは、配列インデックスを入力配列内の要素と交換することにより、インプレースを実現します。これは明らかに数値配列でのみ機能します。文字列で使用する必要があります。もちろん、強い型付けを台無しにして、メモリがインデックスが属していない場所に保存することを許容すると仮定して先に進むことができます。ただし、これは文字列を32ビットのメモリに圧縮できる場合にのみ機能します(32ビット整数と仮定)。それはわずか16文字です(16> log(5,000,000)の場合は無視します)。

著者の1人による別の論文では、正確な説明はまったく示されていませんが、MSLの実行時間は準線形であり、完全に間違っています。

要約するために:動作するリファレンス実装、または動作するインプレース基数ソートの少なくとも良い擬似コード/説明を見つける希望はありますか? DNAストリング?

193
Konrad Rudolph

さて、ここに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塩基の1,000万シーケンスについて、最適化されたイントロソートよりも約3倍高速です。

58
dsimcha

インプレース基数ソートを見たことがありません。基数ソートの性質から、一時配列がメモリーに収まる限り、基数ソートよりもはるかに高速であるとは思いません。

理由:

ソートは入力配列に対して線形読み取りを行いますが、すべての書き込みはほぼランダムに行われます。特定のNから、これは書き込みごとのキャッシュミスになります。このキャッシュミスがアルゴリズムの速度を低下させます。設置されているかどうかにかかわらず、この効果は変わりません。

私はこれがあなたの質問に直接答えることはないことを知っていますが、ソートがボトルネックである場合は、ソーティングアルゴリズムに近い前処理ステップ(ソフトヒープのwikiページで開始できます)。

これにより、キャッシュの局所性が非常に向上します。そうすれば、テキストブックの定位置外の基数ソートのパフォーマンスが向上します。書き込みはほぼランダムに行われますが、少なくとも同じメモリチャンクを中心にクラスター化されるため、キャッシュヒット率が向上します。

しかし、実際にうまくいくかどうかはわかりません。

Btw:DNA文字列のみを扱う場合:文字を2ビットに圧縮して、データを非常に多く圧縮できます。これにより、単純な表現よりも4倍メモリ要件が削減されます。アドレス指定はより複雑になりますが、CPUのALUにはすべてのキャッシュミスの間に費やす時間が多くあります。

20

シーケンスをビット単位でエンコードすることにより、メモリ要件を確実に削減できます。あなたは順列を見ているので、長さ2については、16ステート、つまり4ビットの「ACGT」を使用します。長さ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;
}
8
EvilTeach

データセットが非常に大きい場合、ディスクベースのバッファアプローチが最適だと思います。

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バケット)が返されます。これにより、ディスクベースのバッファーのブランチが少なくなります。これによりパフォーマンスが改善される場合と改善されない場合があるため、試してみてください。

6
FryGuy

手足に出て、heap/ heapsort 実装に切り替えることをお勧めします。この提案にはいくつかの前提があります。

  1. データの読み取りを制御します
  2. ソートされたデータを「開始」するとすぐに、ソートされたデータを使用して意味のある何かを実行できます。

ヒープ/ヒープソートの利点は、データを読み取りながらヒープを構築でき、ヒープを構築した直後に結果を取得できることです。

一歩下がろう。非同期でデータを読み取ることができるほど幸運な場合(つまり、何らかの読み取り要求を送信し、データの準備ができたら通知を受けることができます)、その後、待機中にヒープのチャンクを構築できます入ってくるデータの次のチャンク-ディスクからでも。多くの場合、このアプローチにより、ソートの半分のコストのほとんどをデータの取得に費やした時間の後ろに埋めることができます。

データを読み取った後、最初の要素はすでに利用可能です。データの送信先に応じて、これは素晴らしいことです。別の非同期リーダー、または並列「イベント」モデル、またはUIに送信する場合、チャンクとチャンクを送信することができます。

つまり、データの読み取り方法を制御できず、同期的に読み取られ、完全に書き出されるまでソートされたデータを使用できない場合は、これをすべて無視します。 :(

ウィキペディアの記事を参照してください。

6
Joe

パフォーマンスに関しては、より一般的な文字列比較のソートアルゴリズムをご覧ください。

現在、すべての文字列のすべての要素に触れることになりますが、もっとうまくやることができます!

特に、 バーストソート はこの場合に非常に適しています。おまけとして、burstsortは試行に基づいているため、DNA/RNAで使用される小さなアルファベットサイズに対して非常にうまく機能します。これは、3進検索ノード、ハッシュ、または他のトライノード圧縮スキームを構築する必要がないためです。トライ実装。試行は、接尾辞配列のような最終目標にも役立つ場合があります。

Burstsortの適切な汎用実装は、ソースフォージの http://sourceforge.net/projects/burstsort/ で入手できますが、インプレースではありません。

比較の目的で、C-burstsortの実装は、 http://www.cs.mu.oz.au/~rsinha/papers/SinhaRingZobel-2006.pdf ベンチマークよりもクイックソートよりも4〜5倍速く、いくつかの典型的なワークロードの基数ソート。

4
Edward KMETT

大規模ゲノムシーケンス処理 Drsをご覧ください。笠原と森下。

4つのヌクレオチド文字A、C、G、およびTで構成される文字列は、muchより高速な処理のために、整数に特別にエンコードできます。基数ソートは、本で説明されている多くのアルゴリズムの1つです。この質問に対する受け入れられた答えを適応させることができ、大きなパフォーマンスの改善が見られるはずです。

4
Rudiger

" 余分なスペースなしの基数ソート "は、問題に対処する論文です。

4
eig

trie を使用してみてください。データの並べ替えは、データセットを繰り返し処理して挿入するだけです。構造は自然にソートされ、Bツリーに似ていると考えることができます(比較を行う代わりに、alwaysポインター間接参照を使用します) 。

キャッシュ動作はすべての内部ノードを優先するため、おそらくそれを改善することはないでしょう。ただし、トライの分岐係数をいじることもできます(すべてのノードが単一のキャッシュラインに収まることを確認し、ヒープに類似したトライノードを、レベル順のトラバーサルを表す連続した配列として割り当てます)。試行もデジタル構造(長さkの要素に対するO(k)の挿入/検索/削除)であるため、基数ソートと競合するパフォーマンスが必要です。

3
Tom

burstsort 文字列のパックビット表現です。バーストソートは基数ソートよりもはるかに良い局所性を持っていると主張されており、古典的な試行の代わりにバースト試行で余分なスペースの使用を抑えています。元の紙には測定値があります。

3
Darius Bacon

Radix-Sortはキャッシュを意識しておらず、大規模なセットの最速のソートアルゴリズムではありません。あなたが見ることができます:

また、圧縮を使用して、DNAの各文字を2ビットにエンコードしてから、ソート配列に保存することもできます。

2
bill

dsimchaのMSB基数ソートは見栄えが良いですが、Nilsはキャッシュの局所性が大きな問題サイズであなたを殺しているという観察で問題の中心に近づきます。

非常にシンプルなアプローチをお勧めします。

  1. 基数ソートが効率的な最大サイズmを経験的に推定します。
  2. 入力を使い果たすまで、m要素のブロックを一度に読み取り、基数で並べ替えて、十分なメモリがある場合はメモリバッファに、そうでなければファイルに書き込みます。
  3. マージソート結果のソート済みブロック。

Mergesortは、私が知っている最もキャッシュに優しいソートアルゴリズムです。「配列AまたはBのいずれかから次の項目を読み取り、次に出力バッファーに項目を書き込みます。」 テープドライブで効率的に実行されます。 nアイテムを並べ替えるには2nスペースが必要ですが、キャッシュの局所性が大幅に改善されるため、重要ではないことになります。基数ソートを配置すると、とにかく余分なスペースが必要になります。

最後に、マージソートは再帰なしで実装できることに注意してください。実際にこの方法で実行すると、真の線形メモリアクセスパターンが明確になります。

1
j_random_hacker

まず、問題のコーディングについて考えます。文字列を取り除き、バイナリ表現に置き換えます。最初のバイトを使用して、長さ+エンコードを示します。または、4バイト境界で固定長表現を使用します。次に、基数ソートがはるかに簡単になります。基数ソートの場合、最も重要なことは、内部ループのホットスポットで例外処理を行わないことです。

OK、4-nary問題についてもう少し考えました。 Judy tree のようなソリューションが必要です。次のソリューションでは、可変長文字列を処理できます。固定長の場合は、長さビットを削除するだけで、実際には簡単になります。

16個のポインターのブロックを割り当てます。ブロックは常に整列されるため、ポインターの最下位ビットを再利用できます。専用のストレージアロケーターが必要になる場合があります(大きなストレージを小さなブロックに分割する)。さまざまな種類のブロックがあります。

  • 7ビットの可変長文字列のエンコード。それらがいっぱいになると、次のように置き換えます。
  • 位置は次の2文字をエンコードし、次のブロックへの16個のポインターがあり、次で終わります。
  • 文字列の最後の3文字のビットマップエンコーディング。

ブロックの種類ごとに、LSBに異なる情報を保存する必要があります。可変長の文字列があるため、文字列の終わりも保存する必要があり、最後の種類のブロックは最も長い文字列にのみ使用できます。構造を深く掘り下げると、7ビットの長さをlessに置き換える必要があります。

これにより、ソートされた文字列の合理的に高速で非常にメモリ効率の高いストレージが提供されます。 trie のように動作します。これを機能させるには、十分な単体テストを作成してください。すべてのブロック遷移のカバレッジが必要です。 2種類目のブロックのみで開始したい場合。

さらにパフォーマンスを上げるには、さまざまなブロックタイプとより大きなサイズのブロックを追加する必要があります。ブロックが常に同じサイズで十分な大きさである場合、ポインターに使用するビットをさらに少なくすることができます。 16ポインターのブロックサイズでは、32ビットアドレス空間に既にバイトがあります。興味深いブロックタイプについては、Judyツリーのドキュメントをご覧ください。基本的に、スペース(およびランタイム)のトレードオフのためにコードとエンジニアリング時間を追加します

おそらく、最初の4文字を256幅の直接基数から始めたいと思うでしょう。それはまともなスペース/時間のトレードオフを提供します。この実装では、単純なトライよりもメモリオーバーヘッドがはるかに少なくなります。約3倍小さい(測定していません)。 O(n log n)クイックソートと比較したときに気づいたように、定数が十分に低い場合、O(n)は問題ありません。

ダブルスの処理に興味がありますか?短いシーケンスでは、そうなるでしょう。カウントを処理するためにブロックを適応させるのは難しいですが、スペース効率は非常に高くなります。

1

問題を解決したように見えますが、記録としては、実行可能なインプレース基数ソートの1つのバージョンが「American Flag Sort」であるようです。ここで説明します: Engineering Radix Sort 。一般的な考え方は、各文字で2つのパスを実行することです。最初に各文字の数を数えるので、入力配列をビンに再分割できます。次に、各要素を正しいビンに入れ直します。次に、次の文字位置で各ビンを再帰的にソートします。

1
AShelly