数え上げ・探索の手法、再帰

作田 誠

目次

1  問題の探索木と状態空間

ある初期状態からゴール状態までの道筋を見つけ出す問題が与えられたとき、初期状態にはたくさんの選択肢があり、その中の各一つ一つを選ぶことによって、それぞれ別の状態に遷移する。
さらに、一つ進んだ各状態にはたくさんの選択肢があり、その中の各一つ一つを選ぶことによって、それぞれさらに別の状態に遷移する。
これを続けると、初期状態を根(ルート)とする木構造ができる。これを一般に探索木と言う。

与えられた問題が取り得る状態すべての集合を状態空間という。状態空間は非常にたくさん、もしくは無数の状態を持っている。

2  数え上げ

状態空間全体を数え上げる場合、あるいは状態空間に存在する解をすべて数え上げる問題では、解決の手法は「すべての状態を調べる」ことしかない。

プログラム上では、

ある状態でのすべての可能選択肢を求める手続き

ある状態から一つの選択肢を選んで遷移し別の状態に移る手続き

の他に

一つ遷移した状態から、選択肢を選ばなかったことにして、元の状態に移る手続き

を用意すると、すべての数え上げが可能になる。以下のような再帰的なコードになる。これは後で出てくる深さ優先探索の一種と言える。

リスト1  数え上げの再帰的記述

数え上げ (状態) {
    現在の「状態」での処理を実行
    「状態」でのすべての可能選択肢を求める
    選択肢がない (= 末端状態) ならば戻る

    すべての可能選択肢について {
        「状態」から選択肢を選び進める -> 「状態n」へ
        数え上げ( 状態n )
        「状態n」から選択肢を選ぶ前の元の状態に戻す
    }
}

プログラムで

    数え上げ (開始状態)

を呼べば全状態の数え上げが実行できる。

3  探索 (search)

状態空間の中からあるゴール状態を見つけ出す問題の場合、必ずしも「すべての状態を調べる」必要はない。できるだけ効率よくゴール状態を見つけ出す手法が重要になる。

さまざまな探索手法があるが、ここでは基本的な幅優先探索・深さ優先探索・最良優先探索・A*探索を簡単に説明する。

3.1 幅優先探索 (breadth-first search)

横型探索とも言われる。
状態空間において、まず開始状態から一回遷移した状態をすべて調べる。 次に開始状態から二回遷移した状態、さらに三回遷移した状態、... のように同じ深さの状態をまとめて調べ、順次遷移の深さを増して目的状態に達するかどうかを調べていく探索法である。

幅優先探索は一般に展開中の状態のリストを記憶する必要があるため、実行時に多くのメモリが必要となる。

リスト2  幅優先探索

幅優先探索 {
    OpenList, ClosedList をともに空にする
    開始状態 S を OpenList に入れる

    繰り返し {
        OpenList が空ならば、S からゴールに至る経路はない。終了。
        OpenList の先頭を取り出し、OpenList から除く。取り出した状態を A とする。
        A がゴールなら、解が見つかったので出力し終了。
        状態 A でのすべての可能選択肢を求め、それらにより遷移する状態の集合 PA を求める。
        A を ClosedList の最後に追加する。
        PA が空ならば(すなわち A が末端状態なら)、以下をスキップし、次の繰り返しに行く。
        PA の中の各状態 c について {
            c が OpenList にも ClosedList にもなければ、c を OpenList の最後に追加する。
        }
    }
}

3.2 深さ優先探索 (depth-first search)

縦型探索とも言われる。
状態空間において、開始状態からどんどん可能遷移を続け目的状態に達するかどうかを調べていく探索法である。探索が

は遷移の一つ前の状態に戻って(バックトラックと言う)探索を続けていく。 開始状態のすべての遷移状態で目的状態が見つからなければ「解なし」であることがわかる。

深さ優先探索は一般に再帰で記述され、実行時に大量のメモリを必要としないという利点がある。

リスト3  再帰を使った深さ優先探索

