M.Hiroi's Home Page
http://www.geocities.jp/m_hiroi/

Puzzle DE Programming

思考ゲーム「MiniMax」(1)

[ Home | Puzzle ]

ゲームの説明

今回はパズルではありませんが、簡単な思考ゲームを作ってみましょう。8 行 8 列の盤面に配置された数字を取り合って、合計点が多い方が勝ちというゲームです。名前はたしか MiniMax だったと思いますが、ちょっと記憶があやふやです。

ルールは簡単です。盤面には 1 から 9 までの + と - の数字がランダムに配置されています。最初、先手は 0 行目の中から数字を選びます。次の図を見てください。

  5 -4  9 [6]-5 -3 -6 -9 ← 先手はこの行から選ぶ  
  2 -1  5 -1  8  3 -4 -6
  9  8 -6 -5  2  7 -7 -1
  3  3  9  5 -6 -1  3 -2
 -2  1 -4  1 -9 -5 -6 -8
 -9 -4  8  4  7 -2 -9  2
 -8 -4  8 -5  1 -5 -5  1
  1  2  5 -9 -5 -8 -7 -8
          ↑
          └─────── 後手はこの列から選ぶ

        図:MiniMax ゲーム進行(1)

盤面の行と列は、プログラムに合わせて 0 から数えることにします。先手は 0 行 3 列目 (0, 3) の 6 を選びました。後手が数字を選ぶ場合は、相手が選んだ数字と同じ列の中から選ばなければいけません。この場合は 3 列目から選ぶことになります。つまり、先手は相手が選んだ数字と同じ行から、後手は同じ列から数字を選ぶのです。あとはこれを交互に繰り返します。そして、行または列の数字が無くなったらゲーム終了です。次の図を見てください。

  先手   7 点 : 後手   2 点
   ** ** ** ** ** -3 ** **
   ** ** ** ** **  3 -4 **
   ** ** -6 -5 ** ** ** **
    3  3  9  5 -6 -1  3 -2
   -2 ** -4 ** -9 -5 -6 -8
   -9 [4] 8  4 ** -2 -9 **  ← 先手は 4 を選ぶ
   -8 ** ** ** ** -5 ** **
   ** ** ** -9 ** -8 ** **
      ↑
      └─────── 後手は 3 を選びゲーム終了  

        図:MiniMax ゲーム進行(2)

先手が 7 : 2 で勝っている状況です。ここで、5 行目から数字を選びますが、1 列目の 4 を選ぶと、その列には数字が 3 のひとつしか残りませんね。したがって、後手は 3 を選ぶしかなく、ここでゲームが終了します。11 : 5 で先手の勝ちとなります。

このゲームは単純なので、思考ルーチンも簡単に作ることができます。思考ルーチンの基本アルゴリズム ミニマックス法 を使って数手先読みするだけで、ほとんどの人は勝てなくなるでしょう。


●プログラムの説明

とりあえず、DOS 窓で動作するプログラムをC言語で作りました。GUI ではないので、操作は面倒だとは思いますが、興味のある方は遊んでみてください。そのうちに Tcl/Tk 版でも作りましょう。思考ルーチンの強さはレベル 1 から 6 までの 6 段階あり、オプションスイッチ -L で指定することができます。デフォルトではレベル 3 です。

それではプログラムを説明しましょう。最初に盤面を表すデータ構造を定義します。リバーシなどのボードゲームの場合、1 次元配列を使って盤面を表した方が便利なのですが、今回は簡単に 2 次元配列を使います。そして、この配列をグローバル変数として定義します。

リスト:グローバル変数の定義

int board[SIZE][SIZE];  /* 8行8列 */
int first_value = 0;
int last_value = 0;
int depth = 3;          /* 先読みの手数(default) */

この方法では、先読みするときに盤面を直接書き換えるため、先読みした局面から前の局面に戻す処理が必要になります。このほかに、盤面を局所変数として表す方法もあります。この場合、先読みするたびに局面をコピーしなければいけません。そのかわり、元に戻す処理は不要になります。

たとえば「KALAH(カラー)」のように、1 手指すたびに盤面の大部分が変化するようなゲームでは、盤面を局所変数として定義すればいいでしょう。また、今回のゲームのように、盤面の一部分しか変化しないゲームでは、グローバル変数として定義するのがふつうです。

あと、先手と後手の点数を first_value と last_value で表します。これらの値も先読みするときに書き換えらるので、元の値に戻す処理が必要になります。depth が先読みの手数を表します。たとえば、デフォルトの 3 であれば「自分−相手−自分」の 3 手分先読みします。

●ゲーム木の探索

