データ構造 | 株式会社Altus-Five / 株式会社Altus-Five は、技術力で勝負するシステム開発会社です。 Sun, 01 Jun 2025 16:13:38 +0000 ja hourly 1 https://wordpress.org/?v=6.8.2 /wp-content/uploads/2025/01/cropped-favicon-32x32.png データ構造 | 株式会社Altus-Five / 32 32 全文検索を自社サイト・社内サーバーに構築したいクライアントのための留意点 /blog/2018/07/18/full_text_search/ /blog/2018/07/18/full_text_search/#respond Wed, 18 Jul 2018 14:59:00 +0000 http://43.207.2.176/?p=305 システム開発会社をお探しの企業さんへ。こんなお悩みありませんか? 「全文検索」という言葉をご存知である非エンジニアの方は、 おそらくシステム開発に携わるご担当者さんか、プログラミングや最新技術に興味関心のある方であろうと […]

The post 全文検索を自社サイト・社内サーバーに構築したいクライアントのための留意点 first appeared on 株式会社Altus-Five.

]]>
システム開発会社をお探しの企業さんへ。こんなお悩みありませんか?

「全文検索」という言葉をご存知である非エンジニアの方は、 おそらくシステム開発に携わるご担当者さんか、プログラミングや最新技術に興味関心のある方であろうと思います。

本記事は、自社サイトや社内サーバーで下記のような「全文検索機能」を実装したい方に向けて、 「Altus-Fiveの紹介」と「情報提供」を行う記事です。

  • 求人情報検索
  • 商品情報検索
  • 任意のクエリ(検索キーワード)に対する検索

特に、

  • 他社に見積もりを取ったところ、ニーズが特殊で要望に応えられない または 十分な性能が出せないと言われた
  • 特殊なデータに対する検索なので、既存のパッケージ等では開発できないと言われた
  • アクセスするための機器がPC・スマホ・タブレット等の市販品ではない(店頭で用いるディスプレイなど)

という方には、Altus-Fiveの得意とする 「フルスクラッチ型開発」 でお助けができるかもしれません。 もちろん、単に「全文検索ってどうやってるの?」という方にも興味深い内容のはずですので、 ぜひ、この記事を最後まで読んでみてください。

全文検索とは?

全文検索とは、 コンピュータ内の複数ファイルから、クエリ(検索キーワード)を含む行やファイルを全て見つけ出すこと を指します。 ファイル名などだけでなく、ファイルの中身も見て検索結果を取得するところがポイントです。

これだけではイメージが限定的になるので、具体的なシステムの例をあげてみましょう。

システム例全文検索の例
人材検索サービス「プログラマ」という文字列を(経歴などに)含む人材の一覧を取得する
レシピ検索サービス「ソース」や「スパゲッティ」を含むレシピの一覧を取得する
会計処理サーバー「株式会社フカミハマル」との取引データ一覧を取得する

こうして並べてみると、色々なサービスや、社内サーバーの機能開発において重要な技術であることが分かるかと思います。 そのため、様々な全文検索のためのパッケージが開発されており、 Altus-Fiveでも多数の全文検索開発実績があります。

さて、本記事は「全文検索」について、システムの発注前に知っておくと役立つ「豆知識」を紹介します。 少し長い内容になりますが、ぜひお付き合いください。

全文検索は、実装方式によってコストと性能が変わる

実は、全文検索にはいくつかの実装方式があります。 単に「全文検索機能」と言っても、 その中で動いているロジックには随分と大きな違いがある ということなのですね。

ロジックの違いが生む違いは 「実装コスト」(≒ 工期と見積額) 、 そして 「実行時間」(≒ 検索に対するユーザーの待ち時間) です。 どちらも依頼をされる側にとっては、重要なポイントになるのではないでしょうか。

もちろん、開発者がこの領域の専門家ですので、全面的にお任せいただくことも良いのですが、 発注をされる方が知っておいて損はない知識かと思います。

ここでは大きく 「grep型」 と 「索引型」 の2種類について、具体例を用いて分かりやすく解説してみたいと思います。

grep型の全文検索とは?

grepとは、データベースから必要な情報を検索する際にエンジニアがよく打ち込むコマンドであり、耳にしたことはあるという方もおられると思います。

例えば grep "毒リンゴ" shirayukihime.txtというコマンドを打ち込むと、 白雪姫の文章(shirayukihime.txt)の中で「毒リンゴ」というフレーズが含まれる行を 全て 表示してくれます。 shirayukihime.txtという指定を除けば、今みているフォルダ内にある全てのファイルを対象とした検索を行います(全文検索)。

grepは、本当は「global regular expression print」(ファイル全体から正規表現一致行を表示)の略なのですが、 「グレップ!」 という語感だけで 「検索したい対象を含む行を鷲掴みにして表示する」 という理解をされても、とりあえず差し支えないと思います(たぶん)。

ともあれ大切なことは、grep ≒ 検索であるということ。 ただし、検索にもいくつかの方式があり、grepでは後述する 索引型 検索とは異なる方法で必要なデータを見つけてきます。

全文検索を開発する際は、 grep型と索引型、どちらで実装するかの選択 が非常に重要になるということです。

grep型検索の実装イメージ

さて、grep型の全文検索はどのように「検索対象」を見つけ出してくるかをご説明しましょう。

ここでは 「人材検索サービス」 を例に解説してみたいと思います。 (某企業さんが提供しているような、求職者を検索出来るサービスを想像してみてください。)

システムを作る際は、「データベース」に必要データを入れておきます。

データベースとは「データを入れておく箱」のようなもので、 氏名・性別・現職・学歴・経歴など、システムによって異なる「必要なデータ」を入れておけるような構造をしています。

今回は人材検索が例ですので、 データベースは「アパート」だ と想像して頂くとわかり良いかもしれません(先ほどデータベースは箱だと書きましたが、アパートの部屋もよく『箱』と呼びますね)。

  1. データベースとは、サービスに登録されている人材に、仮想的に住んでもらうためのアパート である
  2. 検索とは、 「この条件に該当する人材はいないか?」と聞かれた時、当てはまる人材(の集まり)をアパート内から連れてくること である

と考えると、「データベース」と「検索」の関連性が掴みやすくなると思います。

検索例(grep型全文検索の応答)

ここでデータベースから 「システム開発経験者」 だけを取り出したいというサービス側からの要求(クエリ)があったとしましょう。 grep型検索の手続きは下記のようなイメージです。

  1. アパートの入り口で「このアパートにシステム開発経験者は住んでいますか?」と聞かれたら、
  2. アパートの全ての部屋のドア を1つ1つ開けて、
  3. 『あなたはシステム開発経験者ですか?』と問いかけ、
  4. YESならばついてきてもらい、
  5. アパートの全室で質問を終えたとき、ついてきてくれた人材の集まりを「検索結果」として提供する

grep型は後述する索引型とは異なり、検索そのものには 特別な準備が不要 。 アパートを巡回する「使いっ走り」(彼の仕事は、専門的にも 『走査』 と呼びます)を用意すれば良いだけなので、開発上の工数は少なくて済む傾向にあります。

注意

ここでは仮に「アパート」としましたが、実際には 道中に猛獣あり、断崖絶壁ありのジャングル から花を摘んでくるような厳しいケースもあり、その場合は実装難度が跳ね上がることになります。(そんな案件もぜひ一度、ご相談ください。)

索引型の全文検索とは?

さて、先ほどの方法とは異なる 「索引型」 の全文検索とはどんなものでしょうか。

索引型の全文検索のポイントは、 「あらかじめ、聞かれそうな質問ごとに、該当する人材のリストを作っておく」 ということです。例えば、

  • 「システム開発経験者」は、アパートの0104号室、0103号室、0302号室、0305号室、…に住んでいる
  • 「現年収500万円台の人材」は、アパートの0102号室、0103号室、0207号室、0301号室、…に住んでいる
  • etc.

といった情報を書き留めておいた「メモ書き」をあらかじめ作っておくようなイメージです。 実はこのメモ書きが 「索引」(index)と呼ばれる もので、索引を作ることは 検索の所要時間を劇的に短くする 効果があります(ただし、全体のデータサイズやデータの性質による)。 索引型の全文検索では、検索のプロセスは下記のように変わり、

  1. アパートの入り口で「このアパートにシステム開発経験者は住んでいますか?」と聞かれたら、
  2. 手元のメモ書きを確認し、
  3. メモ書きに書かれた全ての部屋 のドアを開け、中にいる人材についてきてもらい、
  4. ついてきてくれた人材の集まりを「検索結果」として提供する

