第4週 関数の再帰呼び出しの動作原理
INDEX

目標

  • 再帰による処理の仕組みを理解できる。
  • 繰り返し処理を再帰を使って実現することができる。

予習・復習

 以下のスライドを利用して、予習と復習をしよう。復習では、自分の理解度を確認するために、実際にプログラムを作成し、意図する結果が得られるか確認しよう。
  1. 再帰処理
  2. 再帰処理の考え方

本日の講義・演習予定

  1. 再帰(リカーシブ)
  2. 再帰の考え方
  3. 再帰を利用したプログラム例
  4. スタックオーバーフロー
  5. 演習問題
  6. 提出課題
内 容
  1. 再帰(リカーシブ)
  2. 再帰の考え方
  3. 再帰を利用したプログラム例
  4. スタックオーバーフロー

再帰(リカーシブ)

 関数の中から自分自身を呼び出すことを再帰呼び出し(recursive call)と言います。
 例えば、階乗を求める計算を考えます。整数nの階乗は、
   (a) $n=0$ の場合、$n! = 1$
   (b) $n>0$ の場合、$n! = n \times (n-1)!$
となり、$n>0$の場合にはその計算式に再び階乗値を求める計算$(n-1)!$が表れます。このような再帰的な構造として定義できる処理に対して再帰関数を利用することで簡潔な表記と処理の単純化を実現することができます。
factrial.c
#include <stdio.h>

int factrial(int n)
{
    if(n == 0)
        return 1;					// 0!=1
    else
        return n * factrial(n-1);		// n! = n*(n-1)!
}

int main(void)
{
    int n;
 
    puts("nの階乗値を求めます。");
    printf("正の整数 >> ");
    scanf("%d", &n);
 
    printf("%d\n", factrial(n));
 
    return 0;
}
 
このプログラムにおいて、キーボードから3を入力した場合の再帰関数呼び出しのイメージを示します。
recursive


再帰の考え方

 対象となる問題を、1)漸化式で表すことができる、もしくは、2)小さく分割して解を求めてから一つにまとめることで全体の解を得ることができれるのであれば、再帰呼び出しを使ってプログラムを実現することができます。

漸化式で表せる例

 ある数列が次の漸化式で表されているとします。
$$a_{n+1} = 2a_n + 1 ,\ a_0 = 1$$
 次の項 $a_{n+1}$は、その前の項$a_n$を含んでいることから、再帰的構造を持っていると言えます。また、初項の値が1であることがわかってるので、これを再帰呼び出しの停止条件として利用することができます。
recurrence.c
#include <stdio.h>

/*
 * 漸化式
 *	a[n+1] = 2a[n] + 1,	a[0] = 1
 */
int func(int n)
{
	if(n==0)		//初項
		return 1;
	else
		return 2*func(n-1) + 1;
}

int main(void)
{
	int n;

	printf("正の整数>> ");
	scanf("%d", &n);
	printf("a%d = %d\n", n,func(n));

	return 0;
}
 

問題を小さくして解く例

 問題を2つ以上の小さな問題に分けてそれぞれを再帰的に解くことができ、それらの解をまとめることで全体の解を求めます。これを分割統治法とも言います。
 以下は、11個の整数の要素からなる配列の最大値を求めるプログラムです。配列を中央で左右に分割し、左右のグループ毎に最大値を求めます。
binarySearch.c
#include <stdio.h>
#define N 15

/*
*  二分して最大値を求める
*  @ left: 左端要素番号、right: 右端要素番号
*/
int binaryMax(int a[],int left, int right){
	int mid, s1, s2;

	if(left==right)
		return a[left];

	mid = (left + right)/2;			//左右に分割する中央の要素番号
	s1 = binaryMax(a, left, mid);		//左グループ
	s2 = binaryMax(a, mid+1, right);	//右グループ

	if(s1>s2) return s1; else return s2; //大きい値を戻り値とする
}

void printArray(int a[],int n)
{
	int i;
	for(i=0; i<n; i++) printf("%3d ", a[i]);
}

int main(void)
{
	int a[] = {11,6,9,2,10,5,15,3,12,14,1,7,4,13,8};
	int i;

	printArray(a,N);
	printf("\n");
	printf("max = %d\n", binaryMax(a,0,N-1));

	return 0;
}
 

 再帰処理を実現するための条件は、
  1. 再帰処理における再帰関数は自分自身を呼び出していること。
  2. 自明なケースが存在すること(再帰呼び出し終了条件)
  3. 状態が自明なケースに向かっていく変化していくこと。
である。自明なケースとは、初期状態(基礎的な状態)でその値が明らかであること、もしくは簡単に解が得られる状態にあることを言いいます。


再帰を利用したプログラム例

