フリーキーズ | 独学プログラミング

木構造と二分探索木入門!初心者でもわかるデータ構造の基礎と実装方法

リンドくん

リンドくん

先生、データ構造で「木構造」っていうのを聞いたんですけど、これって何なんですか?

たなべ

たなべ

木構造はデータを階層的に管理する方法なんだ。
実は君の身の回りにもたくさんあるんだよ。例えば、フォルダとファイルの関係とか、家系図とか...そういう「親子関係」があるデータを扱うのに最適なんだ。

プログラミングを学び進めていくと、必ず出会うのが「データ構造」という概念です。
その中でも木構造(ツリー構造)は、非常に重要でありながら、初心者の方にとっては少し難しく感じられる部分かもしれません。

しかし安心してください。木構造の基本的な考え方は、実は私たちの日常生活の中にもたくさん存在しているのです。
会社の組織図、フォルダ階層、トーナメント表など、これらはすべて木構造で表現できるものばかりです。

この記事では、木構造の基本概念から、プログラミングでよく使われる二分探索木まで、初心者の方でも理解できるよう丁寧に解説していきます。

オンラインコミュニティ運営しています

HackATAは、IT技術を習得する人のために広く開かれたオンラインコミュニティです。 現在、無料コミュニティとして開放していますので、ご気軽に参加してください。

✓ 再立ち上げ

✓ コミュニティの方向性について意見募集中

HackATA公式Webサイト

木構造とは何か?基本概念を理解しよう

リンドくん

リンドくん

「木」っていう名前がついてますけど、本当の木と関係あるんですか?

たなべ

たなべ

実は、構造が木の枝分かれに似ているから「木構造」って呼ばれているんだよ。
ただし、プログラミングの世界では上から下に向かって枝分かれする逆さまの木をイメージするんだ。

木構造の基本的な考え方

木構造は、データを階層的に整理するためのデータ構造です。
最も重要な特徴は、各データ(ノード)が親子関係を持つという点にあります。

木構造には以下のような基本的な要素があります。

  • ルート(根) - 木構造の最上位にある、親を持たない唯一のノード
  • ノード(節点) - データを格納する各要素
  • エッジ(枝) - ノード同士をつなぐ線
  • リーフ(葉) - 子を持たない末端のノード
  • 親ノード - 自分より上の階層にあるノード
  • 子ノード - 自分より下の階層にあるノード

例えば、会社の組織図を思い浮かべてみてください。

社長(ルート)
         /    \
    営業部長   技術部長
      /  \      /  \
   田中 佐藤  鈴木 高橋(すべてリーフ)

この構造では、社長がルート、各部長が中間ノード、一般社員がリーフとなります。
このように、階層的な関係性を持つデータを表現するのに木構造は非常に適しているのです。

木構造が使われる場面

木構造は、プログラミングの世界で非常に幅広く活用されています。

  • ファイルシステム - フォルダとファイルの階層構造
  • HTMLのDOM - Webページの要素の構造
  • データベースのインデックス - 高速なデータ検索
  • AIの探索アルゴリズム - ゲームの手順や最適解の探索
  • 組織図や家系図 - 人や組織の関係性の表現

特に重要なのは、木構造を使うことでデータの検索や挿入、削除が効率的に行えるという点です。
例えば、1000個のデータから特定のデータを探す場合、単純なリストでは最悪1000回の比較が必要ですが、適切に構築された木構造なら10回程度の比較で見つけられることもあります。

二分木と二分探索木の違いを知ろう

リンドくん

リンドくん

「二分木」と「二分探索木」って、何が違うんですか?

たなべ

たなべ

二分木は各ノードが最大2つの子を持つ構造で、二分探索木はそれに加えて特定のルールを持っているんだ。このルールがデータ検索を高速にしてくれるんだよ。

二分木とは

二分木(Binary Tree)は、各ノードが最大2つの子ノードを持つ木構造です。
左の子ノードと右の子ノードに分かれており、これが「二分」という名前の由来になっています。

10
     /  \
    5    15
   / \   / \
  3   7 12  20

この例では、各ノードが左右に最大2つの子を持っています。
ただし、必ずしも2つの子を持つ必要はありません。片方だけの場合や、子を持たない場合もあります。

