[C言語] 木構造を実装する方法(二分探索木)

C言語で二分探索木を実装するには、まずノードを表す構造体を定義します。

この構造体には、データを格納する変数と、左右の子ノードへのポインタを持たせます。

次に、ノードの挿入、検索、削除などの操作を関数として実装します。

挿入では、再帰的に木をたどり、適切な位置に新しいノードを追加します。

検索も同様に再帰的に行い、削除は3つのケース(子がない、1つの子がある、2つの子がある)に分けて処理します。

この記事でわかること
  • 二分探索木の基本構造と特性
  • 挿入、検索、削除の操作方法
  • 走査方法の種類とその用途
  • 平衡性の重要性とその影響
  • 二分探索木の応用例と活用方法

目次から探す

二分探索木とは

二分探索木(Binary Search Tree, BST)は、データを効率的に管理するための木構造の一種です。

各ノードは、左の子ノードにはそのノードより小さい値、右の子ノードにはそのノードより大きい値を持つという特性を持っています。

この特性により、データの挿入、検索、削除が平均的にO(log n)の時間で行えるため、大量のデータを扱う際に非常に有用です。

二分探索木は、データの順序を保ちながら、効率的な操作を可能にするため、さまざまなアルゴリズムやデータ構造の基盤として広く利用されています。

二分探索木のノード構造

ノードの定義

二分探索木の基本的な構成要素は「ノード」です。

ノードは、データを格納するための変数と、左右の子ノードを指し示すポインタを持っています。

以下は、C言語でのノードの定義の例です。

#include <stdio.h>
#include <stdlib.h>
// ノード構造体の定義
typedef struct Node {
    int data;               // ノードが持つデータ
    struct Node* left;     // 左の子ノードへのポインタ
    struct Node* right;    // 右の子ノードへのポインタ
} Node;

左右の子ノードの役割

二分探索木では、各ノードは最大で2つの子ノードを持つことができます。

左の子ノードは親ノードよりも小さい値を持ち、右の子ノードは親ノードよりも大きい値を持つという特性があります。

この特性により、木全体が整然とした構造を保ち、効率的な検索や挿入が可能になります。

具体的には、以下のような役割があります。

スクロールできます
子ノードの種類役割
左の子ノード親ノードより小さい値を持つ
右の子ノード親ノードより大きい値を持つ

ノードのメモリ管理

ノードは動的にメモリを確保して生成されるため、メモリ管理が重要です。

C言語では、malloc関数を使用してノードを生成し、使用が終わったらfree関数でメモリを解放する必要があります。

以下は、ノードを生成する関数の例です。

Node* createNode(int value) {
    Node* newNode = (Node*)malloc(sizeof(Node)); // メモリを確保
    newNode->data = value;                        // データを設定
    newNode->left = NULL;                        // 左の子ノードはNULL
    newNode->right = NULL;                       // 右の子ノードはNULL
    return newNode;                               // 新しいノードを返す
}

このように、ノードのメモリ管理を適切に行うことで、メモリリークを防ぎ、プログラムの安定性を保つことができます。

二分探索木の基本操作

ノードの挿入

二分探索木にノードを挿入する際は、木の特性を保つために適切な位置を見つける必要があります。

新しいノードの値が現在のノードの値より小さい場合は左に、そうでない場合は右に進みます。

以下は、ノードを挿入する関数の例です。

Node* insert(Node* root, int value) {
    // ベースケース: 空の木の場合、新しいノードを作成
    if (root == NULL) {
        return createNode(value);
    }
    
    // 挿入位置を決定
    if (value < root->data) {
        root->left = insert(root->left, value); // 左の子ノードに挿入
    } else {
        root->right = insert(root->right, value); // 右の子ノードに挿入
    }
    
    return root; // 更新されたルートノードを返す
}

ノードの検索

ノードの検索も挿入と同様に、木の特性を利用して行います。

検索したい値が現在のノードの値と一致する場合はそのノードを返し、そうでない場合は左または右の子ノードに進みます。

以下は、ノードを検索する関数の例です。

