web-dev-qa-db-ja.com

フィボナッチヒープデータ構造の背後にある直感とは何ですか?

フィボナッチヒープに関するウィキペディアの記事 を読み、データ構造に関するCLRSの説明を読みましたが、このデータ構造が機能する理由についてはほとんどわかりません。フィボナッチヒープがそのように設計されているのはなぜですか?彼らはどのように機能しますか?

ありがとう!

63
templatetypedef

この答えはかなり長くなりますが、フィボナッチヒープがどこから来たかについての洞察を提供するのに役立つと思います。 binomial heaps および amortized analysis にすでに精通していると仮定します。

動機:フィボナッチ数が増える理由

フィボナッチヒープに飛び込む前に、最初にフィボナッチヒープが必要な理由を調べるのはおそらく良いことです。他にも多くの種類のヒープ( binary heaps および binomial heaps など)がありますが、なぜ別のヒープが必要なのでしょうか?

主な理由は Dijkstraのアルゴリズム および Primのアルゴリズム にあります。これらのグラフアルゴリズムは両方とも、関連する優先度を持つノードを保持する優先度キューを維持することにより機能します。興味深いことに、これらのアルゴリズムはdecrease-keyと呼ばれるヒープ操作に依存します。これは、優先度キューにすでにあるエントリを取得し、そのキーを減らします(つまり、優先度を上げます)。実際、これらのアルゴリズムの実行時間の多くは、decrease-keyを呼び出さなければならない回数によって説明されます。減少キーを最適化するデータ構造を構築できれば、これらのアルゴリズムのパフォーマンスを最適化できます。バイナリヒープと二項ヒープの場合、減少キーは時間O(log n)を要します。nは優先度キュー内のノードの数です。それをO(1)に落とすことができれば、ダイクストラのアルゴリズムとプリムのアルゴリズムの時間計算量はO(m log n)から(m + n log n)に落ちます。これは以前より漸近的に速くなります。したがって、キーの減少を効率的にサポートするデータ構造を構築しようとするのは理にかなっています。

より良いヒープ構造の設計を検討するもう1つの理由があります。空のバイナリヒープに要素を追加する場合、各挿入に時間がかかりますO(log n)。 時間内にバイナリヒープを構築するO(n) 可能です。n個の要素すべてを事前に知っている場合、要素がストリームに到着する場合は不可能です。二項ヒープの場合、n個の連続した要素の挿入にはそれぞれ償却時間O(1)がかかりますが、挿入が削除と組み合わされると、挿入はそれぞれΩ(log n)時間かかる場合があります。したがって、挿入に最適化する優先度キューの実装を検索して、それぞれに時間がかかるようにしたい場合がありますO(1)。

ステップ1:遅延二項ヒープ

フィボナッチヒープの構築を開始するには、二項ヒープから始めて、挿入に時間がかかるように変更しますO(1)。これを試すのはそれほど理不尽なことではありません。結局、多くの挿入を行い、多くのデキューを行わない場合は、挿入を最適化するのが理にかなっています。

思い出すと、二項ヒープは、ヒープ内のすべての要素を binomial trees のコレクションに格納することで機能します。次数nの二項ツリーには2があります。n その中のノード、およびヒープは、すべてヒーププロパティに従う二項ツリーのコレクションとしての構造です。通常、二項ヒープの挿入アルゴリズムは次のように機能します。

  • 新しいシングルトンノードを作成します(これは次数0のツリーです)。
  • 順序0:のツリーがある場合
    • 0次の2つのツリーを1次のツリーにマージします。
    • 順序1のツリーがある場合:
      • 順序1の2つのツリーをツリー順序2にマージします。
      • 順序2のツリーがある場合:
      • ...

このプロセスにより、各時点で、各オーダーの最大で1つのツリーが存在することが保証されます。各ツリーはその順序よりも指数関数的に多くのノードを保持するため、これによりツリーの総数が少なくなり、デキューが迅速に実行されます(dequeue-minステップを実行した後、多くの異なるツリーを見る必要がないため)。

