Quantcast
Channel: C言語 – Japanシーモア
Viewing all articles
Browse latest Browse all 2205

初心者でもわかる!C言語再帰関数の理解と活用法10選

$
0
0

はじめに

C言語の初心者でもすぐに理解できる再帰関数の使い方やその活用法について説明します。

特に、具体的なサンプルコードを多く掲載し、その内容を詳しく解説します。

ここで学べることを活かせば、再帰関数を使ったプログラムを自由自在に書くことが可能になります。

●再帰関数とは

再帰関数は、自分自身を呼び出す関数のことを指します。

例えば、ある数値nの階乗を求める再帰関数は、nとn-1の階乗の積を計算します。

ここで、n-1の階乗を求めるために再度同じ関数を呼び出すという特性があります。

このような特性により、繰り返し処理を行うループ文とは異なる、独特の構造を持つプログラムを書くことが可能になります。

●再帰関数の使い方

再帰関数を使う際の一般的な手順は次のようになります。

まず、再帰関数を終了するための終了条件(ベースケース)を定義します。

次に、関数が自分自身を呼び出す再帰的な部分(再帰ケース)を定義します。

これら二つの要素を適切に設計することで、効率的な再帰関数を構築することができます。

次に、具体的な再帰関数のサンプルコードを見ていきましょう。

○サンプルコード1:再帰関数による階乗計算

下記のコードは、再帰関数を使って数値nの階乗を求める例です。

#include <stdio.h>

int factorial(int n) {
    if (n == 0) {
        return 1;
    } else {
        return n * factorial(n - 1);
    }
}

int main() {
    printf("%d", factorial(5));
    return 0;
}

このコードでは、factorial関数を使って階乗を求めています。

factorial関数は、引数nが0の場合には1を返し、それ以外の場合にはnとn-1の階乗の積を返します。

main関数では、5の階乗を計算し、その結果を表示しています。

このコードを実行すると、5の階乗である120が表示されます。

これは、54321の計算結果と一致します。このように、再帰関数は繰り返し計算を行う際に非常に有用です。

○サンプルコード2:再帰関数を使ったフィボナッチ数列

再帰関数を使ってフィボナッチ数列を生成する例を見てみましょう。

フィボナッチ数列とは、前の2つの数の和で次の数が定まる数列です。

#include <stdio.h>

int fibonacci(int n) {
    if (n == 0) {
        return 0;
    } else if (n == 1) {
        return 1;
    } else {
        return fibonacci(n - 1) + fibonacci(n - 2);
    }
}

int main() {
    for (int i = 0; i < 10; i++) {
        printf("%d ", fibonacci(i));
    }
    return 0;
}

このコードでは、fibonacci関数を用いてフィボナッチ数列を計算しています。

nが0や1の場合はそのままnを返し、それ以外の場合はn-1項とn-2項の和を返します。

このように、同じ関数が複数回呼び出される形で再帰的な計算が行われます。

このコードを実行すると、10項までのフィボナッチ数列が表示されます。

0, 1, 1, 2, 3, 5, 8, 13, 21, 34と続きます。

このように、再帰関数を使うことで複雑な数列や構造を効率的に表現することが可能です。

○サンプルコード3:再帰関数によるユークリッドの互除法

次に、再帰関数を使って、最大公約数を計算するユークリッドの互除法を表現する方法を見てみましょう。

ユークリッドの互除法とは、2つの整数の最大公約数を求めるためのアルゴリズムで、再帰の性質を強く反映しています。

次のサンプルコードでは、C言語を使用してユークリッドの互除法を実装したものを表します。

この例では、整数aと整数bの最大公約数を求めるために、ユークリッドの互除法を用いています。

#include <stdio.h>

// ユークリッドの互除法を実装
int gcd(int a, int b) {
    if (b == 0) {
        return a;
    } else {
        return gcd(b, a % b);
    }
}

int main(void) {
    int a = 48;
    int b = 18;
    printf("gcd(%d, %d) = %d\n", a, b, gcd(a, b));
    return 0;
}

このコードでは、まずgcdという関数を定義しています。

この関数では、引数として2つの整数aとbを受け取り、bが0である場合、aを返します。

そうでなければ、gcd関数を再び呼び出し、その際に引数としてbとaをbで割った余りを渡します。