深さ優先探索 {
    再帰探索( 開始状態 )
    すべての状態を調べたが解がなかった場合は解なしで終了。
}

再帰探索( 状態 A ) {
    A がゴールなら、解が見つかったので出力し終了。

    A がゴールでなければ、状態 A でのすべての可能選択肢の集合 M を求める。
    集合 M の各選択肢 m について {
        状態 A から選択肢 m を進めた後の状態 Am へ遷移する。
        再帰探索( Am )
        状態 Am から選択肢 m を進める前の元の状態 A へ戻す。
    }
}

3.3 最良優先探索 (best-first search)

状態 n から目標状態に達するまでの最適順路のコストを予測するヒューリスティックな関数 h(n) ≧ 0 を使用し、まだ調べていない状態から最も h(n) の小さな状態を調べていく探索法である。

未展開状態のリスト、展開済み状態のリストが必要になるため、実行時には大量のメモリが必要となる。

※ ヒューリスティック (heuristic) な関数とは、理論的には保証はないが、経験的・直感的に、あるいは機械学習による調整によって用意されたもっともらしいと思われる関数のこと。

3.4 A*探索 (A* search)

状態 n から目標状態に達するまでの最適順路のコストを予測するヒューリスティックな関数 h(n) だけでなく、開始状態から状態 n に至るまでのコスト関数 g(n) も共に利用する。開始状態から状態 n を経て目標状態に至るまでの最適順路の予測コスト関数 f(n)

f(n) = g(n) + h(n)

を考え、最も f(n) の小さな状態を選んで展開していく探索法である。これを A*探索 (A* search) と言う。

A*探索の効率はヒューリスティック関数の出来次第で、解を見つける場合でも最適解を見つけ出す保証はない。

しかし、真のコスト関数、つまり 状態 n から目標状態に達するまでの最適順路のコスト関数 h*(n) を考えたとき、

すべての状態 n について    h(n)   ≦   h*(n)

となるようなヒューリスティック関数 h(n) を使った場合には必ず最適解が見つけられる。 この条件を満足するヒューリスティック関数を 許容的 (admissible) であると言う。

4  再帰呼び出し

数え上げや探索においてマスターしておくべき再帰呼び出しについて演習を解くことによって理解を深める。

4.1 再帰とは

再帰呼び出し (recursive call) または単に再帰 (recursion) とは、プログラムにおいて、ある関数(手続き、メソッドなど)の中でその関数(手続き、メソッドなど)自身を呼び出すことである。

再帰には、直接自身を呼び出す形式の直接再帰と、呼び出し先の別の関数(手続き、メソッドなど)から自身が呼ばれる形式の間接再帰がある。

無条件に自分自身を呼んでしまうと、無限に呼び出しが起こってしまいプログラムが止まらなくなる。無限再帰などと言われる。
※ 実際には、ある程度呼び出しが深くなるとプログラムは異常終了する。

そのため、再帰呼び出しされる関数(手続き、メソッドなど)では再帰を止めるための条件をちゃんと指定する必要がある。

再帰は、比較的新しいプログラミング言語ではほとんどすべてで利用可能である。 引数の渡し方が 値渡し (call by value) のものは問題なく再帰が利用できる。(C言語など)
プログラミング言語によっては引数が 参照渡し (call by reference) されるものもあり、再帰の利用には注意が必要である。

4.2 階乗の計算

自然数 n の階乗 n! は

n! = n * (n - 1) * (n - 2) * ... * 2 * 1

で表される。ここで n! を fact(n) で表すと

fact(n) = n * fact(n - 1)

という式が成立する。 これを基に擬似コードを書くと

関数 fact ( 引数 n ) {
	n * fact(n - 1) を計算し返す
}

となる。しかし、これは再帰の停止条件が指定されていないので上述の無限再帰が発生してしまう。
そこで

fact(1) = 1! = 1

であることを考慮して、これを停止条件にする。

関数 fact ( 引数 n ) {
	もし n が 1 なら、
		1 を返して戻る
	n * fact(n - 1) を計算し返す
}


