Skip to content

第 9 章 木構造

はじめに

木構造(Tree)は、親子関係を持つ階層的なデータ構造です。データベースのインデックス、ファイルシステム、構文解析など、幅広い分野で使われています。

この章では二分探索木(Binary Search Tree, BST)を Comparable<T> を使ったジェネリクスで TDD 実装します。挿入、削除、探索、走査の全操作を網羅的にテストします。

木構造とは

概念図

uml diagram

二分探索木の性質

二分探索木���以下の性質を満たす二分木です:

  • 左部分木のすべてのキーは親ノードのキーより小さい
  • 右部分木のすべてのキーは親ノードのキーより大きい
  • 左右の部分木もそれぞれ二分探索木である

uml diagram

中順走査(inOrder)すると昇順に並ぶのが二分探索木の重要な性質です。


1. 二分探索木 -- BinarySearchTree\<T>

Red -- 失敗するテストを書く

// src/test/java/algorithm/TreeTest.java
package algorithm;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

class TreeTest {

    @Nested
    class BinarySearchTreeTest {
        private BinarySearchTree<Integer> bst;

        @BeforeEach
        void setUp() { bst = new BinarySearchTree<>(); }

        @Test void 初期状態は空() { assertTrue(bst.isEmpty()); }

        @Test void insertしてcontains() {
            bst.insert(5);
            assertTrue(bst.contains(5));
        }

        @Test void 存在しないキー() { assertFalse(bst.contains(99)); }

        @Test void 複数insert() {
            for (int v : new int[]{5, 3, 7, 1, 4, 6, 8}) bst.insert(v);
            for (int v : new int[]{5, 3, 7, 1, 4, 6, 8}) assertTrue(bst.contains(v));
        }

        @Test void 中順探索は昇順() {
            for (int v : new int[]{5, 3, 7, 1, 4, 6, 8}) bst.insert(v);
            assertEquals(List.of(1, 3, 4, 5, 6, 7, 8), bst.inOrder());
        }

        @Test void 前順探索() {
            for (int v : new int[]{5, 3, 7}) bst.insert(v);
            assertEquals(List.of(5, 3, 7), bst.preOrder());
        }

        @Test void 後順探索() {
            for (int v : new int[]{5, 3, 7}) bst.insert(v);
            assertEquals(List.of(3, 7, 5), bst.postOrder());
        }

        @Test void min() {
            for (int v : new int[]{5, 3, 7, 1, 4}) bst.insert(v);
            assertEquals(1, bst.min());
        }

        @Test void max() {
            for (int v : new int[]{5, 3, 7, 1, 4}) bst.insert(v);
            assertEquals(7, bst.max());
        }

        @Test void min_空で例外() {
            assertThrows(BinarySearchTree.EmptyException.class, () -> bst.min());
        }

        @Test void max_空で例外() {
            assertThrows(BinarySearchTree.EmptyException.class, () -> bst.max());
        }

        @Test void size() {
            for (int v : new int[]{5, 3, 7}) bst.insert(v);
            assertEquals(3, bst.size());
        }

        @Test void contains() {
            bst.insert(42);
            assertTrue(bst.contains(42));
            assertFalse(bst.contains(0));
        }

        @Test void 重複キー挿入は無視() {
            bst.insert(5); bst.insert(5);
            assertEquals(1, bst.size());
        }
    }
}

14 個のテストで基本操作(insert, contains, size, min, max)と走査(inOrder, preOrder, postOrder)を検証しています。

Green -- テストを通す最小のコードを書く

// src/main/java/algorithm/BinarySearchTree.java
package algorithm;

import java.util.ArrayList;
import java.util.List;

public class BinarySearchTree<T extends Comparable<T>> {

    public static class EmptyException extends RuntimeException {
        public EmptyException() { super("木は空です"); }
    }

    private static class BSTNode<T> {
        T key;
        BSTNode<T> left, right;
        BSTNode(T key) { this.key = key; }
    }

    private BSTNode<T> root;
    private int size;

    public BinarySearchTree() { root = null; size = 0; }

    public int size() { return size; }
    public boolean isEmpty() { return root == null; }
    public boolean contains(T key) { return search(key) != null; }

    private BSTNode<T> search(T key) {
        BSTNode<T> ptr = root;
        while (ptr != null) {
            int cmp = key.compareTo(ptr.key);
            if (cmp == 0) return ptr;
            ptr = cmp < 0 ? ptr.left : ptr.right;
        }
        return null;
    }

