イバコの生存記録

いまは競プロ(AtCoder)記事がメインです。

77. C# で MultiSet を実装する(大枠の話と列挙アルゴリズムについて)

ここで言う MultiSet とは C++ の multiset を模したデータ構造です。C# に標準で存在しないため、競技プログラミングなどで利用したい場合は自作する必要があります。

 

MultiSet が満たすべき性質

MultiSet は端的に言うと、順序を保ったまま高速に要素を追加・削除・検索可能なリストです。順序を保つことから、二分探索によって検索を対数時間で行えます。

対数時間がどのくらい速いのか、というイメージは以下の記事を参照してください。

ibako-piyo.hatenablog.com

 

このデータ構造では、以下の性質を満たす必要があります。

  • Add: 要素の追加を対数時間  O(\log N) で実行できる
  • Remove: 要素の削除を対数時間  O(\log N) で実行できる
  • LowerBound: 指定した値以上の要素を対数時間  O(\log N) で検索し、昇順に列挙するイテレータを返す
  • UpperBound: 指定した値より大きな要素を対数時間  O(\log N) で検索し、昇順に列挙するイテレータを返す

LowerBound / UpperBound でイテレータを返すことによって、「指定した値を基準にした順序」を高速に見ることができます。指定した値を対数時間で見つけて、そこからは線形時間でアクセスするイメージです。

これにより、例えば以下のような問題を解くことが可能になります。

atcoder.jp

 

実装方針

要素の検索を対数時間で行いたいことから、二分探索木を内部で持つと良いです。二分探索木は、以下のような性質を持つ木です。

  • 全ての頂点が、高々2つの子頂点を持つ
  • 子を1つ以上持つ全ての親頂点について、「左の子孫の値 < 親 ≦ 右の子孫の値」を満たす(等号の位置はどちらか一方であれば良い)

たとえば、以下のような木が二分探索木の性質を満たしています。

f:id:ibako_piyo:20220303222804p:plain

二分探索木

単純な二分探索木は最悪計算量が  O(N) になります。
たとえば、以下のようにソート済みの値が順に追加された場合、一方向にしか枝が伸びず、ただの片方向リストと何ら変わりない構造となってしまいます。

f:id:ibako_piyo:20220303223153p:plain

二分探索木の最悪パターン

そこで、データ数  N に対して深さの最大値がなるべく  \log N に近付くよう、「バランスの良い木」を維持する平衡二分探索木というデータ構造が採用されます。

平衡二分探索木にはAVL木・赤黒木など様々な実装が知られていますが、今回の記事では詳細を説明しません。なお、AVL木については以下の記事が分かりやすいと思います。今回実装する MultiSet も、AVL木を利用します。

daeudaeu.com

 

今回は、データをAVL木で保持した上で、列挙(GetEnumerator)と二分探索(LowerBound / UpperBound)を MultiSet で実装します。

 

二分探索木における昇順列挙

深さ優先探索の応用で昇順列挙が可能になります。

  1. 根をスタックに追加する
  2. スタックが空なら終了し、そうでない場合、スタックから頂点を1つ取り出す
  3. 葉か、子頂点が全て「既知」であれば、頂点の値を出力して 2. へ
  4. 右の子がいる場合、スタックに右の子を追加して、右の子を「既知」とする
  5. 現在確認している頂点を、再度スタックに追加する
  6. 左の子がいる場合、スタックに左の子を追加して、左の子を「既知」とする
  7. 2. へ

イメージとしては、左・真ん中・右の順で頂点を展開していく、という感じです。

 

LowerBound / UpperBound のアルゴリズム

二分探索フェーズとイテレータを返す(列挙)フェーズに分かれており、やや難解です。

二分探索フェーズ

  1. 空のスタックを用意して、「確認中の頂点」を根とする
  2. 「確認中の頂点」が null なら終了
  3. 「確認中の頂点」の値について、LowerBoundなら「指定値以上」、UpperBoundなら「指定値より大きい」か比較する。
    1. (条件を満たす場合)スタックに「確認中の頂点」を追加し、「確認中の頂点」を左の子に変更して 2. へ
    2. (条件を満たさない場合)「確認中の頂点」を右の子に変更して 2. へ

二分探索フェーズでは、毎ループ木を掘っていくことになるので最悪計算量は  O(\log N) となります。探索過程で現れた「条件を満たす頂点」がスタックに追加されます。

「条件を満たす場合」の処理の心は、「この頂点は条件を満たすが、これより小さくて条件を満たす値があるかもしれない」ということです。二分探索木の性質から、現在見ている値より小さい値が存在する場合、必ず左の子孫にいます。

「条件を満たさない場合」の処理については、「この頂点は条件を満たさないが、これ以上で条件を満たす値があるかもしれない」です。こちらも二分探索木の性質から言えることです。

 

たとえば、先ほど例に出した二分探索木で LowerBound(4) を実行した場合、以下のような遷移となります。黄色頂点がスタックに追加されて、薄オレンジ頂点は探索過程で「確認中の頂点」になるものです。

f:id:ibako_piyo:20220303230827p:plain

LowerBound(4) における挙動

列挙フェーズ

LowerBound(4) によって、[7, 4] の頂点がスタックに追加されました。二分探索木の性質より、スタックに追加された「条件を満たす頂点」の右子孫は、全て条件を満たします。何故なら、「条件を満たす頂点」以上の値を持つことが保証されているからです。

これらを昇順に列挙するイテレータを返すことが出来れば、めでたく完成となります。こちらは以下のようなアルゴリズムになります。

  1. スタックが空なら終了し、そうでない場合、スタックから頂点を1つ取り出す
  2. 頂点の値を出力する
  3. 「頂点の右の子」を根とする部分木について、前述の「昇順列挙」処理を適用する
  4. 1. に戻る

根から順に辿っているので、スタックから取り出される頂点は昇順となります。また、二分探索木の性質から、「取り出された頂点の値 ≦ 任意の右子孫の値 < 次に取り出される頂点の値」となります。よって、このアルゴリズムで昇順列挙が可能になります。

「右子孫の昇順列挙」は、部分木とみなすことで先ほど述べた「二分探索木における昇順列挙」が適用できます。

 

実装例

以下のように、LowerBound / UpperBound では IEnumerable<T> を返すようにした方が便利です。

gist.github.com

 

利用例

先ほど掲載した問題をACできるコードを以下に示します(.NET Core 3.1.201)。

atcoder.jp

 

gist.github.com

 

ポイントは33行目・42行目です。LINQを使えば、今回の問題のような「特定の基準値から  k 番目の値」を直感的に記述できます。