2020-07-11に投稿

十分にネストした配列は、リストと見分けがつかない

読了目安:12分

十分にネストした配列は、リストと見分けがつかない

簡易な優先度付きキュー(PriorityQueue)の実装

こんにちは。
へっぽこ茶コーダーの項垂人です。

今回は皆さんに私お気に入りの自作ライブラリ、優先度付きキューの実装をご紹介したいと思います。
たいしたものではありませんがこれが初の技術系記事です。
競プロ愛好家の皆さんも、そうでない方も、よろしければ是非ご一読下さい。

説明

優先度付きキュー(PriorityQueue,以下PQ)とは、その名の通り、いくつかの要素を格納した中から最も評価値の高いもの素早く取り出すことのできるデータ構造です。
C++やPythonでは標準ライブラリとして用意されていますが、残念ながらRuby言語にはないので、競プロ用に自作してみました。
※詳しい解説はリンク先のwikipediaをどうぞ

用途

まぁ色々あるのでしょうが、代表的なところではスケジューラーあたりですかね。
コンピューターを使う上ではOSがプロセスやI/Oのスケジューリングやロードバランシングをしてくれます。
これはコンピュータの処理性能に大きく関与するので、OS界隈でスケジューラーの刷新といったらちょっとしたトピックだったりしますね。

競プロでの利用例といえば、代表的な最短経路探索アルゴリズム、ダイクストラ法を実装するのに有用です。

余談ですがこれを作った動機の一旦は、超簡単アルゴリズムと評判のベルマンフォード法を実装したら、頂点数400程度のグラフでも実行時間制限2秒を超過してしまった、という苦い経験によるものだったりします。
なおCrystalで提出するとRubyより10倍速くて通るんですね……

特徴

PQの実装は多々ありますが、本作では次の点が特徴的です

  • データ構造はほぼ配列そのもの、余分な管理領域や初期化が不要
  • ほんのひと手間で数値以外でも任意の真値(nil,false以外)を収納可能
  • 比較演算子一つ実装すれば、任意の比較関数を適用可能
  • コード量が少ない(十数行)
  • 速さはそれほどではなく、最悪ケースが存在する(回避策あり)

以下でそれぞれ解説していきます。

PQのデータ構造

ごく単純な実装:配列

さて、PQの目的というのは、ある評価基準で順番に値を取り出すこと、でしたね。
最も単純な実装としては、リストや配列にデータ格納し、min/maxを取り出すとよいですね。
しかしそれでは値を取得するたびにO(N)の探索コストがかかってしまいます。

少し効率化してみましょう。
優先度の高い値に素早く辿り着くには、優先度順にソートしておくのが効果的です。
データが整列済みであれば、先端か末尾の値を見るだけでよく、O(1)で取得できます。

PQ = [4,5,9,1,8].sort #=> [1,4,5,8,9]

この後ただ値を取り出していくだけならこれで十分です。
しかし値を追加するならば、次に要素を取得するまでの間に再び優先度順に並び変えておく必要があり、そうするとソートの回数と重さO(NlogN)が問題になってきます。

それでも要素数が少なかったり、なにかしら整列回数を抑える工夫があれば、時には単純なリストや配列でも十分な場面もあるでしょう。
例えば、ABC141-D ではソート後に追加された要素の中で最も優先度の高いものを記録しておき、先頭要素の優先度が追加要素の最大値を上回っている間はソートしない、という手法で整列回数を低減しています。

一般的な実装:ツリー、というかヒープ

データ量が多かったりソート回数が多いなど、全体を整列させるコストが高すぎる場合は、要素を追加するたびに整列済みとなるようなデータ構造、主に木構造などを利用します。
次のようなアルゴリズムがあるそうです。

はい、これまたWikipediaの引き写しですが、なんだかややこしそうですね。
木の高さ調整やら回転やら、なんだかへっぽコーダーが実装するのは気が重いです。
真面目に読み解いていけば理解できるかもしれませんが、途中で面倒臭くなってしまいそう。
理解力のある頭脳をお持ちだったり、速度が重要となる場合には、十分に評価・検証されてきたこれらの代表的アルゴリズムを実装された方が良いでしょう。
これらは性能が保障されているうえ、PQの実装に限らず多方面で応用も効きそうですし。

本作の実装:ネストした配列

