これはなに
AVL木を実装して記事を書こうとしているんですが,うまくいかないので前提知識であるところの二分探索木(Binary Search Tree)についてまとめることにしました.
前提知識
- 木
- Haskellの基本的な文法
二分探索木とは
二分探索木とは各節点のデータが全順序集合の要素である二分木であって,以下の条件を満たすものです.
- 二分探索木条件:
葉を除く任意の節点に対してその節点のデータは,
① その節点の左部分木の任意のデータよりも大きく
② その節点の右部分木の任意のデータよりも小さい
以下が二分探索木の例です.
たとえば根のデータは4ですが,左部分木中のどのデータも4より小さいですし,右部分木中のどのデータも4より大きいです. 他の節点についても同様のことが成り立つので確かめてみてください.
実装
再帰的なデータ構造を実装するならやっぱりHaskellです.教科書に載っているC言語の長々としたコードが異常に短くなって気持ちいいです.
コードはここに公開しています:
定義
二分探索木をHaskellのデータ型として以下のように定義します.
Nil
は空の木を表します.Node a (BinaryTree a) (BinaryTree a)
は型引数a
で指定した型のデータを持つ部分木で,第2引数のBinaryTree a
は左部分木,第3引数のBinaryTree a
は右部分木を表します.
挿入
データの挿入は再帰的に以下のように定義します.
再帰的に葉の方向に潜りながら注目する節点を移動していきます.
2行目: 再帰の基底部です.
3行目: 挿入するデータx
と注目している節点のデータa
が等しいときです. 二分探索木はデータの重複を認めないので,もとの節点をそのまま返します.
4行目: 挿入するデータx
の方が小さいときです. このデータは今見ている節点の左部分木に挿入されるので,lhs
のあった場所にinsert x lhs
で返ってきた木を付け替えます.
このまま行くところまで行くとじきにNil
へたどり着くので,そこにデータを挿入して(2行目)今度は再帰をさかのぼります.
5行目: 挿入するデータx
の方が大きいときです.4行目と左右対称な動作をします.
挿入の時間計算量の平均値はです.動的配列への挿入などに比べると速いですね.
探索
最小値
最小値の探索はとても簡単です.
より小さい値は各節点の左部分木に存在するので左側に潜りながら探索します. 左部分木がNil
であるような節点にたどり着くと,そこにあるデータが最小値です.
最大値の探索も同様に右側に潜っていくとできます.
データを指定した探索
あるデータが存在するかを探索するのは挿入とほぼ同じプロセスで,BinaryTree a
を返すかBool
を返すかの違いしかありません.
3行目: 探索するデータx
と注目する節点のデータa
が等しい場合,つまり目的のデータが存在しているときで,True
を返します.
4・5行目: 探索するデータx
と注目する節点のデータa
の大小に応じて左部分木または右部分木に潜っていきます. 潜っていっても目的のデータにぶつからない場合はそのうちNil
にたどり着くので,2行目に定義されているようにFalse
を返します.
探索の時間計算量の平均値も挿入と同様にです.
削除
実装は以下の通りです.
要素の削除は今までの操作よりは少し複雑です.
4,5行目: 挿入と同じ要領で,削除対象のデータが含まれるであろう部分木へ潜っていきます.
2行目: 削除対象のデータを持つ節点が見つからないままNil
に到達した場合,Nil
を返します.
6行目: 削除対象のデータが見つかりました.しかし単純にその節点を削除すると二分木がこわれてしまいます.ここでは削除する節点に対して,補助関数delete'
を適用します.
削除対象の節点が1つ以下の子を持つ場合
- 葉である場合,子を持たないのでそのまま削除する
- 子を一つ持つ場合,削除する節点があった場所にその子を根とする部分木を付け替える
これらの処理をdelete'
関数でどう実装しているかを見てみます.
1行目: 左部分木がNil
のときは右部分木もNil
のときはNil
を,そうでなければ右部分木を削除対象の節点のあった場所へ移動させればよいです. まとめてrhs
を返すことでこの2つの場合を表現できます.
2行目: 右部分木のみがNil
の場合です. lhs
を返します.
削除対象の節点が2つの子を持つ場合
このときは削除する節点がある場所へ
- 右部分木の最大データの節点
- 左部分木の最小データの節点
を代わりに移してやります. 今回は右部分木の最大データの節点を移動させるようにしました.
図3の右側の期において,データ5を持つ節点を消す場合はデータ9を持つ節点を移動させます. このとき,操作後も二分探索木条件を満たしていることを確認してください.
delete'
関数の3,4行目を見てください.まずminInSubTree
を削除対象の節点の右部分木の最小値とします. そしてminInSubTree
をデータに持ち,「delete minInSubTree rhs
によってminInSubTree
を右部分木から削除した部分木」を右部分木として持つ節点を返します.
削除の時間計算量の平均も他の操作と同様にです.
平衡二分木へ
ここまで二分探索木に関する基本的な操作を見てきました.挿入,探索,削除のいずれも平均時間計算量はなのですが,最悪時間計算量はになってしまいます.
どういう状況でそうなるのかというと,ほぼソートされた配列を前の要素から順に挿入していき,下の図4のように高さが偏った木ができてしまったときです.
これを解決するのが平衡二分木です.二分探索木は値の順序関係のみを見て構築します. 一方,平衡二分木はこれに加えて木が平衡を保つようにいくつかの制約を加えて,高さがになるように工夫したものです.次はその一種であるAVL木に関する記事を書こうと思います.