この処理が再帰的に行われ、最終的にbが0になったとき、その時のaが2つの数の最大公約数となります。

実行結果は次のようになります。

gcd(48, 18) = 6

このように、48と18の最大公約数6が求められました。

再帰関数を使うことで、複雑な計算をシンプルなコードで表現することができます。

また、このコードはユークリッドの互除法を用いて最大公約数を求めるだけでなく、再帰関数の基本的な考え方、つまり「大きな問題をより小さい問題に分割し、その結果を組み合わせて最終的な解答を得る」という原則を具体的に表しています。

この原則は再帰関数の理解と活用において非常に重要です。

●再帰関数の応用例

再帰関数は、その特性を理解し、適切に活用することで、幅広い問題解決に利用することができます。

それでは、その応用例として具体的なサンプルコードを提示し、詳細に解説します。

○サンプルコード4:再帰関数を用いた深さ優先探索

まずは深さ優先探索(DFS)を再帰関数で実装したコードを紹介します。

DFSは、グラフの探索法の1つで、ある点から進める限り進んで行き(深く探索し)、進めなくなったら一つ前に戻るという探索を行います。

#include<stdio.h>
#define N 5 // ノード数

void dfs(int x, int G[N][N], int visited[N]) {
    printf("%d ", x); // ノードを表示
    visited[x] = 1; // 訪れたことを記録
    for (int i = 0; i < N; i++) {
        if (G[x][i] == 1 && visited[i] == 0) { // 接続していて、まだ訪れていない場合
            dfs(i, G, visited); // dfsを再帰的に呼び出す
        }
    }
}

int main() {
    int G[N][N] = {{0, 1, 1, 0, 0},
                   {1, 0, 0, 1, 1},
                   {1, 0, 0, 0, 1},
                   {0, 1, 0, 0, 1},
                   {0, 1, 1, 1, 0}}; // 隣接行列
    int visited[N] = {0}; // 訪問済みか否かを格納する配列
    dfs(0, G, visited); // 0から探索を開始
    return 0;
}

この例では、深さ優先探索(DFS)を再帰関数を使って実装しています。

再帰の性質を利用し、探索が進めなくなった時に一つ前に戻る動きを再現しています。

実行結果としては、0からスタートして、深さ優先探索の結果を表示します。

○サンプルコード5:再帰関数によるマージソート

次に、再帰関数を用いたマージソートの実装例を紹介します。

マージソートは、データを半分に分割してそれぞれをソートし、後でマージするという処理を再帰的に行うソートアルゴリズムです。

#include<stdio.h>

#define MAX 10 // データ数

void merge(int a[], int left, int mid, int right) {
    int i = left, j = mid + 1, k = left;
    int temp[MAX];
    while (i <= mid && j <= right) {
        if (a[i] <= a[j]) {
            temp[k++] = a[i++];
        } else {
            temp[k++] = a[j++];
        }
    }
    while (i <= mid) {
        temp[k++] = a[i++];
    }
    while (j <= right) {
        temp[k++] = a[j++];
    }
    for (i = left; i <= right; i++) {
        a[i] = temp[i];
    }
}

void mergeSort(int a[], int left, int right) {
    int mid;
    if (left < right) {
        mid = (left + right) / 2;
        mergeSort(a, left, mid);
        mergeSort(a, mid + 1, right);
        merge(a, left, mid, right);
    }
}

int main() {
    int a[MAX] = {10, 9, 8, 7, 6, 5, 4, 3, 2, 1};
    mergeSort(a, 0, MAX - 1);
    for (int i = 0; i < MAX; i++) {
        printf("%d ", a[i]);
    }
    return 0;
}

この例では、再帰関数を使ってマージソートを実装しています。

配列を分割し、それぞれをソート、その後マージするという一連の処理を再帰的に行っています。

実行結果としては、降順に並べられた10個の数字が昇順にソートされて表示されます。

○サンプルコード6:再帰関数を使ったクイックソート

次に紹介する再帰関数の応用例はクイックソートです。

クイックソートは非常に高速なソートアルゴリズムの一つで、その高速さを実現しているのが再帰関数の特性を活かした分割統治法というアルゴリズムです。

クイックソートを実装したC言語のサンプルコードを紹介します。