ただし、これは、ノードをマージする必要があるΘ(log n)ツリーがある可能性があるため、ノードを2項ヒープに挿入する最悪のランタイムがΘ(log n)であることも意味します。これらのツリーを一緒にマージする必要があるのは、デキュー手順を実行するときにツリーの数を少なくする必要があるためであり、ツリーの数を少なくすることによる将来の挿入にはまったく利点がありません。

これにより、二項ヒープからの最初の離脱が導入されます。

変更1:ヒープにノードを挿入する場合、次数0のツリーを作成し、既存のツリーのコレクションに追加します。ツリーを一緒に統合しないでください。

別の変更が可能です。通常、2つの二項ヒープをマージするとき、マージ手順を実行して、結果のツリーに各次数のツリーが最大で1つになるようにそれらを結合します。繰り返しますが、デキューが高速になるようにこの圧縮を行いますが、マージ操作でこれを支払う必要がある本当の理由はありません。したがって、2番目の変更を行います。

変更2:2つのヒープをマージするときは、マージせずにすべてのツリーを結合します。ツリーを一緒に統合しないでください。

この変更を行うと、新しいノードを作成してツリーのコレクションに追加するだけなので、エンキュー操作のO(1)パフォーマンスを簡単に取得できます。ただし、この変更を行って他に何もしないと、dequeue-min操作のパフォーマンスが完全に損なわれます。 dequeue-minは、最小値を見つけるために最小値を削除した後、ヒープ内のすべてのツリーのルートをスキャンする必要があることを思い出してください。 Θ(n)ノードを途中に挿入して追加する場合、デキュー操作はΘ(n)時間を費やしてこれらすべてのツリーを調べる必要があります。これはパフォーマンスに大きな打撃を与えます...それを回避できますか?

挿入によって実際にツリーが追加されるだけの場合、最初のデキューには確かにΩ(n)時間かかります。ただし、それは、every dequeueが高価になる必要があるという意味ではありません。デキューを行った後、ヒープ内のすべてのツリーを合体させて、各順序のツリーが1つだけになるとどうなりますか?これには最初は長い時間がかかりますが、連続して複数のデキューを開始すると、ツリーの数が少なくなるため、将来のデキューが大幅に高速になります。

ただし、このセットアップにはわずかな問題があります。通常の二項ヒープでは、ツリーは常に順番に格納されます。単に新しいツリーをツリーのコレクションに投げ込み、ランダムに合体させ、その後さらにツリーを追加し続ける場合、ツリーが任意の順序になるという保証はありません。したがって、これらのツリーをマージする新しいアルゴリズムが必要になります。

このアルゴリズムの背後にある直感は次のとおりです。ツリーの順序からツリーにマッピングするハッシュテーブルを作成するとします。その後、データ構造内の各ツリーに対して次の操作を実行できます。

  • 調べて、その順序のツリーが既にあるかどうかを確認します。
  • そうでない場合は、現在のツリーをハッシュテーブルに挿入します。
  • さもないと:
    • 現在のツリーをその順序のツリーとマージし、ハッシュテーブルから古いツリーを削除します。
    • このプロセスを再帰的に繰り返します。

この操作により、完了時に各オーダーのツリーが最大で1つになることが保証されます。また、比較的効率的です。 Tの合計ツリーで始まり、tの合計ツリーで終わると仮定します。最終的に実行するマージの合計数はT-t-1になり、マージを実行するたびに実行に時間がかかりますO(1)。したがって、この操作の実行時間は、ツリーの数(各ツリーに少なくとも1回アクセスされる)と実行されたマージの数に比例します。

ツリーの数が少ない場合(たとえば、Θ(log n))、この操作には時間O(log n)しかかかりません。ツリーの数が多い場合(たとえば、Θ(n))、この操作にはΘ(n)時間かかりますが、残りのΘ(log n)ツリーのみが残り、将来のデキューがはるかに高速になります。

