[C言語のはじめ方] Part31: スタック・キュー・リスト構造

基本的なデータ構造を理解しよう!

こんにちは!C言語学習の旅、Step 7へようこそ!ここでは、プログラムでデータを効率的に扱うための基本的な「型」とも言える「データ構造」の中から、特に重要なスタックキュー、そしてリスト構造について学びます。これらは、より複雑なアルゴリズムやプログラムを構築するための基礎となります。

これまでのステップで学んだポインタや構造体、動的メモリ確保の知識がここで活きてきます。準備はいいですか?さっそく見ていきましょう!

スタック (Stack)

スタックは、LIFO (Last-In, First-Out)、つまり「後入れ先出し」の原則に従うデータ構造です。皿洗いで積み重ねたお皿をイメージしてください。一番最後に置いたお皿を一番最初に取り出しますよね?それと同じです。

主な操作

  • Push (プッシュ): スタックの一番上に新しいデータを追加します。
  • Pop (ポップ): スタックの一番上のデータを取り出します(取り出したデータはスタックから削除されます)。

C言語での実装

スタックは配列や連結リストを使って実装できます。ここでは、連結リストを使ったスタックのイメージを見てみましょう。新しいデータ(ノード)を追加するときはリストの先頭に追加し、取り出すときも先頭から取り出します。これはStep 6で学んだ動的メモリ確保 (malloc, free) や Step 4の構造体 (struct)、Step 3のポインタの知識を活用します。

用途例

  • 関数の呼び出しと戻りアドレスの管理(コールスタック)
  • テキストエディタなどの「元に戻す」(Undo)機能
  • 数式の構文解析や評価(逆ポーランド記法など)
  • 深さ優先探索 (DFS: Depth First Search) アルゴリズム
  • ウェブブラウザの「戻る」ボタンの履歴

キュー (Queue)

キューは、FIFO (First-In, First-Out)、つまり「先入れ先出し」の原則に従うデータ構造です。スーパーのレジ待ちの行列を思い浮かべてください。最初に列に並んだ人が、最初に会計を済ませますよね?それと同じ仕組みです。

主な操作

  • Enqueue (エンキュー): キューの末尾 (rear) に新しいデータを追加します。
  • Dequeue (デキュー): キューの先頭 (front) からデータを取り出します(取り出したデータはキューから削除されます)。

C言語での実装

キューも配列や連結リストで実装できます。連結リストで実装する場合、効率的に末尾への追加と先頭からの削除を行うために、リストの先頭 (front)末尾 (rear) の両方を指すポインタを持つことが一般的です。

連結リストによるキューのノード構造と本体、操作例