    public void insert(T key) {
        if (root == null) { root = new BSTNode<>(key); size++; return; }
        BSTNode<T> ptr = root;
        while (true) {
            int cmp = key.compareTo(ptr.key);
            if (cmp == 0) return; // 重複は無視
            if (cmp < 0) {
                if (ptr.left == null) { ptr.left = new BSTNode<>(key); size++; return; }
                ptr = ptr.left;
            } else {
                if (ptr.right == null) { ptr.right = new BSTNode<>(key); size++; return; }
                ptr = ptr.right;
            }
        }
    }

Comparable<T> を使ったジェネリクスにより、比較可能な任意の型で二分探索木を構築できます。compareTo の戻り値で左右の分岐を決定します。


2. ノードの削除

削除は二分探索木で最も複雑な操作です。削除対象のノードの子の数によって 3 パターンに分かれます。

Red -- 失敗するテストを書く

@Test void 葉ノードの削除() {
    for (int v : new int[]{5, 3, 7}) bst.insert(v);
    bst.delete(3);
    assertFalse(bst.contains(3));
    assertTrue(bst.contains(5));
    assertTrue(bst.contains(7));
}

@Test void 子が1つのノードの削除() {
    for (int v : new int[]{5, 3, 7, 1}) bst.insert(v);
    bst.delete(3);
    assertFalse(bst.contains(3));
    assertTrue(bst.contains(1));
}

@Test void 子が2つのノードの削除() {
    for (int v : new int[]{5, 3, 7, 1, 4}) bst.insert(v);
    bst.delete(3);
    assertFalse(bst.contains(3));
    for (int v : new int[]{1, 4, 5, 7}) assertTrue(bst.contains(v));
}

@Test void 根ノードの削除() {
    for (int v : new int[]{5, 3, 7}) bst.insert(v);
    bst.delete(5);
    assertFalse(bst.contains(5));
    assertTrue(bst.contains(3));
    assertTrue(bst.contains(7));
    assertEquals(List.of(3, 7), bst.inOrder());
}

@Test void 存在しないキーの削除() {
    bst.insert(5);
    bst.delete(99);
    assertTrue(bst.contains(5));
}

@Test void 根のみ削除で空() {
    bst.insert(5);
    bst.delete(5);
    assertTrue(bst.isEmpty());
}

@Test void 右子のみのノード削除() {
    for (int v : new int[]{5, 3, 7, 8}) bst.insert(v);
    bst.delete(7);
    assertFalse(bst.contains(7));
    assertTrue(bst.contains(8));
}

@Test void 深い後継ノードの削除() {
    for (int v : new int[]{10, 5, 20, 15, 25, 12, 18}) bst.insert(v);
    bst.delete(10);
    assertFalse(bst.contains(10));
    assertEquals(List.of(5, 12, 15, 18, 20, 25), bst.inOrder());
}

@Test void 右子として親に繋がるノード削除() {
    for (int v : new int[]{5, 3, 7}) bst.insert(v);
    bst.delete(7);
    assertFalse(bst.contains(7));
    assertTrue(bst.contains(5));
}

9 つの削除テストで全パターンを網羅しています:

パターン テスト 説明
葉ノード 葉ノードの削除 子なし → 単純に除去
左子のみ 子が1つのノードの削除 左子を親に接続
右子のみ 右子のみのノード削除 右子を親に接続
子が2つ 子が2つのノードの削除 中順後継ノードで置換
根ノード 根ノードの削除 根の特殊処理
深い後継 深い後継ノードの削除 後継ノードが深い位置にある場合

Green -- テストを通す最小のコードを書く

public void delete(T key) {
    BSTNode<T> parent = null;
    BSTNode<T> ptr = root;
    boolean isLeftChild = false;

    while (ptr != null) {
        int cmp = key.compareTo(ptr.key);
        if (cmp == 0) break;
        parent = ptr;
        if (cmp < 0) { isLeftChild = true; ptr = ptr.left; }
        else { isLeftChild = false; ptr = ptr.right; }
    }
    if (ptr == null) return;

    size--;

    if (ptr.left == null && ptr.right == null) {
        replaceNode(parent, isLeftChild, null);
    } else if (ptr.right == null) {
        replaceNode(parent, isLeftChild, ptr.left);
    } else if (ptr.left == null) {
        replaceNode(parent, isLeftChild, ptr.right);
    } else {
        // 右部分木の最小ノード(中順後継)で置き換え
        BSTNode<T> succParent = ptr;
        BSTNode<T> succ = ptr.right;
        while (succ.left != null) { succParent = succ; succ = succ.left; }
        ptr.key = succ.key;
        if (succParent == ptr) succParent.right = succ.right;
        else succParent.left = succ.right;
        size++; // delete で既に -1 したので +1 で調整
    }
}

private void replaceNode(BSTNode<T> parent, boolean isLeftChild, BSTNode<T> newNode) {
    if (parent == null) root = newNode;
    else if (isLeftChild) parent.left = newNode;
    else parent.right = newNode;
}

子が 2 つの場合の削除は最も複雑です。右部分木の最小ノード(中順後継)を見つけ、削除対象のキーを後継のキーで上書きし、後継ノードを除去します。


3. 木の走査(inOrder / preOrder / postOrder)

3 種類の走査順序で木を巡回します。

uml diagram

Green -- 実装コード

public List<T> inOrder() {
    List<T> result = new ArrayList<>();
    inOrderHelper(root, result);
    return result;
}

private void inOrderHelper(BSTNode<T> node, List<T> result) {
    if (node == null) return;
    inOrderHelper(node.left, result);
    result.add(node.key);
    inOrderHelper(node.right, result);
}

public List<T> preOrder() {
    List<T> result = new ArrayList<>();
    preOrderHelper(root, result);
    return result;
}

private void preOrderHelper(BSTNode<T> node, List<T> result) {
    if (node == null) return;
    result.add(node.key);
    preOrderHelper(node.left, result);
    preOrderHelper(node.right, result);
}

public List<T> postOrder() {
    List<T> result = new ArrayList<>();
    postOrderHelper(root, result);
    return result;
}

private void postOrderHelper(BSTNode<T> node, List<T> result) {
    if (node == null) return;
    postOrderHelper(node.left, result);
    postOrderHelper(node.right, result);
    result.add(node.key);
}

3 つの走査は result.add(node.key) の位置だけが異なります:

  • 前順(preOrder): 根 → 左 → 右
  • 中順(inOrder): 左 → 根 → 右(昇順になる)
  • 後順(postOrder): 左 → 右 → 根

min / max

public T min() {
    if (root == null) throw new EmptyException();
    BSTNode<T> ptr = root;
    while (ptr.left != null) ptr = ptr.left;
    return ptr.key;
}

public T max() {
    if (root == null) throw new EmptyException();
    BSTNode<T> ptr = root;
    while (ptr.right != null) ptr = ptr.right;
    return ptr.key;
}

最小値は左端のノード、最大値は右端のノードです。二分探索木の性質から O(h)(h は木の高さ)で求められます。


Python との比較表

項目 Java Python
ジェネリクス制約 <T extends Comparable<T>> Protocol / ABC
比較 key.compareTo(ptr.key) key < ptr.key / key > ptr.key
null チェ��ク if (root == null) if root is None
内部クラス private static class BSTNode<T> クラス内クラス
例外 EmptyException extends RuntimeException class EmptyError(Exception)
リスト生成 new ArrayList<>() []
不変リスト List.of(1, 3, 4, 5) [1, 3, 4, 5]
型安全性 コンパイル時チェック 実行時のみ

テスト実行結果

TreeTest > BinarySearchTreeTest > 初期状態は空 PASSED
TreeTest > BinarySearchTreeTest > insertしてcontains PASSED
TreeTest > BinarySearchTreeTest > 存在しないキー PASSED
TreeTest > BinarySearchTreeTest > 複数insert PASSED
TreeTest > BinarySearchTreeTest > 中順探索は昇順 PASSED
TreeTest > BinarySearchTreeTest > 前順探索 PASSED
TreeTest > BinarySearchTreeTest > 後順探索 PASSED
TreeTest > BinarySearchTreeTest > min PASSED
TreeTest > BinarySearchTreeTest > max PASSED
TreeTest > BinarySearchTreeTest > min_空で例外 PASSED
TreeTest > BinarySearchTreeTest > max_空で例外 PASSED
TreeTest > BinarySearchTreeTest > 葉ノードの削除 PASSED
TreeTest > BinarySearchTreeTest > 子が1つのノードの削除 PASSED
TreeTest > BinarySearchTreeTest > 子が2つのノードの削除 PASSED
TreeTest > BinarySearchTreeTest > 根ノードの削除 PASSED
TreeTest > BinarySearchTreeTest > 存在しないキーの削除 PASSED
TreeTest > BinarySearchTreeTest > size PASSED
TreeTest > BinarySearchTreeTest > contains PASSED
TreeTest > BinarySearchTreeTest > 根のみ削除で空 PASSED
TreeTest > BinarySearchTreeTest > 重複キー挿入は無視 PASSED
TreeTest > BinarySearchTreeTest > 右子のみのノード削除 PASSED
TreeTest > BinarySearchTreeTest > 深い後継ノードの削除 PASSED
TreeTest > BinarySearchTreeTest > 右子として親に繋がるノード削除 PASSED

BUILD SUCCESSFUL
23 tests completed, 0 failed