かけ算

 かけ算m x nは、次式のようにmをn回足した足し算の繰り返しとして書き改めることができます。
$$m \times n = m + m + \dots + m $$

さらに、かけ算を関数f(m,n)で置き換えると、次式のように表すことができます。
$$f(m,n) = f(m,n-1) + m$$

すなわち、かけ算を漸化式で表すことで、以下のような再帰呼び出しを使ったプログラムとして記述することができます。
multi.c
#include <stdio.h>

/* かけ算 m * n */
int multi(int m, int n)
{
	if([[空欄ア]])
		return [[空欄イ]];
	else
		return [[空欄ウ]];
}

int main(void)
{
	int m,n;
	
	printf("かけ算(M x N)\n");
	printf("整数(M,N)>> ");
	scanf("%d,%d", &m,&n);

	printf("%d\n", multi(m,n));	
	
	return 0;
}
 

組み合わせ

 n個の中からr個取り出す組み合わせ数は以下の式で求められます。
$$_nC_r = \frac{n!}{(n-r)!r!} $$
これをn個からr-1個取り出す組み合わせ数を使って書き改めると次式で表すことができます。
$$ _nC_r = _nC_{r-1} \times \frac{(n-r+1)}{r} $$
すなわち、組み合わせの数を再帰処理で求められるわけです。
combination.c
#include <stdio.h>

int comb(int n, int r)
{
	if ([[空欄ア]])
		return 1;
	else
		return [[空欄イ]]* (n-r+1)/r;
}

int main(void)
{
	int n,r;

	pirntf("組み合わせnCrを求めます。\n");
	printf("正の整数(n,r)>> ");
	scanf("%d,%d", &n,&r);

	printf("%dC%d = %d\n", n,r,comb(n,r));

	return 0;
}
 

10進2進変換

 10進数を2進数に変換するためには10進数を2で割った商と剰余を求める演算を繰り返すことで求めることができます。
tobinary
 次のプログラムは、この繰り返し処理を再帰呼び出しとして実現した例です。
binary.c
#include <stdio.h>

void toBinary(int n);

int main(void)
{
	int n;

	printf("正の整数>> ");
	scanf("%d", &n);
	printf("2進表現: ");
	toBinary(n);
	printf("\n");
	
	return 0;
}

void toBinary(int n)
{
	if(n==0){
		return;
	}
	else{
		toBinary([[空欄ア]]);
		printf("%d",n%2);
	}
}
 

末尾再帰

フィボナッチ数

 フィボナッチ数とは、次式で表されるように