#include <stdio.h>
#include <stdlib.h>
// キューのノードを表す構造体(スタックと同じものを使えます)
typedef struct Node { int data; struct Node* next;
} Node;
// キュー本体(先頭と末尾へのポインタを持つ)
typedef struct Queue { Node* front; // キューの先頭 (取り出す側) Node* rear; // キューの末尾 (追加する側)
} Queue;
// キューを初期化する関数
void initializeQueue(Queue* q) { q->front = NULL; q->rear = NULL;
}
// キューが空かどうかをチェックする関数
int isEmptyQueue(Queue* q) { return q->front == NULL; // frontがNULLなら空
}
// Enqueue操作: キューの末尾にデータを追加する関数
void enqueue(Queue* q, int data) { // 新しいノードを作成 Node* newNode = (Node*)malloc(sizeof(Node)); if (newNode == NULL) { fprintf(stderr, "エラー: メモリ確保に失敗しました。\n"); exit(EXIT_FAILURE); } newNode->data = data; newNode->next = NULL; // 末尾に追加するので、nextは常にNULL if (isEmptyQueue(q)) { // キューが空の場合 q->front = newNode; // frontとrearの両方が新しいノードを指す q->rear = newNode; } else { // キューに要素がある場合 q->rear->next = newNode; // 現在の末尾ノードのnextを新しいノードに向ける q->rear = newNode; // rearポインタを新しいノードに更新 } printf("Enqueued: %d\n", data);
}
// Dequeue操作: キューの先頭からデータを取り出す関数
int dequeue(Queue* q) { if (isEmptyQueue(q)) { fprintf(stderr, "エラー: キューは空です。Dequeueできません。\n"); return -1; // エラーを示す値 } Node* temp = q->front; // 先頭ノードを一時的に保存 int dequeuedData = temp->data; // 取り出すデータを取得 q->front = q->front->next; // frontポインタを次のノードに更新 // もしデキュー後にキューが空になったら、rearもNULLにする必要がある if (q->front == NULL) { q->rear = NULL; } free(temp); // 元の先頭ノードのメモリを解放 printf("Dequeued: %d\n", dequeuedData); return dequeuedData;
}
// キューの先頭データを覗き見する関数 (削除はしない)
int peekQueue(Queue* q) { if (isEmptyQueue(q)) { fprintf(stderr, "エラー: キューは空です。Peekできません。\n"); return -1; // エラーを示す値 } return q->front->data;
}
// メイン関数での使用例
int main() { Queue myQueue; initializeQueue(&myQueue); enqueue(&myQueue, 100); enqueue(&myQueue, 200); enqueue(&myQueue, 300); printf("Front element is %d\n", peekQueue(&myQueue)); // 100 dequeue(&myQueue); // 100を取り出す enqueue(&myQueue, 400); dequeue(&myQueue); // 200を取り出す printf("Front element is %d\n", peekQueue(&myQueue)); // 300 dequeue(&myQueue); // 300を取り出す dequeue(&myQueue); // 400を取り出す dequeue(&myQueue); // 空の状態でDequeueしようとする (エラー) // 注意: キューに残っているノードがあれば、最後に全て解放する処理が必要です。 // この例では全てdequeueしているので不要ですが、注意が必要です。 return 0;
}

ポイント: enqueue では rear ポインタを使って末尾にノードを追加し、rear を更新します。dequeue では front ポインタを使って先頭からノードを取り出し、front を更新します。キューが空になる場合の rear の更新も忘れずに行います。

用途例

  • プリンターへの印刷ジョブの待ち行列
  • キーボード入力やマウスイベントなどの処理待ち
  • ネットワーク通信におけるパケットの順次処理
  • 幅優先探索 (BFS: Breadth First Search) アルゴリズム(グラフやツリーの探索)
  • OSによるタスクスケジューリング(実行待ちタスクの管理)

リスト構造 (Linked List)

リスト構造(連結リスト)は、データを格納する「ノード」と呼ばれる要素を、ポインタを使って鎖のようにつなげたデータ構造です。配列 (Step 2で学習) とは異なり、メモリ上に要素が連続して配置されている必要がありません。そのため、データの追加削除が柔軟かつ効率的に行えるという大きなメリットがあります。

基本的な概念

  • ノード (Node): データ本体と、次のノードを指すポインタ(next)を持ちます。これがリストの構成要素です。
  • ポインタ (Pointer): ノードとノードをつなぐ「鎖」の役割を果たします。リストの先頭を指すポインタ(しばしば head と呼ばれる)が、リスト全体へのアクセス起点となります。

主な種類

リスト構造にはいくつかのバリエーションがあります。

  • 単方向リスト (Singly Linked List): 各ノードが次のノードへのポインタ (next) のみを持つ、一方向のリスト。今回主に説明するのはこれです。
  • 双方向リスト (Doubly Linked List): 各ノードが次 (next) と前 (prev) の両方のノードへのポインタを持つリスト。逆方向への走査も容易ですが、ポインタ管理が少し複雑になります。
  • 循環リスト (Circular Linked List): 末尾のノードが先頭のノードを指す、ループ状になったリスト。リストのどこからでも走査を開始できるなどの利点があります(単方向・双方向どちらもあり)。

C言語での実装(単方向リスト)

リスト構造の実装には、ノードを表す自己参照構造体(自分自身の型へのポインタを持つ構造体)と、動的メモリ確保 (malloc, free) が不可欠です。

単方向リストのノード構造と基本的な操作(先頭への追加、表示、解放)

#include <stdio.h>
#include <stdlib.h>
// リストのノードを表す構造体
typedef struct ListNode { int data; // 格納するデータ struct ListNode* next; // 次のノードへのポインタ
} ListNode;
// リストの先頭にノードを追加する関数
// 新しい先頭ノードのアドレスを返す (headポインタを更新するため)
ListNode* insertAtBeginning(ListNode* head, int data) { // 新しいノードのメモリを確保 ListNode* newNode = (ListNode*)malloc(sizeof(ListNode)); if (newNode == NULL) { fprintf(stderr, "エラー: メモリ確保に失敗しました。\n"); exit(EXIT_FAILURE); } newNode->data = data; // データを格納 newNode->next = head; // 新しいノードの次を、現在のリストの先頭にする return newNode; // 新しいノードが新しいリストの先頭になるので、そのアドレスを返す
}
// リストの全要素を表示する関数
void printList(ListNode* head) { ListNode* current = head; // 先頭から走査を開始 printf("List: "); while (current != NULL) { // currentがNULLになるまで (リストの終端まで) printf("%d -> ", current->data); current = current->next; // 次のノードへ移動 } printf("NULL\n"); // リストの終端を示す
}
// リスト全体のメモリを解放する関数
void freeList(ListNode* head) { ListNode* current = head; ListNode* nextNode; while (current != NULL) { nextNode = current->next; // 次のノードのアドレスを一時的に保存 free(current); // 現在のノードのメモリを解放 current = nextNode; // 次のノードへ移動 } printf("List freed.\n");
}
// メイン関数での使用例
int main() { ListNode* myList = NULL; // 最初は空リスト (先頭ポインタがNULL) // リストの先頭にデータを追加していく myList = insertAtBeginning(myList, 30); // myList: 30 -> NULL myList = insertAtBeginning(myList, 20); // myList: 20 -> 30 -> NULL myList = insertAtBeginning(myList, 10); // myList: 10 -> 20 -> 30 -> NULL printList(myList); // リストの内容を表示 // プログラム終了前に、確保したメモリを解放する freeList(myList); myList = NULL; // 解放後は無効なポインタを指さないようにNULLを入れておくのが安全 return 0;
}

ポイント:

  • ノードを追加する際は malloc でメモリを確保し、ポインタを繋ぎ変えます。
  • リストを走査する際は、一時的なポインタ変数 (current) を用意し、current = current->next で次のノードへ移動します。
  • 使い終わったリストは、各ノードのメモリを free で解放する必要があります。解放漏れ(メモリリーク)に注意しましょう。
  • 上記の例は先頭への追加のみですが、リストの末尾への追加、特定の位置への挿入、ノードの検索、削除といった様々な操作も実装できます。

配列との比較

リスト構造は配列とよく比較されます。それぞれの利点と欠点を理解し、状況に応じて使い分けることが重要です。

特徴配列 (Array)連結リスト (Linked List)
メモリ配置連続したメモリ領域が必要メモリ上に散らばっていても良い(ポインタで接続)
サイズ静的(コンパイル時)または動的(realloc 等で変更可能だがコストがかかる)動的(実行時にノードを追加・削除して容易にサイズ変更)
要素アクセス高速 (O(1)) – インデックスで直接アクセス低速 (O(n)) – 先頭から順にたどる必要あり
要素の挿入/削除低速 (O(n)) – 特に途中への挿入/削除は後続要素のシフトが必要高速 (O(1)) – 対象ノード(またはその前)のポインタが分かれば、ポインタ繋ぎ替えのみ ※ノード探索時間は別途必要
メモリ効率データのみを格納するため効率が良いデータに加え、ポインタ格納分のメモリも必要

O(1) は処理時間がデータ量によらず一定、O(n) はデータ量に比例して処理時間が増加することを意味します(計算量という概念です)。

用途例

  • サイズが事前に予測できない、または頻繁に変動するデータの管理
  • 要素の挿入や削除が頻繁に発生する場面
  • スタックやキューなど、他のデータ構造を実装するための基盤として
  • 音楽プレイヤーのプレイリストのように、曲順の変更や追加・削除が容易に必要な場合
  • OSでのメモリ管理(空き領域リストなど)

まとめ

今回は、基本的なデータ構造であるスタック、キュー、リスト構造について、それぞれの概念、主な操作、C言語での実装イメージ、そして用途例を見てきました。

  • スタック: LIFO (後入れ先出し)。Push / Pop。関数呼び出しやUndo機能など。
  • キュー: FIFO (先入れ先出し)。Enqueue / Dequeue。待ち行列やイベント処理など。
  • リスト構造: ノードとポインタで連結。動的なサイズ変更、柔軟な挿入/削除。配列とは異なる特性を持つ。

これらのデータ構造は、単なるデータの入れ物ではなく、特定の操作を効率的に行うための「仕組み」です。プログラムでどのようなデータ操作が必要になるかを考え、最適なデータ構造を選択することが、パフォーマンスの良い、洗練されたコードを書くための第一歩となります。

次のステップでは、今回学んだデータ構造の知識も活用しながら、簡易的なデータベースの実装や、電卓・ToDoリストといった、より具体的なアプリケーションの作成に挑戦します。お楽しみに!

参考情報

これらのデータ構造について、さらに深く学びたい場合は、以下のキーワードで検索したり、信頼できるC言語の参考書や技術サイトを参照することをおすすめします。

  • C言語 データ構造 スタック 実装
  • C言語 キュー 連結リスト FIFO
  • C言語 連結リスト 挿入 削除 アルゴリズム
  • データ構造とアルゴリズム C言語 入門
  • struct ポインタ 自己参照構造体 C言語
  • malloc free メモリ管理 C言語
ウェブ上の情報を参考にする際は、情報の正確性、信頼性、そして書かれた日付を確認するようにしましょう。特にコード例は、そのまま使うのではなく、内容を理解した上で利用することが大切です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です