アルゴリズム

C言語で実装する幅優先探索の解説:キューを用いたグラフと木の探索アルゴリズム

C言語を用いてグラフや木の探索を行う幅優先探索アルゴリズムの実装方法について説明します。

記事では、キューを利用した探索手法をコード例を交えながら分かりやすく解説し、アルゴリズムの基本的な考え方と実装手順を学べるように構成しております。

キューと幅優先探索の基礎知識

幅優先探索アルゴリズムの概要

幅優先探索(Breadth-First Search, BFS)は、グラフや木構造において、ある始点から隣接するノードを順次訪問していく探索アルゴリズムです。

探索対象の各ノードを、そのノードまでの距離(またはレベル)順に訪問するため、最短経路の検索にも活用できます。

具体的には、開始ノードから出発し、その隣接ノードをすべてチェックした後、次に隣接ノードの隣接ノードを訪問するという手順を踏みます。

キューの役割と基本操作

幅優先探索において、キューはノードの訪問順序を管理するための中心的なデータ構造です。

キューは「先入先出(FIFO)」のデータ構造であり、次の基本操作を用います。

  • enqueue:新たなノードをキューの末尾に追加する処理
  • dequeue:キューの先頭からノードを取り出す処理

これにより、探索中に発見した未訪問のノードは順番にキューに格納され、順次取り出して探索が進められます。

C言語による実装手法

データ構造の定義

ノード構造体の設計

ノードは、それ自体の値と隣接関係を保持する必要があります。

以下のサンプルコードでは、Node 構造体がノードの値dataと、隣接ノードを指す配列neighbors、およびそのノード数numNeighborsを格納する設計例を示しています。

#include <stdio.h>
#include <stdlib.h>
// ノード構造体
typedef struct Node {
    int data;                     // ノードの値
    struct Node **neighbors;      // 隣接ノードの配列
    int numNeighbors;             // 隣接ノードの数
} Node;

キュー構造体の設計

幅優先探索に使用するキューは、ノードのポインタを管理するために設計されます。

以下のサンプルでは、Queue 構造体が、ノードポインタの配列data、先頭と末尾のインデックス、現在の要素数size、および配列の容量capacityを持つ設計例を示しています。

#include <stdio.h>
#include <stdlib.h>
// キュー構造体
typedef struct Queue {
    Node **data;     // ノードのポインタ配列
    int front;       // キューの先頭インデックス
    int rear;        // キューの末尾インデックス
    int size;        // 現在の要素数
    int capacity;    // キューの最大容量
} Queue;

キュー操作関数の実装

エンキューとデキューの処理

実装では、ノードをキューに追加する enqueue関数と、キューから取り出す dequeue関数を定義します。

enqueue関数では、キューが満杯の場合に動的にメモリを拡張し、末尾の位置に新たなノードを追加します。

一方、dequeue関数では、先頭のノードを取り出し、キューの先頭インデックスを進める処理を行います。

#include <stdio.h>
#include <stdlib.h>
// enqueue関数:キューにノードを追加する処理
void enqueue(Queue *q, Node *node) {
    if(q->size >= q->capacity) {
        // キューの容量が不足した場合の拡張処理
        q->capacity *= 2;
        q->data = (Node**)realloc(q->data, sizeof(Node*) * q->capacity);
    }
    q->data[q->rear] = node;
    q->rear = (q->rear + 1) % q->capacity;
    q->size++;
}
// dequeue関数:キューからノードを取り出す処理
Node* dequeue(Queue *q) {
    if(q->size == 0) return NULL;  // キューが空の場合はNULLを返す
    Node* node = q->data[q->front];
    q->front = (q->front + 1) % q->capacity;
    q->size--;
    return node;
}

空キューおよび満キューの確認

キューの状態確認は、探索の流れにおいて重要です。

たとえば、キューが空であるかどうかは、q->size == 0 で確認できます。

また、キューが満杯かどうかは、q->size == q->capacity で判定できます。

これらのチェックは動的なメモリ拡張のトリガーや、探索ループの終了条件として利用されます。

幅優先探索アルゴリズムの実装

初期化と探索の開始

幅優先探索を開始するには、初期ノードの設定とキューの初期化が必要です。

たとえば、以下のサンプルコードでは、createQueue関数でキューを初期化し、探索の起点となるノードをキューに追加しています。

