cv.cm

コーディング · データ構造

二分探索木とは?仕組み・コード・計算量をわかりやすく解説

約8分で読めます

結論

二分探索木(BST)は、どのノードについても、左部分木には小さいキーだけ、右部分木には大きいキーだけが入っている二分木です。このルールのおかげで、検索・挿入・削除は根から1本の経路をたどるだけで済み、木の高さをhとするとO(h)で行えます。木が平衡していればO(log n)ですが、1本の鎖のように偏るとO(n)になります。通りがけ順(中間順)で巡回すると、キーが昇順で得られます。

二分探索木が解決する問題

ソート済み配列は高速な二分探索ができますが、新しい要素を挿入すると、それ以降の要素をすべてずらすことになります。ハッシュテーブルは検索と挿入が高速ですが、順序を保持しないので、「42より大きい最小のキーは?」や「10から20の間のキーを列挙したい」といった問いには答えられません。二分探索木は、キーをソートされた状態に保ちながら、挿入と削除も高速に行えます。JavaのTreeMapやC++のstd::mapのような順序付きのマップやセットの基盤であり、それらは自己平衡型の変種を使っています。

ルールをひとことで

各ノードは、キーと、省略可能な左の子、省略可能な右の子を持ちます。どのノードについても、左部分木のキーはすべてそれより小さく、右部分木のキーはすべてそれより大きくなります。キーを探すには、根から始めて比較します。キーが小さければ左へ、大きければ右へ進み、等しければ終了、木の外に出てしまったら見つからなかったことになります。

具体例:木の構築と検索

8、3、10、1、6、14、4をこの順に挿入します。新しいキーは根から下っていき、木から外れた位置で葉になります。

挿入たどった経路置かれた位置
8空の木根
38:より小さい、左へ8の左の子
108:より大きい、右へ8の右の子
18で左、3で左3の左の子
68で左、3で右3の右の子
148で右、10で右10の右の子
48で左、3で右、6で左6の左の子

4を検索すると、8、3、6、そして4と比較します。7キーの木で4回の比較です。7を検索すると、8、3、6と比較したあと、6に右の子がないので、7は存在しないと分かります。

Pythonでの実装

挿入と検索は短いループで書けます。削除には3つのケースがあります。子がない(そのまま削除)、子が1つ(その子で置き換える)、子が2つ(通りがけ順の後続ノード、つまり右部分木の最小キーをコピーしてから、その後続ノードを削除する)です。

class Node:
    def __init__(self, key):
        self.key, self.left, self.right = key, None, None
def insert(root, key):
    if root is None:
        return Node(key)
    if key < root.key:
        root.left = insert(root.left, key)
    elif key > root.key:
        root.right = insert(root.right, key)
    return root                      # duplicates are ignored
def contains(root, key):
    while root and root.key != key:
        root = root.left if key < root.key else root.right
    return root is not None
def delete(root, key):
    if root is None:
        return None
    if key < root.key:
        root.left = delete(root.left, key)
    elif key > root.key:
        root.right = delete(root.right, key)
    else:
        if root.left is None:
            return root.right
        if root.right is None:
            return root.left
        succ = root.right
        while succ.left:
            succ = succ.left
        root.key = succ.key
        root.right = delete(root.right, succ.key)
    return root
def inorder(root):
    return inorder(root.left) + [root.key] + inorder(root.right) if root else []
root = None
for k in [8, 3, 10, 1, 6, 14, 4]:
    root = insert(root, k)
print(inorder(root))                 # [1, 3, 4, 6, 8, 10, 14]
print(contains(root, 4), contains(root, 7))  # True False
root = delete(root, 3)               # node with two children
print(inorder(root), root.left.key)  # [1, 4, 6, 8, 10, 14] 4

時間計算量と空間計算量