二分探索木の特別なルール

二分探索木(Binary Search Tree, BST)は、二分木の一種ですが、重要なルールを持っています。

  • 左の子ノード < 親ノード < 右の子ノード

このルールにより、以下のような性質が生まれます。

50
       /  \
      30   70
     /  \  /  \
    20  40 60  80

この例を見ると、どのノードでも以下が成り立っています。

  • 30 < 50 < 70
  • 20 < 30 < 40
  • 60 < 70 < 80

このルールのおかげで、データの検索が非常に効率的になります。
例えば、60を探す場合は次のような流れです。

  1. ルートの50と比較 → 60 > 50なので右へ
  2. 70と比較 → 60 < 70なので左へ
  3. 60を発見!

たった3回の比較で見つかりました。これが二分探索木の強みです。

Pythonで二分探索木を実装してみよう

リンドくん

リンドくん

実際にコードで書くとどうなるんですか?

たなべ

たなべ

じゃあ、実際にPythonで二分探索木を作ってみようか。
まずはノードのクラスから始めて、徐々に機能を追加していくよ。

ノードクラスの定義

まず、木構造の基本単位となるノードを定義します。

class Node:
    def __init__(self, value):
        self.value = value      # ノードが保持する値
        self.left = None        # 左の子ノード
        self.right = None       # 右の子ノード

このシンプルなクラスが、木構造の基礎となります。
各ノードは自分の値と、左右の子ノードへの参照を持っています。

二分探索木クラスの実装

次に、二分探索木全体を管理するクラスを作成します。

class BinarySearchTree:
    def __init__(self):
        self.root = None  # 木のルートノード
    
    def insert(self, value):
        """新しい値を二分探索木に挿入する"""
        if self.root is None:
            # 木が空の場合、ルートとして挿入
            self.root = Node(value)
        else:
            # 既にノードがある場合、適切な位置を探して挿入
            self._insert_recursive(self.root, value)
    
    def _insert_recursive(self, current_node, value):
        """再帰的に適切な挿入位置を探す"""
        if value < current_node.value:
            # 挿入する値が現在のノードより小さい場合は左へ
            if current_node.left is None:
                current_node.left = Node(value)
            else:
                self._insert_recursive(current_node.left, value)
        else:
            # 挿入する値が現在のノードより大きい場合は右へ
            if current_node.right is None:
                current_node.right = Node(value)
            else:
                self._insert_recursive(current_node.right, value)

このコードでは、再帰的な手法を使って適切な挿入位置を探しています。
値を比較しながら左右に進んでいき、適切な空き位置を見つけたら新しいノードを作成します。

検索機能の実装

二分探索木の最大の特徴である、効率的な検索機能を実装しましょう。

def search(self, value):
        """指定した値を持つノードを検索する"""
        return self._search_recursive(self.root, value)
    
    def _search_recursive(self, current_node, value):
        """再帰的に値を検索する"""
        # 現在のノードがNoneなら見つからなかった
        if current_node is None:
            return False
        
        # 値が一致したら見つかった
        if current_node.value == value:
            return True
        
        # 探している値が現在のノードより小さければ左を探索
        if value < current_node.value:
            return self._search_recursive(current_node.left, value)
        # 大きければ右を探索
        else:
            return self._search_recursive(current_node.right, value)

検索も挿入と同様に、値を比較しながら左右に進んでいきます。
この方法により、平均的にO(log n)の時間で検索が完了します。

実際に使ってみよう

作成した二分探索木を実際に使ってみましょう。

# 二分探索木を作成
bst = BinarySearchTree()

# 値を挿入
values = [50, 30, 70, 20, 40, 60, 80]
for value in values:
    bst.insert(value)

# 検索してみる
print(bst.search(40))  # True(見つかった)
print(bst.search(100)) # False(見つからなかった)

このコードを実行すると、以下のような木構造が作成されます。

50
       /  \
      30   70
     /  \  /  \
    20  40 60  80

木構造の走査(トラバーサル)を理解しよう

リンドくん

リンドくん

木構造の中のデータを全部見たいときはどうするんですか?

たなべ

たなべ

それは「走査」または「トラバーサル」と呼ばれる操作なんだ。
訪問する順番によって3つの方法があって、それぞれ用途が違うんだよ。