演習1

上の擬似コードを参考にプログラムを作り、n が10程度までの各出力を確認しなさい。

もっと大きな値を n に指定するとどうなるか? 最大いくつまで正しい答えが求められるだろうか?

ただし、再帰関数(メソッド)は fact という名前とする。



演習2

再帰を使わず普通の繰り返しを使って階乗を計算するプログラムを作り、n が10程度までの各出力が一致することを確かめなさい。

もっと大きな値を n に指定するとき、再帰版と同じところまで正しい答えが求められるだろうか?

ただし、関数(メソッド)は fact_loop という名前とする。

ヒント


※
演習1と演習2が独力でできない場合、授業のTeamsの「共有済み」のフォルダ「数え上げ探索再帰」にあるCプログラムのプロトタイプ fact_proto.c またはJavaプログラムのプロトタイプ Fact_proto.java をダウンロード・保存し、サクラエディタなどで変更後、(Javaファイルの場合ファイル名を Fact.java に変更して) 保存し、コンパイル・実行してみなさい。
※

4.2.1 大きな整数の扱い

上の演習の階乗や次の演習のフィボナッチ数列等は、n に対する増加の比率が大きく、n にちょっと大きい値を入れると求まる値が非常に大きな値になる。多くのプログラミング言語では、使用できる整数はビット数が決まっており一定の範囲しか表現できないため、計算できる n の範囲がごく小さくなってしまう。

C言語では、int, long を含め一般的に使われる符号付き整数型の表現形式は規格では長らく決まっていなかった※。実際には、Windowsパソコン上のほとんどのC処理系を含む多くの環境では int は 32 ビット符号つき整数 (2の補数表現) であり、

-231 ~ 231 - 1

すなわち、

-2147483648 ~ 2147483647

の範囲の整数しか扱うことができない。(値を覚えられる人は覚えておくとよい。無理な人は桁数 10 と一番上の桁の値 2 だけ、つまり ±約20億 と覚えておくこと)

※補足: C23規格 (2024年10月発行) で、符号付き整数の内部表現を2の補数表現とすることが規定された。

C言語では、標準ヘッダ stdint.h をインクルードすることで、64ビット整数を表す型として

型説明
int64_t符号つき64ビット整数
uint64_t符号なし64ビット整数

が利用でき、また、標準ヘッダ inttypes.h をインクルードすることで、それら64ビット整数を printf, scanf, sprintf, sscanf 等で使用するマクロが利用できる。 標準ヘッダ inttypes.h は、その中で標準ヘッダ stdint.h をインクルードしているので、結局、標準ヘッダ inttypes.h だけをインクルードすれば int64_t 等を利用・表示・入力できる。

printf系で使用できる書式指定マクロ   (inttypes.h)
マクロ説明
PRId64符号つき64ビット整数の10進表示
PRIu64符号なし64ビット整数の10進表示

例えば、int64_t 型の変数 ii と uint64_t 型の変数 uu の値を printf で10進表示させるには、

int64_t ii;
uint64_t uu;

.....

ii = ... (何らかの値を代入)

uu = ... (何らかの値を代入)

.....

printf( "ii = %" PRId64 "\n", ii);
printf( "uu = %" PRIu64 "\n", uu);

のように、% とマクロを 文字列リテラルの連結 機能を使って連結して使用する。

なお、新しい C23 規格 (2024年10月発行) では、printfの書式指定子の中に

w ビット数

という整数のビット数 (8,16,32,64,128 など) を明示的に指定するビット幅指定子が追加されたので、例えば64ビット整数では

%w64d   …   64ビット符号付き整数の10進表示
%w64u   …   64ビット符号なし整数の10進表示

のように書式指定すればよく、上述の PRI... マクロを使わなくてもよくなった。ただし、2026年9月現在、C23 規格に十分に対応しているC処理系はまだそれほど多くない。

ちなみに、uint64_t では

0 ~ 264 - 1

すなわち、