残念なことにあまりおつむのよろしくない私としましては、多少遅くとももう少しシンプルなものを考えてみたいです。

 構築が簡単でアクセス性能やメモリ効率の良い配列をベースに、常に整列済みの状態を維持していきます。
 でも多量の要素を何度もソートするのは低速なのでできません。
 値の挿入や除去が遅すぎても困ります。
 さてどうしましょう?

 ソート済み配列上で位置の特定は二分探索O(logN)できるので十分速いですね。
 ただし配列への要素挿入は、後続要素を後ろにずらしてしまうとO(N)になるのでNG。
 Rubyの配列はなんでも格納可能なので、挿入先の値を配列に置き換えることにします。
 ネストした配列にさらに要素を加えるときは、必要ならば再帰的にさらにネストします。

それでどうなるかと言いうと、次のようなネストした配列(多分木)ができます。

[1, [2, [3, 4], 5], [6, 7], 8, 9]

ネストしていても見た目で完全に整列済みなのが分かりやすいですね。
もう少し詳しくこれを操作する様子を解説します。

PQ構築と値の挿入

次のような整列済みの配列があるとします。
格納されているのは全て値(ここでは整数)です。

[1, 2, 6, 8, 9]

今回これを、ただこのままの状態でも、考案したPQのデータ構造として全く正当なものとみなします。
整列済み配列がPQになることは先に書いた通りです。
構築がカンタンでなんとも都合が良いですね。😉