Node* search(Node* root, int value) {
    // ベースケース: 木が空または値が見つかった場合
    if (root == NULL || root->data == value) {
        return root;
    }
    
    // 値に応じて左または右の子ノードを検索
    if (value < root->data) {
        return search(root->left, value); // 左の子ノードを検索
    } else {
        return search(root->right, value); // 右の子ノードを検索
    }
}

ノードの削除

ノードの削除は、木の特性を保ちながら行う必要があります。

削除するノードの子ノードの数によって処理が異なります。

以下に、各ケースの処理を示します。

子がない場合の削除

削除するノードが葉ノード(子ノードがない場合)の場合、そのノードを単純にメモリから解放します。

Node* deleteNode(Node* root, int value) {
    // ベースケース: 木が空の場合
    if (root == NULL) {
        return root;
    }
    
    // 値に応じて左または右の子ノードを検索
    if (value < root->data) {
        root->left = deleteNode(root->left, value); // 左の子ノードを削除
    } else if (value > root->data) {
        root->right = deleteNode(root->right, value); // 右の子ノードを削除
    } else {
        // 子がない場合
        if (root->left == NULL && root->right == NULL) {
            free(root); // メモリを解放
            return NULL; // NULLを返す
        }
    }
    return root; // 更新されたルートノードを返す
}

子が1つの場合の削除

削除するノードが子ノードを1つ持つ場合、そのノードを削除し、子ノードを親ノードに接続します。

// 子が1つの場合の処理
if (root->left == NULL) {
    Node* temp = root->right; // 右の子ノードを保存
    free(root); // メモリを解放
    return temp; // 右の子ノードを返す
} else {
    Node* temp = root->left; // 左の子ノードを保存
    free(root); // メモリを解放
    return temp; // 左の子ノードを返す
}

子が2つの場合の削除

削除するノードが子ノードを2つ持つ場合、通常は右部分木の最小値(または左部分木の最大値)を見つけて、その値でノードを置き換えます。

以下はその処理の例です。

// 子が2つの場合の処理
Node* temp = findMin(root->right); // 右部分木の最小値を見つける
root->data = temp->data; // ノードの値を置き換える
root->right = deleteNode(root->right, temp->data); // 最小値を削除

このように、二分探索木の基本操作を理解することで、データの管理や検索が効率的に行えるようになります。

二分探索木の実装手順

ノード構造体の定義

二分探索木を実装するためには、まずノードの構造体を定義します。

ノードはデータを格納するための変数と、左右の子ノードを指し示すポインタを持ちます。

以下は、C言語でのノード構造体の定義です。

#include <stdio.h>
#include <stdlib.h>
// ノード構造体の定義
typedef struct Node {
    int data;               // ノードが持つデータ
    struct Node* left;     // 左の子ノードへのポインタ
    struct Node* right;    // 右の子ノードへのポインタ
} Node;

挿入関数の実装

次に、ノードを二分探索木に挿入する関数を実装します。

この関数は、木の特性を保ちながら新しいノードを適切な位置に挿入します。

以下は、挿入関数の実装例です。

Node* createNode(int value) {
    Node* newNode = (Node*)malloc(sizeof(Node)); // メモリを確保
    newNode->data = value;                        // データを設定
    newNode->left = NULL;                        // 左の子ノードはNULL
    newNode->right = NULL;                       // 右の子ノードはNULL
    return newNode;                               // 新しいノードを返す
}
Node* insert(Node* root, int value) {
    // ベースケース: 空の木の場合、新しいノードを作成
    if (root == NULL) {
        return createNode(value);
    }
    
    // 挿入位置を決定
    if (value < root->data) {
        root->left = insert(root->left, value); // 左の子ノードに挿入
    } else {
        root->right = insert(root->right, value); // 右の子ノードに挿入
    }
    
    return root; // 更新されたルートノードを返す
}

検索関数の実装

次に、特定の値を持つノードを検索する関数を実装します。

この関数は、木の特性を利用して効率的に検索を行います。

以下は、検索関数の実装例です。