償却分析を行い、潜在的な関数を使用することで、物事がどれだけ良くなるかを定量化できます。 Φを潜在的な関数とし、Φをデータ構造内のツリーの数とします。つまり、運用コストは次のとおりです。

  • Insert:O(1)は機能し、ポテンシャルを1つ増やします。償却原価はO(1)です。
  • Merge:O(1)は機能します。一方のヒープのポテンシャルは0に低下し、他方のヒープのポテンシャルは対応する量だけ増加するため、ポテンシャルに正味の変化はありません。したがって、償却後のコストはO(1)です。
  • Dequeue-Min:O(#trees + #merges)は機能し、ポテンシャルをΘ(log n)、つまりツリーの数まで減らします木を熱心にマージしている場合、dは二項ツリーにあります。これを別の方法で説明できます。木の数をΘ(log n)+ Eとして書きましょう。ここで、Eは「過剰な」木の数です。その場合、実行される合計作業はΘ(log n + E + #merges)です。余分なツリーごとに1つのマージを実行することに注意してください。したがって、実行される作業の合計はΘ(log n + E)です。ポテンシャルにより、ツリーの数がΘ(log n)+ EからΘ(log n)に低下するため、ポテンシャルの低下は-Eです。したがって、dequeue-minの償却コストはΘ(log n)です。

Dequeue-minの償却コストがΘ(log n)である理由を確認するもう1つの直感的な方法は、whyに余剰ツリーがあることです。これらの余分なツリーが存在するのは、これらの余分なツリーがすべての余分なツリーを作成し、それらにお金を払っていないためです!したがって、すべてのマージの実行に関連するコストを、それまでにかかっていた個々の挿入に「バックチャージ」し、Θ(log n)「コア」操作と他の多くの操作を残すことができます挿入。

したがって:

変更3:デキュー-最小操作で、すべてのツリーを統合して、各オーダーのツリーが最大で1つになるようにします。

この時点で、挿入とマージは時間O(1)で実行され、償却時間O(log n)で実行されているデキューされます。それはかなり気の利いたことです!ただし、キーの減少はまだ機能していません。それは挑戦的な部分になるだろう。

ステップ2:減少キーの実装

現在、フィボナッチヒープではなく、「遅延2項ヒープ」があります。二項ヒープとフィボナッチヒープの間の実際の変化は、減少キーの実装方法です。

キーの減少操作では、ヒープ内のエントリ(通常、それへのポインタがある)と、既存の優先度よりも低い新しい優先度を取得する必要があることを思い出してください。次に、その要素の優先度を新しいより低い優先度に変更します。

簡単なアルゴリズムを使用して、この操作を非常に迅速に(時間O(log n)で)実装できます。キーを減らす必要のある要素(O(1)時間内に配置できます。これへのポインタがあると仮定していることを思い出してください)を取得し、その優先度を下げます。次に、優先度が親より低い限り、ノードを親ノードと繰り返しスワップし、ノードが停止するか、ツリーのルートに到達すると停止します。各ツリーの高さは最大でO(log n)であり、各比較には時間O(1)がかかるため、この操作にはO(log n)の時間がかかります。

ただし、これよりもさらに良いことをしようとしていることを忘れないでください-ランタイムをO(1)にしたいのです!それはveryにマッチする厳しいバウンドです。これらのツリーの高さはΩ(log n)になる可能性があるため、ノードをツリーの上下に移動するプロセスは使用できません。もっと抜本的なことを試さなければなりません。

ノードのキーを減らしたいとします。ヒーププロパティに違反する唯一の方法は、ノードの新しい優先度がその親の優先度より低い場合です。その特定のノードをルートとするサブツリーを見ると、ヒーププロパティに従います。それでは、まったくおかしいアイデアです。ノードのキーを減らすたびに親ノードへのリンクを切断し、そのノードをルートとするサブツリー全体をツリーの最上位に戻すとどうなるでしょうか。

変更4:ノードのキーを減少キーで減少させ、その優先度が親の優先度よりも小さい場合、それをカットして追加しますルートリスト。

この操作の効果は何ですか?いくつかのことが起こります。

  1. 以前にノードを子として持っていたノードは、間違った数の子を持っていると考えます。次数nの二項ツリーはn個の子を持つように定義されていることを思い出してください。しかし、それはもはや真実ではありません。
  2. ルートリスト内のツリーのコレクションが増加し、将来のデキュー操作のコストが増加します。
  3. ヒープ内のツリーは、必ずしも二項ツリーになるとは限りません。それらは、さまざまな時点で子供を失った「以前の」二項ツリーかもしれません。

数値(1)はそれほど問題ではありません。親からノードを切り取る場合、そのノードの順序を1つ減らすだけで、以前に考えていたよりも子が少ないことを示すことができます。番号(2)も問題ではありません。次のdequeue-min操作で行われた余分な作業を減少キー操作にバックチャージできます。

番号(3)は非常に深刻な問題であり、対処する必要があります。問題は次のとおりです。二項ヒープの効率は、n個のノードのコレクションを異なる順序のΘ(log n)ツリーのコレクションに格納できるという事実に部分的に起因します。この理由は、各二項ツリーには2n その中のノード。ツリーからノードの切り取りを開始できる場合、多数の子(つまり、高次)を含むが、それらに多くのノードがないツリーが存在する危険があります。たとえば、次数kの単一ツリーから始めて、kのすべての孫に対してキー減少操作を実行するとします。これにより、kは次数kのツリーとして残りますが、k + 1個の合計ノードのみが含まれます。このプロセスをあらゆる場所で繰り返し続けると、非常に少数の子を持つさまざまな順序のツリーの束になる可能性があります。そのため、ツリーをグループ化するために合体操作を行う場合、ツリーの数を管理可能なレベルに減らして、実際に失うことを望まないΘ(log n)-時間の境界を破らないかもしれません。

この時点で、私たちは少しバインドされています。 O(1)時間減少キー機能を取得できるように、ツリーを再構成する方法には多くの柔軟性が必要ですが、ツリーを任意に再構成することはできません。 reduce-keyの償却ランタイムがO(log n)より大きい値に増加するまで。

必要な洞察-そして、正直なところ、フィボナッチ山の本当の天才だと思う-は、両者の妥協点です。アイデアは次のとおりです。親からツリーを切り取る場合、すでに親ノードのランクを1つ下げることを計画しています。この問題は、ノードがlotの子を失ったときに実際に発生します。この場合、ツリーの上位ノードがそれを認識せずにランクが大幅に低下します。したがって、各ノードは1人の子を失うことのみが許可されていると言います。ノードが2番目の子を失った場合、そのノードをその親から切り取ります。これにより、ノードがツリーの上位で欠落しているという情報が伝播されます。

これは大きな妥協であることがわかりました。 (ノードが同じツリーの子でない限り)ほとんどのコンテキストでキーを迅速に減少させます。また、ノードを親から切り取って減少キーを「伝播」する必要はほとんどありません。そのノードを祖父母から切り取ります。

どのノードが子を失ったかを追跡するために、各ノードに「マーク」ビットを割り当てます。各ノードは最初にマークビットがクリアされますが、子ノードを失うたびにビットが設定されます。ビットが既に設定された後に2番目の子が失われた場合、ビットをクリアしてから、親からノードを切り取ります。

変更5:最初は偽であった各ノードにマークビットを割り当てます。子がマークされていない親から切り取られるとき、親をマークします。マークされた親から子が切り取られるとき、親のマークを外し、親から親を切り取ります。

この古いStack Overflowの質問では、ツリーがこの方法では、n次の木は少なくともΘ(φn)ノード、ここでφは 黄金比 、約1.61です。これは、各ツリー内のノードの数は、ツリーの順序でまだ指数関数的であることを意味しますが、それは以前よりも低い指数です。その結果、Θ(log n)ビットに隠された用語は異なりますが、キーの減少操作の時間の複雑さについて以前に行った分析は依然として保持されます。

最後に考慮すべき点が1つあります。reduce-keyの複雑さはどうでしょうか。以前は、適切なノードをルートとするツリーを切り取り、ルートリストに移動しただけだったため、O(1)でした。ただし、今度は「カスケードカット」を行う必要があります。この場合、親からノードを切り取り、次にthatノードをits parentなどから切り取ります。 O(1)時間減少キーを与える?

ここでの観察は、各キーの減少操作に「チャージ」を追加して、親から親ノードをカットするために費やすことができるということです。既に2つの子が失われている場合にのみ親からノードをカットするため、各キーの減少操作は、親ノードをカットするために必要な作業の費用を支払うふりをすることができます。親をカットする場合、そのコストを以前のキー減少操作の1つに戻すことができます。したがって、個々のキーの減少操作が完了するまでに時間がかかる場合でも、ランタイムがO(1)で償却されるように、以前の呼び出し全体で作業をいつでも償却できます。

ステップ3:リンクされたリストがたくさん!

最後に詳細を説明します。これまで説明してきたデータ構造は扱いにくいものですが、壊滅的なほど複雑ではないようです。フィボナッチヒープは恐ろしいという評判があります...なぜですか?

その理由は、上記のすべての操作を実装するには、ツリー構造を非常に巧妙に実装する必要があるためです。

通常、多方向ツリーを表すには、各親がすべての子を指すようにする(おそらく子の配列を持つ)か、 左子/右兄弟の表現を使用 のいずれかを使用します。親には1つの子へのポインターがあり、その子は他の子のリストを指します。二項ヒープの場合、これは完璧です。ツリーで行う必要がある主な操作は、1つのルートノードを別のルートノードの子にする結合操作です。したがって、親から子に向けられたツリー内のポインタは完全に合理的です。

フィボナッチヒープの問題は、キーの減少ステップを考慮すると、この表現が非効率的であることです。フィボナッチヒープは、標準の二項ヒープのすべての基本的なポインター操作をサポートする必要がありますand単一の子を親から切り取る能力。

多方向ツリーの標準表現を検討してください。各親ノードにその子へのポインターの配列またはリストを格納させることでツリーを表現する場合、(O(1))で子のリストから子ノードを効率的に削除することはできません。つまり、キーの減少の実行時間は、サブツリーをルートリストに移動する論理的なステップではなく、子を削除する簿記のステップによって支配されます。同じ問題は、左の子、右の兄弟の表現に現れます。

この問題の解決策は、ツリーを奇妙な方法で保存することです。各親ノードには、その子の1つ(および任意の)1つへのポインターが格納されます。その後、子は循環リンクリストに保存され、各子は親に戻ります。 O(1)時間で2つの循環リンクリストを連結し、O(1)時間で1つのエントリを挿入または削除できるため、必要なものを効率的にサポートできます。ツリー操作:

  • 1つのツリーを別のツリーの子にします。最初のツリーに子がない場合、その子ポインターを2番目のツリーを指すように設定します。それ以外の場合は、2番目のツリーを、最初のツリーの循環リンクされた子リストにつなぎます。

  • ツリーから子を削除します。親ノードの子のリンクリストからその子ノードを接合します。親ノードの子を表すために選択された単一ノードの場合、兄弟ノードのいずれかを選択して置き換えます(または、最後の子の場合はポインターをnullに設定します)。

発生する可能性のある異なるエッジケースの数のために、これらすべての操作を実行するときに考慮してチェックする必要のある非常に多くのケースがあります。すべてのポインタージャグリングに関連するオーバーヘッドは、フィボナッチヒープが漸近的な複雑さで示唆されるより実際に遅い理由の1つです(もう1つの大きなものは、補助データ構造を必要とする最小値を削除するためのロジックです)。

変更6:効率的な木の結合と、あるツリーから別のツリーへの切り取りをサポートするツリーのカスタム表現を使用します。

結論

この答えがフィボナッチヒープの謎に光を当てることを願っています。合理的な洞察に基づいた一連の単純なステップによって、より単純な構造(二項ヒープ)からより複雑な構造への論理的な進行を確認できることを願っています。削除を犠牲にして挿入を償却効率化することは不合理ではなく、サブツリーを切り取って減少キーを実装することも同様に狂っていません。そこから、残りの詳細は構造がまだ効率的であることを保証することにありますが、それらはcausesではなく、他の部分のconsequencesです。

フィボナッチヒープについて詳しく知りたい場合は、この2部構成の講義スライドをご覧ください。 パート1 は、二項ヒープを導入し、遅延二項ヒープがどのように機能するかを示します。 パート2 フィボナッチヒープを探索します。これらのスライドは、ここで説明したよりも数学的な深みになります。

お役に立てれば!

143
templatetypedef