#include <stdio.h>

void quicksort(int array[], int left, int right) {
    if (left >= right) {
        return;
    }

    int pivot = array[(left + right) / 2]; 
    int i = left;
    int j = right;

    while (1) {
        while (array[i] < pivot) i++; 
        while (pivot < array[j]) j--; 
        if (i >= j) break;

        int tmp = array[i];
        array[i] = array[j];
        array[j] = tmp;

        i++;
        j--;
    }

    quicksort(array, left, i-1); 
    quicksort(array, j+1, right);
}

int main() {
    int array[] = {30, 88, 32, 11, 49, 82, 25, 73};
    int size = sizeof(array) / sizeof(array[0]);

    quicksort(array, 0, size-1);

    for (int i = 0; i < size; i++) {
        printf("%d ", array[i]);
    }

    return 0;
}

このコードでは、quicksortという名前の関数を使ってクイックソートを実装しています。

この例では、左端と右端のインデックスを引数として受け取り、配列内の要素をソートしています。

まず、関数は左端のインデックスが右端のインデックス以上になった場合、その呼び出しを終了します。

これは、ソートすべき要素がない、つまり配列の部分が既にソート済みであることを示しています。

次に、ピボット値(基準値)を決定します。

このコードでは配列の中央値をピボットとして使用しています。

その後、左端から開始してピボットより大きい値を探し、右端から開始してピボットより小さい値を探します。

それぞれの値を見つけたら、その二つの値を交換します。

そして、左のインデックスを一つ進め、右のインデックスを一つ戻します。

この操作を左のインデックスが右のインデックスを超えるまで続けます。

最後に、左と右の部分配列に対してquicksort関数を再帰的に呼び出します。

左の部分配列は、左端からi-1まで、右の部分配列はj+1から右端までとなります。

main関数では、まず整数型の配列を定義しています。

そして、その配列をquicksort関数でソートし、最後にその結果を出力します。

このコードを実行すると、元の配列が昇順にソートされた結果が出力されます。

つまり、{30, 88, 32, 11, 49, 82, 25, 73}という配列は、{11, 25, 30, 32, 49, 73, 82, 88}という順番になります。

クイックソートは、平均的な性能が非常に高いソートアルゴリズムです。

再帰関数を使用することで、大きなデータセットでも効率的にソートを行うことが可能になります。

ただし、すでにソート済みの配列など、特定の状況下では最悪の性能を表すこともありますので、適切な使い方をすることが重要です。

○サンプルコード7:再帰関数によるパスカルの三角形

パスカルの三角形は、その美しい形状と多くの数学的性質で知られていますが、再帰関数を使って表現することもできます。

ここでは、再帰関数を使ってパスカルの三角形を実装する方法を紹介します。

まずは、パスカルの三角形を表現する再帰関数のサンプルコードを見てみましょう。

#include<stdio.h>

int pascal(int n, int k) {
    // ベースケース
    if (k == 0 || k == n) {
        return 1;
    }
    // 再帰ステップ
    else {
        return pascal(n - 1, k - 1) + pascal(n - 1, k);
    }
}

void printPascal(int n) {
    for (int i = 0; i <= n; i++) {
        for (int j = 0; j <= i; j++) {
            printf("%d ", pascal(i, j));
        }
        printf("\n");
    }
}

int main() {
    printPascal(5);
    return 0;
}

このコードでは、再帰関数pascalを用いて、n行k列目のパスカルの三角形の値を求めています。

ベースケースでは、kが0またはnの時、値は1になります。

これはパスカルの三角形の左端と右端が常に1であることを反映しています。

再帰ステップでは、pascal(n – 1, k – 1)とpascal(n – 1, k)の和を返すことで、パスカルの三角形の任意の点がその左上と上の点の和であるという性質を利用しています。

次にprintPascal関数では、この再帰関数を用いて、指定した行数までのパスカルの三角形を出力しています。

この関数内の2つのforループで、各行の各列の値を計算し、その結果をprintfを使って出力しています。

main関数では、行数5のパスカルの三角形を出力するように、printPascal関数を呼び出しています。

このコードを実行すると、次のような結果が得られます。

1
1 1
1 2 1
1 3 3 1
1 4 6 4 1
1 5 10 10 5 1

