DAWG2(1): ソート済みファイルからのトライ構築
七月にやっていたDAWG(Direct Acyclic Word Graph)シリーズ(?)の続き。
結構長い間中断していて自分でも内容がうろ覚えなので、仕切り直してもう一度。
今回はDAWGの前段階として、ソート済み(かつ各行がユニーク)なファイルからトライを構築する。
DAWG
軽くおさらい。
※ 例によって個人的な理解であり、正しくない可能性あり
- トライに似ている
- 機能的にはDAWGとトライは一緒
- トライの共有可能な接尾部分(サブトライ)をまとめたものがDAWG
- 二つの等価なノード(サブトライ)がある場合、その内の片方のみを使用するようにする
- 一般に、同一のキーセットを表現するために必要なノード数がトライに比べて少なく、サイズ効率が良い
- ただし、接尾部分を共有する関係上、汎用的なマップとして使用するには適さない
- 各キーに(対応する値として)一意なIDを付与した場合は、DAWGはトライと等しくなってしまう
- サブトライの共有が全くできなくなるので
- セットあるいはキーの値が極度に偏っているようなマップ、としての用途に適する
- 各キーに(対応する値として)一意なIDを付与した場合は、DAWGはトライと等しくなってしまう
ソート済みファイルからのトライ構築
以下、あらかじめソートされたキーセットが(改行区切りで)格納されているファイルからトライを構築するためのソースコード。
※ 参照: each-file-line-bytes
(defpackage trie (:use :common-lisp) (:shadow :common-lisp get read-byte) (:export build-from-file ; ソート済みファイルからのトライ構築 node-count ; トライのノード数を数える get)) ; キー検索 (in-package :trie) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;; スペシャル変数 & 型定義 & 宣言 ;; 速度重視の最適化宣言用変数 (defvar *fastest* '(optimize (speed 3) (safety 0) (debug 0))) ;; 型定義 (deftype octet () '(unsigned-byte 8)) (deftype octets () '(simple-array octet)) (deftype array-index () `(mod ,array-dimension-limit)) ;; インライン宣言 (declaim (inline make-byte-stream peek-byte read-byte eat-byte make-node)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;; キー(バイト列)をストリームとして扱うための構造体と関数 (defstruct byte-stream (bytes #() :type octets) (cur 0 :type array-index) (end 0 :type array-index)) ;; 位置を一つ分進めたストリームを返す (defun eat-byte (in) (incf (byte-stream-cur in)) in) ;; 現在位置のバイトを取得する。終端に達している場合は0を返す (defun peek-byte (in) (with-slots (bytes cur end) (the byte-stream in) (if (>= cur end) 0 (aref bytes cur)))) ;; 現在位置のバイトを取得し、位置を一つ分進める。終端に達している場合は0を返す (defun read-byte (in) (prog1 (peek-byte in) (eat-byte in))) ;;;;;;;;;;;;;;;;;;; ;;;; トライのノード (defstruct node (label 0 :type octet) (children '() :type list)) ; 子ノード(サブトライ)群はリンクリストとして保持する ;;;;;;;;; ;;;; 挿入 ;; 新しい子ノードを、parentノードの最初の子として追加する ;; ストリームinの全てのバイトを消費するまで、再帰的に処理を続ける (defun push-child (in parent) (declare #.*fastest*) (let ((new-node (make-node :label (peek-byte in)))) ; ノード作成 (push new-node (node-children parent)) ; 先頭に追加 (when (plusp (read-byte in)) (push-child in new-node)))) ;; キーを挿入する (defun insert (in parent) (declare #.*fastest*) (let ((node (first (node-children parent)))) (if (or (null node) ; 1) parentに子ノードがいない場合 (/= (peek-byte in) (node-label node))) ; 2) 先頭の子ノードとラベル(バイト)が異なる場合 ※1 (push-child in parent) ; キーの残りのバイト列を、子ノードとして追加する (insert (eat-byte in) node)))) ; このバイトに対応するノードは既に存在するので、次のバイト(と子ノード)を処理する #| ※1: キーはソート順で挿入されるため、先頭の子ノードとラベル(バイト)が異なる場合は、 (peek-byte in)の値は、(node-label node)よりも大きいものとなる。 そして、(peek-byte in)の値をラベルとして作成されたノードが、 先頭の子ノードとして新らたに追加されるので、ここで構築するトライの各ノードの 子ノードは、ラベル(バイト)の降順に整列することになる。 |# ;; ソート済み(かつユニーク)のファイルからトライを構築する ;; - 各キーはバイト列として扱う ;; - 各キー(バイト列)の末尾には、終端文字としてヌル文字を付与する (peek-byte関数参照) (defun build-from-file (filepath) (declare #.*fastest*) (let ((trie (make-node))) (each-file-line-bytes (bytes beg end filepath) (let ((in (make-byte-stream :bytes bytes :cur beg :end end))) (declare (dynamic-extent in)) (insert in trie))) trie)) ;;;;;;;;; ;;;; 検索 (defun get-impl (in parent) (loop FOR node IN (node-children parent) DO (cond ((> (peek-byte in) (node-label node)) (return nil)) ((= (peek-byte in) (node-label node)) (return (or (zerop (peek-byte in)) (get-impl (eat-byte in) node))))))) ;; key(文字列)がトライに含まれているかどうかを判定する ;; ※ 今回は動作しさえすれば良いので、最適化宣言等は行っていない (defun get (key trie) (let* ((bytes (sb-ext:string-to-octets key)) ; XXX: この部分はSBCL依存 (in (make-byte-stream :bytes bytes :cur 0 :end (length bytes)))) (get-impl in trie))) ;;;;;;;;;;;;;;;;;;;;; ;;;; ノード数カウント (defun node-count (trie) (loop FOR child IN (node-children trie) SUM (1+ (node-count child))))
構築時間とノード数
後々の作成物との比較のために、上記トライを構築するための時間とそのノード数を計測しておく。
まずはそのためのデータ(キーセット)を用意。
## データ用意 # Wikipediaの記事タイトル: 約500万行 $ ./wikipedia-title.sh > wiki.title.500 $ wc -l wiki.title.500 5340379 # Wikipediaの記事タイトル: 約250万行 $ ruby line-filter.rb 2 wiki.title.500 > wiki.title.250 # Wikipediaの記事タイトル: 約50万行 $ ruby line-filter.rb 10 wiki.title.500 > wiki.title.50
#! /bin/sh # # データ用意用スクリプト1 # 各国語のwikipediaの記事タイトルを取得し、ソート&ユニークした結果を出力する TMP=`mktemp -d` cd $TMP wget http://download.wikimedia.org/jawiki/20100607/jawiki-20100607-all-titles-in-ns0.gz wget http://download.wikimedia.org/euwiki/20100612/euwiki-20100612-all-titles-in-ns0.gz wget http://download.wikimedia.org/zhwiki/20100612/zhwiki-20100612-all-titles-in-ns0.gz wget http://download.wikimedia.org/fiwiki/20100620/fiwiki-20100620-all-titles-in-ns0.gz wget http://download.wikimedia.org/frwiki/20100614/frwiki-20100614-all-titles-in-ns0.gz wget http://download.wikimedia.org/svwiki/20100620/svwiki-20100620-all-titles-in-ns0.gz wget http://download.wikimedia.org/trwiki/20100620/trwiki-20100620-all-titles-in-ns0.gz wget http://download.wikimedia.org/hewiki/20100620/hewiki-20100620-all-titles-in-ns0.gz wget http://download.wikimedia.org/nowiki/20100619/nowiki-20100619-all-titles-in-ns0.gz wget http://download.wikimedia.org/cawiki/20100619/cawiki-20100619-all-titles-in-ns0.gz gzip -d *-ns0.gz cat *-ns0 | LC_ALL=C sort | LC_ALL=C uniq cd .. rm -rf $TMP echo DONE
# データ用意用スクリプト2 # Usage: ruby line-filter.rb <数値:何行に一つを出力するか> <入力ファイル> N = ARGV[0].to_i open(ARGV[1]).each_with_index do |line,i| if i%N == 0 puts line end end
構築時間とノード数。
;;; sbcl-1.0.40 ;;; Wikipediaタイトル: 50万行 ;; 構築 (time (defparameter *trie50* (trie:build-from-file "wiki.title.50"))) Evaluation took: 3.747 seconds of real time 3.740000 seconds of total run time (3.200000 user, 0.540000 system) [ Run times consist of 3.450 seconds GC time, and 0.290 seconds non-GC time. ] 99.81% CPU 8,617,362,375 processor cycles 331,201,008 bytes consed --> *TRIE50* ;; ノード数 (trie:node-count *trie50*) --> 6897632 ; 690万 ;;; Wikipediaタイトル: 250万行 ;; 構築 (time (defparameter *trie250* (trie:build-from-file "wiki.title.250"))) Evaluation took: 23.904 seconds of real time 23.900000 seconds of total run time (20.020000 user, 3.880000 system) [ Run times consist of 22.490 seconds GC time, and 1.410 seconds non-GC time. ] 99.98% CPU 54,975,170,067 processor cycles 1,397,494,464 bytes consed --> *TRIE250* ;; ノード数 (trie:node-count *trie250*) --> 29112012 ; 2911万 ;;; Wikipediaタイトル: 500万行 ;;; ※ メモリ(2GB)が足りなくなったのでパス
GCの処理時間がやたら長いのが気になる。