操作平衡した木最悪の場合(鎖状)理由
検索O(log n)O(n)1レベルにつき1回の比較
挿入O(log n)O(n)葉の位置まで下る
削除O(log n)O(n)ノードを探し、場合によっては後続ノードも探す
最小 / 最大O(log n)O(n)左(または右)のリンクを端までたどる
通りがけ順の巡回O(n)O(n)各ノードを1回ずつ
空間O(n)O(n)キーごとに1ノード。再帰はO(h)のスタックを使う

最悪の場合は、理論上の話ではなく現実に起こります。すでにソートされたキー(1, 2, 3, ...)を素のBSTに挿入すると、どのノードも右の子しか持たず、木は連結リストになってしまいます。ランダムな順序で挿入すれば、高さの期待値はO(log n)です。

平衡型の変種と、よくある間違い

自己平衡木は、挿入や削除のあとに回転で構造を組み替えることで、高さをO(log n)に保ちます。

変種平衡の考え方使われる場所
AVL木2つの部分木の高さの差が最大1検索が多いワークロード
赤黒木色のルールにより、最長の経路が最短の経路の2倍以内に収まるJavaのTreeMap、典型的なC++のstd::mapの実装、Linuxカーネル
B木 / B+木1ノードに多数のキーを持ち、すべての葉が同じ深さにあるデータベースとファイルシステム
Treap、スプレー木ランダムな優先度、またはアクセスしたキーを根に移動するよりシンプルな平衡木、キャッシュ
  • 親と子だけを調べてBSTを検証してしまう。代わりに、有効な(下限、上限)の範囲を引き継いでいきましょう。
  • 再帰的な挿入や削除で、(新しくなった可能性のある)根を返し忘れ、変更が失われる。
  • 偏った木での深い再帰:Pythonのデフォルトの再帰上限は約1000なので、5000ノードの鎖では再帰のコードがクラッシュします。ループを使うか、木を平衡に保ちましょう。
  • 重複の扱いが未定義:重複を無視するのか、数えるのか、常に片側に入れるのかを決めておきます。

BSTを使う場面と、面接でよく出る問題

挿入と削除が頻繁にあり、順序付きのデータが必要なとき(範囲クエリ、floorやceilingの検索、順位統計、ソート順での反復)は、平衡BSTを使います。完全一致のキー検索だけでよければ、ハッシュテーブルを使います。Pythonの標準ライブラリにはツリーマップがありません。小規模またはほぼ静的なデータなら、bisectモジュールとソート済みリストを組み合わせる方法か、サードパーティのsortedcontainersパッケージがよく選ばれます。

面接でよく出る問題:BSTの検証、k番目に小さい要素の発見(通りがけ順の巡回)、最小共通祖先、ソート済み配列から平衡BSTへの変換(真ん中を根にする)、ノードの通りがけ順の後続ノードの発見などです。

よくある質問

二分木と二分探索木の違いは何ですか?

二分木は、各ノードの子を2つまでに制限するだけです。二分探索木はそれに加えて、すべての部分木で、小さいキーが左、大きいキーが右という順序のルールを課します。

BSTの検索がO(log n)なのはなぜですか?

1回比較するごとに1レベル下り、片方の部分木を捨てられます。n個のノードを持つ平衡木には約log2(n)のレベルがあるので、検索に必要な比較もそのくらいの回数です。

ソート済みのデータをBSTに挿入するとどうなりますか?

平衡化しなければ、新しいキーは毎回、直前のキーの右の子になり、高さがnになって、すべての操作がO(n)になります。AVL木や赤黒木のような自己平衡木は、これを防ぎます。

BSTのキーをソート順に取り出すには?

通りがけ順の巡回を行います。左部分木、ノード、右部分木の順に訪問します。時間はO(n)です。

Pythonに組み込みの二分探索木はありますか?

いいえ。Pythonにあるのはdictとset(ハッシュテーブル)と、ソート済みリスト向けのbisectモジュールです。順序付きの木のコンテナはサードパーティのパッケージで提供されています。

コーディング講座の一覧