Node* search(Node* root, int value) {
    // ベースケース: 木が空または値が見つかった場合
    if (root == NULL || root->data == value) {
        return root;
    }
    
    // 値に応じて左または右の子ノードを検索
    if (value < root->data) {
        return search(root->left, value); // 左の子ノードを検索
    } else {
        return search(root->right, value); // 右の子ノードを検索
    }
}

削除関数の実装

ノードを削除する関数も実装します。

削除するノードの子ノードの数によって処理が異なるため、各ケースに応じた処理を行います。

以下は、削除関数の実装例です。

Node* deleteNode(Node* root, int value) {
    // ベースケース: 木が空の場合
    if (root == NULL) {
        return root;
    }
    
    // 値に応じて左または右の子ノードを検索
    if (value < root->data) {
        root->left = deleteNode(root->left, value); // 左の子ノードを削除
    } else if (value > root->data) {
        root->right = deleteNode(root->right, value); // 右の子ノードを削除
    } else {
        // 子がない場合
        if (root->left == NULL && root->right == NULL) {
            free(root); // メモリを解放
            return NULL; // NULLを返す
        }
        // 子が1つの場合
        else if (root->left == NULL) {
            Node* temp = root->right; // 右の子ノードを保存
            free(root); // メモリを解放
            return temp; // 右の子ノードを返す
        } else if (root->right == NULL) {
            Node* temp = root->left; // 左の子ノードを保存
            free(root); // メモリを解放
            return temp; // 左の子ノードを返す
        }
        // 子が2つの場合
        Node* temp = findMin(root->right); // 右部分木の最小値を見つける
        root->data = temp->data; // ノードの値を置き換える
        root->right = deleteNode(root->right, temp->data); // 最小値を削除
    }
    return root; // 更新されたルートノードを返す
}

メモリ解放の実装

最後に、使用したメモリを解放するための関数を実装します。

木全体を走査し、各ノードのメモリを解放します。

以下は、メモリ解放の実装例です。

void freeTree(Node* root) {
    if (root != NULL) {
        freeTree(root->left);  // 左の子ノードを解放
        freeTree(root->right); // 右の子ノードを解放
        free(root);            // 現在のノードを解放
    }
}

このように、二分探索木の基本的な操作を実装することで、データの管理や検索、削除が効率的に行えるようになります。

二分探索木の走査

二分探索木の走査は、木のノードを特定の順序で訪問する方法です。

主に3つの走査方法があり、それぞれ異なる順序でノードを訪れます。

これらの走査方法は、データの取得や処理に役立ちます。

以下にそれぞれの走査方法を説明します。

中間順走査(In-order Traversal)

中間順走査は、左の子ノードを訪問した後、親ノードを訪問し、最後に右の子ノードを訪問する方法です。

この走査方法を用いると、二分探索木のノードを昇順に取得することができます。

以下は、中間順走査の実装例です。

void inOrderTraversal(Node* root) {
    if (root != NULL) {
        inOrderTraversal(root->left);  // 左の子ノードを走査
        printf("%d ", root->data);      // 現在のノードを出力
        inOrderTraversal(root->right); // 右の子ノードを走査
    }
}

先行順走査(Pre-order Traversal)

先行順走査は、親ノードを訪問した後、左の子ノードを訪問し、最後に右の子ノードを訪問する方法です。

この走査方法は、木の構造を保存する際に役立ちます。

以下は、先行順走査の実装例です。

void preOrderTraversal(Node* root) {
    if (root != NULL) {
        printf("%d ", root->data);      // 現在のノードを出力
        preOrderTraversal(root->left);  // 左の子ノードを走査
        preOrderTraversal(root->right); // 右の子ノードを走査
    }
}

後行順走査(Post-order Traversal)

後行順走査は、左の子ノードを訪問した後、右の子ノードを訪問し、最後に親ノードを訪問する方法です。

この走査方法は、ノードを削除する際に役立ちます。

以下は、後行順走査の実装例です。

void postOrderTraversal(Node* root) {
    if (root != NULL) {
        postOrderTraversal(root->left);  // 左の子ノードを走査
        postOrderTraversal(root->right); // 右の子ノードを走査
        printf("%d ", root->data);      // 現在のノードを出力
    }
}