これは、5行までのパスカルの三角形を正しく出力していることを示しています。

1行目、2行目と進むごとに、それぞれの行の数値が上記の数値と一致していますね。

しかし、注意点として、このコードはnが大きくなると非常に時間がかかります。

なぜなら、同じ計算を何度も繰り返すためです。

この問題を解決するためには、一度計算した結果を保存して再利用する「メモ化」を導入すると良いでしょう。

しかし、それは少し高度なテクニックで、初心者の段階では必要ありません。

今は、再帰関数がどのように働いているかを理解し、それをコードで表現できることが大切です。

○サンプルコード8:再帰関数を用いたタワー・オブ・ハノイ

複雑な問題を解く際にも、再帰関数は有用です。

ここでは「タワー・オブ・ハノイ」という古典的なパズルを再帰関数で解く方法をご紹介します。

まず、タワー・オブ・ハノイの問題を簡単に説明します。

3本の棒があり、一つの棒には異なる大きさの輪が順番に積まれています。

この輪を別の棒に移すルールは次のとおりです。

  1. 一度に一つの輪しか移動できない。
  2. 小さい輪の上に大きな輪を置くことはできない。

以上のルールを守りながら、全ての輪を別の棒に移すのが目標となります。

この問題を再帰関数を使って解くコードを見てみましょう。

#include<stdio.h>

void hanoi(int n, char from, char to, char aux)
{
    if (n == 1)
    {
        printf("輪 %d を棒 %c から棒 %c へ移動します。\n", n, from, to);
        return;
    }
    hanoi(n-1, from, aux, to);
    printf("輪 %d を棒 %c から棒 %c へ移動します。\n", n, from, to);
    hanoi(n-1, aux, to, from);
}

int main()
{
    int n = 3;
    hanoi(n, 'A', 'C', 'B');
    return 0;
}

このコードでは、タワー・オブ・ハノイを解くための再帰関数hanoiを定義しています。

この関数では、輪の数(n)と3つの棒(from, to, aux)を引数として受け取り、輪の移動の手順を表示します。

if文でnが1の場合、つまり輪が一つだけの場合は直接目的地に移動させます。

それ以外の場合、まずn-1個の輪をfromからauxに移動させ、次に残った最大の輪をfromからtoに移動させ、最後にauxに移動させておいたn-1個の輪をtoに移動させます。

これを再帰的に繰り返すことで、全ての輪が目的の棒に移動します。

main関数では、輪の数を3とし、棒Aから棒Cへ輪を移動させるためのhanoi関数を呼び出しています。

このコードを実行すると、次のような出力が得られます。

輪 1 を棒 A から棒 C へ移動します。
輪 2 を棒 A から棒 B へ移動します。
輪 1 を棒 C から棒 B へ移動します。
輪 3 を棒 A から棒 C へ移動します。
輪 1 を棒 B から棒 A へ移動します。
輪 2 を棒 B から棒 C へ移動します。
輪 1 を棒 A から棒 C へ移動します。

この出力は、タワー・オブ・ハノイの問題を解くための手順を表しています。

各ステップでどの輪がどの棒からどの棒へ移動するかが表示されます。

このように再帰関数を使うことで、タワー・オブ・ハノイのような複雑な問題も簡潔に解くことができます。

問題をより小さなサブプロブレムに分割し、それぞれを再帰的に解くことで全体の解を得ることが可能となります。

○サンプルコード9:再帰関数によるバックトラッキング

今回紹介するのは、再帰関数を使ったバックトラッキングの手法についてです。

バックトラッキングとは、探索や検索を行う際に、試行錯誤をしながら最適な解を探し出す手法です。

これは再帰関数の特性を最大限に活用した例で、よく「N-Queen問題」の解法として用いられます。

N-Queen問題を解くためのバックトラッキングを用いたコードを紹介します。

N-Queen問題とは、N×NのチェスボードにN個のクイーンを互いに取り合わないように配置する問題です。

クイーンはチェスの駒で、縦、横、斜めに移動できるため、配置には工夫が必要となります。

#include<stdio.h>

// チェスボードのサイズ
#define N 8

// チェスボード
int board[N][N];