次は、指し手を選ぶ処理を作りましょう。この処理は、ゲームの木を深さ優先で探索して、ミニマックス法で指し手を選びます。

リスト:先手の選択

int select_first( int his, int prev_move, int *move )
{
  if( his == depth ){
    return first_value - last_value;  /* 先読み終了 */
  } else {
    int x;
    int py = GET_Y( prev_move );
    int value = NO_VALUE;
    for( x = 0; x < SIZE; x++ ){   /* 先手は横方向 */
      if( board[x][py] != NO_VALUE ){
	int v, m;
	int save_board = board[x][py];
	int save_value = first_value;
	first_value += board[x][py];
	board[x][py] = NO_VALUE;
	if( check_finish( x, py ) ){
	  v = finish_value();	      /* 終了 */
	} else {
	  v = select_last( his + 1, MAKE_XY( x, py ), &m );
	}
	/* 単純なミニマックス -- 大きい方を選ぶ */
	if( value == NO_VALUE || v > value ){
	  value = v;
	  *move = MAKE_XY( x, py );
	}
	board[x][py] = save_board;   /* 盤面を元に戻す */
	first_value = save_value;
      }
    }
    return value;
  }
}

select_first は先手の指し手を選択します。後手の指し手を選択する関数が select_last です。選んだ指し手は引数 *move に格納し、そのときの評価値を返します。引数 his が先読みの深さを表し、prev_move は相手の指し手を表します。下位 8 ビットが行 ( y ) の位置、上位 8 ビットが列 ( x ) の位置を表しています。この値を取り出すためマクロ GET_X, GET_Y を、また値を生成するためマクロ MAKE_XY を定義して使っています。

his が depth に達したら先読みは終了です。そのときの局面を評価します。評価関数はとても簡単で、first_value と last_value の差を求めるだけです。したがって、値がプラスであれば先手が有利、マイナスになれば後手が有利となります。こんな単純な評価関数だからといって、甘く見てはいけません。先読みの手数を増やすだけで、思考ルーチンはぐっと強くなります。

探索の途中であれば、盤面から数字を選んで後手側の選択処理を呼び出します。数字を取り除いた場所には NO_VALUE が格納されているので、それ以外の場所から数字を取り出します。このとき、盤面の値を元に戻すため、取り出した数字と先手の値 first_value を保存しておきます。関数 check_finish はゲームの終了をチェックします。そのときの評価値は finish_value で計算します。ゲームが終了していなければ、後手の選択処理 select_last を呼び出します。どちらの評価値も変数 v に格納します。

次の処理がミニマックス法の心臓部です。先手は評価値の大きい方が有利なわけですから、いちばん大きな評価値(マックス)の指し手を選びます。選んだ指し手は *move に格納し、その評価値は value に格納しておきます。評価値 v が value よりも大きい値であれば、その指し手を選べばいいわけです。あとは、すべての指し手を調べるだけです。後手側の選択処理ならば、逆にいちばん小さな評価値(ミニマム)の指し手を選択すればいいのです。ミニマックス法の処理はたったこれだけです。とても簡単ですね。

まだ指し手を選んでいない場合、value は NO_VALUE に初期化されているので、最初に調べた指し手がセットされます。この処理は value の初期値を工夫すると省略することができます。これはαβ枝刈りを実装するときに改良することにしましょう。それから、次の指し手を調べる前に、盤面の状態を元に戻すことをお忘れなく。

●ゲーム終了のチェック

ゲーム終了のチェックは簡単です。取った数字の位置の行と列をチェックするだけです。

リスト:終了のチェック

int check_finish( int x, int y )
{
  int i, xf = 0, yf = 0;
  /* 縦、横で数字が無くなったか */
  for( i = 0; i < SIZE; i++ ){
    if( board[x][i] != NO_VALUE ) xf = 1;
    if( board[i][y] != NO_VALUE ) yf = 1;
  }
  if( xf == 0 || yf == 0 ) return TRUE;
  return FALSE;
}

とくに難しいところはないですね。終了時の評価値は、通常の評価値よりも大きな値を返します。

リスト:終了時の評価値

int finish_value( void )
{
  if( first_value > last_value ){
    /* 先手の勝ち */
    return MAX_VALUE + first_value - last_value;
  } else if( first_value < last_value ){
    /* 後手の勝ち */
    return MIN_VALUE + first_value - last_value;
  } else {
    /* 引き分け */
    return 0;
  }
}

先手が勝ちの場合は MAX_VALUE(1000) より大きな値、後手が勝ちの場合は MIN_VALUE(-1000) よりも小さな値を返します。通常の評価値 first_value - last_value を返すと、勝負がついていない局面の評価値の方を選択する場合があり、ズルズルとゲームが続いてしまうのです。特別な値を返すことにより、ミニマックス法の中で勝敗がつく指し手を選ぶことができます。

