第 9 回 再帰処理、グラフ理論

本日の内容


このドキュメントは http://edu.net.c.dendai.ac.jp/ 上で公開されています。

9-1. 再帰処理

自分の先祖を考えると、これは、おとうさん、おかあさん、おとうさんのおと うさん、おとうさんのおかあさん……と永遠に記述しなければならなくなりま すが、これを「おとうさん、おかあさんと、おとうさんの先祖とお母さんの先 祖」と言う風に、先祖を規定するのに先祖という言葉を使って規定すると形の上 では短い表現で記述できます。 これを帰納的定義といいます。 なお、一見循環論法に見えますが、「先祖とは先祖です」という文とは違いま す。 「先祖」を「おとうさんとおかあさんとおとうさんの先祖とおかあさんの先祖」 とすると、さらに「おとうさんとおかあさんとおとうさんの「おとうさんとお かあさんとおとうさんの先祖とおかあさんの先祖」とおかあさんの先祖」と置 き換えることができます。 この置き換えを繰り返すことで、最初に述べた「おとうさん、おかあさん、お とうさんのおとうさん、おとうさんのおかあさん……」と同じことが表現でき ることがわかります。 帰納的定義はこのように短い記述で無限の構造を表現できるという性質があり ます。

関数の定義で自分自身の関数を呼び出すことを再帰と言います。 再帰も帰納的定義になります。 Java 言語は再帰が可能なため、関数定義で帰納的定義を使用することができます。 これを使うと、短い記述で複雑な問題を解くことができます。

問題の分割構造 ある問題を解く際に、その問題を同じ性質を持つ小さい部分に分解できるとし ます。 すると、その問題を解く関数 solve は再帰処理を使うと次のように書くこと ができます。


public staitc int solve(解くべき問題 t){
  if(t がもう分解できない){ 
    t を処理;
    return t の答え;
  }
  t1 = t と同じ性質を持つ小さい部分;
  ans1 = solve(t1);  /* 再帰 */
  分解した残りと ans1 によりtの答を計算;
  return t の答え;
}

例9-1

階乗 n!=n*(n-1)*...*2*1 は n!=n*(n-1)! と右辺にも階乗を含む構造に分解 できます。 一方 0!=1 となります。 従って、プログラムは次のようになります。


public static long kaijo(int n){
   if(n==0){
     return 1;
   }
   return n*kaijo(n-1);
}     

例9-2

フィボナッチ数列とは、手前の数と、その手前の数の和を次々と求めてできる 数列です。

1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, ...

a0=1, a1=1 とすると、n 番目(n≧ 2)は an= an-1+ an-2 と書けます。 この時の n 番目の値を求める関数は素朴な書き方をすると次のようになり ます。


public static long fibo(int n){
  if(n==0) return 1;
  if(n==1) return 1;
  return fibo(n-1)+fibo(n-2);
}

例9-3

nCm

組合せの数 nCm の求め方を考えます。 これは n 個の中から m 個を取り出す取り出し方は何通りあるかということで す。 ここで、n 個のものの中から一つのものに注目します。

n-1Cm
注目したものを取り出さなかった時
残りの n-1 個の中から m 個を取り出すことになるので、組合せは n-1Cm 通り
n-1Cm-1
注目したものを取り出した時
残りの n-1 個の中から m-1 個を取り出すことになるので、組合せは n-1Cm-1 通り

従って、 nCm = n-1Cm + n-1Cm-1 が得られます。 なお、n 個のものから0 個を取る取り方や、 n 個全部を取り出す取り方はと もに 1 通りしかありません。つまり nC0 = nCn = 1 です。

例9-4

ハノイの塔とは次のようなパズルです。 大きさが一回りずつ違う円盤を大きい順に下から並べます。 そして、次のルールでその円盤の山を移動させます。

  1. 一度には一枚しか動かせない。
  2. 移動可能な場所は、現在位置を含み三箇所
  3. 小さい円盤の上に大きい円盤を置くことはできない

以下に 3 枚での例を示します。

abc
1 =
==
===
--a--



--b--



--c--
2
==
===
--a--


=
--b--



--c--
move 1 from a to b
3

===
--a--


=
--b--


==
--c--
move 2 from a to c
4

===
--a--



--b--

=
==
--c--
move 1 from b to c
5


--a--


===
--b--

=
==
--c--
move 3 from a to b
6

=
--a--


===
--b--


==
--c--
move 1 from c to a
7