という4ステップで済みます。

ここでポイントは、先ほどの「grep型」と比べてステップが減っていることではなく、 「システム開発経験者の住んでいる部屋のドア」しか開けなくて良い ということです。

grep型検索では全ての部屋のドアを開けるため、「システム開発経験者の数」ではなく、 「アパート全体の大きさ」に比例した分だけ「使いっ走り」が汗をかかなければなりませんでした。

索引型検索ではあらかじめ「システム開発経験者の住んでいる部屋」をメモしてありますから、 「使いっ走り」は効率的に部屋を巡ることができます。 随分と「賢い」方法になっていることが分かるかと思います。

二つの方式は「ユーザーの待ち時間」が異なる

さて、システム開発者は「使いっ走り」に優しくしたい訳ではなく、 重要なのはその間待ちぼうけを食らう 「ユーザーの待ち時間」 です。

grep型と索引型の検索では、ユーザーの待ち時間が大きく異なります。 さらに 「全体のデータサイズ(登録人材数)が大きいほど、待ち時間の差は大きくなる傾向にある」 ということが分かるかと思います。

索引型全文検索のポイント – どのように索引を作るか

では常に索引型全文検索が優れているのか?というと、そうとは言い切れません。

索引型全文検索には、 データベース作成の段階で、索引をどう作るかを決める という工程があります。

現実的にシステム開発として考えた場合、「どのように索引を作るか?」という考案部分の工程が入ってきますので、工期も費用も大きくなることは否めません。

ただ、 待ち時間を考えると、grep型の検索が使い物にならないケース は多くあります。 その場合は当然、索引型全文検索などの高速な手法を選ばざるを得ないことになります。

さて、この時「索引」をどのように作るか、ということは、いくつかのパターンがあります。

  • データベース全文を走査し、実際に「よく出てきた単語」ごとの覚え書きを作っておく場合
  • 本物の辞書(シソーラスなど)を参照する場合

今回は一例として「人材検索サービス」を考えましたが、ケースバイケースで「良い索引の作り方」は異なってきます。より詳しい内容については、今後のAltus-Fiveブログで紹介できればと考えています。

いずれにせよ重要なことは、 grep型と索引型、発注者側も二つの方式があることを理解した上で、システムの要件にあった方式を選ぶことが大切 ということです。 以上、長い「留意点」についてお付き合いをいただき、ありがとうございました。

Altus-Fiveの強み

ここまで、システム開発を依頼したいクライアントさんを念頭におきながら、全文検索システムの方式について解説してみました。 最後に、システム開発会社としてのAltus-Fiveの強みを一点だけ、紹介させて頂きたいと思います。

フルスクラッチの開発を最も得意とする。

「フルスクラッチ開発」は、既存パッケージ等に頼らず、技術を組み合わせて0からシステムを作る開発方式。 システムの発注経験の少ない企業さんだと、「システムは0から作るのが当然では?」と思われるかもしれませんが、 実際には「パッケージ」と呼ばれる、プログラムの部品を繋げて作ることが主流です。

全文検索には全文検索のためのパッケージが、もちろん存在しています。 ただし、それぞれのパッケージには導入のための要件があり、冒頭で述べたような事情がある場合には 使えないケースも多い のです。

そんな「既存パッケージが使えない開発のご依頼」を頂いた時が、Altus-Fiveの本領発揮です。 フルスクラッチ案件が得意なので、貴社の要件 あるいは 既存システムを精査し、最適な技術の組を自社で考案・提案することが可能です。

もちろんシステム開発には「餅は餅屋」な側面もあることは確かですので、適宜外部の知見やパッケージを頼ることは検討します。

しかし、ただ「パッケージを繋げるだけ」の開発であれば、自社も他社も「出来ること」は同じになってしまう。 それでは開発会社としての魅力は出せないなぁ…ということで、あえてフルスクラッチにこだわり、1から10まで自社で作るシステムに、技術者魂を燃やして取り組んできました。

パッケージを繋げることが得意な会社と、1から10まで自社で作ることが得意な会社の違い。 その部分を「見える化」することが、実はAltus-Five最大の課題です。 弊社ブログでも長く取り組んできましたが、なかなかエンド企業様にご理解頂くことが難しい領域です。

もし今お困りのことがあるのであれば、まずは開発のご相談を頂くことが、 いちばんわかりやすい形で弊社の強みを感じて頂ける方法かもしれません。

「他社の見積もりでは時間面・費用面から依頼が出来なかった」という方でも、ぜひ一度ご来社頂きたいと思います。

「一度会って、話を聞いてみたい」というお打ち合わせも、大歓迎です。

お問い合わせはこちら。

The post 全文検索を自社サイト・社内サーバーに構築したいクライアントのための留意点 first appeared on 株式会社Altus-Five.

]]>
/blog/2018/07/18/full_text_search/feed/ 0
zip, compress, gzip, bzip2 – ファイル圧縮の形式に関する覚書 /blog/2018/06/14/file_compress_extensions/ /blog/2018/06/14/file_compress_extensions/#respond Wed, 13 Jun 2018 15:02:00 +0000 http://43.207.2.176/?p=307 本記事の内容 zip, compress, gzip, bzip2の違い(それぞれの歴史) zipとは? zipは1989年にフィル・カッツによって提案された圧縮形式で、現在最も多く用いられている圧縮形式であると同時に、 […]

The post zip, compress, gzip, bzip2 – ファイル圧縮の形式に関する覚書 first appeared on 株式会社Altus-Five.

]]>
本記事の内容
  • zip, compress, gzip, bzip2の開発経緯と技術について
  • 圧縮とアーカイブの違い
  • zip, compress, gzip, bzip2の性能比較(デモ)
  • bzip2の圧縮性能を支える技術のキーワード(BWT、ハフマン符号)

zip, compress, gzip, bzip2の違い(それぞれの歴史)

zipとは?

zipは1989年にフィル・カッツによって提案された圧縮形式で、現在最も多く用いられている圧縮形式であると同時に、後述する多くの圧縮形式に名前を貸し与えています。

ファイルは辞書を用いて圧縮され、Lempel, Zivらによって1977年に提案されたLZ77法が使われています。 また、文字列の符号化にはハフマン符号が用いられています(上記まとめたアルゴリズム名は『Deflate』)。

zipはアーカイバとしての機能を持ち、複数ファイルをまとめつつ、ファイルサイズを下げることが出来ます。

compressとは

Lempel, Zivらによって提案されたアルゴリズムには、 1977年に提案されたLZ77法と1978年に提案されたLZ78法の2種類があるのですが、 compressでは、LZ88法の改良版として提案された、Lempel-Ziv-Welchのアルゴリズム(LZW)が用いられています。 (ちなみに、LZWの提案は1984年。)

gzipとは

gzipはGNU ZIPの略称で、オープンソースプロジェクトの走りとも言える(というと偉い人から怒られるのですが…)GNUプロジェクトの一環として開発された圧縮方式です。 当時広まっていたcompressの代替を目して開発された方式で、zipと同様に圧縮はDeflateによります。

zipとは異なりアーカイブ機能はありません。

bzip2とは

bzip2は1996年に公開されたファイル圧縮形式で、gzipに加えて下記の処理が加わっており、圧縮効率がなお高まっています。

  • BWT(Burrows-Wheeler Transformation, バロウズ・ホイラー変換)を用いた前処理
  • Move to Front法による前処理

またgzipと同様、ハフマン符号を用いている点もポイントです。

これらの処理を行う分、処理速度ではgzipに軍配が上がるとされています。 こちらも、zipとは異なりアーカイブ機能はありません。

補足:圧縮とアーカイブの違い

用語定義
圧縮ファイルサイズを小さくすること
アーカイブ複数ファイルを一つにまとめること

アーカイブを行うプログラムを アーカイバ とも呼び、 上述した形式には、アーカイバとしての機能があるものとないものが存在します。

辞書、BWT、ハフマン符号 – 圧縮にかかわる技術を学ぶには