// 再帰関数によるバックトラッキング
int solve(int col) {
    if (col >= N) return 1;
    for (int i = 0; i < N; i++) {
        if (isSafe(i, col)) {
            board[i][col] = 1;
            if (solve(col + 1)) return 1;
            board[i][col] = 0; // バックトラック
        }
    }
    return 0;
}

// クイーンが安全に配置できるかを確認する関数
int isSafe(int row, int col) {
    int i, j;
    for (i = 0; i < col; i++) if (board[row][i]) return 0;
    for (i=row, j=col; i>=0 && j>=0; i--, j--) if (board[i][j]) return 0;
    for (i=row, j=col; j>=0 && i<N; i++, j--) if (board[i][j]) return 0;
    return 1;
}

// ボードを表示する関数
void printBoard() {
    int i, j;
    for (i = 0; i < N; i++) {
        for (j = 0; j < N; j++) printf("%d ", board[i][j]);
        printf("\n");
    }
}

// メイン関数
int main() {
    if (solve(0) == 0) printf("No solution\n");
    else printBoard();
    return 0;
}

このコードでは、再帰関数solveを使ってN-Queen問題を解いています。

この例では8×8のチェスボードに8個のクイーンを取り合わないように配置しています。

solve関数は列(col)を引数として、その列にクイーンが配置可能かどうかを判定しています。

配置が可能であれば、その位置にクイーンを置き(board[i][col] = 1)、次の列へと進みます。

もし次の列以降で適切な配置ができない場合は、再度この列の配置を見直します(バックトラック)。

また、isSafe関数は指定された位置にクイーンが配置できるかどうかをチェックしています。

これはその位置の左側全て、左上対角線、左下対角線上にクイーンが存在しないことを確認することで判定しています。

クイーンが配置可能なら1を、不可能なら0を返します。

最後に、main関数でバックトラッキングによる解法を実行します。

solve関数が1を返した場合、すなわち解が見つかった場合には、チェスボードの状態を表示します。

このバックトラッキングを用いた方法は、全ての可能性を試行しながら最適な解を見つけ出すのに適しています。

再帰関数を用いて、これまで試行した解を覚えておき、必要に応じて一つ前の状態に戻る(バックトラックする)ことで、計算効率を上げることができます。

それでは、このコードを実行した結果を見てみましょう。

下記のように8×8のチェスボードに8個のクイーンが取り合わない形で配置されていることが確認できます。

1 0 0 0 0 0 0 0 
0 0 0 0 1 0 0 0 
0 0 0 0 0 0 0 1 
0 0 0 0 0 1 0 0 
0 0 1 0 0 0 0 0 
0 0 0 0 0 0 1 0 
0 1 0 0 0 0 0 0 
0 0 0 1 0 0 0 0 

ここで、1はクイーンが配置されている位置を、0はクイーンが配置されていない位置を表しています。

これが再帰関数を用いたバックトラッキングの一例です。

○サンプルコード10:再帰関数を使ったツリー構造のトラバース

ツリー構造のトラバース(巡回)は、再帰関数の典型的な応用例の一つです。

ツリー構造とは、ノードと枝で表現されるデータ構造のことで、ノードがデータを、枝がデータ間の関連性を表しています。

そして、このツリー構造の全てのノードを効率良く巡回するために再帰関数が使われます。

ここでの目標は、指定したルートから始まるツリー構造の全ノードを一度だけ訪れることです。

ツリー構造を再帰的に巡回するための簡単なC言語のコードを紹介します。

#include<stdio.h>

// ツリーノードの定義
typedef struct node {
    int data;
    struct node* left;
    struct node* right;
} node;

// 新しいノードを作成する関数
node* createNode(int data) {
    node* newNode = malloc(sizeof(node));
    if(newNode == NULL) {
        printf("ノードの作成に失敗しました。\n");
        exit(0);
    }
    newNode->data = data;
    newNode->left = NULL;
    newNode->right = NULL;
    return newNode;
}

// 再帰関数によるツリーの巡回
void traverseTree(node* root) {
    if(root == NULL) return; // ベースケース
    printf("%d ", root->data); // 現在のノードのデータを表示
    traverseTree(root->left); // 左の子ノードに移動
    traverseTree(root->right); // 右の子ノードに移動
}