これらの走査方法を使用することで、二分探索木のデータをさまざまな順序で取得し、必要な処理を行うことができます。

各走査方法は、特定の用途に応じて使い分けることが重要です。

完成したサンプルコード

以下に、二分探索木の基本的な操作(挿入、検索、削除、走査)を含む完成したサンプルコードを示します。

このコードは、ノードの作成、挿入、検索、削除、走査を行うことができます。

#include <stdio.h>
#include <stdlib.h>
// ノード構造体の定義
typedef struct Node {
    int data;               // ノードが持つデータ
    struct Node* left;     // 左の子ノードへのポインタ
    struct Node* right;    // 右の子ノードへのポインタ
} Node;
// ノードを作成する関数
Node* createNode(int value) {
    Node* newNode = (Node*)malloc(sizeof(Node)); // メモリを確保
    newNode->data = value;                        // データを設定
    newNode->left = NULL;                        // 左の子ノードはNULL
    newNode->right = NULL;                       // 右の子ノードはNULL
    return newNode;                               // 新しいノードを返す
}
// ノードを挿入する関数
Node* insert(Node* root, int value) {
    if (root == NULL) {
        return createNode(value); // 空の木の場合、新しいノードを作成
    }
    if (value < root->data) {
        root->left = insert(root->left, value); // 左の子ノードに挿入
    } else {
        root->right = insert(root->right, value); // 右の子ノードに挿入
    }
    return root; // 更新されたルートノードを返す
}
// ノードを検索する関数
Node* search(Node* root, int value) {
    if (root == NULL || root->data == value) {
        return root; // 木が空または値が見つかった場合
    }
    if (value < root->data) {
        return search(root->left, value); // 左の子ノードを検索
    } else {
        return search(root->right, value); // 右の子ノードを検索
    }
}
// ノードを削除する関数
Node* deleteNode(Node* root, int value) {
    if (root == NULL) {
        return root; // 木が空の場合
    }
    if (value < root->data) {
        root->left = deleteNode(root->left, value); // 左の子ノードを削除
    } else if (value > root->data) {
        root->right = deleteNode(root->right, value); // 右の子ノードを削除
    } else {
        // 子がない場合
        if (root->left == NULL && root->right == NULL) {
            free(root); // メモリを解放
            return NULL; // NULLを返す
        }
        // 子が1つの場合
        else if (root->left == NULL) {
            Node* temp = root->right; // 右の子ノードを保存
            free(root); // メモリを解放
            return temp; // 右の子ノードを返す
        } else if (root->right == NULL) {
            Node* temp = root->left; // 左の子ノードを保存
            free(root); // メモリを解放
            return temp; // 左の子ノードを返す
        }
        // 子が2つの場合
        Node* temp = root->right; // 右部分木の最小値を見つける
        while (temp && temp->left != NULL) {
            temp = temp->left; // 最小値を見つける
        }
        root->data = temp->data; // ノードの値を置き換える
        root->right = deleteNode(root->right, temp->data); // 最小値を削除
    }
    return root; // 更新されたルートノードを返す
}
// 中間順走査
void inOrderTraversal(Node* root) {
    if (root != NULL) {
        inOrderTraversal(root->left);  // 左の子ノードを走査
        printf("%d ", root->data);      // 現在のノードを出力
        inOrderTraversal(root->right); // 右の子ノードを走査
    }
}
// メモリを解放する関数
void freeTree(Node* root) {
    if (root != NULL) {
        freeTree(root->left);  // 左の子ノードを解放
        freeTree(root->right); // 右の子ノードを解放
        free(root);            // 現在のノードを解放
    }
}
// メイン関数
int main() {
    Node* root = NULL; // 木の初期化
    // ノードの挿入
    root = insert(root, 50);
    root = insert(root, 30);
    root = insert(root, 20);
    root = insert(root, 40);
    root = insert(root, 70);
    root = insert(root, 60);
    root = insert(root, 80);
    // 中間順走査
    printf("中間順走査: ");
    inOrderTraversal(root); // 出力: 20 30 40 50 60 70 80
    printf("\n");
    // ノードの検索
    Node* foundNode = search(root, 40);
    if (foundNode) {
        printf("ノード40が見つかりました。\n");
    } else {
        printf("ノード40は見つかりませんでした。\n");
    }
    // ノードの削除
    root = deleteNode(root, 20); // ノード20を削除
    printf("ノード20を削除後の中間順走査: ");
    inOrderTraversal(root); // 出力: 30 40 50 60 70 80
    printf("\n");
    // メモリの解放
    freeTree(root);
    return 0; // プログラムの終了
}