さて、ここまでの内容にはいくつかの用語が登場してきました。

  • 辞書を用いた圧縮
    • LZ77法
  • BWT(Burrows-Wheeler Transformation, バロウズ・ホイラー変換)
  • ハフマン符号

実は上記はいずれも、『高速文字列解析の世界 データ圧縮・全文検索・テキストマイニング』(岡野原、2012)の中で詳細に解説されています。

Altus-Fiveブログでは本書を読み解きながら、今後 これら技術に関するデモ実装をPythonで行いたい と考えておりますので、ご興味のある方はぜひブックマーク、もしくははてブを押していって頂けると嬉しいです。

おまけ:テキスト圧縮の性能比較 – zip, gzip, compress, bzip2を比べてみた

今回はデモとして、青空文庫からダウンロードした相対性理論の論文(sotaisei_riron.txt)を圧縮してみました。

圧縮方式BeforeAfter
zip29KB12KB
gzip29KB12KB
compress29KB15KB
bzip229KB10KB

このファイルについて4形式を比べた時、 bzip2の性能が最も良い という結果となっています。

なぜbzip2は性能が良いのか?

先ほどの繰り返しになりますが、

  1. 索引作成の前処理としてBWT(Burrows-Wheeler Transformation, バロウズ・ホイラー変換)、MTF (Move-To-Front) 法を行なっている
  2. 圧縮方式としてハフマン符号を用いており、入力テキストそれぞれに二分木(ハフマン木)を生成。最適な符号化を行なっている

といった理由が挙げられます。 上記キーワードの詳細は、すべて『高速文字列解析の世界』(岡野原,2012)に載っていますので(MTF法を除く)、よければご参照ください。 本記事はあくまで「歴史的経緯のまとめ」ですが、技術の深みの面白さについて伝えていくため、 今後、BWTやハフマン符号の概略を示す記事をAltus-Fiveブログで掲載したいと思っています。

参考記事

アルタスファイブのブログでは、データ構造に関する記事を定期的に執筆しております。 よろしければ、ご参照ください。

The post zip, compress, gzip, bzip2 – ファイル圧縮の形式に関する覚書 first appeared on 株式会社Altus-Five.

]]>
/blog/2018/06/14/file_compress_extensions/feed/ 0
Rubyの配列(Array)を魔改造して、連想配列として使ってみた /blog/2018/05/07/associative-array/ /blog/2018/05/07/associative-array/#respond Sun, 06 May 2018 15:15:00 +0000 http://43.207.2.176/?p=313 可読性が高く、高速な「連想配列」 エンジニアであれば、「連想配列」のお世話になる機会は多いと思います。言語によりますが、任意のデータをkeyとして指定できる連想配列は、 コードの可読性を高く保ったまま、ニーズに合わせた柔 […]

The post Rubyの配列(Array)を魔改造して、連想配列として使ってみた first appeared on 株式会社Altus-Five.

]]>
可読性が高く、高速な「連想配列」

エンジニアであれば、「連想配列」のお世話になる機会は多いと思います。言語によりますが、任意のデータをkeyとして指定できる連想配列は、 コードの可読性を高く保ったまま、ニーズに合わせた柔軟なシステム実装をサポートしてくれる強力なツールです。

ユーザーとして連想配列を使う分には、普段あまり意識しませんが、 各言語に標準実装されている連想配列の利便性は、下記の2点を満たすことで支えられています。

  1. keyを引数とすることでvalueを取り出す(もしくは代入する)操作が簡便であること
  2. 上記の操作が、連想配列に格納済みのデータサイズによらず高速であること(重要!

今回は、普段お世話になっている「連想配列」について、あらためてその成り立ちや性質を見直してみたいと思います。 本記事(および 次回)は、

  • 「連想配列はどんな技術で実現されている?」疑問に思った方
  • 「自分で連想配列を実装してみたい」という物好きな方
  • 「平均検索速度が定数時間O(1)ってどういう意味なんだ」と疑問に思い夜も眠れない方

に捧げます。

連想配列は、配列ではない!

(みなさんの興味を惹くために、ちょっとラジカルな見出しをつけてみました。)

本記事では、配列と連想配列を 似たようなものとみなすことをやめ、その成り立ちを解剖する ことで、 連想配列(特にハッシュによるもの = ハッシュテーブル)がいかにありがたいツールであるかを明らかにしてみたいと思います。 なお、ここまでで御察しの通り、 筆者は連想配列、ハッシュテーブルが大好き です。 まだエンジニアリング経験の浅い学生時代、C++を中心にコーディングをしていたのですが、 C++11に unordered_map クラスとして連想配列が存在することを知らず、自前のハッシュテーブルを作ったりしていました。 要素へのアクセスが目に見えて高速化され、とても感動した覚えがあります。

連想配列と配列の対比

さて、連想配列と配列の違いについて詳しく見ていきましょう。 両者の共通点といえば、

  • データを格納するために、メモリを使うこと(あたりまえ)
  • key(もしくは添字)の指定によってデータにアクセスすること

であり、逆にいえば それくらいしか共通点はない 、と筆者は認識しております。 そのわずかな接点についても、下記のような違いがあり、むしろこの相違点が「配列と連想配列の違いである」と理解されている方も多いかと思います。

point配列連想配列
確保するメモリ領域の広さ任意、もしくは言語ごとの実装により動的確保言語ごとの実装による
指定するkey(添字)確保するメモリ領域の先頭からのオフセット(ゆえに整数に限る)任意(整数や文字列など)

ですが、実は上記のちがい以上に、 key(添字)・valueの組による値の参照/代入の過程 が、大きく異なっている点が両者の違いの本質です。 以下では、簡単な実験的実装をテーマに、両者のロジックの中身がどう異なるかを明るみにしてみたいと思います。

Rubyの配列を(無理やり)連想配列にしてみよう

なお、Rubyの連想配列クラスはHashクラスですが、これはハッシュ関数を用いて実装されたハッシュテーブルであることを意味します。 今回は、連想配列の定義を、

  • keyに対するvalueを記憶させることができる (array["altus"] = 5)
  • keyを渡すと、記憶されているvalueを取得できる (p(array["altus"]) #=> 5)
  • keyに対するvalueを上書きすることが出来る (array["altus"] = 50 #"altus"に対応するvalueを50に上書き)

であるとし、Hashクラスとは別の 「粗悪な」 連想配列を作ってみたいと思います。

「粗悪な」連想配列を実装してみる

まずは、下記ソースコードをご覧ください。

# 配列を連想配列化する(強引な)実装

class MyAssociativeArray < Array
  def []= (k, v)
    found = false
    self.each {|item|
      if item[0] == k then
        item[1] = v
        found = true
        break
      end
    }
    if not found then
      self.push([k, v])
    end
  end

  def [] (query)
    found = false
    value = nil
    self.each {|item| #線形走査
      if item[0] == query then
        found = true
        value = item[1]
        break
      end
    }
    value
  end
end
view rawassociative_array.rb hosted with ❤ by GitHub

上記コードは、RubyのArrayクラス(配列クラス)を継承し、MyAssociativeArrayクラスを生成しています。 要素取り出し []と代入演算子[]=をオーバーロードすることで、 arr["altus"] = 5 や p(arr["altus"]) といったアクセスが実現。 MyAssociativeArrayクラスを 連想配列として用いる ことに成功していることがわかりますね。

ほか、値の上書きや、存在しないkeyに対してnilを返すといった実装にも成功しており、 Arrayクラスをベースに、とりあえず連想配列として使える実装ができたことがわかります。

どうやっているのか、以下ソースコードの解説をしていきます。

RubyのArrayクラスを継承・メソッドをオーバーロードして連想配列にするまで

代入メソッド([]=)の実装

まず、Arrayクラスを継承し、MyAssociativeArrayクラスを定義します。

class MyAssociativeArray < Array
view rawassociative_array.rb hosted with ❤ by GitHub

そのため、MyAssociativeArrayクラスは、Arrayクラスの性質を受け継いでいます。 Rubyでは組み込みクラスのメソッドを上書きできるので(シンタックスシュガーの一種)、 「手軽かつ、パッと見わかりやすく、性能が悪いので反面教師にもなる」オリジナル連想配列が作れるのではないか、 というのが本記事のモチベーションでした(余談)。

さて、肝心のメソッド上書きの内容です。 はじめに「値の代入」に取り掛かりました。そのためにオーバーロードする必要のあるクラスメソッドは []=です。 このメソッドは第一引数に添字(key)、第二引数に値(value)を取ります。

今回の実装は、配列の要素としてkey, valueの組(2つの要素からなる配列)をもたせてしまおう という原始的なもので、 第一要素を 線形走査 することで、お目当ての要素にアクセスしたり、値を上書きすることができます(この点が 粗悪 たる所以です。詳しくは後述)。

代入すべきkey, valueが与えられた場合、まずは過去に格納したデータの中に、同一keyの値が存在しないかどうかを調べます。

    self.each {|item|
      if item[0] == k then
        item[1] = v
        found = true
        break
      end
    }
view rawassociative_array.rb hosted with ❤ by GitHub

もし存在すれば、該当する要素のvalueを上書きし(上記L9)、 存在しなければ、key, valueの組を新しく配列に格納します(下記コード)。

    if not found then
      self.push([k, v])
    end
view rawassociative_array.rb hosted with ❤ by GitHub

蛇足

第一要素がkey, 第二要素がvalueとみなす実装は、暗黙的な悪い実装なので、 連想配列の中身を連想配列で定義したくなりますが、もちろん今回は Hashクラスの利用は禁じ手です。

さて、これで値の代入ができるようになりました。

参照メソッドの実装

次は値の参照です。こちらも線形走査をする点は変わらないので、一気に紹介してしまいます。

  def [] (query)
    found = false
    value = nil
    self.each {|item| #線形走査
      if item[0] == query then
        found = true
        value = item[1]
        break
      end
    }
    value
  end
view rawassociative_array.rb hosted with ❤ by GitHub

たとえば p(arr["altus"])とすれば、arr において ”altus” keyに対応する要素を返すので、 格納されているvalue、例えば 5が出力されるといった具合です。 もし該当するkeyの要素が存在しない場合は nilを返します。

代入・参照に線形時間かかる連想配列 – 上記実装は何が悪いのか?

さて、ここまで度々(しつこく?)触れてきた、上記連想配列の「粗悪さ」について解説してみたいと思います。

ここまでの実装をイメージ化すると、下図のようになります。

(代入・参照とも仕組みは同じなので、一つの図で済ませてしまいました。)

たとえばarr["altus"]のように、keyに対応するvalueを参照する処理や、 arr["altus"] = 5のような代入処理の際に、上図で表されるような処理、 すなわち「既存のkey一つひとつに対する、与えられたkey(query)との等値判定」を行って、 等値なものが見つかったタイミングでvalueを返したり、代入したりといった処理を行います。

これがいわゆる 線形走査 であり、において、データサイズnに対して比例する時間、すなわちO(n)の時間がかかります(最悪値評価)。

ちなみに、参照・代入における最悪ケースは何かと言うと、「まだ連想配列内に存在しないkeyをqueryとして投げた場合」 です。 非常によくあるケースが最悪値なので、この性能の悪さは実用上の障害になり、 ある程度データサイズが大きくなると、上記の連想配列はほとんど役に立ちません。

では、どうすれば性能の良い連想配列を得られるのでしょう… という問いへの答え(の一つ)が、 次回の記事でご紹介する「ハッシュテーブル」です。 ご期待ください!

参考資料

The post Rubyの配列(Array)を魔改造して、連想配列として使ってみた first appeared on 株式会社Altus-Five.

]]>
/blog/2018/05/07/associative-array/feed/ 0
「ハッシュ」完全理解のための覚書 ハッシュテーブルをRubyで実装してみる /blog/2018/05/07/hashtable/ /blog/2018/05/07/hashtable/#respond Sun, 06 May 2018 15:04:00 +0000 http://43.207.2.176/?p=309 「ハッシュ」を完全理解するための道とは 前回、エンジニアが常々お世話になる「連想配列」についてのあれこれを述べました。Rubyのシンタックスシュガーを使って、 配列を連想配列に魔改造することで、どんな性能の連想 […]

The post 「ハッシュ」完全理解のための覚書 ハッシュテーブルをRubyで実装してみる first appeared on 株式会社Altus-Five.

]]>
「ハッシュ」を完全理解するための道とは