=
--a--

==
===
--b--



--c--
move 2 from c to b
8


--a--
=
==
===
--b--



--c--
move 1 from a to b

この解法を再帰的に考えます。 もし、hanoi(n-1,"a","b","c") が n-1 枚の円盤を a から b へ移動する解法 を出力するとします(c はもう一つの領域)。 すると、 n 枚の円盤を a から b へ移動する解法は次のように考えられます。

  1. まず、 n の上に乗っている n-1 枚の円盤を a から c へ移動します。つ まり hanoi(n-1,"a","c","b")
  2. そして、n を a から b へ移動します。 move n from a to b
  3. 最後に c にある n-1 枚の円盤を b に移します。 hanoi(n-1,"c","b","a")

このようにすると hanoi(n,"a","b","c") の解法を出力します。


class Rei {
    private static void move(int n, String x, String y){
	System.out.println("move "+n+" from "+x+" to "+y);
    }
    public static void hanoi(int n, String x, String y, String z){
	if(n==0) return;
	hanoi(n-1,x,z,y);
	move(n,x,y);
	hanoi(n-1,z,y,x);
    }
    public static void main(String[] arg){
	hanoi(Integer.parseInt(arg[0]),"a","b","c");
    }
}

9-2. グラフ

プログラムで扱うデータ構造としてグラフを取り上げます。 グラフとは頂点とそれを結ぶからなるものです。

グラフの例

頂点は vertex、 節、 node などの呼び方があります、辺は edge、枝、 branch とも言います。 辺に向きがないグラフを無向グラフ、 辺に向きがあるグラフを有向グラフと言います。

有向グラフ

互いに接続している頂点の列を道(path)と言います。 無向グラフにおいて、全ての頂点の組合せについて道がある場合、そのグラフ は連結であると言います。

頂点に接続している辺の数をその頂点の次数degreearityと言います。 有向グラフにおいては出る辺の数を出次数out degree、入る辺の数を入次数in degreeと言 います。

グラフには様々な形があり、それについて名前が付けられています。 無向グラフにおいて、木とは、ループのない連結グラフのことを言います。 次数が 1 の頂点をleafと言います。

根付有向木とは、次の条件を満たす有向グラフのことです。

  1. 根と呼ばれる頂点が一つだけ存在し、有向辺は入りません。
  2. 他の頂点は必ず一本の有向辺が入ります。
  3. 全ての頂点に、根からただ一つの道が存在します。

根付有向木で接続している二つの頂点に対して、辺が出る頂点を、辺が入る頂点をと言います。 出次数が 0 (入次数が 1)の頂点をと言います。 根付有向木において、出る辺の数が高々 2 本の木を二分木二進木binary tree と言います。

二分木

ここでさらに左側の枝には必ず葉が接続する二分木を考えると次のような構造になり ます。 これを線形リストと呼びます。

線形リスト

すべての頂点の次数が 2 の連結グラフは リング になります。

また、すべての頂点間に辺がある無向グラフを完全グラフと言い ます。

頂点が二つのグループに分割でき、同じグループ同士には辺がないグラフを 二部グラフと言います。 特に、異なるグループのすべての頂点の組み合わせに辺がある無向二部グラフ を完全二部グラフと言います。

9-3. 入力

ここで、話が少しそれますが、 Java におけるデータの入力方法をまとめます。

なお、数値入力と言っても、実際にコンピュータは「入力」が「数値」だと直接認識するわけではありません。 仮に「123」のような数値を取得するためには、まず文字である"1" と "2" と "3" を入手し、それらを結合して "123" という文字列を得、それを数を表 す文字列として解釈して最終的に数値にします。

バイトから文字

Java での標準入力である java.lang.System.in は java.io.InputStream のイ ンスタンスです。 入力されるデータは基本的には byte の集まりです。したがって、もっとも基 本的な仕組である、 この java.io.InputStream の read メソッドは byte 単位になります。 これを、まず、文字として扱うために java.io.Reader 型に変換します。 但し、java.io.Reader は抽象クラスでインスタンス化できません。 InputStream 型であれば Reader のサブクラスである InputStreamReader を 使用します。 変換するには、InputStreamReader のコンストラクタに InputStream のイン スタンスを指定します。 さらに、文字に変換するため、コンストラクタに文字コードを指定することも できます。

例9-5


import java.io.*;
...
Reader isr = new InputStreamReader(System.in);
Reader isr = new InputStreamReader(System.in,"Shift_JIS");
File f = new File("filename");
Reader fr = new FileReader(f);

