ソート済みファイルからO(1)のメモリ使用量でDoubleArrayを構築する方法 #前半

格納するキー数に大してO(1)のメモリ使用量で、DoubleArrayを構築する方法を思いついたので、その実装メモ。
今回はその前半。
いきなりDoubleArrayの実装を行うとやや複雑となるので、まずはより単純な通常のトライ(オンメモリ)の実装から始める。

基本的な考え方

例えば、以下のようなキーセットがあるとする。

東京カネカ食品販売
東京カルテット
東京ガスエネルギー
東京ガス都市開発
東京クラウン
東西

これらを上から挿入してトライを構築していく過程は、以下の図のようになる。





この図の内の白色、オレンジ、灰色のノードは、それぞれ「最後に挿入されたキーに対応するノード」、「一つ前に挿入されたキーに対応するノード」、「それ以前に挿入されたキーに対応するノード」を表す。※ 左の定義で、白色とオレンジの領域が重なる場合は、白色が優先される。白色と灰色、オレンジと灰色も同様。
キーセットが既にソートされているという前提上、一度オレンジ・灰色となったノードが再び白色になることはない。
つまり、これら(オレンジ or 灰色)のノード(サブトライ)の構造はこの時点で確定しており、以降のキー挿入で変化することはないので、別にオンメモリで保持しておく必要もない。
この確定した部分を順次(DoubleArray形式に変換して)ファイルに書き出していけば、メモリ上に保持するのは白色のノードだけで良いことになる。
で、白色のノードはキーセットサイズに依存しない(個々のキーの長さにのみ依存する)ので、キーセットサイズに対してO(1)のメモリ使用量でDoubleArrayが構築できることになる。

オンメモリトライの実装

DoubleArrayの実装の前に、まずは簡単なリストを使ったトライの実装から入る。
まずはパッケージ定義など。

(defpackage trie
  (:use common-lisp)
  (:export build
           member?))
(in-package :trie)

;; octet-stream: SBCL等のユニコード文字列をバイトストリームとして扱うためのパッケージ
;; - http://d.hatena.ne.jp/sile/20110807/1312720282
(rename-package :octet-stream :octet-stream '(:stream))

次はノード用の構造体を用意(ついでに定数もここで定義しておく)

(defstruct node
  (label      0 :type (unsigned-byte 8))  ; ノードのラベル
  (children '() :type list))              ; 子ノードのリスト ※ 実装上の都合でラベルの降順に並んでいる

(defmethod print-object ((o node) stream)
  (print-unreadable-object (o stream :type t :identity t)))

(defconstant +EOS_LABEL+ #x00)  ; 文字列の終端を表すラベル値  ※ 0を特別扱いしている関係上、キーにヌル文字を含むことはできない

ソート済みファイルからトライを構築する関数。

(defun build (filepath)
  (with-open-file (in filepath)
    (loop WITH trie = (make-node)
          FOR line = (read-line in nil nil)
          WHILE line
      DO
      (insert (stream:make line) trie)
      FINALLY
      (change-active-node trie)
      (return trie))))

(defun insert (in parent &aux (node (car (node-children parent))))
  (if (null node)
      (insert-new-nodes in parent)           ; 新規ノードを挿入
    (if (/= (stream:peek in) (node-label node))
        (progn (change-active-node parent)   ; 白色 => オレンジ、になったノードを処理する
               (insert-new-nodes in parent)) ; 新規ノードを挿入
      (insert (stream:eat in) node))))  ; 既存ノードを共有

;; 文字列終端に達するまでノードを追加する
(defun insert-new-nodes (in parent)
  (if (stream:eos? in)
      (push (make-node :label +EOS_LABEL+) (node-children parent))
    (let ((new-node (make-node :label (stream:read in))))
      (push new-node (node-children parent))
      (insert-new-nodes in new-node))))

(defun change-active-node (parent)
  (let ((fixed-node (car (node-children parent))))
    (declare (ignore fixed-node))))  ; 今回は何もしない

最後は検索関数。

(defun member? (key trie)
  (labels ((eos-node? (node)
             (let ((last-child (car (last (node-children node)))))
               (= (node-label last-child) +EOS_LABEL+)))
           (recur (in node)
             (if (stream:eos? in)
                 (eos-node? node)
               (let ((child (find (stream:read in) (node-children node) :key #'node-label)))
                 (when child
                   (recur in child))))))
    (recur (stream:make key) trie)))

以上でオンメモリトライの実装は終了。
次回は、これをベースにしてDoubleArrayを実装する。

ソースコード全体

(defpackage trie
  (:use common-lisp)
  (:export build
           member?))
(in-package :trie)

(rename-package :octet-stream :octet-stream '(:stream))

;;;;;;;;;;;;;
;;; defstruct
(defstruct node
  (label      0 :type (unsigned-byte 8))
  (children '() :type list))

(defmethod print-object ((o node) stream)
  (print-unreadable-object (o stream :type t :identity t)))

(defconstant +EOS_LABEL+ #x00)

;;;;;;;;;
;;; build
(defun build (filepath)
  (with-open-file (in filepath)
    (loop WITH trie = (make-node)
          FOR line = (read-line in nil nil)
          WHILE line
      DO
      (insert (stream:make line) trie)
      FINALLY
      (change-active-node trie)
      (return trie))))

(defun insert (in parent &aux (node (car (node-children parent))))
  (if (null node)
      (insert-new-nodes in parent)
    (if (/= (stream:peek in) (node-label node))
        (progn (change-active-node parent)
               (insert-new-nodes in parent))
      (insert (stream:eat in) node))))

(defun insert-new-nodes (in parent)
  (if (stream:eos? in)
      (push (make-node :label +EOS_LABEL+) (node-children parent))
    (let ((new-node (make-node :label (stream:read in))))
      (push new-node (node-children parent))
      (insert-new-nodes in new-node))))

(defun change-active-node (parent)
  (let ((fixed-node (car (node-children parent))))
    (declare (ignore fixed-node))))

;;;;;;;;;;;
;;; member?
(defun member? (key trie)
  (labels ((eos-node? (node)
             (let ((last-child (car (last (node-children node)))))
               (= (node-label last-child) +EOS_LABEL+)))
           (recur (in node)
             (if (stream:eos? in)
                 (eos-node? node)
               (let ((child (find (stream:read in) (node-children node) :key #'node-label)))
                 (when child
                   (recur in child))))))
    (recur (stream:make key) trie)))