$$ F_1 = 1 $$ $$ F_2 = 1 $$ $$ F_n = F_{n-2} + F_{n-1} $$
1つ前の累算値と2つ前の累算値の和で表される数でした。これを、繰り返し処理for文を使うと、
int fibo(int n)
{
	int i, fn, f1=1, f2=1;

	if (n==1 || n==2){
		return 1;
	}
	else {
		for(i=0; i<n; i++){
			fn = f2 + f1;
			f2 = f1;
			f1 = fn;
		}
		return fn;
}
のように、順次、一つ前と二つ前の処理結果を変数f1とf2にそれぞれ記憶させていくことで求めることができます。
 次に、フィボナッチ数がそれ以前と同じ処理によって得られる値を繰り返し利用していることから、再帰処理を使って、次のように書き直すことができます。
int fibo(int n)
{
	if (n==1 || n==2)
		return 1;
	else
		return fibo(n-2) + fibo(n-1);
}

末尾再帰

 再帰関数の自分自身の呼び出し位置が関数の最後で再帰関数のみを呼び出すことを末尾再帰と呼びます。処理結果が戻ってくるのを待って演算するのではなく、下記のソースコードの例のように関数呼び出しの際に引数で処理結果(f1+f2)を渡していきます。
int fibo(int n, int f2, int f1)
{
	if (n==1 || n==2)	//停止条件
		return f1;
	else
		return fibo(n-1,f1,f1+f2);
}


スタックオーバフロー

 再帰関数の呼び出しが深くなると、すなわち呼び出し回数が多くなりすぎるとスタックオーバーフローと呼ばれる状態になり、プログラムは停止してしまいます。関数は呼び出されるとメモリのスタック領域にローカル変数や呼び出し元に戻るための戻り先(リターンアドレス)などの情報を保存し、呼び出しが終了するとその情報を破棄します。再帰関数のように、呼び出した関数から関数を呼び出す処理が繰り返されると、最初に呼び出した関数の処理が終了するまで呼び出された関数の分だけスタック領域を消費することになります。そのため、再起呼び出しが深くなりすぎた場合には、スタック領域をはみ出して他の領域のデータを壊すことになります。
 もし、スタックオーバーフローが問題になった場合には、for文などを使って繰り返し処理に変換します。また、末尾呼び出しの最適化機能を持つ処理系が利用できれば、再帰処理を末尾再帰に書き換えることによってスタックを回避することができます。


expand_lessBack to TOP

演習問題

  1. 非負の整数nを入力して1からnまでの奇数の和を求めるプログラムを作成しなさい。ただし、再帰呼び出しを使って実現すること。

  2. 次式で定義されるアッカーマン関数を再帰関数として定義して、非負の整数m,nを入力して、その値ack(m,n)を求めなさい。また、mとnの値を0から始めてそれぞれ1ずつ増やしながら、処理時間と関数ack()が呼び出される回数の関係を観察しなさい。
  3. \begin{eqnarray} Ack(m,n) = \begin{cases} n+1 & (m=0) \\ Ack(m-1,1) & (n=0) \\ Ack(m-1,Ack(m,n-1)) & (m>0,n>0) \end{cases} \end{eqnarray}

  4. 10個の整数{73,45,67,55,80,82,70,92,67,88}で初期化された配列の合計を求めるプログラムを作成しなさい。ただし、再帰呼び出しを使って実現すること。

  5. 整数xとnを入力して、xのn乗値を求めるプログラムを以下の異なる二つの再帰処理の考え方に従って作成しなさい。また、どちらの方が処理時間が短いか、その理由を考えて予想しなさい。
    1) 漸化式に置き換える方法 $$ x^n = x^{n-1} \times x $$
    2) 2つに分ける方法 $$ \begin{eqnarray} x^n = \begin{cases} x^{n/2} \times x^{n/2} & (n:偶数) \\ x^{n/2} \times x^{n/2} \times x & (n:奇数) \\ \end{cases} \end{eqnarray} $$

  6. 正の整数(10進数)を入力して8進数に変換表示するプログラムを作成しなさい。ただし、変換指定子%oは使わず、再帰処理によって求めること。

  7. 正の整数(10進数)を入力して16進数に変換表示するプログラムを作成しなさい。ただし、変換指定子%xは使わず、再帰処理によって求めること。

  8. 小数点以下の10進数を入力して2進小数に変換するプログラムを作成せよ。ただし、再帰処理によって実現すること。また、10進数の0.1のように2進小数では 0.0001100110011・・・のように循環小数になる場合もあるため、10進数で1.0-8未満の値は無視するものとします。

  9. 例えば、2進数1101は記数法で以下のように書き表すことができます。
    \[ 1101(2) => 2^3 + 2^2 + 2^0 = 13(10) \] 同様に小数点以下の2進数0.1101は以下のように書き表すことができます。
    \[ 0.1101(2) => 2^{-1} + 2^{-2} + 2^{-4} = 0.5 + 0.25 + 0.0625 = 0.8125(10) \] これを一般化して、小数点以下の2進数 \( 0.a_1a_2a_3 \)・・・は分数で
    \[ \frac{a_1}{2} + \frac{a_2}{2^2} + \frac{a_3}{2^3}・・・ \] と書けます。これを2倍数するとa1は整数になります。a1を除いた値をさらに2倍するとa2が整数になります。このように、小数部分を2倍して整数部分を取り出すことで2進小数を得ることができます。
     それでは、前出の小数0.8125(10)を2進数へ変換してみましょう。
    0.8125 × 2 = 1.625 ==> 1
    0.625 × 2 = 1.25 ==> 1
    0.25 × 2 = 0.5 ==> 0
    0.5 × 2 = 1.0 ==> 1

  10. 24個のデータをサイズ5のグループに分割して、中央値を求める。

演習問題解答

OPEN ANSWER
  1. 回答例 ex4-2.c
  2. code/4/ex11-2.c

  3. 回答例 ex4-3.c
  4. code/4/ex11-3.c

  5. 回答例 ex4-4.c
  6. code/4/ex11-4.c

  7. 回答例 ex4-5.c
  8. code/4/ex11-5.c

  9. 回答例 ex4-6.c
  10. code/4/ex11-6.c

  11. 回答例 ex4-7.c
  12. code/4/ex11-7.c

  13. 回答例 ex4-8.c
  14. code/4/ex11-8.c

expand_lessBack to TOP

今週の確認テストテスト(PDF)

OPEN ANSWER
1.
int func(int n)
{
	if(n==1)
		return 1;
	else
		return 2*func(n-1) + 1;
}
							


2.
int fib(int n)
{
	if(n==1 || n==2)
		return 1;
	else
		return fib(n-2) + fib(n-1);
}
							


3.
(1) 0
(2) 2010012

本日の提出課題