ではここに3を挿入を挿入してみましょう。
既に整列済みなので、挿入位置は標準ライブラリの二分探索(Array#bsearch_index)で見つかります。
後続要素を動かさずに値2の位置に値3を挿入するため、値2,3の配列に置き換えます。

[1, [2, 3], 6, 8, 9]

ネスト深度付きで表すとこうです。

深さ0: [1, [ ], 6, 8, 9]
深さ1: [2, 3]

配列がネストして1段深くなるかわりに、深さ0の配列のサイズには変化ありません。
ネストすることで計算量の嵩む後続要素の移動が不要となります。
一方、配列末尾への要素追加や除去での伸縮にはコストがあまりかからないため、末尾追記はそのまま実行します。
続いて5を挿入してみましょう。
※なおRubyにおいては配列先頭でも伸縮が高速です

全体 : [1, [2, 3, 5], 6, 8, 9]
深さ0: [1, [], 6, 8, 9]
深さ1: [2, 3, 5]

そしてさらに4を追加。
深さ1の配列は先頭値1末尾値5なので、中間にもう1段配列をネストして挿入します。
つまり3の値を[3,4]の配列に置き換えます。

全体 : [1, [2, [3, 4], 5], 6, 8, 9]
深さ0: [1, [], 6, 8, 9]
深さ1: [2, [], 5]
深さ2: [3, 4]

ご覧の通り全体の配列も、どの深さの部分配列においても、全てが整列済みとなるネストした配列ができました。
ただしこのとき配列中には数値と多段にネストした配列が混在しています。
値を挿入位置を探るとき、数値と配列ではそのまま値の比較ができません。
配列の中から値をの箇所を探し出す必要がありますが、その位置が不定だと面倒です。

値を探す処理が重くならないよう、配列を構成する際、先頭要素は常に数値とすると定めることにします。
そうすれば二分探索の比較関数中で、"オブジェクトが値ならそのまま"、"配列ならば先頭要素の値"、を評価値として比較できます。

pq.bsearch_index {|x| n <= ((x.is_a? Array) ? x[0] : x) }

返値indexの要素が配列であれば再帰的に同じ探索操作をして、最終的にindex位置の値が数値ならそこが挿入する位置です。
※ちなみにその後の処理を簡略化するため、どうせなら先頭だけでなく末尾も常に数値となるよう操作しておきましょう。

このような感じのコードになります
わりと短縮気味なので、目が潰れないよう注意

def enq(a,x) # a:整列済み配列, x:挿入する値
  b,i = a,-1
  until a.frozen? # while a.is_a? Array
    return a << x  if (x<=>a[-1]) >= 0 
    return a.unshift x  if (x<=>a[0]) <= 0
    a = (b=a)[i = a.bsearch_index{|y| 0 > (x<=>(y.frozen? ? y: y[0]))} - 1]
  end  if a[i]
  return b.insert(i+1, x)  if b[i+2] == nil
  if i > 0 then b[i] = [a,x]
  elsif (a=b[1]).frozen? then b[1] = [x,a]
  else enq a,x end
end
値の取得・除去

上述の操作で次のようなネストした配列ができあがりました。

[1, [2, [3, 4], 5], [6, 7], 8, 9]

構築したPQから最優先の値を参照するには、評価基準に応じて配列の先頭(最小値)または末尾(最大値)を見ればよいわけです。

そして不要になった値は、shift(先頭除去)またはpop(末尾除去)で取り除きます。
次の例では先頭の1を除去します。

[[2, [3, 4], 5], [6, 7], 8, 9]

たったこれだけですと言いたいところですが、気を付けないと「配列の先頭と末尾は値に限定する」決まりが崩れてしまいます。
先頭/末尾除去後の次の値が配列だった場合、その配列を1段均して挿入しましょう。
ここでは先頭要素 [2, [3, 4], 5] を取り出し、先端に展開します。

[2, [3, 4], 5, [6, 7], 8, 9]

すると再びのPQの条件を満たした構造に戻りました。
この状態であれば、続けて値を取り出すことも、別の値を挿入するも自由に可能です。

実際のコードがこちら
※ メソッド名が謎ですが、ただ短くしたかっただけですご容赦ください

def deq(a) r=a.shift; a[0].frozen? or a.unshift *a.shift; r end
def poq(a) r=a.pop; a[-1].frozen? or a.push *a.pop; r end
コードの解説
  • スプラット演算子(*)で、展開した配列を引数として渡せる
    • 先頭要素の展開:a.unshift *a.shift
    • 末尾要素の展開:a.push *a.pop
  • フリーズしていない要素は配列とみなす
    • x.is_a? Array よりも !x.frozen? の方が短い
  • フリーズしている要素は何であれ値とみなす
  • 値要素の大小比較には <=> 演算子を利用する
利点
  • 整列済みの配列そのものがデータ構造である
    • 特別な初期化や構築の処理ほぼ不要 (sortするだけ)
    • ネストしない1次元の配列に戻すのも簡単 (flattenするだけ)
    • 別に専用の管理領域を必要としない
  • コード実装量が少ない (ゴルフ好き)
    • 挿入は数行、除去は1行
      ※1行の定義について異論はあるかも
  • フリーズしていれば任意の真値(nil,false以外)を値要素にできる
    • 値は数値(Numeric)に限定されない
    • 文字列 -"String" では辞書順で並ぶ
    • 配列も格納している子要素の順に並ぶ
      • 評価値と関連付けたいオブジェクトを同時に収容できる
    • <=> 比較演算子さえ実装すれば任意の基準で整列できる
      • 本当は < を使った方がシンプルだが、その演算子はArrayが標準で持っていない
      • 特異メソッドなどで後付け可能
欠点
  • ヒープソートなどと比べると多少遅い
    • だいたい2倍とか、悪い状況ではもっと
  • 入力値が最悪パターンの場合、多分木が構成できず連結リストになってしまう
    • タイトル回収🙄
    • 最悪パターン時の構成:[1,[],9] ⇒ [2,[],8] ⇒ [3,[],7] ⇒ [4,5,6]
    • 挿入の計算量がO(N)  ※この実装ではネスト深度はN/18程度
    • 評価値の差の絶対値が最小の最近傍要素に挿入するとネスト深度を軽減できる
      • log2(N)程度の深さに収まる?? ※未検証
      • 幾分発生しづらい状況なので、対処を入れるかは要検討
      • 評価値の差分を計算できる数値でしか使えない

所感

いやはや紹介記事を書くというのは、理解を深めるのにとても有用ですね。
自分で作ったものながら、数値以外も収納できるとか、最悪パターンはどうだとか、考えてもいませんでした。
この手法の他にも、ネストした配列ではなく連結リストのリストか配列を作って、マージソート的な手法が使えないかなども思い付きはしましたが、ツリー然とした構造を作るのなら、その前にヒープやその他の木構造を習得した方が良さそうです。
余裕があればまた検討してみたいところです。

ただ今回初記事とはいっても、想定の10倍くらい時間がかかりました。
本当ならゴルフ仕様のコードをちゃんと読めるようにするとか、モジュールにまとめてクラス化もするとか、色々やりたかったけれど無限に時間が溶けてゆくのでやめにします。
あまりこだわりすぎると記事を書き上げるのも頓挫しそうなので。
もし何か機会があれば記事をアップデートするなり出来ればとおもいます。
他にも面白いと思った競プロの問題などネタはまだあるので、もう幾つかはくらいは書きあげたいですね。
とりあえず次はこのPQでAtCoder最強園児に挑んでみましょうか。

あと最後に、このような「整列済みのネストした配列」という構造を持つのは、至極単純で自然な発想ではないかと思っています。
もしこれに何かしら名前が付いているのをご存知でしたら、どなたか教えてください。

おまけ

実装全体

def deq(a) r=a.shift; a[0].frozen? or a.unshift *a.shift; r end
def poq(a) r=a.pop; a[-1].frozen? or a.push *a.pop; r end
def enq(a,x)
  (b=a)[i=-1] && until a.frozen?
    0 > (x<=>a[-1]) or return a << x
    0 < (x<=>a[0]) or return a.unshift x
    a = (b=a)[i = a.bsearch_index{|y| 0 > (x<=>(y.frozen? ? y: y[0]))}-1]; end
  b[i+2] ? (i>0 ? b[i]=[a,x]: ((a=b[1]).frozen? ? b[1]=[x,a]: enq(a,x)))
  : b.insert(i+1, x)
end

途中要素検索・削除対応

def inq(a,x) # 値xを挿入できる位置と、そこにある値yを返す y == a[i] == b[j][i]
  a[i=0] && (y=a; z=until y.frozen?
    0 > c=(x<=>y[-1]) or break [y[c+=y.size-1],c]
    0 < c=(x<=>y[0]) or break [(y[c] if c>=0),c]
    a,b,j,y = y,a,i,y[i=y.bsearch_index{|y| 0>(x<=>(y.frozen? ? y: y[0]))}-1]
  end) && [*z,y,i,a] or [y,i,a,j,b]
end
def enq(a,x)
  y,i,a,j,b = inq a,x
  a[i+9] or return a[i+1] ? a.insert(i+1,x) : a<<x
  y or return a.unshift x
  z = i>0 ? [y,x]: [x,y=a[i=1]]
  y.frozen? ? a[i]=z: enq(y,x)
end
def rmq(a,x) # a:PQから値xを除去する (xが無ければ偽を返す)
  y,i,a,j,b = inq a,x
  (y<=>x)==0 and (a.delete_at i; a[1] or j && b[j]=a[0]; x)
end

検索削除対応/スタックトレース版

def inq(a,x,s=0) # 値xを挿入できる位置の値yと、そこに至る経路sを返す
  a[i=0] && i=until a.frozen?
    0 > c=(x<=>a[-1]) or break a.size-1+c
    0 < c=(x<=>a[0]) or break c
    s << i = a.bsearch_index{|y| 0 > (x<=>(y.frozen? ? y: y[0]))} - 1
    a = (b=a)[i]; end
  i ? (s<< i;a[i] if i>=0) : a
end
def enq(a,x) # y == a.dig(*s)
  y = inq a,x,s=[]
  i = s.pop
  a = a.dig *s if s[0]
  a[i+3] or return a[i+1] ? a.insert(i+1,x) : a<< x
  y or return a.unshift x
  z = i>0 ? [y,x]: (a[0],x=x,a[0] if i<0; [x,y=a[i=1]])
  y.frozen? ? a[i]=z: enq(y,x)
end
def rmq(a,x) # a:PQから値xを除去する (xが無ければ偽を返す)
  y = inq a,x,s=[]
  return if (y<=>x)!=0
  i,j = s.pop,s.pop
  b = s[0] ? a.dig(*s) : a
  c = j ? b[j] : a
  c.delete_at i
  c[1] or j && b[j]=c[0]
  x
end
Originally published at github.com
ツイッターでシェア
みんなに共有、忘れないようにメモ

項垂人

コードゴルフ好き Rubist 万年初心者FreeBSDユーザー

Crieitは誰でも投稿できるサービスです。 是非記事の投稿をお願いします。どんな軽い内容でも投稿できます。

また、「こんな記事が読みたいけど見つからない!」という方は是非記事投稿リクエストボードへ!

有料記事を販売できるようになりました!

こじんまりと作業ログやメモ、進捗を書き残しておきたい方はボード機能をご利用ください。
ボードとは?

コメント