こんにちは、masm11 です!
みなさんは、Ruby で集合を表現する時に、何を使いますか? Set を使いますか? Set って内部的には Hash を使ってますよね。そう、Hash でも実現できます。
今回は、Set を Hash で置き換えた話をします。
Set とは
Set とは、ようするに集合です。集合なので、同じ要素は2つ以上ありません。
require 'set' set = Set.new (1..10).each do |i| set << i end (1..10).each do |i| set << i end set.each do |i| puts i end
luna:~ % ruby set1.rb 1 2 3 4 5 6 7 8 9 10
集合なので、時々、交差を取りたくなります。 交差、intersection。つまり、両方の集合に存在する要素を得る演算です。
set1 = Set.new (1..10).each do |i| set1 << i end set2 = Set.new (5..15).each do |i| set2 << i end set = set1.intersection(set2) set.each do |i| puts i end
これを実行すると、set1 には 1~10、set2 には 5~15 が入っているので、 その intersection は 5~10 です。
luna:~ % ruby set2.rb 5 6 7 8 9 10
intersection の実装
ところで、intersection はどのように実装されているのでしょう?
実装はこちらにあります。
def &(enum) n = self.class.new if enum.is_a?(Set) if enum.size > size each { |o| n.add(o) if enum.include?(o) } else enum.each { |o| n.add(o) if include?(o) } end else do_with_enum(enum) { |o| n.add(o) if include?(o) } end n end alias intersection &
n は結果ですね。今回は set1 も set2 も Set なので、enum.is_a?(Set)
は true です。
そして、サイズは同じなので、else です。結局、
enum.each { |o| n.add(o) if include?(o) }
↑この一行がすべてのようです。
- 引数の要素すべてについて、それがレシーバにもあれば、それを n に追加する
なるほど。include? はどのように実装されているのでしょう?
def include?(o) @hash[o] end
そのまんまですね。@hash
は、キーは要素で、値は true です。
Ruby でループしてるんですね… これって遅くないですか?
Array の場合と比べてみる
とりあえず、遅そうな Array の場合と比べてみます。
N = 10000000 set1 = Set.new(1..N) set2 = Set.new(1..N) ary1 = (1..N).to_a ary2 = (1..N).to_a Benchmark.bm do |x| x.report { set1.intersection(set2) } x.report { ary1.intersection(ary2) } end
結果がこちら。
luna:~ % ruby set_vs_array.rb user system total real 5.815057 0.184966 6.000023 ( 6.000748) 2.950291 0.135012 3.085303 ( 3.087259)
Array の方が倍速!
Array の方は C で書かれているとは言え、速すぎませんか? 要素数1千万の intersection ですよ?
一体どんな秘密が隠されてるのでしょうか? C の実装を見てみました。
hash = ary_make_hash(ary2); for (i=0; i<RARRAY_LEN(ary1); i++) { v = RARRAY_AREF(ary1, i); vv = (st_data_t)v; if (rb_hash_stlike_delete(hash, &vv, 0)) { rb_ary_push(ary3, v); } }
- ary2 から hash を作る
- ary1 各要素について、hash に要素があれば、それを削除して ary3 (結果) に突っ込む
なるほど… Hash を作ることで高速化してました。これなら速いですね。
Set#intersection
を再実装する
Set は遅いです。Array にすら負けています。 というか、Ruby でループしてる時点で負けでしょう。
Set をやめて Hash を直接使うことにします。 Hash なら C で実装されてるので速いはず!
hash1 = Hash[ary1.zip([true] * N)] hash2 = Hash[ary2.zip([true] * N)]
ここまで書いて、はたと気づきました。Hash には intersection なんてないんですよ… どうしましょう… 何か代わりになるメソッドは…
これですね、slice。 引数にキーを並べればいいようです。
N = 10000000 set1 = Set.new(1..N) set2 = Set.new(1..N) ary1 = (1..N).to_a ary2 = (1..N).to_a hash1 = Hash[ary1.zip([true] * N)] hash2 = Hash[ary2.zip([true] * N)] Benchmark.bm do |x| x.report { set1.intersection(set2) } x.report { ary1.intersection(ary2) } x.report { hash1.slice(*hash2.keys) } end
実行結果が↓こちら。
luna:~ % ruby by-hash.rb user system total real 6.361795 0.188878 6.550673 ( 6.554033) 3.133403 0.129874 3.263277 ( 3.265233) by-hash.rb:25:in `block (2 levels) in <main>': stack level too deep (SystemStackError) from /usr/lib/ruby/3.0.0/benchmark.rb:296:in `measure' from /usr/lib/ruby/3.0.0/benchmark.rb:378:in `item' from by-hash.rb:24:in `block in <main>' from /usr/lib/ruby/3.0.0/benchmark.rb:176:in `benchmark' from /usr/lib/ruby/3.0.0/benchmark.rb:208:in `bm' from by-hash.rb:17:in `<main>' luna:~ %
あらら… どうも、スタックが溢れたようです…。 こういう時は RUBY_THREAD_VM_STACK_SIZE を指定すれば大きくすることができます。 もう一度!
luna:~ % RUBY_THREAD_VM_STACK_SIZE=100000000 ruby by-hash.rb user system total real 6.086419 0.187998 6.274417 ( 6.275484) 2.960836 0.119998 3.080834 ( 3.081353) 3.381075 0.120012 3.501087 ( 3.501670)
Array には負けましたが、Set よりは十分高速ですね!
メモリは食いますが、今回、N=10000000 なので仕方ないですね。 N=10000 くらいなら環境変数なしでも全然問題ありません。
その他の演算
上記では intersection がないので代わりに slice を使いました。
その他でいうと、union には merge が使えて、difference には except が使えそうです。
まとめ
自前で作った全文検索ツールで、Set を使っていたのですが、 Hash を使うように書き換えました。 気持ち速いような気がしています。
Python で機械学習をやってると、ループ遅いなぁ、と感じるのですが、 それは Ruby でも同じということですね。ループは極力 C に任せましょう!
ではまた!