int main() {
    node* root = createNode(1);
    root->left = createNode(2);
    root->right = createNode(3);
    root->left->left = createNode(4);
    root->left->right = createNode(5);
    printf("ツリーの巡回結果: ");
    traverseTree(root);
    return 0;
}

このコードでは、まずツリーノードを定義しています。

各ノードは整数データを持ち、左と右の子ノードへのポインタも持っています。

次に、新しいノードを作成するための関数が定義されています。

この関数は整数データを受け取り、そのデータを持つ新しいノードを作成して返します。

そして、traverseTree関数はツリーを巡回するための再帰関数です。

この関数はツリーのルートノードを引数に取り、ツリーの各ノードのデータを表示します。

この関数の再帰的な性質は、各ノードを訪れた後にそのノードの左と右の子ノードに対して自身を呼び出すところにあります。

これにより、全てのノードが一度だけ訪れられます。

main関数では、まずノードを作成してツリーを形成します。そして、traverseTree関数を用いてツリーを巡回します。

この例では、1がルート、23が子ノード、452の子ノードという形のツリーを作成しています。

このコードを実行すると、出力は”ツリーの巡回結果: 1 2 4 5 3″となります。

これは、ルートから始まり、全てのノードを一度だけ訪れる巡回が行われていることを表しています。

再帰関数は、ツリーのような階層的なデータ構造を扱う際に特に力を発揮します。

このようなデータ構造では、同じ操作を異なるレベルで繰り返すことがよくありますが、再帰関数を使えばこのような操作を簡潔に表現することが可能です。

●再帰関数の注意点と対処法

再帰関数は非常に強力なツールであり、上手に使うことで複雑な問題を簡単に解くことができます。

しかし、再帰関数を使う際にはいくつかの注意点があります。

一つ目はスタックオーバーフローです。再帰関数が深すぎると、コールスタックがオーバーフローしてプログラムがクラッシュします。

これを避けるためには、再帰の深さを適切に制御することが重要です。

また、再帰関数の実行はしばしば大量のメモリを消費します。

そのため、メモリの使用量を適切に管理することが必要です。

一部の言語では尾再帰最適化というテクニックが提供されており、これを使うことで再帰の深さに関わらず一定のメモリしか消費しないようにできます。

しかし、C言語の標準ではこの最適化は保証されていません。

もう一つの注意点は、不必要な計算を避けることです。

フィボナッチ数列の例では、同じ計算が何度も行われていました。これを避けるためには、計算結果をキャッシュして再利用するといった方法があります。

これらの問題は再帰関数の使用を難しくするかもしれませんが、それでも再帰関数は非常に有用なツールであり、理解しておくべきです。

これらの問題を理解し、それぞれに適切に対処することで、再帰関数を最大限に活用することができます。

●再帰関数のカスタマイズ方法

再帰関数は、状況に応じてカスタマイズすることが可能です。

再帰関数の基本的な構造を理解すれば、あらゆる種類の問題に対応することが可能です。

例えば、階乗を計算する再帰関数では、ベースケースは0の階乗が1であるという事実に基づいています。

しかし、他の問題では、ベースケースが異なるかもしれません。

ベースケースを正しく設定することは、再帰関数が正しく機能するための鍵です。

また、再帰的に呼び出される部分を変更することで、関数の振る舞いを変えることも可能です。

例えば、ツリーの巡回では、左の子ノードを先に訪れるか、右の子ノードを先に訪れるかを変更することで、巡回の順序を変えることができます。

再帰関数は、基本的なパターンを理解すれば、あらゆる問題に対応するためにカスタマイズすることが可能です。

再帰関数の力を最大限に引き出すためには、基本的な構造を理解し、その構造を自分の目的に合わせて調整することが重要です。

まとめ

C言語の再帰関数を理解し、活用するための方法を紹介しました。

再帰関数は強力なプログラミングツールであり、階層的なデータ構造や、同じ操作を繰り返す必要がある問題を解決するのに非常に有用です。

しかし、適切に使いこなすためには、再帰の基本原則を理解し、注意点と対処法を学ぶことが重要です。

この記事が、C言語の再帰関数についての理解を深め、より複雑な問題を解決するための道具として再帰関数を使うことができるようになる一助となれば幸いです。


Viewing all articles
Browse latest Browse all 2205

Trending Articles