文字から文字列

Reader により文字の入力はできますが、通常のデータ処理で必要なのは文字 列です。 そのため、文字の入力を集めて取り出す java.io.BufferedReader クラスを使 います。 これは Reader オブジェクトをコンストラクタに指定してインスタンス化でき ます。 この java.io.BufferedReader には readLine メソッドがあり、直接行を文字列 で取り出すことができます。 ここまでが通常の入力文字列の取扱い方法です。


Reader r = new InputStreamReader(System.in);
BufferedReader br = new BufferedReader(r);
String input;
while((input = br.readLine())!=null){
   /* input の処理 */
}

文字列から数列へ

入力している文字列が単一の数を表しているのであれば java.lang.Double.parseDouble や java.lang.Integer.parseInt メソッドを 使って double 型や int 型の値を取り出すことができます。


double dData = Double.parseDouble("123.456");
int iData = Integer.parseInt("123");

一方、複数の数値が空白、改行、"," などで区切られていたらどのようにして 取り出せば良いでしょうか? このような文字列の表現のルールを文法と呼びます。 文字列の文法の解釈法などはコンパイラの発達に伴い深く研究されてきました。 この文法に関する詳しい話はデータ構造とアルゴリズム II で紹介します。 ここでは、単純にいくつかの区切り記号がある場合のみを考えます。

Java 1.4 から java.lang.String には split というメソッドが追加されまし た。これは(正規表現という)パターンを指定することで、文字列をパターンで 区切り、文字列の配列と返すものです。

例9-6


"123,456,,789".split(",") == {"123","456","","789"}
"123a456ba789".split("[ab]") == {"123","456","","789"}
"123=====456===789".split("=+") == {"123","456","789"}
"123=abc=456bca=aa789".split("[=abc]+") == {"123","456","789"}

split メソッドの引数には区切り記号の文字列を入れます。 但し、複数の区切り記号がある場合には[]で括る必要があります。 また、空白などの 1 個以上の連続は通常はひとつの区切りと考えますが、そ の場合は区切り記号の後ろに+(プラス記号)を付けます。

この split を使って空白で区切られた数値を連続して読むことを考えます。 ここで、このパターンとして便利な文字クラスをひとつ紹介しま す。 それは空白文字を示す \s という記号です。これには空白の他、 TAB、改行、 改ページなど、通常画面上で空白になる文字全てを含んでいます。 そのため、区切り記号のパターンとして "\\s+" を指定すれば("" の中では \(バックスラッシュ) は二個で一つの\(バックスラッシュ)を意味することに注 意)、全ての空白などを取り除いて文字列を分解してくれます。

C 言語の scanf 関数では空白も改行も数値の区切りとして同じ意味に取りま す。 上記の \s+ 指定で文字列を分解する際は、同様の処理が可能になります。 そこで、 readLine で取得した文字列を一端改行記号を付けて、入力全部を一 つの文字列に納めます。 そして、 split("\\s+") で分解すれば、全ての空白や改行を取り除いて、文 字列の配列になります。 これを数値化すれば数値の配列が得られることになります。

例9-7


import java.io.*;
class Rei {
    private static String getAll(Reader r) throws IOException {
	BufferedReader br = new BufferedReader(r);
	StringBuilder sb = new StringBuilder();
	String input;
	while((input = br.readLine())!=null){
	    sb.append(input);
	    sb.append("\n");
	}
	return sb.toString();
    }
    private static int[] parseIntArray(String s){
	String[] dataArray = s.split("\\s+");
	int[] result = new int[dataArray.length];
	for(int i=0; i<result.length ; i++){
	    result[i] = Integer.parseInt(dataArray[i]);
	}
	return result;
    }
    public static void main(String[] arg) throws IOException {
	Reader r = new InputStreamReader(System.in);
	int[] array = parseIntArray(getAll(r));
	for(int i : array){
	    System.out.println(i);
	}
    }
}

このプログラムを実行し、数値を空白や改行で区切って入力し、 Windows で は Ctrll-Z、 Unix 系では Ctrl-D を入力すると、入力した数値を一行に一つ 表示します。

入力のフォーマットが決まっている場合

