はじめに
皆さん、C言語を学んでいるとスタックという言葉をよく耳にするかもしれません。
本記事では、C言語でのスタックの実装とその活用例について、初心者向けに詳細に解説します。
プログラミングの基礎知識としてスタックはとても重要な概念です。
なぜなら、スタックはデータ構造の一つであり、コンピュータサイエンスの世界で頻繁に出てくるからです。
それでは、一緒にスタックの実装から活用までを一貫して学んでいきましょう。
●スタックとは
スタックは、データの追加や取得を一方向からしか行えないデータ構造です。
スタックは「Last In First Out(LIFO)」という特性を持っており、最後に追加されたデータが最初に取り出されます。
これは、本物のスタック(例えば、皿の山)と同じ動作原理です。
皿の山に新しい皿を追加すると、それが最上部になり、皿を取る時は最上部から取る、というのがスタックです。
○スタックの特性
この「Last In First Out(LIFO)」という特性は、スタックが活用される様々なシーンで重要な役割を果たします。
たとえば、プログラムの関数呼び出しの管理や、後述する逆ポーランド記法の計算などに用いられます。
○スタックの用途
スタックの用途は多岐に渡ります。
具体的には、逆ポーランド記法の計算、括弧のマッチング、関数の呼び出しスタック、深さ優先探索(DFS)、ヒストグラムで最大の長方形、文字列の逆転、編集距離の計算などがあります。
本記事ではこれらの活用例についても、サンプルコードと共に解説していきます。
●C言語でのスタックの実装
スタックをC言語で実装するためには、配列を使ってデータの追加と取り出しを管理します。
この実装では配列のインデックスを利用し、スタックの一番上の要素の位置を把握します。
○サンプルコード1:スタックの作成
まずは、スタックを作成する基本的なコードを見てみましょう。
#include <stdio.h>
#define MAX_SIZE 100
typedef struct {
int data[MAX_SIZE];
int top;
} Stack;
void init(Stack* s) {
s->top = -1;
}
int isEmpty(Stack* s) {
return s->top == -1;
}
このコードでは、スタックの基本的な構造体を作成し、その初期化関数とスタックが空であるかどうかを判定する関数を実装しています。
ここで、MAX_SIZE
はスタックの最大サイズを表し、data
はスタックの要素を格納する配列です。
top
はスタックの一番上の要素の位置を表します。
最初にスタックを初期化するときは、top
は-1とします。
これは、まだスタックには何も要素がないことを表しています。
そして、isEmpty
関数ではスタックが空であるかどうかを判定します。
もしtop
が-1であれば、スタックは空と判断できます。
○サンプルコード2:スタックへのデータの追加
次に、スタックへのデータの追加を行う関数を見てみましょう。
void push(Stack* s, int item) {
if (s->top == MAX_SIZE - 1) {
printf("スタックが満杯です。\n");
return;
}
s->data[++s->top] = item;
}
このコードでは、push
関数を使ってスタックに要素を追加しています。
ただし、スタックがすでに満杯(つまり、top
がMAX_SIZE - 1
)の場合はこれ以上要素を追加することはできません。
その場合は、エラーメッセージを出力します。
それ以外の場合は、まずtop
を一つ増やしてから、その位置に新しい要素を追加します。
○サンプルコード3:スタックからのデータの取り出し
最後に、スタックからデータを取り出す関数を見てみましょう。
int pop(Stack* s) {
if (isEmpty(s)) {
printf("スタックは空です。\n");
return -1;
}
return s->data[s->top--];
}
このコードでは、pop
関数を使ってスタックから要素を取り出しています。
ただし、スタックが空(つまり、isEmpty
関数が真を返す)の場合は何も取り出すことはできません。
その場合は、エラーメッセージを出力し、-1を返します。
それ以外の場合は、最上部の要素を取り出した後、top
を一つ減らします。
●スタックの活用例
それでは、具体的なスタックの活用例とそれぞれのサンプルコードを見ていきましょう。
○サンプルコード4:逆ポーランド記法の計算
逆ポーランド記法(RPN: Reverse Polish Notation)は、演算子を後ろに置くことで括弧を必要としない数式の記法です。
スタックを活用することで、逆ポーランド記法の数式を容易に計算することができます。
下記のサンプルコードは、逆ポーランド記法の数式を計算するプログラムを示しています。
#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>
#define MAX_SIZE 100
typedef struct {
int data[MAX_SIZE];
int top;
} Stack;
void init(Stack* s) {
s->top = -1;
}
int isEmpty(Stack* s) {
return s->top == -1;
}
void push(Stack* s, int item) {
if (s->top == MAX_SIZE - 1) {
printf("スタックが満杯です。\n");
return;
}
s->data[++s->top] = item;
}
int pop(Stack* s) {
if (isEmpty(s)) {
printf("スタックは空です。\n");
return -1;
}
return s->data[s->top--];
}
int evalRPN(char* expression) {
Stack s;
init(&s);
while (*expression) {
if (isdigit(*expression)) {
push(&s, *expression - '0');
} else {
int val1 = pop(&s);
int val2 = pop(&s);
switch (*expression) {
case '+': push(&s, val2 + val1); break;
case '-': push(&s, val2 - val1); break;
case '*': push(&s, val2 * val1); break;
case '/': push(&s, val2 / val1); break;
}
}
expression++;
}
return pop(&s);
}
int main() {
char expression[] = "62/3-42*+";
printf("逆ポーランド記法での計算結果: %d\n", evalRPN(expression));
return 0;
}
このコードでは、逆ポーランド記法で書かれた数式を計算するevalRPN
関数を紹介しています。
この例では、数字が出てきた場合にはスタックにプッシュし、演算子が出てきた場合にはスタックから二つの値をポップし、その二つの値に対して該当する演算を行い、結果をスタックにプッシュしています。
最後にスタックから一つ値をポップすると、それが数式の最終結果となります。
このコードを実行すると、「62/3-42*+」という逆ポーランド記法の数式の計算結果が出力されます。
詳しく説明すると、この数式は「6分の2から3を引き、その結果に4と2の積を足す」という操作を意味しています。
したがって、このプログラムを実行すると、「逆ポーランド記法での計算結果: 8」と表示されます。
スタックを使って逆ポーランド記法の計算をすることで、計算の途中結果を保持しておき、次の計算に繋げることが容易になります。
これは逆ポーランド記法の大きな利点の一つであり、スタックがその特性を最大限に活かす一例と言えます。
○サンプルコード5:括弧のマッチング
スタックの活用例として、次に紹介するのは括弧のマッチングです。
これは、プログラムの構文解析やテキストエディタでの構文エラーチェックなどで頻繁に用いられる手法です。
このコードでは、開き括弧と閉じ括弧が正しくマッチしているかどうかを判断します。
つまり、「(」と「)」、「{」と「}」、「[」と「]」が正しくペアになっているかどうかをチェックするためのコードです。
#include<stdio.h>
#include<stdlib.h>
#define MAX_SIZE 100
typedef struct {
char data[MAX_SIZE];
int top;
} Stack;
void Init(Stack *s) {
s->top = -1;
}
void Push(Stack *s, char c) {
if (s->top == MAX_SIZE - 1) {
printf("スタックが満杯です\n");
return;
}
s->data[++s->top] = c;
}
char Pop(Stack *s) {
if (s->top == -1) {
printf("スタックが空です\n");
return '\0';
}
return s->data[s->top--];
}
int IsEmpty(Stack *s) {
return s->top == -1;
}
int IsMatch(char a, char b) {
return (a == '(' && b == ')') || (a == '[' && b == ']') || (a == '{' && b == '}');
}
int IsValid(char *str) {
Stack s;
Init(&s);
for (int i = 0; str[i] != '\0'; i++) {
if (str[i] == '(' || str[i] == '[' || str[i] == '{') {
Push(&s, str[i]);
} else if (str[i] == ')' || str[i] == ']' || str[i] == '}') {
if (IsEmpty(&s) || !IsMatch(Pop(&s), str[i])) {
return 0;
}
}
}
return IsEmpty(&s);
}
int main() {
char str[] = "{[()]}";
printf("%s\n", IsValid(str) ? "有効" : "無効");
return 0;
}
このコードでは、「IsValid」関数に文字列を渡すと、その文字列内の括弧が正しくマッチしているかを判定してくれます。開き括弧をスタックに積み、閉じ括弧が出現したときにスタックからデータを取り出し、そのペアが正しいかどうかを判定します。
全ての文字を調べ終えた後で、スタックが空なら括弧のペアは正しくマッチしていると判断します。
なお、このプログラムは括弧が入れ子になった場合でも正しく動作します。
なぜなら、新しく開かれた括弧が閉じられるまでの間、その外側の括弧はスタックの中に保持され続けるからです。
このため、内側の括弧が閉じられると、次に閉じられるべき括弧はその直後に開かれたもの、つまり最後にスタックに積まれたもの(スタックのトップ)になります。
これはまさしくスタックのLIFO(Last In First Out、後入れ先出し)という特性を活かした処理と言えます。
このサンプルコードを実行すると、「{[()]}」という括弧の組み合わせは正しくマッチしているので、「有効」と表示されます。
試しに、正しくない括弧の組み合わせ、「{(])」などを試してみてください。正しくマッチしていないことから、「無効」と表示されるはずです。
○サンプルコード6:関数の呼び出しスタック
実際のプログラムでは、関数の呼び出しにスタックを使用します。
関数が呼び出されると、その関数のローカル変数や引数、戻りアドレス(次に実行すべきコードの場所)などがスタックにプッシュされます。
関数が終了すると、その情報がポップされ、制御が呼び出し元に戻ります。
具体的には以下のコードのようになります。
ここでは、funcA
とfuncB
という二つの関数を定義し、それぞれの関数の呼び出しを行っています。
関数の呼び出しスタックを確認するために、printf
関数を使用して各関数の開始と終了を表示しています。
#include <stdio.h>
void funcB() {
printf("関数Bが呼ばれました。\n");
printf("関数Bが終了します。\n");
}
void funcA() {
printf("関数Aが呼ばれました。\n");
funcB();
printf("関数Aが終了します。\n");
}
int main() {
printf("main関数が呼ばれました。\n");
funcA();
printf("main関数が終了します。\n");
return 0;
}
このコードを実行すると次のような結果が得られます。
main関数が呼ばれました。
関数Aが呼ばれました。
関数Bが呼ばれました。
関数Bが終了します。
関数Aが終了します。
main関数が終了します。
これを見ると、まずmain関数が呼ばれ、次にその中からfuncAが呼ばれ、さらにその中からfuncBが呼ばれる流れが分かります。
そして、funcBが終了した後、制御がfuncAに戻り、その後main関数に戻っていることが確認できます。
これがスタックによる関数呼び出しの概念で、関数が終了するとそれまでの状態に戻ることを示しています。
○サンプルコード7:DFS(深さ優先探索)
次に、グラフ探索アルゴリズムの一つである深さ優先探索(DFS)をスタックを用いて実装してみましょう。
深さ優先探索は、探索を開始する頂点から、まず深く探索を進め、そこまでの探索が終わったら次の頂点に移るという方法です。
これはスタックの特性と非常に合致しており、スタックを活用した実装が可能です。
ここでは、グラフを隣接リストで表現し、それに対して深さ優先探索を行っています。
#include <stdio.h>
#include <stdbool.h>
#include <stdlib.h>
#define N 100 // 頂点の最大数
#define WHITE 0 // 未訪問
#define GRAY 1 // 訪問中
#define BLACK 2 // 訪問済み
typedef struct {
int top;
int stack[N];
} Stack;
bool isEmpty(Stack *s) {
return s->top == 0;
}
bool isFull(Stack *s) {
return s->top == N;
}
void push(Stack *s, int x) {
if (isFull(s)) {
printf("エラー: スタックが一杯です\n");
return;
}
s->stack[s->top++] = x;
}
int pop(Stack *s) {
if (isEmpty(s)) {
printf("エラー: スタックが空です\n");
exit(1);
}
return s->stack[--s->top];
}
typedef struct {
int n; // 頂点数
int adj[N][N]; // 隣接行列
} Graph;
void dfs(Graph *g, int start) {
int color[N]; // 訪問状態を記録
for (int i = 0; i < g->n; i++) {
color[i] = WHITE; // 未訪問に初期化
}
Stack s;
s.top = 0;
push(&s, start); // スタート地点をプッシュ
while (!isEmpty(&s)) {
int v = pop(&s); // スタックから1つ頂点を取り出す
if (color[v] == BLACK) {
continue;
}
color[v] = GRAY; // 訪問中に設定
printf("頂点%dを訪問しました\n", v);
for (int i = 0; i < g->n; i++) { // 隣接している頂点について
if (g->adj[v][i] == 1 && color[i] == WHITE) { // 未訪問であれば
push(&s, i); // スタックにプッシュ
}
}
color[v] = BLACK; // 訪問済みに設定
}
}
int main() {
// グラフの初期化
Graph g;
g.n = 6;
for (int i = 0; i < g.n; i++) {
for (int j = 0; j < g.n; j++) {
g.adj[i][j] = 0;
}
}
// グラフの辺を追加
g.adj[0][1] = g.adj[1][0] = 1;
g.adj[0][4] = g.adj[4][0] = 1;
g.adj[1][2] = g.adj[2][1] = 1;
g.adj[2][3] = g.adj[3][2] = 1;
g.adj[3][4] = g.adj[4][3] = 1;
g.adj[4][5] = g.adj[5][4] = 1;
// 深さ優先探索を実行
dfs(&g, 0);
return 0;
}
このコードでは、スタックに現在訪問している頂点を保存し、訪問が終わったらその頂点をポップします。
そして、訪問が終わった頂点から出ている辺を辿っていき、辿った先の頂点がまだ訪問していなければ、その頂点をスタックにプッシュします。
このようなプロセスを繰り返すことで、深さ優先探索を実現しています。
○サンプルコード8:ヒストグラムで最大の長方形
ヒストグラムで最大の長方形を見つける問題は、スタックを活用する典型的なアルゴリズムの一つです。
この問題は、与えられたヒストグラムの中で最大の長方形の面積を求めるというもので、効率的に解くためにスタックを利用します。
具体的なC言語のコードを紹介します。
#include <stdio.h>
#include <stdlib.h>
#define MAX_N 1000 // ヒストグラムの最大幅
typedef struct {
int top;
int stack[MAX_N];
} Stack;
void push(Stack *s, int x) {
s->stack[s->top++] = x;
}
int pop(Stack *s) {
return s->stack[--s->top];
}
int top(Stack *s) {
return s->stack[s->top - 1];
}
int isEmpty(Stack *s) {
return s->top == 0;
}
int largestRectangle(int n, int *hist) {
int maxArea = 0;
Stack s;
s.top = 0;
int left[MAX_N];
for (int i = 0; i < n; i++) {
while (!isEmpty(&s) && hist[top(&s)] >= hist[i]) {
pop(&s);
}
left[i] = (isEmpty(&s)) ? 0 : top(&s) + 1;
push(&s, i);
}
s.top = 0;
int right[MAX_N];
for (int i = n - 1; i >= 0; i--) {
while (!isEmpty(&s) && hist[top(&s)] >= hist[i]) {
pop(&s);
}
right[i] = (isEmpty(&s)) ? n - 1 : top(&s) - 1;
push(&s, i);
}
for (int i = 0; i < n; i++) {
maxArea = max(maxArea, hist[i] * (right[i] - left[i] + 1));
}
return maxArea;
}
int main() {
int n = 5;
int hist[MAX_N] = {5, 3, 2, 4, 3};
printf("最大の長方形の面積は%dです。\n", largestRectangle(n, hist));
return 0;
}
このコードでは、まず各棒の左側で最も高さが高い棒を見つけ、その位置を記録します。同様に、各棒の右側で最も高さが高い棒を見つけ、その位置を記録します。
これにより、各棒について、その棒を最低高さとする長方形が形成できる最も広い範囲が求められます。
最後に、全ての棒について求めた範囲から最大の面積を計算します。
このアルゴリズムのポイントは、スタックを利用して左右の範囲を効率的に求めていることです。
スタックの上にある棒が常に高さが昇順になるように保つことで、左右の範囲を一度のスキャンで求めることができます。
このコードを実行すると、「最大の長方形の面積は6です」と表示されます。
これは、3番目の棒(高さ2)から4番目の棒(高さ4)までの長方形の面積が最大であることを表しています。
○サンプルコード9:文字列の逆転
文字列の逆転は、プログラミングにおける基本的なアルゴリズムの一つであり、スタックを活用する典型的な例となります。
C言語での実装を見てみましょう。
#include<stdio.h>
#include<string.h>
#define MAX 100
char stack[MAX];
int top = -1;
void push(char c){
if(top == MAX-1){
printf("スタックが一杯です\n");
return;
}
stack[++top] = c;
}
char pop(){
if(top == -1){
printf("スタックが空です\n");
return '\0';
}
return stack[top--];
}
void reverse(char *str){
int len = strlen(str);
int i;
for(i=0; i<len; i++){
push(str[i]);
}
for(i=0; i<len; i++){
str[i] = pop();
}
}
int main(){
char str[] = "C言語で学ぶスタックの実装と活用";
printf("逆転前:%s\n", str);
reverse(str);
printf("逆転後:%s\n", str);
return 0;
}
このコードではスタックを用いて文字列を逆転させる機能を実装しています。
この例では、まずスタックに文字列の各文字を一つずつプッシュしていきます。
そして、すべての文字をプッシュした後、ポップを行いながら元の文字列を上書きしています。
これにより、スタックのLIFO(Last In First Out)特性を利用した文字列の逆転が実現します。
具体的な実行結果は以下の通りです。
逆転前:C言語で学ぶスタックの実装と活用
逆転後:用活と装実のクタツブ学で語言C
このように、スタックを用いて文字列を逆転させることは、入力されたデータを逆の順番で出力したいときに非常に便利です。
例えば、回文(前から読んでも後ろから読んでも同じ文字列)を判定する際にもこの逆転操作が有効に利用できます。
○サンプルコード10:編集距離の計算
最後に紹介するスタックの活用例は「編集距離の計算」です。
編集距離とは、ある文字列を別の文字列に変換するための最小の操作数を指します。
操作とは、文字の挿入、削除、置換の3つです。
この計算は、文字列間の類似度を測るために使用されることが多く、検索エンジンの裏側で動くアルゴリズムなどにも応用されます。
#include <stdio.h>
#include <string.h>
#define min(a,b) ((a) < (b) ? (a) : (b))
int main() {
char str1[] = "kitten";
char str2[] = "sitting";
int m = strlen(str1);
int n = strlen(str2);
int dp[m+1][n+1];
for (int i=0; i<=m; i++) {
for (int j=0; j<=n; j++) {
if (i==0)
dp[i][j] = j;
else if (j==0)
dp[i][j] = i;
else if (str1[i-1] == str2[j-1])
dp[i][j] = dp[i-1][j-1];
else
dp[i][j] = 1 + min(min(dp[i][j-1], dp[i-1][j]), dp[i-1][j-1]);
}
}
printf("編集距離: %d\n", dp[m][n]);
return 0;
}
このコードでは、2つの文字列str1とstr2の編集距離を計算しています。
この例では、文字列”kitten”と”sitting”の編集距離を求めています。
編集距離の計算は動的計画法を用いて実現され、その計算過程がスタックに格納されます。
計算結果を表示する部分では、動的計画法の結果を表す2次元配列dpの右下の値、つまりdp[m][n]が最終的な編集距離となります。
このコードを実行すると、次のような出力結果を得ることができます。
編集距離: 3
つまり、文字列”kitten”を文字列”sitting”に変換するためには最小で3つの操作が必要であるという結果が得られます。
●注意点と対処法
スタックの使用には注意点がいくつかあります。
まず、スタックが一杯になると、これ以上の要素を追加することができません。
この状態をスタックオーバーフローといいます。
したがって、スタックのサイズはその使用目的に応じて適切に選ぶ必要があります。
また、空のスタックからデータを取り出すとエラーになるので、データを取り出す前にスタックが空でないことを確認することも重要です。
一方で、スタックは非常に効率的なデータ構造であり、適切に使うことで多くの問題を解決することができます。
プログラムの途中結果を保存したり、データを一時的に保持したり、アルゴリズムのステップを制御したりと、多様な用途で活用することが可能です。
まとめ
今回は、C言語でスタックを実装し、その活用例をいくつか見てきました。
スタックは非常にシンプルなデータ構造でありながら、その効率性と使いやすさから様々な場面で活用されています。
これらの基本的な概念とコード例を理解し、自分のプログラムに適用することで、より効果的なコードを書くことができるようになるでしょう。
これからもプログラミングの旅を楽しみながら、様々なデータ構造やアルゴリズムを学んでいきましょう。