このサンプルコードでは、二分探索木の基本的な機能を実装しています。

ノードの挿入、検索、削除、走査を行い、結果を出力します。

プログラムの実行結果は、木の構造に応じて異なりますが、基本的な操作が正しく機能することを確認できます。

二分探索木の応用

二分探索木は、データの効率的な管理や検索に利用されるだけでなく、さまざまな応用が存在します。

以下に、二分探索木の主な応用例を紹介します。

平衡二分探索木(AVL木、赤黒木)

平衡二分探索木は、通常の二分探索木の特性を保ちながら、木の高さを制限することで、最悪の場合の性能を改善したデータ構造です。

代表的なものにAVL木と赤黒木があります。

  • AVL木: 各ノードの左右の部分木の高さの差が1以下になるように調整される木です。

挿入や削除の際に回転操作を行い、常に平衡を保ちます。

これにより、検索、挿入、削除の操作がO(log n)の時間で行えます。

  • 赤黒木: 各ノードに色(赤または黒)を持たせ、特定のルールに従って木の構造を保つことで、平衡を維持します。

赤黒木も挿入や削除の際に回転操作を行い、最悪の場合でもO(log n)の時間で操作が可能です。

これらの平衡二分探索木は、データの挿入や削除が頻繁に行われる場合に特に有効です。

二分ヒープとの違い

二分ヒープは、完全二分木の特性を持つデータ構造で、主に優先度キューの実装に使用されます。

二分探索木との主な違いは以下の通りです。

スクロールできます
特徴二分探索木二分ヒープ
構造左右の子ノードの値に基づく完全二分木
データの順序中間順走査で昇順に取得可能最小値または最大値が根にある
主な用途検索、挿入、削除優先度キューの実装
操作の時間計算量O(log n)O(log n)

二分探索木はデータの順序を保ちながら効率的に検索や操作ができるのに対し、二分ヒープは優先度に基づく操作に特化しています。

二分探索木を使ったソート(木ソート)

二分探索木を利用したソート方法として「木ソート」があります。

この方法では、まずデータを二分探索木に挿入し、その後中間順走査を行うことで、データを昇順に取得します。

木ソートの手順は以下の通りです。

  1. ソートしたいデータを二分探索木に挿入します。
  2. 中間順走査を行い、ノードの値を順に取得します。

木ソートの時間計算量は、平均的にはO(n log n)ですが、最悪の場合はO(n^2)になることがあります。

これは、挿入するデータがすでにソートされている場合など、木が偏った形になるためです。

そのため、木ソートはデータの特性に応じて使用することが重要です。

このように、二分探索木はさまざまな応用があり、特定の状況において非常に有用なデータ構造です。

二分探索木のパフォーマンス

二分探索木のパフォーマンスは、主に操作の時間計算量と木の構造に依存します。

以下に、平均時間計算量、最悪時間計算量、そして平衡性の重要性について説明します。

平均時間計算量

二分探索木の平均時間計算量は、挿入、検索、削除の各操作においてO(log n)です。

これは、木がバランスよく構成されている場合に成り立ちます。

具体的には、ノードの数がnの場合、木の高さはおおよそ\(\log_2 n\)に近く、各操作は木の高さに比例して時間がかかるためです。

したがって、平均的なケースでは、二分探索木は非常に効率的なデータ構造となります。

最悪時間計算量

最悪時間計算量は、木が偏った形(例えば、すべてのノードが左または右にのみ存在する場合)になるとO(n)になります。