前回、エンジニアが常々お世話になる「連想配列」についてのあれこれを述べました。Rubyのシンタックスシュガーを使って、 配列を連想配列に魔改造することで、どんな性能の連想配列になるかを確かめる ことが前回の内容でした。

今回は、連想配列実装の正式な(?)方法、ハッシュテーブルを実際に作ってみよう という試みです。粗悪な性能の連想配列だけでなく、実用性の高い連想配列も、さほど長くないコードで実現できるんです。

Rubyでの実装を通じて「ハッシュ」を(なんとなくではなく)理解しよう、というのが本記事のゴールになります。

注釈 ハッシュの完全理解とは言いつつも、今回 暗号化の話には触れない ので、その旨ご注意頂ければ幸いです。 ただし、暗号化についても今回の話と基本は同一ですので、理解の助けになるはずです。

実装が理解への近道だ

筆者は学生時代にハッシュテーブルを自作して、その性能の良さ(要素へのアクセスの機敏さ)に感動した人間であることを 前回 述べました。

ハッシュテーブルについて書かれた(まともな)文献については、「ハッシュ」と名のつく用語が複数出てきます。ハッシュ関数、ハッシュ値、などなど。

その関連性については色々な喩えがあるものの、 keyの成績や身長などが引き合いに出され、 それ内部ロジックどーなってんのよ? と言いたくなるような例が用いられたりしており、 「とりあえず」の理解の助けにはなるものの、エンジニアにとっては少し物足りない記述になっているように思えます。

本記事の内容は、下記の2つの項目について理解するにあたっては、なかなか良い内容なのではないかと自負しています。

  1. ハッシュ関数は、文字列など仕様によって規定されるいかなるkeyも入力できる形式でなければならない
    • その役割はハッシュ値を発行し、メモリ上の要素の格納場所を決めることである
  2. 異なるkeyに対するハッシュ値は、可能な限り重複がないことが望ましい
    • そのような性質を満たす「よい」ハッシュ関数のうち、極めてシンプルな実装のものも存在する(後述)

また、本記事では 下記3つの用語について、明確に区別すること をゴールとして、ハッシュテーブルの仕組みについてまとめていきます。

  1. ハッシュ関数
  2. ハッシュ値
  3. ハッシュテーブル(ハッシュ)

最後に、ハッシュテーブルの性能について書かれた文献を読むと、たいてい 「定数時間O(1)で要素にアクセスできる(ゆえに早い、最高だ!)」 と書かれています。 一体これがどういうことなのかも、分かりやすい解説をあまり見かけたことがありません。この点にも、チャレンジしてみたいと思います。

ハッシュ関数の設計

ハッシュテーブルの実装に必要な 最重要のメソッド がハッシュ関数です。

ここでは、文字列をkeyとするハッシュテーブルをユースケースとして想定し、 ハッシュ関数をRubyのメソッドとして、一から実装してみたいと思います。

ハッシュ関数の役割 – メモリ上のどこに要素を格納するか決める

早速前回の内容を引くのですが、 配列を強引に連想配列化した MyAssociativeArrayの実装では、 「メモリ上のどこにkey, valueの組を格納するか?」という問題については触れてきませんでした。 ここで、前回実装した連想配列クラス、 MyAssociativeArrayにおける要素代入のメソッドを見直してみましょう。


  def []= (k, v)
    found = false
    self.each {|item|
      if item[0] == k then
        item[1] = v
        found = true
        break
      end
    }
    if not found then
      self.push([k, v])
    end
  end
view rawassociative_array.rb hosted with ❤ by GitHub

ポイントは、MyAssociativeArrayクラスの継承元であるArrayクラスのpush関数を使ってkey, valueの組を格納している点です。 push関数は配列の末尾に要素を追加するメソッドなので、MyAssociativeArrayでは、新しく到着するkey, valueの組を既存要素の末尾に付け足していくという、ナイーブな方法でメモリ内に格納しています。

ハッシュテーブル実装の第一のポイントは、 メモリ内のどこに要素を格納するか? について、もう少し計画的なやり方を用いることです。

その際に登場するのが、先述した ハッシュ関数 です。