3つの基本的な走査方法

木構造を走査する方法には、主に以下の3種類があります。

1. 中順走査(In-order Traversal)

左の子 → 自分 → 右の子の順で訪問します。
二分探索木では、この方法で走査すると昇順にデータが得られるという重要な性質があります。

def inorder_traversal(self, node):
        """中順走査で値を表示"""
        if node is not None:
            self.inorder_traversal(node.left)   # 左の子を訪問
            print(node.value, end=' ')          # 自分を処理
            self.inorder_traversal(node.right)  # 右の子を訪問

実行結果:20 30 40 50 60 70 80(昇順になる)

2. 前順走査(Pre-order Traversal)

自分 → 左の子 → 右の子の順で訪問します。
木構造のコピーを作る際などに使われます。

def preorder_traversal(self, node):
        """前順走査で値を表示"""
        if node is not None:
            print(node.value, end=' ')          # 自分を処理
            self.preorder_traversal(node.left)  # 左の子を訪問
            self.preorder_traversal(node.right) # 右の子を訪問

実行結果:50 30 20 40 70 60 80

3. 後順走査(Post-order Traversal)

左の子 → 右の子 → 自分の順で訪問します。
木構造を削除する際などに使われます。

def postorder_traversal(self, node):
        """後順走査で値を表示"""
        if node is not None:
            self.postorder_traversal(node.left)  # 左の子を訪問
            self.postorder_traversal(node.right) # 右の子を訪問
            print(node.value, end=' ')           # 自分を処理

実行結果:20 40 30 60 80 70 50

これらの走査方法を理解することで、木構造のデータを目的に応じて効率的に処理できるようになります。

二分探索木の活用例

リンドくん

リンドくん

二分探索木って、実際のプログラムではどんなときに使うんですか?

たなべ

たなべ

いい質問だね!実はデータベースのインデックス辞書機能など、身近なところでたくさん使われているんだよ。具体例を見てみようか。

実用例① 単語帳アプリケーション

二分探索木を使った簡単な単語帳を作ってみましょう。

class WordNode:
    def __init__(self, word, meaning):
        self.word = word        # 単語
        self.meaning = meaning  # 意味
        self.left = None
        self.right = None

class WordDictionary:
    def __init__(self):
        self.root = None
    
    def add_word(self, word, meaning):
        """単語と意味を追加"""
        if self.root is None:
            self.root = WordNode(word, meaning)
        else:
            self._add_recursive(self.root, word, meaning)
    
    def _add_recursive(self, node, word, meaning):
        if word < node.word:
            if node.left is None:
                node.left = WordNode(word, meaning)
            else:
                self._add_recursive(node.left, word, meaning)
        else:
            if node.right is None:
                node.right = WordNode(word, meaning)
            else:
                self._add_recursive(node.right, word, meaning)
    
    def search_word(self, word):
        """単語の意味を検索"""
        return self._search_recursive(self.root, word)
    
    def _search_recursive(self, node, word):
        if node is None:
            return "単語が見つかりません"
        
        if node.word == word:
            return node.meaning
        
        if word < node.word:
            return self._search_recursive(node.left, word)
        else:
            return self._search_recursive(node.right, word)

# 使用例
dictionary = WordDictionary()
dictionary.add_word("apple", "リンゴ")
dictionary.add_word("banana", "バナナ")
dictionary.add_word("cherry", "さくらんぼ")

print(dictionary.search_word("banana"))  # 「バナナ」と表示

この例では、単語をアルファベット順に管理し、高速に検索できる仕組みを作っています。

実用例② 成績管理システム

学生の成績を効率的に管理するシステムです。

class StudentNode:
    def __init__(self, student_id, name, score):
        self.student_id = student_id  # 学生ID
        self.name = name              # 名前
        self.score = score            # 成績
        self.left = None
        self.right = None

