Prolog には 差分リスト という独自のデータ構造があります。「差分リスト」という特別なデータ型があるわけではありません。普通のリストには違いないのですが、考え方がちょっとひねくれています。
例として [a, b, c] というリストを考えてみましょう。このリストを 2 つのリストの違いで表してみます。[a, b, c, d, e] から [d, e] を取り除くと [a, b, c] になりますね。また、[a, b, c, f] から [f]、もしくは [a, b, c] から [ ] を取り除いても [a, b, c] を表すことができます。
このように、2 つのリストの差分 [Xs, Ys] でひとつのリストを表すことから、差分リストと呼ばれているのです。Xs を差分リストの 頭部、Ys を差分リストの 尾部 と呼びます。次に示すリストは、すべて [a, b, c] というリストを表しています。
[ [a, b, c, d, e], [d, e] ] [ [a, b, c, f], [f] ] [ [a, b, c], [ ] ] [ [a, b, c | X], X]
最後の例のように、変数を使って差分リストを表現する方法はとくに重要です。変数は何にでもマッチングしますので、上記の 3 つの例はすべて最後の例とマッチングします。
?- [[a, b, c, d, e], [d, e]] = [[a , b, c | X], X]. X = [d, e] ; NO ?- [[a, b, c, f], [f]] = [[a , b, c | X], X]. X = [f] ; NO ?- [[a, b, c], []] = [[a , b, c | X], X]. X = [] ; NO
= はマッチングを行う述語です。一般に差分リストを使う場合、差分を表すデータには興味がありませんから、最後の例のように変数を使って表します。
差分リストは、通常のリストに比べてリスト操作が簡単になる、という利点があります。簡単な例題として、差分リストを結合する append_dl を作ってみましょう。第 1 引数と第 2 引数に結合する差分リストを与え、第 3 引数に結果を求めます。リストの結合 append に比べて簡単に定義することができます。
リスト:差分リストの結合 append_dl([Xs, Ys], [Ys, Zs], [Xs, Zs]).
差分リストを連結する場合、第 1 引数の尾部と第 2 引数の頭部がマッチングしないといけません。Ys が連結器の役割をしていると考えればいいでしょう。それでは実行してみます。
?- append_dl([[a, b, c | X], X], [[d, e | Y], Y], Z). X = [d, e | _G409] Y = _G409 Z = [[a, b, c, d, e | _G409], _G409] ; NO
変数 Z には、2 つの差分リストを連結した差分リストが求まっていますね。それでは、プログラムの動作を詳しく説明しましょう。差分リストのポイントは、Prolog の特徴である「自由変数同士のパターンマッチングは成功する」ことにあります。まず、第 1 引数のマッチングを見てみましょう。
[[a, b, c | X], X] = [Xs ,Ys]. Xs = [a, b, c | X] Ys = X
X と Ys は自由変数なので、パターンマッチングに成功します。この場合、X と Ys は自由変数のままであることに注意してください。したがって、X か Ys のどちらかがパターンマッチングに成功すれば、X と Ys の値が定まることになります。次に、第 2 引数のマッチングを見てみましょう。
[[d, e | Y], Y] = [Ys, Zs]. Ys = [d, e | Y] Zs = Y
Y と Zs は自由変数のままです。Ys がマッチングしたので、X の値が [d, e | Zs] になって、Xs の値は [a, b, c, d, e | Zs] となります。Y と Zs は自由変数のままです。 最後に、第 3 引数のマッチングを見てみましょう。
Z = [Xs, Zs]. => [[a, b, c, d, e | Zs], Zs]
Zs は自由変数のままです。変数 _G409 は Zs のことです。プログラムをコンパイルすると、変数名は Prolog の都合のいいように変換されます。このように、差分リストは自由変数の性質を上手に使っていて、Prolog らしいデータ構造といえるでしょう。
実際に差分リストを使う場合は、このような述語を使わずに直接リストを操作します。簡単な例題として、リストから整数を取り出す take_integer を、差分リストを使って書き直してみましょう。
リスト:take_integer 差分リスト版 take_integer(X, Y) :- take_int_sub(X, [Y, []]). take_int_sub([X | Xs], [Ys, Zs]) :- take_int_sub(X, [Ys, Ys1]), take_int_sub(Xs, [Ys1, Zs]), !. take_int_sub(X, [[X | Xs], Xs]) :- integer(X), !. take_int_sub(X, [Ys, Ys]).
take_int_sub は、差分リストを使ってリストを結合します。take-integer は、その結果を取り出します。take_int_sub の第 2 引数が差分リストです。3 行目の差分リスト [Ys, Ys1] と [Ys1, Zs] を、2 行目の [Ys, Zs] で結合します。この場合、Ys1 が連結器の役割をしています。
3 番目の規則で、X が整数の場合には、要素がひとつの差分リストを作ります。最後の規則で、整数以外のデータは空の差分リストを作ります。差分リストは、頭部と尾部が等しい場合が空になりますので、[Ys, Ys] となります。
それでは実行してみましょう。
?- take_integer([a, 1, [b, 2]], Z). Z = [1, 2] ; NO
正常に動作していますね。リストを結合する場合、append よりも差分リストを使った方が効率は良くなります。リストを分解して再構築するプログラムでは、差分リストを使ってみるといいでしょう。
Prolog は、バックトラックすることで、すべての解を見つけることができます。それでは、バックトラックによって見つけた解を、リストにまとめたい場合はどうするのでしょうか。たとえば、簡単な例題:家系図 で示した家系図を例に考えてみましょう。
┌─── 三郎 幸子 │ ┃──┤ ┌── 一郎 │ 太郎 │ └─── 洋子 ┃──┼── 次郎 花子 │ └── 友子 図:家系図
父親 X の子供をすべてリスト Ls に求めようとして、次のようなプログラムを作りました。
リスト:父親 X の子供をすべて L に求める all_children(X, Ls) :- children_sub(X, [], Ls). children_sub(X, C, Ls) :- father_of(X, C1), not(member(C1, C)), !, children_sub(X, [C1 | C], Ls). children_sub(_, Ls, Ls).
2 番目の規則で、father_of(X, C1) が成功する間は、第 2 引数のリストに子供を追加していきます。このとき、member を使って解が重複していないかチェックします。それでは実行してみましょう。
?- all_children(taro, L). L = [tomoko, jiro, ichiro] ; NO ?- all_children(ichiro, L). L = [youko, saburo] ; NO
父親の名前を指定すると、うまく動作します。それでは、父親を変数にすると、どうなるでしょうか。実際に試してみましょう。
?- all_children(X, L); X = taro L = [tomoko, jiro, ichiro] ; NO
父親が一郎の場合の関係を求めることができません。カットをはずすとバックトラックすることはできますが、正しい解を求めることはできません。また、バックトラックしたとしても、このままでは、すべての解をリストにまとめるということはできません。
このような場合、findall という述語を使います。まずは実行例を見てください。
?- findall(Y, father_of(X, Y), Ls). X = _G343 Y = _G342 Ls = [ichiro, jiro, tomoko, saburo, youko] ; NO
findall は第 2 引数をゴールとして実行し、そのときの第 1 引数の値をリスト Ls に追加します。そして、ゴールが失敗するまで繰り返します。
この例では、まず father_of(X, Y) を失敗するまで繰り返し実行します。成功した場合は第 1 引数 Y の値、つまり father_of(X, Y) の Y の値をリスト Ls に追加します。もし、ゴールの実行が一度でも成功しなかった場合は、Ls の値は空リストになります。
findall は 集合述語 と呼ばれます。ほかの述語と組み合わせることで、大変便利に使うことができます。このほかに、SWI-Prolog には bagof と setof という集合述語が用意されています。使用する機会がありましたら、詳しく説明したいと思います。
簡単な応用例として、「ハノイの塔」を解くプログラムを作りましょう。ハノイの塔は、棒に刺さっている大きさが異なる複数の円盤を、次の規則に従ってほかの棒に移動させるパズルです。
ハノイの塔は、再帰を使えば簡単に解ける問題です。たとえば、3 枚の円盤が左の棒に刺さっているとします。この場合、いちばん大きな円盤を中央の棒に移すには、その上の 2 枚の円盤を右の棒に移しておけばいいですね。いちばん大きな円盤を中央に移したら、右の棒に移した 2 枚の円盤を中央の棒に移すことを考えればよいわけです。したがって、n 枚の円盤を左から中央の棒に移すプログラムは次のように表現できます。
これを素直にプログラムすると次のようになります。
リスト:ハノイの塔 hanoi(1, From, To, _) :- write([From, to, To]), nl. hanoi(N, From, To, Via) :- N1 is N - 1, hanoi(N1, From, Via, To), write([From, to, To]), nl, hanoi(N1, Via, To, From).
N は動かす円盤の枚数、From は最初に円盤が刺さっている棒、To は円盤を移す棒、Via は残りの棒を示します。最初の規則が、動かす円盤の枚数が 1 枚の場合に対応し、再帰の停止条件になっています。
2 番目の規則は、円盤が複数枚ある場合に対応します。まず、円盤の枚数 N から 1 引いた値を N1 とします。次に hanoi を再帰呼び出しして、N1 枚の円盤を棒 Via に移動します。棒 From に残った円盤は 1 枚なので、それを棒 To に移動します。これを write で出力します。nl は改行するための述語です。最後に、棒 Via に移した円盤を棒 To に移動します。ここでも再帰呼び出しが行われます。
これで完成です。それでは実行してみましょう。
?- hanoi(3, a, b, c). [a, to, b] [a, to, c] [b, to, c] [a, to, b] [c, to, a] [c, to, b] [a, to, b] yes
きれいに印字したい場合は述語 format を使いましょう。この述語はC言語の関数 printf や Common Lisp の関数 format に相当する働きをします。format は表示に関していろいろな指定を行うことができますが、その分使い方が少しだけ複雑になります。
format('書式文字列', 引数リスト).
第 1 引数は書式文字列で、出力に関する様々な指定を行います。これにはアトムを使います。format はアトムをそのまま出力するのですが、文字列の途中にチルダ ~ が表れると、その後ろの文字を変換指示子として理解し、引数リストのデータをその指示に従って表示します。よく使われる指示子を表に示します。
指示子 | 機能 |
---|---|
a | アトムを表示する |
d | 整数を表示 (10進) |
e, f, g | 浮動小数点数を表示 (C言語 printf と同じ) |
r | 整数を表示 (2 - 32 までの基数を指定する) |
n | 改行 |
t | タブ |
~ | チルダ |
簡単な例を示しましょう。
?- format('number ~d , ~8r, ~16r~n', [256, 256, 256]). number 256, 400, 100 YES
書式文字列の中には、複数の変換指示子を設定することができます。チルダの前までは、そのまま文字を表示します。チルダ ~ の次の文字 d, r, n が変換指示子です。d と r は整数値を表示する指示子で、n は改行を表す指示子です。r の場合、~ の後ろに基数を指定することができます。与えるデータと指示子の数が合わないとエラーになります。ご注意くださいませ。
アトムを表示する場合は a 変換指示子を使い、チルダを出力したい場合は ~~ と続けて書きます。このほかにも、浮動小数点数を表示する指示子などがあります。それらの機能は、必要になった時点で説明することにしましょう。
format を使ってプログラムを書き換えると、次のようになります。
リスト:ハノイの塔 hanoi(1, From, To, _) :- format('~a to ~a~n', [From, To]). hanoi(N, From, To, Via) :- N1 is N - 1, hanoi(N1, From, Via, To), format('~a to ~a~n', [From, To]), hanoi(N1, Via, To, From).
それでは実行してみましょう。
?- hanoi(3, a, b, c). a to b a to c b to c a to b c to a c to b a to b yes
ソートは、ある規則に従ってデータを順番に並べることです。たとえば、データが整数であれば、大きい順かもしくは小さい順に並べます。ソートは昔から研究されている分野で、優秀なアルゴリズムが確立しています。その中でもクイックソートは、高速のアルゴリズムとして有名です。もちろん、Prolog でもクイックソートをプログラムすることができます。要素が整数のリストをクイックソートしてみることにしましょう。
クイックソートはある値を基準にして、要素をそれより大きいものと小さいものの 2 つに分割していくことでソートを行います。基準になる値のことを 枢軸 といいます。枢軸は、要素の中から適当な値を選んでいいのですが、リストの場合は、配列のように任意の箇所を簡単に選ぶことができません。この場合、いちばん簡単に求めることができる先頭の要素を枢軸とします。
リストを 2 つに分けたら、それらを同様にソートします。これは、再帰を使えば簡単に実現できます。その結果を枢軸を挟んで結合します。これを図に表すと次のようになります。
5 3 7 6 9 8 1 2 4 5 を枢軸に分割 (3 1 2 4) 5 (7 6 9 8) 3を枢軸に分割 7を枢軸に分割 (1 2) 3 (4) | 5 | (6) 7 (9 8) ・・・分割を繰り返していく・・・ 図:クイックソート
このようにリストを分割していくと、最後は空リストになります。ここが再帰の停止条件になります。あとは分割したリストを append で結合していけばいいわけです。プログラムは次のようになります。
リスト:クイックソート quick([X | Xs], Ys) :- partition(Xs, X, Littles, Bigs), quick(Littles, Ls), quick(Bigs, Bs), append(Ls, [X | Bs], Ys). quick([], []).
述語 quick は、リストをソートした結果を Ys にセットします。述語 partition は、リスト Xs を X より小さい Littles と、X より大きい Bigs に 2 分割します。そして、2 分割したリストに対して quick を再帰呼び出しします。最後に、append でソート済みのリスト Ls と Bs を枢軸 X を挟んで結合します。quick([ ], [ ]) が再帰呼び出しの停止条件です。
ここまではクイックソートの動作説明と同じなので簡単だと思います。リストを分割する partition はちょっとだけ複雑です。
リスト:分割 (Y が枢軸となる) partition([X | Xs], Y, [X | Ls], Bs) :- X =< Y, partition(Xs, Y, Ls, Bs). partition([X | Xs], Y, Ls, [X | Bs]) :- X > Y, partition(Xs, Y, Ls, Bs). partition([], Y, [], []).
第 1 引数のリストから先頭の要素 X を取り出して枢軸 Y と比較します。もし、枢軸より小さければ Ls に追加し、大きければ Bs に追加します。最初の規則が枢軸より小さい場合で、2 番目の規則が枢軸より大きい場合です。最後が空リストの場合で、これが再帰の停止条件になります。
プログラムはこれで完成です。それでは実行してみましょう。
?- quick1([5, 3, 7, 6, 9, 8, 1, 2, 4], X). X= [1, 2, 3, 4, 5, 6, 7, 8, 9] ; NO
正常に動作していますね。このプログラムは、差分リストを使うとリストの結合が簡単になります。差分リストに変更したプログラムを示しましょう。
リスト:差分リストを使ったクイックソート quick1(Xs, Ys) :- quick_sub(Xs, [Ys, []]). quick_sub([X | Xs], [Ys, Zs]) :- partition(Xs, X, Littles, Bigs), quick_sub(Littles, [Ys, [X | Ys1]]), quick_sub(Bigs, [Ys1, Zs]). quick_sub([], [Xs, Xs]).
quick_sub は差分リストを使ってリストを組み立てます。2 番目の規則では、Ys が差分リストの頭部で、Zs が尾部を表します。partition を呼び出して、リストを Littles と Bigs に分けるのは同じです。差分リスト [Ys, Zs] を [Ys, Ys1] + [Ys1, Zs] と考えて、再帰呼び出しを行います。
ただし、このままでは枢軸 X が仲間はずれですね。X を差分リストの後ろに追加するには、差分リストの尾部を [Ys, [X | Ys1]] とします。これで X が追加できるなんて不思議ですね。たとえば、X の値が 3 で Littles をソートした結果が [[1, 2 | Foo], Foo] とし、Bigs をソートした結果が [[4, 5 | Bar], Bar] だったとしましょう。マッチングは次のようになります。
[Ys , [3 | Ys1]] = [[1, 2 | Foo], Foo] Ys = [1, 2 | Foo] [3 | Ys1] = Foo => Ys = [1, 2, 3 | Ys1] [Ys1, Zs] = [[4, 5 | Bar], Bar] Ys1 = [4, 5 | Bar] Bar = Zs => Ys = [1, 2, 3, 4, 5 | Zs]
このように、枢軸 3 がきちんと追加されています。差分リストの扱いに慣れていないと、ちょっと難しいところだと思います。
ところで、今回は例題にクイックソートを取り上げましたが、リストをソートするならば「マージソート」の方が適しています。
8 クイーンは、コンピュータに解かせるパズルではとくに有名な問題です。8 クイーンは、8 行 8 列のチェスの升目に、8 個のクイーンを互いの利き筋が重ならないように配置する問題です。クイーンは将棋の飛車と角をあわせた駒で、縦横斜めに任意に動くことができます。解答の一例を次に示します。
列 1 2 3 4 5 6 7 8 *-----------------* 1 | Q . . . . . . . | 2 | . . . . Q . . . | 3 | . . . . . . . Q | 行 4 | . . . . . Q . . | 5 | . . Q . . . . . | 6 | . . . . . . Q . | 7 | . Q . . . . . . | 8 | . . . Q . . . . | *-----------------* 図:8 クイーンの解答例
8 クイーンを解くには、すべての置き方を試してみるしか方法はありません。最初のクイーンは、盤上の好きなところへ置くことができるので、64 通りの置き方があります。次のクイーンは 63 通り、その次は 62 通りあります。したがって、置き方の総数は 64 から 57 までの整数を掛け算した 178462987637760 通りもあります。これはとても大きな数ですね。
ところが、解答例を見ればわかるように、同じ行と列に 2 つ以上のクイーンを置くことはできません。上図の解答例をリストを使って表すと、 次のようになります。
1 2 3 4 5 6 7 8 <--- 列の位置 --------------------------- [1, 7, 5, 8, 2, 4, 6, 3] <--- 要素が行の位置を表す 図:リストでの行と列の表現方法
列をリストの位置に、行番号を要素に対応させれば、各要素には 1 から 8 までの数字が重複しないで入ることになります。すなわち、1 から 8 までの順列の総数である 8! = 40320 通りの置き方を調べればよいことになります。ぐっと数が減りましたね。パズルを解く場合は、そのパズル固有の性質をうまく使って、調べなければならない場合の数を減らすように工夫することが大切です。
順列を生成するプログラムは簡単に作成することができます。あとは、その順列が 8 クイーンの条件を満たしているかチェックすればいいわけです。このように、正解の可能性があるデータを作りそれをチェックするという方法を生成検定法 (generate and test)といいます。可能性のあるデータをもれなく作るのに、バックトラックは最適です。ただし、「生成するデータ数が多くなると時間がとてもかかる」という弱点があるので注意してください。
それではプログラムを作りましょう。最初に順列を発生する述語 perm を作ります。述語 perm(L, Z) は、リスト L の要素の順列を生成し、それを変数 Z にセットします。
リスト:順列の生成 perm([],[]). perm(Xs, [Z | Zs]) :- select(Z, Xs, Ys), perm(Ys, Zs).
述語 select を使ってリスト Xs から要素をひとつ選びます。その要素 Z をリストの先頭に加えます。次に、残りのリスト Ys の中から要素をひとつ取り出してリストに加えます。この処理は再帰を使えば簡単ですね。あとはリストの要素がなくなるまで、この処理を繰り返せばいいわけです。最初の規則が再帰呼び出しの停止条件です。
これでバックトラックが発生すれば、新しい順列を生成することができます。それでは実際に実行してみましょう。
?- perm([1, 2, 3], Z), write(Z), nl, fail. [1, 2, 3] [1, 3, 2] [2, 1, 3] [2, 3, 1] [3, 1, 2] [3, 2, 1] NO
1, 2, 3 の順列ですから全部で 6 通りあります。正常に動作していますね。
あとは perm で生成した順列が、条件を満たすかチェックすれば良いのです。チェックする述語を safe とすると、8 クイーンを解くプログラムは次のようになります。
queen(Q) :- perm([1,2,3,4,5,6,7,8], Q), safe(Q).
perm でデータを生成し safe で条件をチェックする、というたいへん簡単な構造になっています。これが生成検定法の基本型です。それでは safe を作りましょう。
リスト:利き筋が重なっていないか safe([Qt | Qr]) :- not(attack(Qt, Qr)), safe(Qr). safe([]).
リストの先頭の要素からチェックしていきます。衝突のチェックは斜めの利き筋を調るだけです。端にあるクイーンから順番に調べるとすると、斜めの利き筋は次のように表せます。
1 2 3 --> 調べる方向 *------------- | . . . . . . | . . . -3. . 5 - 3 = 2 | . . -2. . . 5 - 2 = 3 | . -1. . . . 5 - 1 = 4 | Q . . . . . Q の位置は 5 | . +1. . . . 5 + 1 = 6 | . . +2. . . 5 + 2 = 7 | . . . +3. . 5 + 2 = 8 *------------- 図:衝突の検出
図を見てもらえばおわかりのように、Q が行 5 にある場合、ひとつ隣の列は 4 と 6 が利き筋に当たります。2 つ隣の列の場合は 3 と 7 が利き筋に当たります。このように単純な足し算と引き算で、利き筋を計算することができます。これをプログラムすると次のようになります。
リスト:衝突の検出 attack(X, Xs) :- attack_sub(X, 1, Xs). attack_sub(X, N, [Y|Ys]) :- (X =:= Y + N ; X =:= Y - N). attack_sub(X, N, [Y|Ys]) :- N1 is N + 1, attack_sub(X, N1, Ys).
attack は、斜めの利き筋に当たった場合に成功する述語です。attack_sub は、リストの先頭から斜めの利き筋に当たるか調べます。第 1 引数がクイーンの位置、第 2 引数が位置の差分、第 3 引数がリストになります。
2 番目の規則で、リストから先頭の要素 Y を取りだし、利き筋に当たるか調べます。これは、Y + N または Y - N が X と等しいかチェックするだけです。衝突していない場合は失敗するので、3 番目の規則が選択されます。この規則はリストを分解して差分をひとつ増やし、attack_sub を再帰呼び出しするだけです。これで次のクイーンをチェックすることができます。
これでプログラムは完成です。それでは実行してみましょう。
?- queen_f(Q), write(Q), nl, fail. [1, 5, 8, 6, 3, 7, 2, 4] ・・・・・省略・・・・・ [8, 4, 1, 3, 6, 2, 7, 5] NO
解は全部で 92 通りあります。実行時間は Pentium 166 MHz で約 15 秒もかかります。実行時間はとても遅いですね。実はこのプログラム、とても非効率なことをやっているのです。
実行速度が遅い理由は、失敗することがわかっている順列も生成してしまうからです。
たとえば、最初 (1, 1) の位置にクイーンを置くと、次のクイーンは (2, 2) の位置に置くことはできませんね。したがって、[1, 2, X, X, X, X, X, X,] という配置はすべて失敗するのですが、順列を発生させてからチェックする方法では、このような無駄を省くことができません。
そこで、クイーンの配置を決めるたびに衝突のチェックを行うことにします。これをプログラムすると次のようになります。
リスト:8 クイーン (改良版) queen_f(Q) :- queen_sub([1,2,3,4,5,6,7,8], [], Q). queen_sub(L, SafeQs, Q) :- select(X, L, RestQs), not(attack(X, SafeQs)), queen_sub(RestQs, [X | SafeQs], Q). queen_sub([], Q, Q).
queen_sub は第 2 引数のリストに 8 クイーンの配置を格納します。まず select でリスト L からひとつの要素 X を選び、それが SafeQs に格納されているクイーンと利き筋が重ならないかチェックします。衝突しないことを確認したら、X をリスト SafeQs に加えて queen_sub を再帰呼び出します。最後の規則が再帰呼び出しの停止条件です。
このように、できるだけ早い段階でチェックを入れることで、無駄なデータをカットすることを 枝刈り と呼びます。バックトラックを使って問題を解く場合、この枝刈りのよしあしによって実行時間が大きく左右されるのです。
それでは実行結果を示します。
?- queen_f(Q), write(Q), nl, fail. [4, 2, 7, 3, 6, 8, 5, 1] ・・・・・省略・・・・・ [5, 7, 2, 6, 3, 1, 4, 8] NO
実行時間は約 0.7 秒まで短縮しました。ちなみに、Perl は約 1 秒かかりますので、これは速い方だと思います。ところで、今回は単純にリストを出力するだけなので、ちょっと面白くありません。最初に示した図のように、クイーンの配置を表示するプログラムを作るといいでしょう。これは簡単にプログラムできるので、皆さんにお任せいたします。いい練習問題になると思います。
それから、このプログラムではクイーンの数を簡単に変更できますが、数を増やすと実行時間が爆発的に増えるので、このままでは現実的ではありません。クイーンを増やす場合は、ほかの枝刈りを考えてみてください。