このコードは、1つのノードをキューに追加したことを確認する簡単な例となっています。

#include <stdio.h>
#include <stdlib.h>
// 基本的なノード構造体(簡略化)
typedef struct Node {
    int data;    // ノードの値
} Node;
// キュー構造体
typedef struct Queue {
    Node **data;
    int front;
    int rear;
    int size;
    int capacity;
} Queue;
// キューの初期化関数
Queue* createQueue(int capacity) {
    Queue *q = (Queue*)malloc(sizeof(Queue));
    q->data = (Node**)malloc(sizeof(Node*) * capacity);
    q->front = 0;
    q->rear = 0;
    q->size = 0;
    q->capacity = capacity;
    return q;
}
// enqueue関数
void enqueue(Queue *q, Node *node) {
    if(q->size >= q->capacity) {
        q->capacity *= 2;
        q->data = (Node**)realloc(q->data, sizeof(Node*) * q->capacity);
    }
    q->data[q->rear] = node;
    q->rear = (q->rear + 1) % q->capacity;
    q->size++;
}
int main() {
    Node nodeA = {1};  // 初期ノードの例
    Queue *queue = createQueue(4);
    enqueue(queue, &nodeA);
    printf("Starting node enqueued. Queue size: %d\n", queue->size);
    // メモリの解放
    free(queue->data);
    free(queue);
    return 0;
}
Starting node enqueued. Queue size: 1

探索手続きの流れ

キューへのノード追加

探索ループ内では、現在のノードから隣接するノードを順次チェックし、未訪問のノードをキューに追加していきます。

たとえば、各ノードの隣接リストを走査し、まだ訪問していないノードに対して以下のような処理を行います。

  • ノードの隣接情報にアクセスする
  • 隣接ノードが未訪問であれば、訪問済みとして印を付け、enqueue を呼び出してキューに追加

ノードの訪問管理

幅優先探索では、同じノードを複数回訪問しないように、訪問済みの情報を管理する必要があります。

一般的には、ノードのインデックスを用いたブール型の配列 visited を用い、ノードがキューに追加された時点や取り出された時点でチェックします。

この管理により、無限ループを防ぐとともに、効率的な探索を実現します。

探索終了条件の設定

探索の終了条件は、通常、以下のように設定されます。

  • キューが空になった場合
  • 探索対象の目的ノードに到達した場合

探索ループ内で、取り出したノードの値が目的のものと一致するかどうかを確認し、一致した場合は探索ループを抜ける設計とします。

コード例と詳細解説

各処理の解説

以下に示すサンプルコードは、グラフをノードの集合として扱い、幅優先探索を実装したものです。

各関数は、ノードやキューの初期化、キュー操作、探索アルゴリズムの流れに沿って実装されており、全体として1つのグラフ上で探索が実行されます。

コメントを参考にしながら、各処理の役割をご確認ください。

エラーチェックのポイント

メモリの動的確保や再割り当て時には、返却値が NULL でないかのチェックが必要です。

このサンプルコードでは、解説を重視するため簡略化していますが、実際の実装では mallocrealloc の戻り値のチェックを追加する点に注意してください。

効率的なメモリ管理の工夫

キューの容量が不足した場合、realloc を用いて容量を動的に拡大する処理は、メモリの無駄を減らすための工夫です。

また、探索終了後には、動的に確保したメモリ(キューや訪問管理用の配列、ノードや隣接リスト)を必ず解放することで、メモリリークを防止します。

以下は、全体を統合したサンプルコードです。

実行可能なプログラムとなっており、グラフ上で幅優先探索をシンプルに実装しています。