0 ~ 18446744073709551615

の範囲の整数を扱うことができる。(最大値は約1800京、桁数 20、一番上の桁の値 1)


Javaでは、int型は 32 ビット符号つき整数 (2の補数表現) と規定されているが、long 型という 64 ビット符号つき整数 (2の補数表現) が用意されている。Javaの long では、

-263 ~ 263 - 1

すなわち、

-9223372036854775808 ~ 9223372036854775807

の範囲の整数を扱うことができる。(±約900京、桁数 19、一番上の桁の値 9)

なお、Javaでは符号なし整数型は用意されていないが、表示の際に限っては、int なら Integer.toUnsignedString メソッド、long なら Long.toUnsignedString メソッドで符号なし整数としての10進文字列化をして表示させることができる。また、それらのメソッドの第2パラメータに進数の基数を指定して、符号なし整数として指定進数での文字列変換をさせることもできる。


固定ビット長で表せない範囲の整数を扱うには多倍長整数ライブラリを使う。C言語では、標準で用意されているものはないが、多くのライブラリが公開されている。例えば、GMP (The GNU Multiple Precision Arithmetic Library) が広く利用されている。
Javaでは、標準APIで

java.math.BigInteger クラス

が用意されているので、これを使うのが手軽である。また、他の高速性を謳うライブラリも利用可能である。


課題2 階乗 (一般課題)

テキストファイル (拡張子 .txt) に、

  • 学籍番号・氏名
  • 課題番号   課題名
  • 演習1・演習2の、
    ソースコードの関数(C言語)またはメソッド(Java) fact と fact_loop の部分のみ、
    および n が 20 のときの出力

をまとめ、Web授業ページトップから課題提出しなさい。


4.3 フィボナッチ数列の計算

フィボナッチ数列 fibo(n) は

fibo(1) = fibo(2) = 1
n ≧ 3 の時、fibo(n) = fibo(n - 1) + fibo(n - 2)

で表される。

表1  フィボナッチ数列の最初の部分
n1234567...
fibo(n)11235813...

これを基に擬似コードを書くと以下のようになる。

関数 fibo ( 引数 n ) {
	もし n が 1 または 2 なら、
		1 を返して戻る
	fibo(n - 1) + fibo(n - 2) を計算し返す
}


演習3

上の擬似コードを参考にプログラムを作り、n が10程度までの各出力を確認しなさい。

ただし、再帰関数(メソッド)は fibo という名前とする。

n に 50 程度の大きな値を指定するとどうなるか?



演習4

実はフィボナッチ数列を再帰を単純に使って記述するのは非常に効率が悪い。
再帰を使わず普通の繰り返しを使ってフィボナッチ数列を計算するプログラムを作り、n が10程度までの各出力が一致することを確かめなさい。作業用の変数または配列を利用するとよい。

ただし、関数(メソッド)は fibo_loop という名前とする。

n に前演習と同様の大きな値を指定するとどうなるか?

ヒント


※
独力でできない場合、授業のTeamsの「共有済み」のフォルダ「数え上げ探索再帰」にあるCプログラムのプロトタイプ fibo_proto.c またはJavaプログラムのプロトタイプ Fibo_proto.java をダウンロード・保存し、サクラエディタなどで変更後、(Javaファイルの場合ファイル名を Fibo.java に変更して) 保存し、コンパイル・実行してみなさい。
※

下に、n = 6 で再帰版 fibo を呼び出した時の再帰呼び出しの様子を木構造で示す。

fibo 6 recursive

上図からも、n が大きくなると再帰の呼び出し回数が爆発的に増えていくことが予想できる。
実際、上記単純な再帰版の計算では、理論的解析から、関数(メソッド)が下記に比例した回数呼ばれることが知られている。

fibo(n) に対して、
((1 + √5)/2)(n - 1)

(1 + √5)/2 = 1.61803... は 黄金比 (golden ratio) と言われる定数であり、計算量がこの定数のべき乗で表される効率の悪い計算法となる。

4.3.1 再帰版の改良


