2002/12/21 文:T.G HTML化:竹内
人工知能の分野における代表的な手法として探索法があります。探索法は、最短経路の発見やゲームの探索など多くの分野で応用されています。今回は、ゲームの探索に関して取り扱いたいと思います。(ゲームと言っても色々ありますが、今回は特に運の要素が無い頭を使う2人で対戦するゲームを扱います。)
ゲームにおけるお互いの可能な手とそれによって変化する局面は、木によって表現することができます。局面を評価することができれば、探索で良い手を求めることができます。主にゲームの木の探索には、ゲームの終わりまで探索して必勝の手を判断するAND/OR木探索と、ある程度の深さまで呼んだあと、局面の静的な評価を計算して最も良い手を決定するミニマックス法があります。前者はオセロの終盤や詰将棋、後者はオセロの中盤や将棋の指し将棋に使われる手法です。
そこで、今回は簡単なゲームをAND/OR木探索で解くことに関して、実際にC言語によるプログラムを書いて説明することにします。
次のようなルールのゲーム(石取りゲーム)を、探索によって先手が勝つことができるかどうかを求めることにします。図1はゲームの初期局面です。
[ルール]
|
・2つの円があり、それぞれを山と呼ぶ |
図1 石取りゲームの初期局面
このゲームは先手必勝と言えます。なぜなら、先手が右から1個の石を取れば、後手は左右どちらかから1個だけを取ることしかできず、そのあと先手が残りの1個を取ってしまうからです。
初期局面で先手は、左から1個、右から1個、右から2個の3種類の作戦があることがわかります。作戦は一般に手と呼ばれます。この先手の最初の手を図で表現すると次の図2のようになります。
図2 先手の最初の手
今度は、先手の手、後手の手、更に先手の手と交互になっている手を木で表現してみます。また、ゲームの終わりにはどちらが勝ったかを書くことにします。その結果、図3のようになります。このような木はゲームの木と呼ばれます。
図3 ゲームの木
この木をコンピュータで表現して探索するのが今回のテーマとなります。そこで、C言語で記述することにします。(プログラムの全体は最後に載せます。)
最初に、定数から決めることにしましょう。先手と後手を適当な異なる数字で定義します。同様に、勝ちと負けに関しても定義します。山の数は左と右の2つなので2とします。
#define SENTE 0 /* 先手 */
#define GOTE 1 /* 後手 */
#define KACHI 1 /* 勝ち */
#define MAKE -1 /* 負け */
#define YAMA_KAZU 2 /* 山の数 */
次に大域変数として局面を作ります。必要な情報は「それぞれの山にある石の数」と「手番」です。石の数の変数は配列として、添え字が0の方を左の石の数、1の方を右の石の数とすることにします。手番には前に定義したSENTEかGOTEが代入されることになります。
/* 局面(大域変数) */
int ishi_kazu[YAMA_KAZU]; /* 石の数 */
int teban; /* 手番 */
手の形式は構造体で定義することにします。必要な情報は、どちらの山から石を取るのかと、何個の石を取るのかなので、2つの変数を用意します。
/* 手の形式 */
struct move{
int basho; /* どの山の石か */
int kazu; /* 何個石を取るか */
};
ゲームでは局面と手の表示が必要ですので、そのための関数も作っておきます。
void kyokumen_hyouji(void); /* 局面を表示する関数 */
void te_hyouji(struct move te); /* 手を表示する関数 */
ある局面の「手を生成する関数」とその手で「局面を進める関数」と「局面を戻す関数」を示します。「手を生成する関数」の引数には配列へのポインタを指定して、その配列に現在局面における手を全て列挙します。この関数は、戻り値として何個の手を生成したのかを返します。ちなみに、戻り値が0のときは手が無い、つまり負けであるということになります。
int te_seisei(struct move *te_list); /* 手を生成する関数 */
void kyokumen_susumeru(struct move te); /* 次の手によって局面を進める関数 */
void kyokumen_modosu(struct move te); /* 前の手によって局面を戻す関数 */
探索の関数tansakuを示します。この関数は自分と同じ関数を呼び出す再帰関数となっています。引数でこの局面における最善手を得ることができます。この最善手は初期局面における実際に指す手を決定するのに使います。戻り値は勝ちか負けかとなります。この関数では最初、探索順を見てみるために、kyokumen_hyouji関数を呼びます(テスト用なので、実戦では必要ありません。)。次に、te_seisei関数で手を配列に列挙して、手の個数を得ます。手の個数が0ならこの局面は負けなのでMAKEを返します。続いて(for文から)、1つめの生成した手で局面を進めて、新しい局面で同じ関数tansakuを実行します。(探索前にte_hyouji関数を呼びますが、これもテスト用です。)tansaku関数で得られた相手にとっての評価値を、変数aite_hyoukaに格納します。そのあと局面を戻します。aite_hyoukaがMAKEだったら、その手を指すことで相手側の負けにすることができるので、KACHIを返します。もし相手側の勝ちだったら、更に別の手で探索します。この局面での全ての手が自分にとっての負けであることがわかったらMAKEを返します。このような、1つでも勝ちなら勝ちであり、全部負けなら負けであるという木の探索をAND/OR木探索と呼びます。
int tansaku(struct move *saizenshu); /* 探索する関数 */
最後に局面を初期化する関数shokikaとmain関数を示します。局面を初期化する関数は、大域変数である局面を初期局面とする関数です。ゲームのルールの通り、左の石を1個、右の石を2個にして、手番を先手とします。main関数では、局面を初期化する関数と探索する関数を呼んで、探索した結果を画面に表示します。
void shokika(void); /* 局面を初期化する関数 */
int main(void); /* main 関数 */
プログラムを実行すると次のように表示されます。
左の石=1, 右の石=2, 先手
左から1個
左の石=0, 右の石=2, 後手
右から1個
左の石=0, 右の石=1, 先手
右から1個
左の石=0, 右の石=0, 後手
右から2個
左の石=0, 右の石=0, 先手
右から1個
左の石=1, 右の石=1, 後手
左から1個
左の石=0, 右の石=1, 先手
右から1個
左の石=0, 右の石=0, 後手
右から1個
左の石=1, 右の石=0, 先手
左から1個
左の石=0, 右の石=0, 後手
-----------------------
勝つ手があります
右から1個
右から1個取れば勝つことができるという正しい結果を得ることができました。画面に表示された局面の表示を見ると、どのような順番で探索したのかを見ることができます。図4に示します。
図4 探索順
この図から、最初に木の一番左を一気に下まで探索して、少しずつ戻りつつ探索している様子がわかります。このような探索法を深さ優先探索と呼びます。また、初期局面で右側を2個取るのは探索してないことがわかります(図の枝刈り)。探索しなかった理由は、初期局面で2番目に探索した「右側の石を1個取る」という手で勝ちとなることがわかったので、AND/OR木探索においてはもう探索する必要がないと判断することができるからです。
今回は探索法の説明をしました。探索法で重要なことの1つは、少しでも早く答えを見つけることです。そのため、問題に合わせてどのような順番で探索するのが有効であるのかを考える必要があります。
最後にゲームプログラミングのおすすめの本を示しておきます。共立出版の「ゲームプログラミング」と「コンピュータ将棋の進歩1〜3」です。これらは、主に思考系ゲームを扱っている文献です。特に探索法に関して詳しく解説されていますので、今回の内容に興味を持った人にとっては面白い本であると思います。
[プログラム]
#include <stdio.h>
#define SENTE 0 /* 先手 */
#define GOTE 1 /* 後手 */
#define KACHI 1 /* 勝ち */
#define MAKE -1 /* 負け */
#define YAMA_KAZU 2 /* 山の数 */
/* 局面(大域変数) */
int ishi_kazu[YAMA_KAZU]; /* 各山の石の数 */
int teban; /* 手番 */
/* 手の形式 */
struct move{
int basho; /* どの山の石か */
int kazu; /* 何個石を取るか */
};
/* 局面を表示する関数 */
void kyokumen_hyouji(void){
printf("左の石=%d, 右の石=%d, ",ishi_kazu[0],ishi_kazu[1]);
if(teban == SENTE)printf("先手\n");
else printf("後手\n");
}
/* 手を表示する関数 */
void te_hyouji(struct move te){
if(te.basho == 0)printf("左から");
else printf("右から");
printf("%d個\n",te.kazu);
}
/* 手を生成する関数 */
int te_seisei(struct move *te_list){
int i,j;
int te_kazu = 0; /* 手の数を0で初期化 */
for(i=0;i<YAMA_KAZU;i++){ /* それぞれの山について手を生成 */
for(j=1;j<=ishi_kazu[i];j++){ /* 1個〜山の石の数まで */
te_list[te_kazu].basho = i; /* 山を指定 */
te_list[te_kazu].kazu = j; /* 取る石の数を指定 */
te_kazu++; /* 手の数を増やす */
}
}
return te_kazu; /* 手の数を返す */
}
/* 次の手によって局面を進める関数 */
void kyokumen_susumeru(struct move te){
ishi_kazu[te.basho] -= te.kazu; /* 石の数を減らす */
if(teban==SENTE)teban = GOTE; /* 手番を変える */
else teban = SENTE;
}
/* 前の手によって局面を戻す関数 */
void kyokumen_modosu(struct move te){
ishi_kazu[te.basho] += te.kazu; /* 石の数を戻す */
if(teban==SENTE)teban = GOTE; /* 手番を変える */
else teban = SENTE;
}
/* 探索する関数 */
int tansaku(struct move *saizenshu){
int i;
int te_kazu; /* 手の数 */
int aite_hyouka; /* 手を指した後の相手にとっての評価値 */
struct move aite_saizenshu; /* 次の局面の最善手 */
struct move te_list[100]; /* 手のリスト */
kyokumen_hyouji(); /* 局面を表示する */
te_kazu = te_seisei(te_list); /* 手を生成 */
if(te_kazu == 0)return MAKE; /* 手が無いので負け */
for(i=0;i<te_kazu;i++){ /* それぞれの手で探索 */
kyokumen_susumeru(te_list[i]); /* 局面を進める */
te_hyouji(te_list[i]); /* 手を表示する */
aite_hyouka = tansaku(&aite_saizenshu); /* 探索する */
kyokumen_modosu(te_list[i]); /* 局面を戻す */
*saizenshu = te_list[i]; /* 最善手の候補を格納 */
if(aite_hyouka == MAKE)return KACHI; /* 1つ勝ちなら勝ち */
}
return MAKE; /* 全部負けなので負け */
}
/* 局面を初期化する関数 */
void shokika(void){
ishi_kazu[0] = 1; /* 左の石の数 */
ishi_kazu[1] = 2; /* 右の石の数 */
teban = SENTE; /* 手番 */
}
/* main 関数 */
int main(void){
struct move te; /* 実際に指す手 */
int hyouka; /* この局面の評価値 */
shokika(); /* 局面を初期化 */
hyouka = tansaku(&te); /* 探索する */
printf("------------------------\n");
if(hyouka==KACHI){ /* 勝つ手がある */
printf("勝つ手があります\n");
te_hyouji(te); /* 手を表示 */
}
else printf("勝つ手がありません\n"); /* 勝つ手がない */
return 0;
}