[入門]関数プログラミング―質の高いコードをすばやく直感的に書ける!

第4章木構造とハッシュ―平衡二分探索木「赤黒木」知る豊かなデータ型

この章ではリストから一歩進み、永続データとして利用できる木構造を説明します。木構造の例として赤黒木という平衡二分探索木を取り上げ、ハッシュテーブル(以下、ハッシュと略記)を実装します。

ハッシュを実現できる木構造

関数プログラミングと(C言語などで使われる)配列は相性が良くありません。なぜなら、配列を永続データとして使おうとすると、一部を変更するだけでも配列全体をコピーしなければならないからです。ということは、関数プログラミングでは、必要に応じて平均O(1)[1]で要素を追加したり検索したりできるハッシュがないことを意味します[2]⁠。

もちろん、キーと値の組を要素に持つリスト(連想リスト:型は[(k,v)])を使えば、要素の追加や検索は可能ですが、効率がO(N)となりうれしくありません。そこで、関数プログラミングではハッシュの実現にO(log N)の平衡木を使います。この章では、赤黒木という平衡二分探索木を使ってハッシュを実現します。

木構造の特徴

木構造は永続データとして利用できます。図1を見てください。左の探索木に要素oを挿入すると、左の木を破壊することなく、右側の新しい木が作られていることがわかります。このように、木に要素を加えたり削除したりする場合、新たに作られるノードは、探索に使われたパス上のもののみです。たとえば、10,000個の要素を持つ木がバランスしているとすると、新たに作られるのは単純計算(log2 10000)で平均13ノードとなります。古い木が不要になれば、共有されていないノードは、GCGarbage Collectionによって回収されます。

図1 永続データとしての木構造
図1 永続データとしての木構造

木構造を使えば、集合、ハッシュ、ヒープ、キュー、両端キュー、操作効率の良い巨大な文字列などを実現できます。これらは通常ライブラリとして整備されているので、単に使うだけなら中身を知る必要はありません。APIApplication Program Interfaceを理解するだけで大丈夫です。

ハッシュのAPI

これから赤黒木を用いてハッシュを実装します。まず、そのAPIを示します。

empty :: Hash k v
insert :: Ord k => (k,v) -> Hash k v -> Hash k v
fromList :: Ord k => [(k,v)] -> Hash k v
search :: Ord k => k -> Hash k v -> Maybe v

ハッシュの型はHash k vとします。キーの型がkで、値のほうがvになります。型変数が2つ出てくると最初はわかりにくいかもしれませんが、連想リストの型[(k,v)]と同等の意味です。

empty─⁠─空のハッシュ

emptyは空のハッシュを表します。emptyはO(1)です。

insert─⁠─要素を追加する

insertは、キーと値の組をハッシュに登録し、新しいハッシュを返します。insertはO(log N)です。Ord k =>というのは、任意のkではだめで、⁠順序付けられる性質を持つk」という意味です。たとえば、IntやCharは順序付けられますが、関数は順序付けられません。赤黒木は探索木なので、キーを順序付ける必要があります。

まだ実装していませんが、理解しやすいように使用例を示します。実際に試す場合は、のちほど作成するtree.hsを読み込んでください。

> empty
Leaf
> insert (1,'a') empty
Node B Leaf (1,'a') Leaf

返ってくる値は赤黒木のリテラル表現ですが、ここではまだ理解する必要はありません。木の内部に、加えたキーと値があることがわかれば十分です。このハッシュは永続データですから、たとえば2回要素を加える場合は、insertが返したハッシュにさらにinsertする必要があります。

> insert (2,'b') (insert (1,'a') empty)
Node B Leaf (1,'a') (Node R Leaf (2,'b') Leaf)

fromList─⁠─要素をまとめて登録する

上記の例のようにinsertを何回も使うのは面倒なので、連想リストをハッシュに変換する関数fromListも実装します。fromListはO(N log N)です。

> fromList [(1,'a'),(2,'b')]
Node B Leaf (1,'a') (Node R Leaf (2,'b') Leaf)

search─⁠─要素を検索する

ハッシュを検索するには関数searchを使います。searchはO(log N)です。

> let hash = fromList [(1,'a'),(2,'b')]
> search 1 hash
Just 'a'
> search 3 hash
Nothing

ハッシュにキーが格納されている場合は「Just値」が返り、そうでない場合はNothingが返ってきています。これらは、searchの戻り値の型であるMaybe vに対応しています。

型の定義

Maybeという新しい型が出てきましたので、ここではいったんハッシュの実装をお休みし、型について説明します。

列挙型

Haskellで最も簡単な型は真理値Boolです。次のように定義されています。

data Bool = False | True

Haskellで新しい型を定義する際は、dataというキーワードを使います。Boolのような列挙型では、=の右側に値を|で区切って列挙します。この値のことを構成子と呼びます。型と構成子は大文字で始める必要があります。

列挙型の例として、順序を表す型Orderingも見てみましょう。

data Ordering = LT | EQ | GT