#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#define INITIAL_CAPACITY 4
// ノード構造体
typedef struct Node {
    int data;                    // ノードの値
    struct Node **neighbors;     // 隣接ノードの配列
    int numNeighbors;            // 隣接ノードの数
} Node;
// キュー構造体
typedef struct Queue {
    Node **data;    // ノードのポインタ配列
    int front;      // 先頭インデックス
    int rear;       // 末尾インデックス
    int size;       // 現在の要素数
    int capacity;   // 配列の容量
} Queue;
// キューの初期化
Queue* createQueue(int capacity) {
    Queue *q = (Queue*)malloc(sizeof(Queue));
    q->data = (Node**)malloc(sizeof(Node*) * capacity);
    q->front = 0;
    q->rear = 0;
    q->size = 0;
    q->capacity = capacity;
    return q;
}
// キューが空か確認する関数
bool isEmpty(Queue *q) {
    return (q->size == 0);
}
// キューにノードを追加する関数
void enqueue(Queue *q, Node *node) {
    if(q->size >= q->capacity) {
        q->capacity *= 2;
        q->data = (Node**)realloc(q->data, sizeof(Node*) * q->capacity);
    }
    q->data[q->rear] = node;
    q->rear = (q->rear + 1) % q->capacity;
    q->size++;
}
// キューからノードを取り出す関数
Node* dequeue(Queue *q) {
    if(isEmpty(q)) return NULL;
    Node *node = q->data[q->front];
    q->front = (q->front + 1) % q->capacity;
    q->size--;
    return node;
}
// 幅優先探索の実装
void bfs(Node **graph, int numNodes, int start, int target) {
    bool *visited = (bool*)calloc(numNodes, sizeof(bool));
    Queue *queue = createQueue(INITIAL_CAPACITY);
    // 開始ノードをキューに追加
    enqueue(queue, graph[start]);
    visited[start] = true;
    while(!isEmpty(queue)) {
        Node *current = dequeue(queue);
        printf("訪問ノード: %d\n", current->data);
        // 目的のノードに到達した場合、探索を終了
        if(current->data == target) {
            printf("目的のノード %d に到達しました。\n", target);
            break;
        }
        // 隣接ノードを探索
        for (int i = 0; i < current->numNeighbors; i++) {
            int neighborIndex = current->neighbors[i]->data;  // ノード値をインデックスとして利用
            if(!visited[neighborIndex]) {
                visited[neighborIndex] = true;
                enqueue(queue, current->neighbors[i]);
            }
        }
    }
    // メモリの解放
    free(visited);
    free(queue->data);
    free(queue);
}
// メイン関数
int main() {
    int numNodes = 4;
    // ノードの動的確保と初期化
    Node **graph = (Node**)malloc(sizeof(Node*) * numNodes);
    for (int i = 0; i < numNodes; i++) {
        graph[i] = (Node*)malloc(sizeof(Node));
        graph[i]->data = i;
        graph[i]->neighbors = NULL;
        graph[i]->numNeighbors = 0;
    }
    // グラフの構築
    // ノード0: 隣接はノード1, ノード2
    graph[0]->numNeighbors = 2;
    graph[0]->neighbors = (Node**)malloc(sizeof(Node*) * 2);
    graph[0]->neighbors[0] = graph[1];
    graph[0]->neighbors[1] = graph[2];
    // ノード1: 隣接はノード2, ノード3
    graph[1]->numNeighbors = 2;
    graph[1]->neighbors = (Node**)malloc(sizeof(Node*) * 2);
    graph[1]->neighbors[0] = graph[2];
    graph[1]->neighbors[1] = graph[3];
    // ノード2: 隣接はノード3
    graph[2]->numNeighbors = 1;
    graph[2]->neighbors = (Node**)malloc(sizeof(Node*) * 1);
    graph[2]->neighbors[0] = graph[3];
    // ノード3: 隣接はなし
    graph[3]->numNeighbors = 0;
    graph[3]->neighbors = NULL;
    // 幅優先探索を実行(開始ノード0から目的ノード3へ)
    bfs(graph, numNodes, 0, 3);
    // 作成したノードと隣接リストのメモリを解放
    for (int i = 0; i < numNodes; i++) {
        if(graph[i]->neighbors != NULL) {
            free(graph[i]->neighbors);
        }
        free(graph[i]);
    }
    free(graph);
    return 0;
}
訪問ノード: 0
訪問ノード: 1
訪問ノード: 2
訪問ノード: 3
目的のノード 3 に到達しました。

まとめ

この記事では、幅優先探索の基本と、C言語におけるキューの役割や基本操作について学べます。

ノードとキューの構造体設計から、エンキュー・デキュー処理、探索の初期化や終了条件まで、実例を交えて具体的に解説しています。

各処理のエラーチェックやメモリ管理のポイントも理解でき、実際に動くプログラムのサンプルコードを通して全体の流れを把握できる内容です。

関連記事

Back to top button
目次へ