Java 5 より、 C 言語の scanf のようなフォーマットを決め打ちして読み込 む方式が追加されました。 java.util.Scanner は java.util.Iterator<String> を実装しているクラスで す。 java.io.InputStream や java.io.File のインスタンスをコンストラクタに与 えると、区切り記号をデフォルトでは "\\s+" として入力列を区分し、イ テレータと同様に要素の有無を hasNext() で判定し、要素を next() で取り 出します。 さらに、次の要素が整数として読み込めるか否かを hasNextInt() で判定し、 nextInt() で要素を整数として読み込みます。 同様に double 型に対しては hasNextDouble() と nextDouble() があり、他 の基本型に対して同様のメソッドがあります。

なお、コンストラクタには文字コードを与えることも出来ます。 さらに区切り文字の正規表現も useDelimiter メソッドで与えることが出来ます。

9-4. Java でのグラフの表現

コンピュータで情報処理をする際、情報をグラフの形で抽象化して処理するこ とがしばしばあります。 そこで、コンピュータでグラフを表現するしかたを考えます。

文字列

プログラムへの入出力のために、単純に文字列としてのやりとりを考えます。 まず、グラフの頂点にはすべて名前が付けられているとします。頂点の名前の 集合を V とします。 すると、辺は二つの頂点の組み合わせで表せます。 辺の集合を E とすると、 E には (a,b) など、頂点の名前のペアが含まれる ことになります。 なお、有向グラフでは辺は (出発点,終着点) で表すことにします。

グラフは頂点と辺からなりますので、頂点の集合の要素と辺の集合の要素をそ れぞれ列挙すればグラフを表現できることになります。 下のグラフを要素の列挙で表現すると次のようになります。

有向グラフ
V = { a , b , c , d }, E = {(a, c), (b, a), (b, c), (c, b), (d, c)}

行列

頂点の数が n 個の時、n × n の正方行列を考えます。 もし、 i 番目の頂点から j 番目の頂点へ辺がある場合 (i,j) 成分を 1 に、 なければ (i,j) 成分を 0 にしたものはグラフの構造を表すことができます。 このような行列を 隣接行列 と言います。 なお、無向グラフで i と j に辺がある場合、 (i,j) 成分と (j,i) 成分をと もに 1 にします。

有向グラフ
abcd
a0010
b1010
c0100
d0010

さて、入力した数値から隣接行列を作ることを考えましょう。 ここでは単純のために、配列 array に対して、 array[0] に行列のサイズが 入っていて、その後は array[0]*array[0] 個の要素が入っているものとし、 int[][] 型の正方行列を返す関数を作ります。

例9-8


    private static int[][] getSquareMatrix(int[] array){
	int n=array[0];
	int[][] result = new int[n][];
	int index=1;
	for(int i=0; i<n; i++){
	    result[i] = new int[n];
	    for(int j=0; j<n; j++){
		result[i][j] = array[index++];
	    }
	}
	return result;
    }

例9-9

なお、 java.util.Scanner を使用すると、次のようなシンプルなプログラム になります。


    private static int[][] getMatrix(Scanner scanner){
	int n = scanner.nextInt();
	int[][] matrix = new int[n][];
	for(int i=0; i<n; i++){
	    matrix[i] = new int[n];
	    for(int j=0; j<n; j++){
		matrix[i][j] = scanner.nextInt();
	    }
	}
	return matrix;
    }

この隣接行列を使ったサンプルプログラムとして、隣接行列から文字列の表現 に変換するプログラムを示します。

例9-10


    private static char getAlphabet(int i){
	return (char)('a'+i);
    }
    private static String matrixToString(int[][] mat){
	StringBuilder sb = new StringBuilder();
	sb.append("V={");
	boolean comma = false;
	for(int i=0;i<mat.length;i++){
	    if(comma){
		sb.append(",");
	    }
	    sb.append(getAlphabet(i));
	    comma = true;
	}
	sb.append("},E={");
	comma=false;
	for(int i=0;i<mat.length;i++){
	    for(int j=0;j<mat.length;j++){
		if(mat[i][j]!=0){
		    if(comma){
			sb.append(",");
		    }
		    sb.append("("+getAlphabet(i)+","+getAlphabet(j)+")");
		    comma = true;
		}
	    }
	}
	sb.append("}");
	return sb.toString();
    }

参照による表現

オブジェクトのインスタンス変数として、自分自身の型を持ち、別のインスタ ンスを参照する形で、インスタンスを頂点、参照を辺として表す方法がありま す。

例9-11

有向グラフ

以下は、右のグラフを参照により表現し、その後文字列の表現に変換していま す。


