web-dev-qa-db-ja.com

バイナリヒープの効率的な実装

バイナリヒープ を効率的に実装する方法についての情報を探しています。ヒープを効率的に実装することについての素晴らしい記事がどこかにあるはずだと思いますが、私はそれを見つけていません。実際、私はの問題に関するリソースを見つけることができませんでした 効率的 ヒープを配列に格納するなどの基本を超えた実装。以下で説明するものを超えて、高速なバイナリヒープを作成するための手法を探しています。

Microsoft Visual C++およびGCCのstd :: priority_queueよりも高速な、またはstd :: make_heap、std :: Push_heap、およびstd :: pop_heapを使用するC++実装をすでに作成しました。以下は、私が実装ですでにカバーしているテクニックです。私は最後の2つだけを思いついたが、それらが新しいアイデアだとは思えない。

(編集:メモリ最適化に関するセクションを追加)

  • インデックスを1から開始
    バイナリヒープの ウィキペディア実装ノート を見てください。ヒープのルートがインデックス0に配置されている場合、インデックスnのノードの親、左子、および右子の式は、それぞれ(n-1)/ 2、2n + 1、および2n +2です。 1ベースの配列を使用する場合、数式はより単純なn/2、2n、および2n + 1になります。したがって、1ベースの配列を使用すると、親と左子がより効率的になります。 pが0ベースの配列を指し、q = p --1の場合、p [0]にq [1]としてアクセスできるため、1ベースの配列を使用してもオーバーヘッドは発生しません。
    • リーフと交換する前に、ポップ/リムーバル移動要素をヒープの下部に作成します
      ヒープ上のポップは、上部の要素を左端の下部の葉に置き換え、ヒーププロパティが復元されるまで下に移動することで説明されることがよくあります。これには、通過するレベルごとに2つの比較が必要であり、リーフをヒープの最上部に移動したため、ヒープのはるか下に移動する可能性があります。したがって、2 lognより少し少ない比較を期待する必要があります。

      代わりに、一番上の要素があったヒープに穴を残すことができます。次に、大きい方の子を繰り返し上に移動して、その穴をヒープの下に移動します。これには、通過するレベルごとに1つの比較のみが必要です。このようにして、穴は葉になります。この時点で、右端の一番下の葉を穴の位置に移動し、ヒーププロパティが復元されるまでその値を上に移動できます。移動した値は葉だったので、ツリーのはるか上に移動するとは思われません。したがって、log nの比較よりも少し多く、以前よりも優れていると予想する必要があります。

    • 交換トップをサポート
      max要素を削除し、新しい要素も挿入するとします。次に、上記の削除/ポップの実装のいずれかを実行できますが、右下のリーフを移動する代わりに、挿入/プッシュする新しい値を使用します。 (ほとんどの操作がこの種の場合、トーナメントツリーはヒープよりも優れていることがわかりましたが、それ以外の場合はヒープがわずかに優れています。)
    • Sizeof(T)を2の累乗にする
      親、左子、右子の数式はインデックスで機能し、ポインタ値で直接機能させることはできません。したがって、インデックスを操作することになります。これは、インデックスiから配列pの値p [i]を検索することを意味します。 pがT *で、iが整数の場合、
      &(p[i]) == static_cast<char*>(p) + sizeof(T) * i
      

      コンパイラは、p [i]を取得するためにこの計算を実行する必要があります。 sizeof(T)はコンパイル時定数であり、sizeof(T)が2の累乗である場合、乗算をより効率的に実行できます。私の実装は、8パディングバイトを追加してsizeof(T)を24から32に増やすことで高速化されました。キャッシュの効率が低下したということは、おそらくこれが十分に大きなデータセットの利点ではないことを意味します。

    • インデックスを事前に乗算する
      これは私のデータセットのパフォーマンスが23%向上しました。親、左子、右子を見つける以外にインデックスで行う唯一のことは、配列でインデックスを検索することです。したがって、インデックスiの代わりにj = sizeof(T)* iを追跡する場合、p [i]の評価で暗黙的に行われる乗算なしでルックアップp [i]を実行できます。
      &(p[i]) == static_cast<char*>(p) + sizeof(T) * i == static_cast<char*>(p) + j
      

      次に、j値の左子式と右子式はそれぞれ2 * jと2 * j + sizeof(T)になります。親の数式はもう少しトリッキーで、j値をi値に変換して、次のように戻す以外の方法は見つかりませんでした。

      parentOnJ(j) = parent(j/sizeof(T))*sizeof(T) == (j/(2*sizeof(T))*sizeof(T)
      

      Sizeof(T)が2の累乗である場合、これは2シフトにコンパイルされます。これは、インデックスiを使用する通常の親よりも1操作多くなります。ただし、ルックアップ時に1つの操作を保存します。したがって、正味の効果は、この方法で親を見つけるのに同じ時間がかかる一方で、左子と右子の検索が速くなることです。

    • メモリの最適化

      TokenMacGuyとtemplatetypedefの回答は、キャッシュミスを減らすメモリベースの最適化を指摘しています。非常に大きなデータセットまたは使用頻度の低い優先キューの場合、OSによってキューの一部をディスクにスワップアウトできます。その場合、ディスクからのスワップインは非常に遅いため、キャッシュを最適に利用するために多くのオーバーヘッドを追加する価値があります。私のデータはメモリに簡単に収まり、継続的に使用されるため、キューのどの部分もディスクにスワップされない可能性があります。これは、優先度付きキューのほとんどの用途に当てはまると思います。

      CPUキャッシュをより有効に活用するように設計された他の優先キューがあります。たとえば、4ヒープではキャッシュミスが少なくなり、余分なオーバーヘッドの量はそれほど多くありません。 LaMarca and Ladner 1996年に、整列された4ヒープに移行することでパフォーマンスが75%向上したと報告しています。ただし、 Hendriks 2010年には次のように報告されています。

      データの局所性を改善し、キャッシュミスを減らすために、LaMarcaとLadner [17]によって提案された暗黙的なヒープの改善もテストされました。 4ウェイヒープを実装しました。これは、非常に偏った入力データに対しては2ウェイヒープよりもわずかに優れた一貫性を示していますが、キューサイズが非常に大きい場合のみです。非常に大きなキューサイズは、階層ヒープによってより適切に処理されます。

    • 質問
      これらよりも多くのテクニックがありますか?
  • 51
    Bjarke H. Roune

    このトピックに関する興味深い論文/記事では、ヒープの全体的なレイアウトでのキャッシュ/ページングの動作について考察しています。データ構造の実装の他のほとんどの部分よりも、キャッシュミスやページインの支払いに非常にコストがかかるという考えです。このホワイトペーパーでは、これに対処するヒープレイアウトについて説明しています。

    ポール・ヘニング・カンプが間違ったことをしている

    @TokenMacGuyの投稿の詳細として、 キャッシュを無視するデータ構造 を調べることをお勧めします。アイデアは、任意のキャッシングシステムに対して、キャッシュミスの数を最小限に抑えるデータ構造を構築することです。それらはトリッキーですが、マルチレイヤーキャッシュシステム(たとえば、レジ​​スタ/ L1/L2/VM)を扱う場合でもうまく機能するため、実際にはあなたの観点からは役立つかもしれません。

    実際には 最適なキャッシュ忘却優先キューを詳述した論文 興味深いかもしれません。このデータ構造は、すべてのレベルでキャッシュミスの数を最小限に抑えようとするため、速度の点であらゆる種類の利点があります。

    3
    templatetypedef

    バイナリヒープのwikiページでこのリンクを見逃したのか、それとも価値がないと判断したのかはわかりませんが、どちらの方法でも: http://en.wikipedia.org/wiki/B-heap

    1
    NoSenseEtAl

    最初のポイント:アレイベースの実装に「スペアスポット」があることさえ無駄ではありません。とにかく、多くの操作には一時的な要素が必要です。毎回新しい要素を初期化するのではなく、インデックス[0]に専用の要素があると便利です。

    0
    Gaminic