class GradeManagement:
    def __init__(self):
        self.root = None
    
    def register_student(self, student_id, name, score):
        """学生を登録"""
        if self.root is None:
            self.root = StudentNode(student_id, name, score)
        else:
            self._register_recursive(self.root, student_id, name, score)
    
    def _register_recursive(self, node, student_id, name, score):
        if student_id < node.student_id:
            if node.left is None:
                node.left = StudentNode(student_id, name, score)
            else:
                self._register_recursive(node.left, student_id, name, score)
        else:
            if node.right is None:
                node.right = StudentNode(student_id, name, score)
            else:
                self._register_recursive(node.right, student_id, name, score)
    
    def find_student(self, student_id):
        """学生IDから情報を検索"""
        return self._find_recursive(self.root, student_id)
    
    def _find_recursive(self, node, student_id):
        if node is None:
            return None
        
        if node.student_id == student_id:
            return f"名前: {node.name}, 成績: {node.score}点"
        
        if student_id < node.student_id:
            return self._find_recursive(node.left, student_id)
        else:
            return self._find_recursive(node.right, student_id)

# 使用例
grade_system = GradeManagement()
grade_system.register_student(1001, "田中", 85)
grade_system.register_student(1003, "佐藤", 92)
grade_system.register_student(1002, "鈴木", 78)

print(grade_system.find_student(1002))  # 「名前: 鈴木, 成績: 78点」

このように、二分探索木を使うことで学生IDによる高速な検索が可能になります。

木構造のメリットとデメリットを知ろう

リンドくん

リンドくん

木構造って便利そうですけど、何か注意点はあるんですか?

たなべ

たなべ

鋭い質問だね!
どんなデータ構造にも得意なことと苦手なことがあるんだ。木構造も例外じゃないよ。

二分探索木のメリット

二分探索木には以下のような優れた特徴があります。

検索が高速

  • 平均的にO(log n)の時間で検索が完了します
  • 1000個のデータでも約10回の比較で見つかります

挿入・削除が効率的

  • 配列と違い、途中への挿入でデータ全体をずらす必要がありません
  • 適切な位置を見つけて挿入するだけです

データが自動的にソートされる

  • 中順走査を行うだけで、昇順のデータが得られます
  • ソート処理を別途行う必要がありません

柔軟な構造

  • データの追加や削除に応じて構造が変化します
  • メモリの無駄が少なくなります

二分探索木のデメリットと注意点

一方で、以下のような課題もあります。

バランスが崩れる可能性

  • データを昇順に挿入すると、木が一直線になってしまいます
  • この場合、検索効率がO(n)に悪化してしまいます
# 悪い例:昇順に挿入
bst = BinarySearchTree()
for i in range(1, 6):
    bst.insert(i)

# 結果的に以下のような一直線の構造になる
# 1
#  \
#   2
#    \
#     3
#      \
#       4
#        \
#         5

メモリ使用量が多い

  • 各ノードが左右の子への参照を持つため、配列より多くのメモリを使います
  • 小規模なデータでは配列の方が効率的な場合もあります

実装が複雑

  • 配列やリストと比べて、実装にやや手間がかかります
  • 削除操作は特に複雑になります

これらの課題を解決するために、AVL木赤黒木といった自己バランス型の木構造も開発されています。
しかし、基本を理解する上では、まず二分探索木をしっかり学ぶことが重要です。

まとめ

リンドくん

リンドくん

木構造、最初は難しそうでしたけど、だんだん理解できてきました!

たなべ

たなべ

素晴らしいね!
木構造はデータ構造の基礎の一つだから、これを理解できれば、もっと高度なアルゴリズムも学べるようになるよ。ぜひ実際にコードを書いて試してみてね!

この記事では、木構造と二分探索木について基礎から実装まで解説してきました。
最後に、重要なポイントをおさらいしましょう。

  • 木構造はデータを階層的に管理する方法で、親子関係を持つノードで構成されます
  • 二分探索木は左 < 親 < 右のルールを持ち、効率的な検索が可能です
  • 走査方法には中順・前順・後順があり、目的に応じて使い分けます
  • 実用例として辞書、データベース、ファイルシステムなど多岐にわたります

プログラミングにおいて、適切なデータ構造を選択することは非常に重要です。
木構造は、その中でも特に汎用性が高く、多くの場面で活躍する基礎的な技術です。

ぜひ今回学んだ内容を活かして、実際にコードを書いて動かしてみてください。
手を動かすことで、理解がさらに深まるはずです。

この記事をシェア

関連するコンテンツ