演習5

再帰版が遅いのは、数列の中の同じ値の計算が非常に多数回実行されているからである。
そこで、途中で求まる数列の値を記憶して再利用する メモ化 (memoization) という手法を用いる。

C言語で上記のプロトタイプを基にプログラムを作っていた場合、以下のようなコードにすればよい。

fibo memo C

Javaのプロトタイプを基にプログラムを作っていた場合は下記のようになる。

fibo memo Java

下に、n = 6 でメモ化付きの再帰版 fibo を呼び出した時の再帰呼び出しの様子を木構造で示す。

fibo 6 recursive memo

呼び出しは深さ優先で左の枝を先に計算していく。// の部分はメモの配列の値を使っており、さらなる計算が抑止されていることが分かる。実質、直線的な呼び出し構造となっている。

実際に、再帰版をメモ化により改良し、元のプログラムで時間がかかった n の値でやってみると、メモ化により劇的に効率が改善することが分かる。
n = 93 を入れたときのプログラム出力を示しなさい。


4.4 ハノイの塔

ハノイの塔は再帰プログラムでよく例に出されるパズルである。

パズル: ハノイの塔


台の上に3本の棒 A, B, C があり、Aだけに何枚かのドーナツ盤が積まれている。 積まれているドーナツ盤はすべて大きさが違い、下のものほど段々に半径が大きくなっている。

以下のルールで全てのドーナツ盤を A から C に移しなさい。

  • 一回に一枚のドーナツ盤を動かす。
  • 移動の途中で小さいドーナツ盤の上により大きなドーナツ盤を積んではいけない。
  • 棒に積む以外、他の所にドーナツ盤を置いてはいけない。

パズルの開始状態
ハノイの塔開始状態図

パズルのゴール状態
ハノイの塔ゴール状態図

この問題は、再帰の考え方を使うときわめてすっきり解ける。解答手順を模式的に表した図を下に示す。

ハノイの塔解答手順図1
ハノイの塔解答手順図2

上図では移動を表す矢印が二種類ある。①③にある、細い方の矢印はドーナツ盤の (n - 1) 枚の塊 (山) のマクロ的な移動を表しており、実際の個々のドーナツ盤の移動がどのように構成されるかは気にしていない。一方、②にある、太い方の矢印は実際の一つのドーナツ盤の移動を表している。


ドーナツ盤の数を n とし、ルールを守ってそれらを A から C に移動させる処理を

hanoi( n, A, C )

と書くと決める。解答手順の途中で、

n 枚のうち、上の (n - 1) 枚のドーナツ盤が A から B に移動している状態

が考えられる。この場合、一番下の一枚は移動には関係ないので無視してよい。

「①初期状態」から「②上の (n - 1) 枚のドーナツ盤が A から B に移動している状態」へ移行させる手順は

hanoi( n - 1, A, B )

と表すことができる。

「②上の (n - 1) 枚のドーナツ盤が A から B に移動している状態」では、一番大きいドーナツ盤だけが A に残っている。 これを空の C に移動させる。

「A のドーナツ盤を C に移動させる」

この結果、「③上の (n - 1) 枚のドーナツ盤が A から B に移動し、一番下の1枚が A から C に移動した状態」になる。

この状態から、B に積まれている (n - 1) 枚のドーナツ盤を B から C へ移動させれば、手順は完結する。この処理は

hanoi( n - 1, B, C )

と表すことができる。

以上をまとめて、n 枚のドーナツ盤を A から C に移動させる処理 hanoi( n, A, C ) は

処理 hanoi( n, A, C ) {
	hanoi( n - 1, A, B )
	A のドーナツ盤を C に移動させる
	hanoi( n - 1, B, C )
}

と擬似コードで記述できる。実際には、上の擬似コードの A, B, C に相当する部分が変化するので、移動位置を一般化して以下のような擬似コードになる。

擬似コード(A)