このような場合、木の高さがnに達し、各操作が木の高さに比例して時間がかかるためです。

最悪のケースは、データがすでにソートされている場合や、挿入順序が特定のパターンに従っている場合に発生しやすいです。

平衡性の重要性

二分探索木のパフォーマンスを最大限に引き出すためには、木の平衡性が非常に重要です。

平衡な二分探索木では、各操作の時間計算量がO(log n)に保たれ、効率的なデータ管理が可能になります。

平衡性を保つための手法として、以下のようなものがあります。

  • AVL木: 各ノードの左右の部分木の高さの差を1以下に保つことで、常に平衡を維持します。
  • 赤黒木: 特定のルールに従ってノードの色を管理し、木の構造を保つことで、平衡を維持します。

これらの平衡二分探索木を使用することで、最悪のケースを回避し、常に効率的な操作を実現することができます。

平衡性を保つことは、特にデータの挿入や削除が頻繁に行われる場合において、パフォーマンスを向上させるために不可欠です。

よくある質問

二分探索木と配列の違いは?

二分探索木と配列は、データを格納するための異なるデータ構造ですが、それぞれに特性があります。

主な違いは以下の通りです。

  • データの順序: 二分探索木は、ノードの左側に小さい値、右側に大きい値を持つ特性があり、データの順序を保ちながら効率的に検索や挿入ができます。

一方、配列はインデックスに基づいてデータを格納し、順序を保つためにはソートが必要です。

  • 操作の時間計算量: 二分探索木では、平均的にO(log n)で検索、挿入、削除が可能ですが、配列では、特に挿入や削除の際にO(n)の時間がかかることがあります。
  • メモリの使用: 配列は固定サイズでメモリを確保するため、サイズ変更が難しいですが、二分探索木は動的にメモリを確保し、必要に応じてサイズを調整できます。

二分探索木の削除が難しいのはなぜ?

二分探索木の削除が難しい理由は、削除するノードの子ノードの数によって処理が異なるためです。

具体的には、以下のようなケースがあります。

  • 子がない場合: 単純にノードを削除し、親ノードのポインタをNULLに設定すれば良いです。
  • 子が1つの場合: 削除するノードをその子ノードで置き換えますが、ポインタの更新が必要です。
  • 子が2つの場合: 削除するノードの代わりに、右部分木の最小値または左部分木の最大値を見つけてその値で置き換え、その後、最小値または最大値を削除する必要があります。

この過程で、木の構造を保つためにポインタの更新が複雑になります。

これらの理由から、削除操作は慎重に行う必要があり、特に子が2つの場合は複雑な処理が必要です。

二分探索木のメモリ管理で気をつけることは?

二分探索木のメモリ管理において注意すべき点は以下の通りです。

  • メモリの確保: ノードを作成する際には、malloc関数を使用してメモリを確保しますが、確保したメモリが適切に解放されているか確認することが重要です。
  • メモリの解放: ノードを削除した際には、必ずfree関数を使用してメモリを解放し、メモリリークを防ぐ必要があります。

特に、木全体を解放する際には、再帰的にすべてのノードを解放する関数を実装することが推奨されます。

  • ポインタの管理: ノードの削除や挿入の際に、ポインタの更新を正確に行うことが重要です。

誤ったポインタの設定は、メモリの不整合やプログラムのクラッシュを引き起こす可能性があります。

これらの点に注意することで、二分探索木のメモリ管理を適切に行い、安定したプログラムを実現することができます。

まとめ

この記事では、二分探索木の基本的な概念から実装方法、基本操作、応用、パフォーマンスに至るまで幅広く解説しました。

二分探索木は、データの効率的な管理や検索を可能にする強力なデータ構造であり、特に平衡性を保つことがその性能を最大限に引き出す鍵となります。

今後は、実際に二分探索木を使ったプログラムを作成し、さまざまなデータ処理の場面でその利点を体験してみてください。

当サイトはリンクフリーです。出典元を明記していただければ、ご自由に引用していただいて構いません。

関連カテゴリーから探す

  • URLをコピーしました!
目次から探す