ちりもつもればミルキーウェイ

好奇心に可処分時間が奪われる

よくわかる bloom filter

bloom filter とは

bloom filter とは、ざっくりいうと ある集合において、低コストにある要素が存在しないことがわかる データ構造です。

どのくらいコストが低いかというと、要素の登録や存在確認がともに O(k) です(kは利用するハッシュの数)。
集合の最大要素数や、実際に登録されているデータ数に影響されずに固定時間で結果を返せます。

特徴としては

  • 登録、存在確認のコストが O(k)
  • 「存在するよ!」といってるのに探してみたら存在しない場合がある(偽陽性がある)
  • 「存在しないよ!」といったものは100%存在しない(偽陰性はない)

なので、偽陽性が許容できる場合においては高速な存在確認としてつかえます。
想定できる使い方としては、探索コストが高いデータを検索する前にbloom filterをつかう、など。

しくみ

一言でいうと単語を複数のbit列のindex値に変換して、そのindex番目のbitがすべて立ってるかチェックして、立ってれば存在している、一つでも立っていなければ存在しない!と判断させるだけです。

単語をbit列のindex値に変換するときにhashを使います。(bit列内でonにするindex値を十分に分散させるため。ここに偏りがあると偽陽性率があがる。はず。)

以下の説明では

  • bit列の長さ8
  • hashの数は1

のものを利用して説明します

(実際にはbit列の長さが小さすぎると衝突の可能性がますのでこんなに短いbit列を使うことはほぼないはずだし、hashの数は要素数やbit列長によって最適な数がもとめられるので1個であることはあまりないはず。まあ説明のための例なので細かいところは気にしないですすめる)

登録

ある単語の登録は、その単語をhash関数にかけてbit列のindex値を取得し、そのbitをたてます。

たとえば apple という文字列をhash化した結果が 5 だったとすると、bit列の5番目のbitをたてます

index 7 6 5 4 3 2 1 0
bit 0 0 1 0 0 0 0 0

存在確認

ある単語の存在確認は、その単語をhash関数にかけてbitのindex値を取得し、そのbitが立っているか確認します。

apple の存在確認をしてみます。
apple はhash化すると 5 になるのでした。
先程追加したbit列を確認すると、5番目のbitは立っているので、 apple は存在していると判断できます。

index 7 6 5 4 3 2 1 0
bit 0 0 1 0 0 0 0 0

つぎに orange の存在確認をしてみます。
orange はhash化すると 7 になりました。
このbit列から7番目の値を確認すると0です。bitが立っていないので、 orange は存在していないと判断できます。

index 7 6 5 4 3 2 1 0
bit 0 0 1 0 0 0 0 0

つぎに banana の存在確認をしてみます。
banana はhash化すると 5 になりました。
bit列から5番目の値を確認すると、bitが立っているので banana は存在していると判断されます! 実際には banana はまだ追加されていないので、これは偽陽性のパターンです。

index 7 6 5 4 3 2 1 0
bit 0 0 1 0 0 0 0 0

かんたんな実装

以下に実装を交えて説明します。実装はGo

試せるコードをplaygroundにもおいておきます
(割とtimeoutします。playgroundの機嫌が良ければ実行できます。素朴に実装してるためmd5を12回振ってるので大変なのかな...ハッシュ生成コスト的には、本来はダブルハッシュ法などをつかったほうがいいです)

play.golang.org

ある長さを持ったbit列をつくります。(ここでは例として長さは8)
Goなら以下でOK

var bits uint8

ある単語を登録するには、その単語をハッシュ化してbitのindexをとります。

bit列の長さが8なので、0~7の範囲になるようなhash関数を準備します
今回はかませるhashの数は3つとします。bit列の長さや追加する要素数によって最適なhash数はもとめることができますが、今回はそのあたりは考慮しません。

func hash(s string) (idxes [3]uint8) {
    // md5結果の終端byteの上位3bitの値(0~7)をbitsのindex値として返す
    // 最終的に分散された3bit値が取れればいいので、8の剰余をとってもどちらでもよい。
    // ことなるハッシュがとりたいので適当にsaltをふる
    idxes[0] = md5.Sum([]byte(s+"1"))[15] >> 5
    idxes[1] = md5.Sum([]byte(s+"2"))[15] >> 5
    idxes[2] = md5.Sum([]byte(s+"3"))[15] >> 5
    return idxes
}

bit列に値を追加したいときは、ハッシュを使ってindexを取得し、そのindex番目のbitを立てます。
idx番目のbitをとるにはidxぶんだけ1を左シフトすればよいです。

func add(s string) {
    idxes := hash(s)
    for _, idx := range idxes {
        // idx番目のbitを立てる
        bits |= 1<<idx
    }
}

bit列の値を存在チェックしたいときも同じくハッシュを使って、index番目のbitが立っているか確認すればよいです。
すべてのindex番目のbitがたっていれば その要素は存在していると判断できます。

func exists(s string) bool {
    idxes := hash(s)
    for _, idx := range idxes {
        // idx番目のbitが立っていないケースを判定
        if 1<<idx != 1<<idx & bits {
            return false
        }
    }
    return true
}

準備はできたので、てきとうにそれらを呼び出してみましょう。
bit列に単語を登録したり、単語が存在するかbit列を確認したりしてみます。

func main() {
    add("test1")
    fmt.Printf("added bits: %b\n\n", bits)
    // 存在する
    fmt.Printf("is exists 'test1': %t\n\n", exists("test1"))
    // 存在しない
    fmt.Printf("is exists 'test2': %t\n\n", exists("test2"))
    // 存在しないのに存在すると判定している(偽陽性)
    fmt.Printf("is exists 'test12': %t\n\n", exists("test12"))
}

今までの実装にlogなどを適宜仕込みつつ実行すると以下のように出力されます

bit on: 10000000
bit on: 1
bit on: 10
added bits: 10000011

bit check: 10000000
bit check: 1
bit check: 10
is exists 'test1': true

bit check: 100000
is exists 'test2': false

bit check: 1
bit check: 10
bit check: 10000000
is exists 'test12': true

感想

  • 偽陽性を許容してぱぱっと存在確認したいときによさそう
  • いろいろと亜種がいて Scalable Bloom Filter なるものもいるようなので気になる
  • 現実世界の実装では、hashを複数回生成するのはコストがかかるのでダブルハッシュ法とかをつかうらしい
  • bit列の中身のbitが全部立つとどんな入力に対しても存在すると思い込むはずなので、偽陽性が上がりそう
    • ある程度はbitを残せるような要素数がいいのかな
    • bitが埋まってきたらhash関数の数を増やせば偽陽性は減りそう
    • そのあたりの適切なbit長/要素数/hash数まわりの関係性についてはもう少し自分の中で整理して理解を深めたい