次の各プログラムを作成しなさい。
  • 課題1
  • 正の整数xとnを入力して、xのn乗を求めるプログラム。ただし、xのn乗を戻り値とする再帰関数power(int x, int n)を定義して利用すること。
    (実行例)
    --- stdin ---
    2 5
    --- stdout ---
    32			
    		

  • 課題2
  • 正の整数mとnを入力して、mをnで割った時の商を求めるプログラム。ただし、正の整数mとnの商を戻り値とする再帰関数div(int m, int n)を定義して利用すること。
    (実行例)
    --- stdin ---
    17 5 
    --- stdout ---
    3			
    		

  • 課題3
  • 非負の整数nを入力して以下の漸化式の第n項を求めるプログラム。ただし、漸化式の第n項を再帰呼び出しを使って求める関数fibを定義して利用すること。 $$a_0=0, a_1=1, a_n=a_{n-1} + a_{n-2} (n \geq 2)$$
    (実行例)
    --- stdin ---
    7
    --- stdout ---
    13			
    		

  • 課題4
  • 非負の整数nを入力して以下の漸化式の第n項を求めるプログラム。ただし、漸化式の第n項を再帰呼び出しを使って求める関数trbを定義して利用すること。 $$a_0=0, a_1=0, a_2=1, a_n=a_{n-1} + a_{n-2} + a_{n-3} (n \geq 3)$$
    (実行例)
    --- stdin ---
    8
    --- stdout ---
    24			
    		

  • 課題5
  • 正の整数nを入力して、nの2乗和Sを求めるプログラム。ただし、正の整数nを引数、nの2乗和を戻り値とする関数sumofsquareを定義して利用すること。 $$S = 1^2 + 2^2 + 3^2 + \dots + n^2$$
    (実行例)
    --- stdin ---
    5
    --- stdout ---
    55			
    		

 二つの正の整数の最大公約数をユークリッドの互除法を使って求めるプログラムkadai4.cを作成しなさい。ただし、以下の要件を満たすこと。

[要件]
    ・下記のソースコードを埋めることで完成させること。
    ・最大公約数は再帰処理を使って求めること。
    ・下記の実行例のように、ユークリッド互除法による最大公約数を求める過程が表示されること。
    ・下記のソースコードの3つのテストすべてでOKが表示されること。

[ユークリッド互除法について]
 二つの正の整数をa,b、その最大公約数をgcd(a,b)で表すものとすると、ユークリッドの互除法では以下の手順で最大公約数を求めることができる。
    1)a÷bの剰余をcとする。
    2)c=0ならば、最大公約数はbとなる。
    3)そうでなければ、gcd(b,c)が最大公約数となる。

[実行例]
TEST1 ... 
No.  a   b   c  GCD
 1  13   7   6  gcd(7,6)
 2   7   6   1  gcd(6,1)
 3   6   1   0  gcd(1,0)
OK GCD(13,7) -> 1

TEST2 ...

	

ソースコード
#include <stdio.h>

int gcd(int a, int b)
{
	// ここを埋める
}

int main(void)
{
	int ans;

	printf("TEST1 ... \n");
	printf("No.  a   b   c  GCD\n");
	ans = gcd(13,7);
	if(ans==1)
		printf("OK GCD(13,7) -> %d\n",ans);
	else
		printf("BAD GCD(13,7) !!! %d\n",ans);

	printf("\nTEST2 ... \n");
	printf("No.  a   b   c  GCD\n");
	ans = gcd(6,9);
	if(ans==3)
		printf("OK GCD(6,9) -> %d\n",ans);
	else
		printf("BAD GCD(6,9) !!! %d\n",ans);

	printf("\nTEST3 ... \n");
	printf("No.  a   b   c  GCD\n");
	ans = gcd(236,22);
	if(ans==2)
		printf("OK GCD(236,22) -> %d\n",ans);
	else
		printf("BAD GCD(236,22) !!! %d\n",ans);

	return 0;
}
	


  • [提出方法]
    1. ideone.comを利用して作成した課題プログラムのURLをCoursePowerへ提出のこと。
    2. 提出するideone.comの課題プログラムには、実行結果が表示されていること。
    3. プログラムの正当性についてどのようにして検証したか簡単に記すこと。
    4. 詳細はスライド「ideoneの使い方」参照のこと。

  • [評価について]
  • プログラムの内容の評価とは別に以下の要件を満たすことを評価の前提とする。
    1. C言語で記述されていること。
    2. ソースコードファイルのコンパイル結果に、ワーニングやエラーが出力されていないこと。
    3. 所定の実行結果が得られるていること。
    4. ソースコードは、インデントや適当な改行が施された見やすい状態であること。

  • [提出期限]
  • 2023年 7月10日(月)までとする。ただし、以降の提出も受け付ける。