import java.util.*;
class Node {
    private String name;
    private LinkedList<Node> edge;
    public Node(String name){
	this.name = name;
	edge = new LinkedList<Node>();
    }
    public void addNode(Node n){
	edge.add(n);
    }
    public String getName(){
	return name;
    }
    public LinkedList<Node> getEdge(){
	return edge;
    }
}
class Rei {
    private static String nodesToString(Node[] g){
	StringBuilder sb = new StringBuilder();
	boolean comma;
	sb.append("V={");
	comma = false;
	for(Node n : g){
	    if(comma){
		sb.append(",");
	    }
	    sb.append(n.getName());
	    comma = true;
	}
	sb.append("}, E={");
	comma = false;
	for(Node n : g){
	    for(Node e : n.getEdge()){
		if(comma){
		    sb.append(",");
		}
		sb.append("("+n.getName()+","+e.getName()+")");
		comma = true;
	    }
	}
	sb.append("}");
	return sb.toString();
    }
    public static void main(String[] arg){
	Node a = new Node("a");
	Node b = new Node("b");
	Node c = new Node("c");
	Node d = new Node("d");
	Node[] graph = {a,b,c,d};
	a.addNode(c);
	b.addNode(a);
	b.addNode(c);
	c.addNode(b);
	d.addNode(c);
	System.out.println(nodesToString(graph));
    }
}

9-5. 演習問題

演習9-1

例9-1 のプログラムを使って、 5! と 6! を求めなさい。

演習9-2

例9-2 のプログラムについて以下の問いに答えなさい。

  1. fibo(20)を求めなさい。
  2. fibo(0) から fibo(50) まで表示するプロ グラムを作りなさい。
  3. fibo(50)などを計算する速度が遅い理由を考えなさい。
  4. 例9-2 で示したプログラムを改良することを考えます。 再帰的な呼び出しを多数行いますが、実際は同じ値を何度も計算しています。 実際、 fibo(50) を計算するには fibo(0) から fibo(49) まで順番に 50 個 の値があれば fibo(50) の値は求まります。 そこで、一度計算した値を配列にしまう事を考えます。 そして、毎回値を計算するのに再帰処理を行うのではなく、一度計算した値に 関しては保存してある値を返すことにします。 このような改良をしたプログラムを作成し、上記のように fibo(50) まで求め なさい。 なお、「一度計算したかどうか」を判定するのに、次の事項を使用できます。
    1. fibo(x) は必ず 0 より大きい
    2. Java の配列は new で生成すると 0 に初期化される

演習9-3

組合せの数を求める再帰的なプログラムを組みなさい。 そして、 5C220C2 を求めなさい。

演習9-4

ハノイの塔において、 n 枚のディスクは何手で移動できるか、n の式を求め なさい。

演習9-5

頂点が 4 つの二分木は何種類あるか? 実際に作図して求めなさい。

演習9-6

表計算ソフトやデータベースソフトでは、古くから CSV(Comma Separated Value)というファイルフォーマットが使えるようになっています。 これは、行は CRLF(\r\n)で区切られていて、列は , (カンマ)で区切られてい るデータです。 以下は CSV のデータになります。

1,2,3
4,5,6

この他、文字列は""で括られるというルールがあります。 さて、表計算ソフトのデータを Java で活用するために、数値だけからなる CSV ファイルを double[][] 型に変換するプログラムを書きなさい。

演習9-7

次のグラフの隣接行列を求めなさい。

グラフの例

演習9-8

一筆書きができるグラフ(オイラーグラフ)である必要十分条件は、奇数の 次数を持つ頂点が 0 個か 2 個であることです。

  1. グラフの入力の仕様を考え、一筆書きができるかどうかを判定するプログラムを作りなさい。
  2. 次の図形が一筆書き可能かどうかを作成したプログラムで判定しなさい。
    1. 図1
    2. 図2
    3. 図3
  3. 一筆書きの必要十分条件を証明しなさい。

ヒント

グラフの入力には隣接行列で入力するのが楽です。 最初に行列のサイズを入れ、そのあと、隣接行列を入力するようにします。

発展問題

あたえられたグラフが連結グラフであるかどうか判定するプログラムを作りな さい。

隣接行列に対して and/or による行列の積を取ると、 k 乗が k 歩で行ける頂 点を表します。


坂本直志 <sakamoto@c.dendai.ac.jp>
東京電機大学工学部情報通信工学科