ハッシュを噛ませた場合の実装イメージ(要素格納の方法)を上図に表してみました。

データ代入の手続き

  1. あらかじめメモリ上の決まった広さの領域を確保しておく
    • 本記事ではテーブルサイズと呼ぶこととします
  2. key, valueを受け取ったら、ハッシュ関数 にkeyを入力
  3. ハッシュ値 を得て、1.で確保した領域のサイズTABLESIZEで割った値 offset を得ることで、メモリ上のどこに格納するかを決める

それぞれ、実際にハッシュテーブルを実装したコード(hashtable.rb)を参照しつつ、解説していきたいと思います。

今回、Arrayオブジェクトをインスタンス変数として持つMyHashTableクラスを実装することで、ハッシュテーブルの実装をしてみたいと思います。 まず、1.で述べた「決まった広さの領域を確保する」ことからです。今回は下記のように行います:

  def initialize()
    @arr = Array(TABLESIZE)
  end
view rawhashtable.rb hosted with ❤ by GitHub

あらかじめ定めた定数 TABLESIZE の要素数をもつ配列(Arrayオブジェクト)を生成し、インスタンス変数 @arr として保持しているだけの単純なコードです。 これで、メモリ上にkey, valueの組を保持するための領域が確保されます。 ( TABLESIZEの適切な決め方については、後ほど触れたいと思います。)

次に、代入処理を見てみましょう。(上述した流れの2, 3にあたる部分です。) 代入処理ではまず、確保した配列領域のどの位置にkey, valueの組を格納するかを決定します。配列の先頭から要素いくつ分後ろに格納するかを表すoffset変数を用意し(0オリジン)、ここに数値を代入します。 (もちろん、 0 <= offset < TABLESIZEを満たすような値を与える必要があります。)

さて、肝心のoffsetの数値は下記コードで決定されています。

  def []= (k, v)
    offset = hash_function(k) % TABLESIZE
view rawhashtable.rb hosted with ❤ by GitHub

右辺のMyHashFunctionクラスのone_at_a_timeメソッドを呼び出し、引数としてk(key)を渡しています。それをTABLESIZEで割った値をoffsetとして採用しています。

ここで、このone_at_a_timeこそが ハッシュ関数 と呼ばれるものです。 One at a timeハッシュは、質の良いハッシュ関数について論じたJenkinsの文献(英語)に記載されている中で、もっとも古典的なハッシュ関数です。 シンプルなアルゴリズムで動作の理解が易しいこと、デモとしては十分な性能があることから、今回の目的にピッタリと判断しました。動作を理解するのに必要な知識は ビット演算のみ ですので、不慣れな方は、ビット演算に関する文献を片手に読めば十分理解できるのではないかと思います。

one_at_a_timeハッシュ関数の中身をみてみましょう。


  def one_at_a_time(key) #keyは文字列を想定
    hash = 0
    key.each_char {|ch| #文字を取り出す
      hash += ch.ord
      hash += (hash << 10)
      hash ^= (hash >> 6)
    }
    hash += (hash << 3)
    hash ^= (hash >> 11)
    hash += (hash << 15)
    hash %= (2**30)
    # 0 <= hash < 2**30 の範囲で扱えるような数値に補正
    # FixNum クラスの数値範囲による
  end
view rawhash_function.rb hosted with ❤ by GitHub

ここで、上記コードは下記4つのステップで説明できます。

  1. key文字列の先頭から1文字取り出し、文字コード化し、二進数化する(L7)
  2. 1.で得られた値をhash変数に加える(L7)
  3. hash変数の値をビット演算でいい感じにかき混ぜる(L8-L9)
  4. 以下、文字列の末尾文字まで繰り返し
  5. 得られた値を最後にもう一度かき混ぜて出力(L11-L14)

例えば、このハッシュ関数に”altus”というkeyを入力してみると、0x25746a3bという出力値が返されます。この数値を ハッシュ値 と呼びます。

ハッシュ値をTABLESIZEで割った値をoffsetとして採用することで、配列内のどの位置に要素を格納するかを決定します。

このようにしてoffsetを確定したら、その位置にkey, valueの組を格納します。 ただし、最初の実装と同様、「既にそのkeyをもつ要素が格納されていれば、値を上書きする」よう実装する必要がありますので、ロジックは若干長くなります。 下記コードのL21が、肝心の代入を行なっている箇所です。

    if @arr[offset].nil? then
      @arr[offset] = Array.new().push([k, v])
    else
      found = false
      @arr[offset].each {|e|
        if(e[0] == k)
          found = true
          e[1] = v
          break
        end
      }
      if not found
        @arr[offset].push([k, v])
      end
    end
view rawhashtable.rb hosted with ❤ by GitHub

ハッシュ関数に求められる2つの性質

  1. 上記手続きには一切ランダム性がなく、同一のkeyを与えた時は必ず同じ出力値を返すこと(入力に対する出力の一意性)
  2. 異なるkeyを与えたとき、同じ出力値を返す可能性が極めて低いこと

ハッシュテーブルにおける値の参照 – keyのハッシュ値で格納場所がわかる

代入の次は参照です。最初の実装では線形走査でkeyにヒットする要素を見つけていましたが、今回は代入の方法が変わったため、参照の方法が劇的に効率的になります。 ポイントは明快で、 参照にもkeyのハッシュ値を使えば良い のです。

今回、代入時の要素格納を、keyのハッシュ値に基づいて行うようロジックを変更しました。そのため、要素参照時にもハッシュ値を発行し、ハッシュ値の指す位置を調べることで、与えられたkeyをもつ要素が、既に連想配列内にあるかどうかを高速に調べることができるのです。 実装方針としては、下記の通りです。

  1. keyに対応するハッシュ値からoffsetを求める
  2. offsetの指す位置に、与えられたkeyを持つ要素が格納されているかどうかを調べる

「keyに対応するハッシュ値からoffsetを求める」コードは下記の通りで、代入の時と全く同一です。

    hash_value = hash_function(k)
    offset = hash_value % TABLESIZE
view rawhashtable.rb hosted with ❤ by GitHub

「offsetの指す位置に、与えられたkeyを持つ要素が格納されているかどうかを調べる」コードは下記の通りです。

    value = nil
    if @arr[offset].nil?
      puts("not found")
    else
      found = false
      @arr[offset].each {|e|
        if e[0] == k
          found = true
          print("found: ", e[1], "\n")
          value = e[1]
        end
      }
      if not found
        puts("not found")
      end
    end
    puts()
view rawhashtable.rb hosted with ❤ by GitHub

まとめると、ハッシュテーブルの実装上の工夫、およびその結果としての性能の良さ(高速さ)は、下記の文章で言い表すことができます。

  • ハッシュテーブルでは、値の代入と参照を同じ手段(ハッシュ関数)を介して行うため、 1回 の等値判定で指定のkeyを持つ要素が見つかる
  • 同様の理由から、指定のkeyをもつ要素が連想配列内に存在しない場合は、 1回 の等値判定でそのことがわかる
    • ただし、いずれも例外あり(後述する『衝突』が起きた場合)

このことが、 ハッシュテーブルは定数時間O(1)で値の参照/代入が可能である という文言の意味するところなのですね。

衝突(collision)とは? – ハッシュテーブルの性能が劣化するケース

さて、上記ソースコードを載せつつさらりと流しましたが、性能に関して見過ごせない点があったことにお気づきだったでしょうか。 「ハッシュテーブルは1回の等値判定で参照/代入ができる!」などと言いつつ、 実は今回も、 参照/代入の実装において線形走査(each)を使っていた のですね。

代入

      @arr[offset].each {|e|
        if(e[0] == k)
          found = true
          e[1] = v
          break
        end
      }
view rawhashtable.rb hosted with ❤ by GitHub

参照


      @arr[offset].each {|e|
        if e[0] == k
          found = true
          print("found: ", e[1], "\n")
          value = e[1]
        end
      }
view rawhashtable.rb hosted with ❤ by GitHub

さて、こちらは 衝突(collision)に対する処理 と呼ばれるもので、ハッシュテーブルに実装について書かれた文献であれば、必ず言及されている重要な処理です。

衝突とは、 相異なる複数の入力キーに対して、同じハッシュ値が発行されること、もしくは配列内の同じ位置(offset)が格納場所として指定されてしまうこと を指します。