●ゲームの進行

あとはゲームを進める処理を作るだけです。プログラムは次のようになります。

リスト:ゲームの進行

void play( void )
{
  int prev_move = 0;
  int turn = 1;
  while( TRUE ){
    int move, x, y;
    if( turn & 0x01 ){             /* 先手 */
      y = GET_Y( prev_move );
      print_board( y );
      move = input_number( y );
      x = GET_X( move );
      printf( "先手 (%d,%d) %d を選びます:", x, y, board[x][y] );
      first_value += board[x][y];
    } else {                       /* 後手(COM) */
      select_last( 0, prev_move, &move );
      x = GET_X( move );
      y = GET_Y( move );
      printf( "後手 (%d,%d) %d を選びます:", x, y, board[x][y] );
      last_value += board[x][y];

    }
    board[x][y] = NO_VALUE;
    prev_move = move;
    printf("先手 %3d 点 : 後手 %3d 点 \n", first_value, last_value );
    turn++;
    if( check_finish( x, y ) ){
      printf("試合終了\n");
      break;
    }
  }
}

変数 turn で手番を表します。奇数のときは先手、偶数のときは後手となります。先手のときは人間側の入力を受け付けます。後手のときは select_last を呼び出して、ミニマックス法により指し手を選びます。この場合、指し手だけが必要なので評価値は使っていません。あとは指定された位置から数字を取り除き、ゲームの終了をチェックします。

ゲームの骨格となるプログラムを説明しました。詳細は プログラムリスト を読んでください。ミニマックス法はあまりに簡単だったので、拍子抜けされた方もいるでしょう。次回はαβ枝刈りを実装して、その効果を確認してみたいと思います。


●プログラムリスト

/*
 * minimax.c : 数字を取り合うゲーム
 *
 *             Copyright (C) 2000 by Makoto Hiroi
 */

#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>
#include <time.h>

/* 定数定義 */
#define TRUE  1
#define FALSE 0
#define SIZE  8
#define FIRST 0
#define LAST  1
#define	RSIZE 256  /* リードバッファサイズ */
#define NO_LINE (-1)
#define NO_VALUE  10000
#define MAX_VALUE 1000
#define MIN_VALUE (-1000)

/* マクロ */
#define GET_X( c )      ((c)>> 8)
#define GET_Y( c )      ((c) & 0xff)
#define MAKE_XY( x, y ) (((x) << 8) + (y))

/* 関数宣言 */
int select_last( int his, int prev_move, int *move );

/* グローバル変数定義 */
int board[SIZE][SIZE];    /* 8行8列 */
int first_value = 0;
int last_value = 0;
int depth = 3;            /* 先読みの手数(default) */

/* 盤面の初期化 */
void init_board( void )
{
  int x, y;
  for( x = 0; x < SIZE; x++ ){
    for( y = 0; y < SIZE; y++ ){
      int n = rand();
      if( n > RAND_MAX / 2 ){
	board[x][y] = - (n % 9 + 1);
      } else {
	board[x][y] = n % 9 + 1;
      }
    }
  }
}

/* 盤面の出力 */
void print_board( int line )
{
  int x, y;
  printf("  ");
  for( x = 0; x < SIZE; x++ ){
    printf("%3d", x );
  }
  printf("\n  --------------------------\n");
  for( y = 0; y < SIZE; y++ ){
    printf("%c ", ( line == y ? '[' : ' ' ) );
    for( x = 0; x < SIZE; x++ ){
      if( board[x][y] == NO_VALUE ){
	printf( " **" );
      } else {
	printf( "%3d", board[x][y] );
      }
    }
    printf(" %c", ( line == y ? ']' : ' ' ) );
    printf("\n");
  }
}

/****** 思考ルーチン ******/

/* 終了時の評価値 */
int finish_value( void )
{
  if( first_value > last_value ){
    /* 先手の勝ち */
    return MAX_VALUE + first_value - last_value;
  } else if( first_value < last_value ){
    /* 後手の勝ち */
    return MIN_VALUE + first_value - last_value;
  } else {
    /* 引き分け */
    return 0;
  }
}

/* 終了のチェック */
int check_finish( int x, int y )
{
  int i, xf = 0, yf = 0;
  /* 縦、横で数字が無くなったか */
  for( i = 0; i < SIZE; i++ ){
    if( board[x][i] != NO_VALUE ) xf = 1;
    if( board[i][y] != NO_VALUE ) yf = 1;
  }
  if( xf == 0 || yf == 0 ) return TRUE;
  return FALSE;
}