LT、EQ、GTは、それぞれ「より小さい」⁠等しい」⁠より大きい」を表します。第3章で出てきたcompareは、実はこのOrderingを返します。

> compare 1 2
LT

型変数を取る型

前節で出てきたMaybeは次のように定義されています。

data Maybe a = Nothing | Just a

Maybeは型変数を取る型です。まだ型変数に親しみが湧かない方は、aを具体的な型、たとえばCharに置き換えてみてください。

-- 以下は疑似コード
data Maybe Char = Nothing | Just Char

Maybeは、ある型に失敗する可能性を付加するための型です。Nothingが失敗を、Justが成功を表します。たとえば先述の「search」では、要素が格納されていないときにNothing、格納されているときにJust 'a'が返ってきていました。

重要なのできちんと区別していただきたいのですが、Charは失敗しない型、Maybe Charは失敗するかもしれない型です。一般的に関数型言語では、型を見れば失敗する可能性があるのかがわかります。そして、そういった関数を使った場合は、⁠パターンマッチを使って)失敗も処理しないと、コンパイラが警告を発します。

一方、命令型にはほかの型に紛れ込めるnull(nil、None)が存在します。型を見ただけでは、関数が失敗する可能性があるのかわかりません。このため、nullの処理を忘れて、実行時にnullを踏んでしまうことがたびたび起こります。nullの発明者であり、チューリング賞を受賞したTony Hoare氏は、⁠nullは10億ドルの失敗」と後悔しているようです。

構造体

実はMaybeは、列挙型と構造体が混合されていた例になっていました。少しわかりにくかったかもしれませんので、ここでは簡単な構造体を定義してみます。

data Pair = MkPair Int Char

構造体Pairは、型IntとCharを格納する型で、構成子はMkPairになります。実は、型と構成子の名前空間は分かれているので、同じ名前にしてもかまいません。また、格納する型を抽象化して、型変数にすることもできます。

data Pair k v = Pair k v

再帰型

構造体では、構成子のあとに型を書きました。ここに自分自身の型を書けば再帰型になります。次はIntを格納する二分木の例です。

data Tree = Leaf | Node Tree Int Tree

Leafと Nodeが構成子、Nodeの右にあるTreeとIntは型です。

赤黒木を実装しよう

赤黒木とは