One-at-a-timeハッシュは、古典的ながらもよく考慮されたハッシュ関数ですので、相異なる複数の入力キーに対して同じハッシュ値を発行してしまうことは稀ですが、 その出力値は 0以上2^31未満と幅が大きいことから、今回は実用上TABLESIZEを指定し、One-at-a-timeの出力値をTABLESIZEで割った値を用いることで、値を0以上TABLESIZE未満に補正して用います。

今回の実装では、TABLESIZE52291を指定していましたので、ある程度大きな要素数になると、かなりの高確率で衝突が生じることになります。

ハッシュテーブルのサイズに関する余談

この時TABLESIZE365と指定してみると、 有名な「誕生日のパラドックス」と同じ問題設定になります。 「何人集まれば、その中に誕生日が同一の2人(以上)がいる確率が、50%を超えるか?」という問題に対する答えを問うもので、 その答えは 「23人」 であるという、意外な結論が有名です。 この興味深い結果をハッシュ値の文脈に載せてみると、

前提:TABLESIZE365に指定し、理想的なハッシュ関数を用いたとする

主張:keyについてランダムに要素を追加する場合、 要素数が「23」に達するまでに1回以上の衝突が起きる確率は50%以上 である

と読み換えることができ、誕生日のパラドックスは上記の主張が「正しい」ことを示す、技術的にもおもしろい(厄介な)帰結だということが分かります。

さて、話が逸れましたが、TABLESIZEをどんなに大きくした場合でも、衝突の起きる確率は0にはなりませんので、プログラム上はその際の処理を書いておく必要が生じます。 それが、先ほど挙げた参照/代入のロジックにおけるeach文の役割だったのですね。

以下、その内容を再掲・解説しておきますので、ご興味のある方はコードを解読してみてください。

代入における衝突対策ロジック

  def []= (k, v)
    offset = hash_function(k) % TABLESIZE

    if @arr[offset].nil? then
      @arr[offset] = Array.new().push([k, v])
    else
      found = false
      @arr[offset].each {|e|
        if(e[0] == k)
          found = true
          e[1] = v
          break
        end
      }
      if not found
        @arr[offset].push([k, v])
      end
    end
  end
view rawhashtable.rb hosted with ❤ by GitHub
  1. 新規のkeyに対するoffsetの位置に既存の要素が存在するかどうかを確かめる(L14)
  2. 存在しない場合は単純に要素を格納すればOK
    • ただし、今後衝突が起きた時のため配列を作っておき、いま代入したいkey, valueはその先頭要素として格納しておく(L15)
  3. 存在する場合は、既存keyの再入力(keyに対する値の上書き)か、異なるkeyに対するoffsetの重複(衝突)かを判定する(L18-L19)
  4. keyに対する値の上書きの場合は、上書き処理を行う(L21)
  5. 衝突の場合は、以前に入力されたkey, valueにおいて、上記2.の手続きで配列を準備してあるので、その末尾にいま入力したいkey, valueをpushする(L26)

参照における衝突対策ロジック

    value = nil
    if @arr[offset].nil?
      puts("not found")
    else
      found = false
      @arr[offset].each {|e|
        if e[0] == k
          found = true
          print("found: ", e[1], "\n")
          value = e[1]
        end
      }
      if not found
        puts("not found")
      end
    end
    puts()
    value
  end
view rawhashtable.rb hosted with ❤ by GitHub
  1. クエリとして投げられたkeyに対するoffsetの位置に要素が存在するかどうかを判定する(L39)
  2. 存在しない場合、nilを返す
  3. 存在する場合、@arr[offset]の要素に対して「クエリ」と「格納されているkey」との等値判定を行い、求める要素があるかどうかを調べる
    • ここで@arr[offset]内に複数要素が存在するのは、過去に衝突が起きた場合である。多くの場合、@arr[offset]の要素数は1であるため、eachループは1回しか回らない

まとめ

今回、コードを読みやすく、保守・拡張しやすくしてくれる「連想配列」について、その原理に迫ってみました。 定数時間O(1)で要素へのアクセスを可能にする実装の一つに「ハッシュテーブル」があり、その構成要素として「ハッシュ関数」が重要な役割を果たしていることを、Rubyで実際に連想配列を実装してみることでおさらいしてみました。 前回の冒頭で述べた「連想配列は配列ではない」という(物騒な)主張は、本記事でまとめた内容をゼミで学んで以来、実装と実験によって体感できた実体験に基づいています。 インタフェースが一見同じでも、内部の仕組みは随分違うし、そのことに助けられることがあるんだなぁ…と感動したことを覚えています。

普段ツールとして見ている/使っている実装について、 内部の仕組みがわかるといちいち感動できる 、そんな経験が広がっていけば、プログラミングはますます深く、楽しくなるのではないでしょうか。 (綺麗にまとめてみました!)

参考資料

The post 「ハッシュ」完全理解のための覚書 ハッシュテーブルをRubyで実装してみる first appeared on 株式会社Altus-Five.

]]>
/blog/2018/05/07/hashtable/feed/ 0
【Pythonでテキスト処理】Double arrayでTrieを実装してみた /blog/2017/11/13/double-array/ /blog/2017/11/13/double-array/#respond Sun, 12 Nov 2017 15:50:00 +0000 http://43.207.2.176/?p=345 Trieを実装する上で必要不可欠な、データ構造の工夫 前回のブログで、辞書検索、サジェスト、形態素解析などを実装するのに使われるTrieの概略を紹介しました。 木構造の各エッジに文字のラベルを付けることで辞書を表現すると […]

The post 【Pythonでテキスト処理】Double arrayでTrieを実装してみた first appeared on 株式会社Altus-Five.

]]>
Trieを実装する上で必要不可欠な、データ構造の工夫

前回のブログで、辞書検索、サジェスト、形態素解析などを実装するのに使われるTrieの概略を紹介しました。

木構造の各エッジに文字のラベルを付けることで辞書を表現するという抽象的な説明を与え、それを実現するLOUDSという簡潔データ構造を紹介しました。 今回はLOUDSとは別の、Double Array(ダブル配列)というデータ構造で実際にTrieをPythonで構築し、共通接頭辞検索を行えるようにします。 実装方法については 『日本語入力を支える技術』(徳永, 2012)、通称「徳永本」に準拠しますので、同著を読まれた方が実装を試みる際の参考にもなると思います。 また、Double arrayのエッセンスを紹介していきますので、参考書籍をお持ちでない方もDouble arrayによるTrieの実装について理解できることを目指しています。

前提:Trieを実装する上で、データ構造の選択は重要

まず、Trieを実装する際のデータ構造として、LOUDSやDouble Arrayといった様々な選択肢が存在する背景を紹介しておきます。 検索の高速性を担保しつつTrieを実装する素朴な方法として、各ノードからの文字ごとの遷移先をテーブルに格納しておくというやり方が挙げられます。

このようなTrieを想定してみましょう(ノードの番号は幅優先で付与)。

上記のTrieに対応するテーブルが上図です。各セルを (行ラベル, 列ラベル)という組で表すことにすると、例えば (2, E)に4が格納されていることになりますね。

これは、Trie上の「2」というラベルが付いたノードから、文字「E」のラベルがついたエッジを辿ることで、「4」 というラベルが付いたノードに遷移できることを表します。 このようなテーブルを二次元配列などの形式でメモリ上に保持すれば、例えば「AGI」という入力に対してTrie上のノード5に効率よく遷移出来ます。そのことを、Pythonのコードを用いて確かめてみましょう。

【Python】テーブルによるTrieと共通接頭辞検索ソースコード

ここからは、GitHubで公開した下記のソースコードを引用しながら解説を進めます。100行に満たないソースコードですが、『日本語入力を支える技術』で解説されている「テーブルによるTrieの実装」をそのまま実現しています。

https://github.com/msato-ok/trie-master/blob/master/tabletrie.py

まず辞書の生成ですが、’MyTrie’というクラスでTrieを表すものとし、’MyTrie’のオブジェクトが一つの辞書を表します。オブジェクトを生成する際には、語彙が格納された配列を入力します。

class MyTrie():
    def __init__(self, vocabulary): #入力は辞書式順序でソート済みであることを想定