/* 先手の選択 */
int select_first( int his, int prev_move, int *move )
{
  if( his == depth ){
    /* 先読み終了 */
    return first_value - last_value;
  } else {
    int x;
    int py = GET_Y( prev_move );
    int value = NO_VALUE;
    /* 先手は横方向 */
    for( x = 0; x < SIZE; x++ ){
      if( board[x][py] != NO_VALUE ){
	int v;
	int m;
	int save_board = board[x][py];
	int save_value = first_value;
	first_value += board[x][py];
	board[x][py] = NO_VALUE;
	if( check_finish( x, py ) ){
	  /* 終了 */
	  v = finish_value();
	} else {
	  /* 後手番 */
	  v = select_last( his + 1, MAKE_XY( x, py ), &m );
	}
	/* 単純なミニマックス -- 大きい方を選ぶ */
	if( value == NO_VALUE || v > value ){
	  value = v;
	  *move = MAKE_XY( x, py );
	}
	/* 盤面を元に戻す */
	board[x][py] = save_board;
	first_value = save_value;
      }
    }
    return value;
  }
}

/* 後手の選択 */
int select_last( int his, int prev_move, int *move )
{
  if( his == depth ){
    /* 先読み終了 */
    return first_value - last_value;
  } else {
    int y;
    int px = GET_X( prev_move );
    int value = NO_VALUE;
    /* 後手は縦方向 */
    for( y = 0; y < SIZE; y++ ){
      if( board[px][y] != NO_VALUE ){
	int v;
	int m;
	int save_board = board[px][y];
	int save_value = last_value;
	last_value += board[px][y];
	board[px][y] = NO_VALUE;
	if( check_finish( px, y ) ){
	  /* 終了 */
	  v = finish_value();
	} else {
	  /* 先手番 */
	  v = select_first( his + 1, MAKE_XY( px, y ), &m );
	}
	/* 単純なミニマックス -- 小さいを選ぶ */
	if( value == NO_VALUE || v < value ){
	  value = v;
	  *move = MAKE_XY( px, y );
	}
	/* 盤面を元に戻す */
	board[px][y] = save_board;
	last_value = save_value;
      }
    }
    return value;
  }
}

/* 数値入力 */
int input_number( int y )
{
  int pos;
  char buffer[RSIZE];
  printf("0 - 7 の数値を入力してください ");
  for(;;){
    if( fgets( buffer, RSIZE, stdin ) != NULL ){
      if( sscanf( buffer, "%d", &pos ) == 1 ){
        if( 0 <= pos && pos <= 7 ){
          if( board[pos][y] == NO_VALUE ){
            printf("%d には数字がありません\n", pos );
            continue;
          }
          break;
        }
      }
    }
    printf("\n入力エラー:0 - 7 の数値を入力してください\n");
  }
  return MAKE_XY(pos, y);
}

/* ゲームの実行 */
void play( void )
{
  int prev_move = 0;
  int turn = 1;
  while( TRUE ){
    int move, x, y;
    if( turn & 0x01 ){
      /* 先手 */
      y = GET_Y( prev_move );
      print_board( y );
      move = input_number( y );
      x = GET_X( move );
      printf( "先手 (%d,%d) %d を選びます:", x, y, board[x][y] );
      first_value += board[x][y];
    } else {
      /* 後手(COM) */
      select_last( 0, prev_move, &move );
      x = GET_X( move );
      y = GET_Y( move );
      printf( "後手 (%d,%d) %d を選びます:", x, y, board[x][y] );
      last_value += board[x][y];

    }
    board[x][y] = NO_VALUE;
    prev_move = move;
    printf("先手 %3d 点 : 後手 %3d 点 \n", first_value, last_value );
    turn++;
    if( check_finish( x, y ) ){
      printf("試合終了\n");
      break;
    }
  }
}

/* 引数解析 */
void getargs( int argc, char *argv[] )
{
  int i;
  for( i = 1; i < argc && argv[i][0] == '-'; i++ ){
    int opt = toupper( argv[i][1] );
    if( opt == 'L' ){
      int lv = atoi( &argv[i][2] );
      if( lv < 1 || lv > 6 ){
        fprintf( stderr, "レベルは 1 - 6 を指定してください\n");
        exit( EXIT_FAILURE );
      }
      depth = lv;
    } else {
      fprintf( stderr, "不正なオプションスイッチです\n" );
      exit( EXIT_FAILURE );
    }
  }
}

int main( int argc, char *argv[] )
{
  srand( time( NULL ) );
  getargs( argc, argv );
  init_board();
  play();
  return 0;
}

Copyright (C) 2000-2003 Makoto Hiroi
All rights reserved.

[ Home | Puzzle ]