処理 hanoi( n, 移動元, 移動先 ) {
	「移動元」「移動先」以外の「もう一つの棒」を求める
	hanoi( n - 1, 移動元, もう一つの棒 )
	「移動元」のドーナツ盤を「移動先」に移動させる
	hanoi( n - 1, もう一つの棒, 移動先 )
}

なお、上記擬似コードではなく、「もう一つの棒」も hanoi のパラメータとして渡す書き方もできる。その場合は「もう一つの棒」を求める処理はいらなくなるので、以下のような擬似コードになる。

擬似コード(B)

処理 hanoi( n, 移動元, 移動先, もう一つの棒 ) {
	hanoi( n - 1, 移動元, もう一つの棒, 移動先 )
	「移動元」のドーナツ盤を「移動先」に移動させる
	hanoi( n - 1, もう一つの棒, 移動先, 移動元 )
}


演習6

上の擬似コード(A)または(B)を参考にプログラムを作り、n が5程度までの各出力を確認しなさい。 ただし、擬似コードには再帰の停止条件が入ってないので、自分で停止条件を入れること。

再帰関数(メソッド)は hanoi という名前とする。

※
独力でできない場合、授業のTeamsの「共有済み」のフォルダ「数え上げ探索再帰」にあるCプログラムのプロトタイプ hanoi_proto.c またはJavaプログラムのプロトタイプ Hanoi_proto.java をダウンロード・保存し、サクラエディタなどで変更後、(Javaファイルの場合ファイル名を Hanoi.java に変更して) 保存し、コンパイル・実行してみなさい。
※

ソースコードの再帰関数 hanoi (C言語) または 再帰メソッド hanoi (Java) の部分のみ、および、n が 3 のときの出力をテキストファイルに保存しなさい。


4.5 移動制限つきのハノイの塔

ハノイの塔の変種問題として、移動が必ず真ん中の棒 B を介さなければいけないという制限をつけた問題を解く。

この制限により、各ドーナツ板の

A から C への移動
C から A への移動

は禁止されるので、元の問題より複雑な手順が要求される。
考え方としては、元問題と同様に、n 枚のドーナツ板を上の (n - 1) 枚と一番下の1枚に分けて考えて、再帰的に考えて解く。解答手順は下図のようになる。

移動制限つきのハノイの塔解答手順図1
移動制限つきのハノイの塔解答手順図2

なお上図の③⑤を見ると、(n - 1)枚の山のマクロ的な移動は A → C あるいは C → A に限られていることが分かる。これらはマクロ的な移動なので制限されていない。
従って、移動制限つきのハノイの塔では、再帰関数(メソッド)の中での「もう一つの棒」は必ず B になるので、コード上で固定してしまってもよい。


演習7

元のハノイの塔問題のソースファイルをコピーしファイル名を変更し (Java ではクラス名も同じに変更のこと)、上の解答手順図を参考にプログラムを修正し、コンパイル・実行し、n が3までの各出力を確認しなさい。

再帰関数(メソッド)は hanoi という名前のままとする。

ソースコードの再帰関数 hanoi (C言語) または 再帰メソッド hanoi (Java) の部分のみ、および、n が 2 のときの出力をテキストファイルに保存しなさい。



課題3 フィボナッチ数列、ハノイの塔 (一般課題)

以下をテキストファイル (拡張子 txt) にまとめ、講義のWebページから提出しなさい。

  • 先頭に「学籍番号・氏名」と「課題番号 課題名」
  • 繰り返し版のフィボナッチ数列 演習4、およびメモ化により効率を改善した再帰版のフィボナッチ数列演習5 の、ソースコードの関数(C言語)またはメソッド(Java) fibo_loop と fibo の部分およびメモ化で使用する配列のコードのみ、および n が 93 のときの出力
  • 演習6のソースコードの再帰関数 hanoi (C言語) または 再帰メソッド hanoi (Java) の部分のみ、および、n が3のときの出力
  • 演習7のソースコードの再帰関数 hanoi (C言語) または 再帰メソッド hanoi (Java) の部分のみ、および、n が 2 のときの出力