view rawtabletrie.py hosted with ❤ by GitHub

例えば下記のようにすると、辞書を生成することが出来ます。

        self.sub_dictionaries[present_node].view_terms()

vocabulary = ['age', 'agile', 'alias', 'all', 'alt', 'altus'] #語彙
view rawtabletrie.py hosted with ❤ by GitHub

テーブルによるTrieの構築について、アイデアは既にご紹介しましたので、その具体的な実装内容はソースコードをご覧ください(’MyTrie’クラスの’init’関数の中身)。

この時、共通接頭辞検索のコードは下記のようになります。

                #suffix[0] == '#'のときはそのノード自身が単語を表す。新規ノード不要

    def common_prefix_search(self, query):
        present_node = 0 #Trieの根ノードから探索開始
        for alphabet in query:
            if alphabet in self.table[present_node]:
                present_node = self.table[present_node][alphabet]
            else:
                print('The result for query \'' + query + '\' was not found.')
                return
        #for文を抜けているので、検索結果が存在
view rawtabletrie.py hosted with ❤ by GitHub

共通接頭辞検索のアルゴリズムは、入力として‘query’という文字列を取ります。例えば下記のようなものが考えられるでしょう。

vocabulary = ['age', 'agile', 'alias', 'all', 'alt', 'altus'] #語彙

dictionary = MyTrie(vocabulary)

dictionary.common_prefix_search('altus')
dictionary.common_prefix_search('alt')
view rawtabletrie.py hosted with ❤ by GitHub

出力は’query’が表すノード下にある全ての単語です。一例として、下記が正しい出力です。

上述したリンク先のリポジトリをcloneし、「tabletrie.py」を実行して頂くと、同様の結果が確認可能です。

テーブルによる実装の弱点と、Double arrayの必要性

この実装はシンプルな上、高速な検索が可能ですが、「Trieのノード数、および使われる文字数、それぞれに比例したメモリが必要」という弱点があります。

つまり、この素朴な方法では、文字数が大きくなったり、辞書の単語数が大きくなると、メモリに乗り切らないという問題が生じてしまうのです。 そこで、「よりメモリ効率の良いデータ構造を用いれば、巨大な入力(語彙)に対しても使えるようなTrieによる辞書が実装できないか」という問が生じます。

Trieを格納・運用できるデータ構造については、検索の高速性をある程度犠牲にしてメモリ効率を上げていくというのが基本方針です。そのような「メモリ効率と高速性、両者のバランスが取れたデータ構造」を「簡潔データ構造」と呼びます。今回のテーマであるDouble array(ダブル配列)は、簡潔データ構造の一種です。

補足:Trieによる形態素解析と「文字数」の上限について

※この段落は読まなくても、理解上差し支えありません。

先ほど、テーブルによる実装は「Trieのノード数、および使われる文字数、それぞれに比例したメモリが必要」と述べました。 ここで「ノード数」は単語数に従って増大するため問題だということが伝わりやすいと思いますが、「文字数が大きくなる」というイメージは持ちづらいかもしれません。 アルファベットなら26文字、ひらがななら五十音といった風に、上限があるように感じられるので、問題がないと思われるかもしれません。 しかし形態素解析のような用途にTrieを用いたい場合は、文字数は増大の可能性があります。 形態素解析においては、「わがはいはねこである」という入力文章に対し、単語の結びつきの強さによって「吾輩は猫である」という文章と「我が杯羽根子である」という文章のどちらがもっともらしいかという判断を行います。このとき、先ほどの「文字」にあたるものは「単語」(ex. 吾輩、杯、羽根、etc.)であり、その数はアプリケーションの用途によります。 よって文字数の上限を定めることはできず、テーブルによるTrieの「文字数に比例してメモリが必要」という性質は大きな障壁となるのです。

Double arrayによるTrieの実装

Double arrayによるTrieの実装をPythonで行ったコードは、下記URLからアクセスできます(GitHubリポジトリ)。

https://github.com/msato-ok/trie-master/blob/master/double_array_trie.py

まずは、テーブルによる素朴な実装とDouble arrayによる実装はどう違うのか、簡単に対比してみたいと思います。

テーブルによる実装では、Trieのノードそれぞれについて、各文字による遷移先を示すための行(すなわち配列)を用意しました。

一方、Double arrayを用いることは、語彙(入力する単語の集合)がどんなに大きくても、2本の配列だけでTrie全体を表現することを可能にしてくれます。 「ダブル配列」という言葉からは配列の本数が倍加するような印象を受ける方もおられるかもしれません。そうではなく、「どんなに巨大な辞書でも、2本の配列だけで、対応するTrieを表現できる」ことが、このデータ構造の特色なのです。

例えば、下記が上述のTrieに対応する、Double arrayの例です。

ここでDouble arrayとは、上図にあるCheck, Baseという2つの配列のことです。2本の配列に格納された値を適切に読み込んでいくことで、上記のTrieを再現したり、共通接頭辞検索を高速に行えるようになっています。 しかし、なぜこの2本の配列によってTrieを表現出来るのか、配列そのものを見ていても理解は難しいでしょう。ですので一旦 Check, Baseが何を表すのかということは忘れることにします。

代わりに、上図のもう一つの特徴的な点に着目します。それは、Trieのノードに付与された番号が先ほどとは変わっているという点です。 これはデタラメな数値ではなく、Double arrayによるTrieの構築アルゴリズムに従って論理的に付けられた番号です。その規則を知ることで、Double arrayによるTrieの構築と、検索に用いる際のアルゴリズムが直観的に理解しやすくなります。

Double arrayにおける各ノードの番号付け規則

番号付け規則を理解するための「データ構造もどき」

少し遠回りですが、Double arrayについて理解するためのステップとして、Double arrayから幾つかの要素を省いた「データ構造もどき」について考えてみると、理解しやすくなります。 すなわち、下記のシンプルな規則に基づいてTrie(?)を構築するとどうなるか、想像してみましょう。

  • 各ノードの番号として、ノードに入ってくるエッジのラベル「A, B, C, …」を、「1, 2, 3, …」に置き換えた数値を採用する

結果は下図のようになります。

ここでは便宜的にノードとノードの間にエッジを引いて、Trieの形を保たせていますが、実際に情報として存在するのは各ノードの番号だけです。 この「図」をTrieだと考えて運用しようとすると、2つの障害が発生することを、下図で示してみました。

  1. 複数のノードに同じ番号が付けられてしまい、例えば「1」のノードから「L (12)」で遷移しようとしたとき、複数ある「12」のノードのうちどれに遷移すればいいかわからない
  2. 「1」のノードから「E (5)」で遷移しようとしたとき、遷移先は存在しない(「1」の子ノードに「5」のラベルが付いたノードはない)にもかかわらず、離れた「5」のノードに遷移できるように見えてしまう

Double arrayは、この2つの症状を克服するために工夫を加えたものと解釈することが出来ます。

正しいノード遷移を可能にするための2つの工夫

まず前述した問題1.については、ノードの番号が重複しないようにすれば解決します。 ノード間の番号の重複を解決するために、ちょっとした工夫を加えたものが下図です。

こちらは、一部のノードに「+1」もしくは「+2」という数値を持たせ(もっと大きな値を持たせてもよい)、ノードの番号を付ける際の規則を、下記のようにほんの少しだけアップデートした結果の図です。

  • 各ノードの番号として、親ノードの持つ数値に対し、ノードに入ってくるエッジのラベル「A, B, C, …」を、「1, 2, 3, …」に置き換えた数値を加えたものを採用する

ひとまずこのようにすると、ノードの番号の重複については解決出来ることになります。

上図では、「子ノードの番号に加えた量」を、親ノードの傍に付記していました。実際にメモリ上でその値を記憶しておくためには配列をよく用いますので、親ノードの番号を添え字として、配列の中に値を格納してみることにしましょう。

実はこの配列が、冒頭で天下り的に示した「Double array」の「Base」という配列に一致するものとなっています。 要は、Baseというのは、「子ノードの番号を付ける際のベースとなる量」を表す数値であり、ノードの番号を重複させないために加えるものなのですね。