赤黒木の説明に戻りましょう。赤黒木とは、各ノードが赤か黒に塗られた二分木です(本当はリンクに色が付くのですが、ノードに色を付けて説明することが多いようです⁠⁠。終端を表す葉は、黒として扱われます。図2に赤黒木の例を示します。

図2 赤黒木の例
図2 赤黒木の例

※丸がノードで、三角が葉を表す。黒が黒、白が赤を意味する。
ノードにはキーとなる整数のみを表示している

二分木が赤黒木として成り立つためには、次の不変条件を満たさなければなりません。

  • 不変条件1:(図2で最上部にある)根は黒である
  • 不変条件2:根から葉に至るすべてのパスで、黒の数が同じ
  • 不変条件3:赤は連続してはならない

図2では、根は黒で、(図中では白)は連続しておらず、すべてのパスで葉も含めた黒の数が3になっています。

不変条件から中間ノードが最も少ない葉(浅い葉)は、そこに至るパス上のノードがすべて黒だとわかります。また、中間ノードが最も多い葉(深い葉)は、そこに至るパス上で、黒と黒の間すべてに赤が挟まっている場合になります。図2では、ノード1が最も浅く深さ2、ノード6が最も深く深さ4です。このように、最も浅い部分と最も深い部分の差が高々2倍であるという意味でバランス(平衡)しています。

赤黒木の定義

赤黒木の定義には、まず赤と黒の色が必要です。これをColorという名前の型で定義しましょう。これ以降、すべてファイルtree.hsに書き込むようにしてください[3]⁠。

data Color = R | B deriving Show

Rが赤、Bが黒を表します。deriving Showは、対話環境でこのまま表示してくれるようにするおまじないです(前述のBool、Maybe、Orderingなどにも、実際はこのおまじないが付けられています⁠⁠。Hash k vは木構造なので、再帰的に定義します。

data Hash k v = Leaf -- 葉は黒
              | Node Color (Hash k v) (k,v) (Hash k v)
              deriving Show

Haskellでは、このようにインデントされた行は継続行として扱われますので、このコードは1行に書いたのと同じ意味になります。emptyはLeafで表現できますので、定義は次のようになります。

empty :: Hash k v
empty = Leaf

insertの実装

赤黒木へ要素を追加するには次のようにします。

  • 新しい要素は赤いノードとして、根から下に向かって挿入する。赤を挿入するので、不変条件2は守られる
  • 新しい赤いノードが黒と黒の間に収まれば問題ない。しかし、赤の下に収まった場合、赤が連続し、不変条件3が満たせなくなる。そこで、不変条件2と不変条件3を満たすように、上に向かってバランスを取りながら戻る
  • バランスを取った結果、最終的に根が赤になって、不変条件1が満たされないことがある。そこで、根を黒にして不変条件1を満たす。黒ノードが増えるのは根に限る。根で黒が増えた場合は、すべてのパスで黒の数が1増えることになる

ここでは、insertを補助関数insert'を使って定義しました。turnBは、無条件に根を黒に変える関数です。

turnB :: Hash k v -> Hash k v
turnB (Node c l (k,v) r) = Node B l (k,v) r

insert :: Ord k => (k,v) -> Hash k v -> Hash k v
insert (k,v) t = turnB (insert' k v t)

insert'の定義は次のようになります。

insert' :: Ord k => k -> v -> Hash k v -> Hash k v
insert' kx vx Leaf = Node R Leaf (kx,vx) Leaf
insert' kx vx (Node c l (k,v) r) = case compare kx k of
    -- 左部分木に挿入してバランスを回復
    LT -> balance c (insert' kx vx l) (k,v) r
    -- 右部分木に挿入してバランスを回復
    GT -> balance c l (k,v) (insert' kx vx r)
    -- 元のノードをそのまま返す
    EQ -> Node c l (k,v) r

葉にたどり着いた場合は、赤いノードを作成しています。

対象がノードの場合を扱うコードに、caseという新しい構文が出てきました。caseは、パターンマッチのための構文です。caseとofの間の計算結果であるデータに対して、⁠パターン -> 本体」を列挙します。

つまり、対象がノードの場合、新しい要素のキーとノードのキーを比較します。同じであれば、元のノードをそのまま返します(新しい値に置き換える仕様でもかまいません⁠⁠。小さければ左の部分木に、大きければ右の部分木に要素を挿入します。この様子を図3に示します。

図3 赤いノードが挿入される様子:赤黒木に5を挿入する
図3 赤いノードが挿入される様子:赤黒木に5を挿入する

新しい赤いノードを挿入したら、次にバランスを取ります。バランスを取る関数balanceは次のようになります。

balance :: Color -> Hash k v -> (k,v) -> Hash k v -> Hash k v
-- 場合1
balance B (Node R (Node R a x b) y c) z d =
    Node R (Node B a x b) y (Node B c z d)
-- 場合2
balance B (Node R a x (Node R b y c)) z d =
    Node R (Node B a x b) y (Node B c z d)
-- 場合3
balance B a x (Node R b y (Node R c z d)) =
    Node R (Node B a x b) y (Node B c z d)
-- 場合4
balance B a x (Node R (Node R b y c) z d) =
    Node R (Node B a x b) y (Node B c z d)
-- それ以外
balance c a x b = Node c a x b

balanceの引数の意味は、⁠色」⁠左の木」⁠キーと値の組」⁠右の木」です。このコードを理解するために、図4を見てください。連続した赤に対して、黒を挟むことによって、不変条件3を満たすことがわかります。パスに対する黒の数も変化していませんから、不変条件2も守られます。この図が入れ子になったパターンマッチのおかげで、直感的に実現できています。これが豊かな型と、その表裏一体の機能であるパターンマッチの力です。

図4 バランスの回復
図4 バランスの回復

バランスが回復される様子を図5に示します。

図5 バランスが回復される様子
図5 バランスが回復される様子
※矢印は関数呼び出しから戻ることを表す

fromListの実装

fromListは、foldlと前に作成したinsertを使えば実装できます。foldlが要求する型に合わせるために、補助関数insert''も定義しています。

insert'' :: Ord k => Hash k v -> (k, v) -> Hash k v
insert'' t kv = insert kv t

fromList :: Ord k => [(k,v)] -> Hash k v
fromList = foldl insert'' empty

searchの実装

searchの実装は簡単です。もし、葉にたどり着いてしまった場合は、そのキーは格納されていないことになりますので、Nothingを返します。

ノードを対象にしている場合、検索キーとノードのキーを比較します。小さければ、左の木を再帰的に検索します。大きければ、右の木を再帰的に検索します。等しければ、それをJustでくるんで返します。

search :: Ord k => k -> Hash k v -> Maybe v
search kx Leaf = Nothing
search kx (Node c l (k,v) r) = case compare kx k of
    LT -> search kx l
    GT -> search kx r
    EQ -> Just v

命令的な実装との比較

赤黒木のinsertを命令プログラミングで実装するとどうなるでしょうか? ⁠アルゴリズムイントロダクション 改訂2 版 第1 巻 数学的基礎とデータ構造』注4の第13章3節に、入門的な実装が載っています。その方法は、場合分けが6通りあり、ポインタの再代入とあいまって、とてもわかりにくいと感じるでしょう。興味があれば、この章で紹介した関数的な実装と比較してみてください[5]⁠。

関数型も命令型と同様の開発手順

これまで書いたコードだけでも、立派なハッシュライブラリになります。まだわからないのは、定義した型や関数を公開/非公開にする方法ぐらいでしょう。⁠関数型では大きなプログラムを作れる気がしない」という感想をときどき耳にしますが、関数型でもこのように関数を定義していき、ライブラリとしてまとめ、あとはライブラリを利用するメインのコードを書くという命令型と同様の作業になるので、けっして難しくはありません。

おすすめ記事

記事・ニュース一覧