もう一つの工夫は、問題2.を解決するためのものです。 Base配列を導入することでノードの番号の重複は解消されましたが、「遷移出来ないノードに遷移出来るように見える」という問題は依然として残っています。例えば:

  • ノード「0」から「E (5)」という文字によって、ノード「5」に遷移出来るように見える
  • ノード「12」から「F (6)」という文字によって、ノード「7」に遷移出来るように見える(ノード『12』は+1というBaseの値を持つことに注意)

これを解決する方法は実は単純です。各ノードに対し、親ノードの番号を覚えさせておくことで、子ノードへ遷移しようとするとき「遷移先が自分の子ノードかどうか」チェック出来るようにすれば良いのです。

このようにすると、子ノードではないノードへと遷移してしまう問題を防ぐことが可能になります。 例えば「1」というノードからは「E (5)」という文字によってノード「5」に遷移出来るように一見見えますが、ノード「5」の親は「7」です。つまりノード「5」はノード「1」の子ではありませんから、遷移は出来ないということがわかるのです。

実はこの「各ノードの親を記憶させた配列」が、Double arrayのもう一つのデータである「Check」という配列にあたるのです。

上図は最初に天下り的に示した「Double arrayによるTrie」と同じ図になっています。 Double arrayに格納されているデータが何を表すかに関する、概念的な説明は以上です。

【Python】Double arrayを構築し、共通接頭辞検索を行うコード

https://github.com/msato-ok/trie-master/blob/master/double_array_trie.py

上記ソースコードでは、与えられた語彙(単語の集合)に対し、実際にDouble arrayに基づくTrieを構築し、共通接頭辞検索を行うことが出来ます。 行数は130行程度と、tableによる実装とさほど変わりません(ただし、高速化のための工夫は行っていないことにご注意ください)。

Check, Baseへの値の入力は、MyTrieクラスのコンストラクタ内で行っています。ここでは入力語彙に基づき、Check, Baseに適切な値を格納していきます。

また、double_array_trie.pyMyTrie. common_prefix_search関数は、Check, Base二つの配列を用いて下記のような処理を行うことが出来ます。

  • 入力語彙のうち、「AL」から始まるすべての単語を出力する(共通接頭辞検索)
  • 入力語彙のうち、「ALP」や「ALA」から始まる単語が存在しないことを判定する
    def common_prefix_search(self, query):
        present_node = 0 #Trieの根ノードから探索開始
        for alphabet in query:
            if not self.base[present_node] + (ord(alphabet) - self.origin) in self.check:
                #該当列のcheckの値が存在しない
                print('The result for query \'' + query + '\' was not found.')
                return
            elif self.check[self.base[present_node] + (ord(alphabet) - self.origin)] != present_node:
                #該当列のcheckの値が現在のノードと一致しない
                print('The result for query \'' + query + '\' was not found.')
                return
            else:
                present_node = self.base[present_node] + (ord(alphabet) - self.origin)
        #for文を抜けているので、検索結果が存在
        print('The result for query \'' + query + '\' is:')
        self.sub_dictionaries[present_node].view_terms()
view rawdouble_array_trie.py hosted with ❤ by GitHub

Trieをシステム開発に応用する

弊社はフルスクラッチの開発案件を得意としており、Trieやそれに類するデータ構造を用いて、共通接頭辞検索を応用した機能を柔軟に実装することが出来ます。 例えば、「求人案件情報の検索において、全文検索に基づくサジェストを出したい」と言った要求は、「Java」という入力に対して「Java」や「Java エンジニア」、「JavaScript」といった出力を返すことなので、共通接頭辞検索を行うことに他なりません。 「できない」と思えるような技術課題について、技術力を駆使してお応えする準備がございます。まずはお気軽に、開発案件のご相談を頂ければ幸いです。

また、様々なデータ構造を活用して、顧客の課題を解決する最善の方法を模索したいエンジニアからの求人応募も常にお待ちしております。ぜひ「採用情報」をご覧になってみてください。

The post 【Pythonでテキスト処理】Double arrayでTrieを実装してみた first appeared on 株式会社Altus-Five.

]]>
/blog/2017/11/13/double-array/feed/ 0
テキスト処理に使われるTrie(トライ木)とLOUDSに関する概略 /blog/2017/09/06/trie/ /blog/2017/09/06/trie/#respond Tue, 05 Sep 2017 16:11:00 +0000 http://43.207.2.176/?p=364 Trie(トライ木)を用いた検索は高速・省メモリ Trie(= Retrieval tree, 検索木)はテキスト処理において必需品と言えるデータ構造です。辞書検索、日本語入力、サジェストの実装や、形態素解析に使われる辞 […]

The post テキスト処理に使われるTrie(トライ木)とLOUDSに関する概略 first appeared on 株式会社Altus-Five.

]]>
Trie(トライ木)を用いた検索は高速・省メモリ

Trie(= Retrieval tree, 検索木)はテキスト処理において必需品と言えるデータ構造です。辞書検索、日本語入力、サジェストの実装や、形態素解析に使われる辞書(形態素辞書)が主な用途と言えるでしょうか。Pythonの自然言語処理パッケージNLTK(Natural Language Tool Kit)でも、形態素解析にトライ木を用いています。

参考:http://www.nltk.org/book-jp/ch12.html

Trie(トライ木)とは?

例として、辞書として使われているトライ木の「A」から始まる単語を格納した部分を図示してみました。このように、探索木の各エッジに文字を割り当て、根ノードから葉ノードに至る経路(path)上の文字を拾っていった時の文字列によって辞書を表現します。この辞書には、「AGE」「AGILE」「ALIAS」「ALL」「ALT」「ALTUS」という6つの単語が含まれています。このように、経路によって定義される個々の単語をキーと呼びます。 形態素解析におけるトライ木の活用で言えば、各ノードには形態素が対応し、葉ノードまでの経路が文章を表すことになります。

なぜ辞書検索や形態素解析にトライ木が使われるかというと、大きく下記の理由によります。

  • 共通接頭辞検索(Common-Prefix Search)に適したデータ構造であること
  • 膨大な辞書データを格納する場合や、文字の種類が多い場合でも、検索が高速であること
  • 木構造を表現する際にLOUDSなどの簡潔データ構造を用いれば、省メモリで辞書を表現出来ること

LOUDSとは?

LOUDSとは、木構造をメモリ効率よく表現するための簡潔データ構造です。 Trieに使う場合のLOUDSの概念図を下図に示します。

左側の木がLOUDSによって表現されるTrieであり、右側の表がLOUDS(ビット列)及び、その付加情報としての文字です。 LOUDSとは、仮想ノード(Virtual node)を根ノードの親として用意したあと、下記の手続きで構築されるビット列を用いて、木構造を表現しようというデータ構造です。

  1. ノードが持つ子ノードの数に等しい数だけ「1」のビットを立て、そのあとに「0」のビットを立てる
  2. 仮想ノードから幅優先で木を探索し、1.の手続きを繰り返す

LOUDSは、木構造を最小に近いビット数で表現できます。また、Trieとして用いる際にも、接頭辞検索などの要求に対する処理をある程度高速に行える、バランスの良いデータ構造(簡潔データ構造)であることが知られています。

有名なところでは、Google日本語入力のオープンソース版であるMozcでは、LOUDSを用いてTrieを表現しています。 一方、工藤 拓氏(現Google)が開発した著名なオープンソース形態素解析エンジンMeCabには、ダブル配列(Double-Array)という別のデータ構造に基づくTrieが使われています。

弊社の実績 – 全文検索やSNS解析にトライ木を応用

弊社でも、トライ木(またはトライ木に類するデータ構造)が随所で活躍しています。 例えば、ある「案件情報検索サイト」の全文検索にサジェスト機能を付けた際、サジェストの表示(フロントエンドUI)はAutocompleteを用いましたが、データの辞書化と辞書検索は下記の要領で自作しました。

  1. 案件情報テキストから単語を抽出
  2. 辞書をトライ木で表現
  3. 共通接頭語検索によりサジェストを出力(出現頻度順に10個)

他には、「ある特定の語順が出現したツイートを抽出する」SNS解析システムの実装にトライ木を応用しました。 そのシステムの概略と、具体的な実装上の課題については、今後の記事で紹介していきたいと思います。

The post テキスト処理に使われるTrie(トライ木)とLOUDSに関する概略 first appeared on 株式会社Altus-Five.

]]>
/blog/2017/09/06/trie/feed/ 0