Developer

4×4 オセロ完全解析【第3回】
2020.05.11
Lv3

4×4 オセロ完全解析【第3回】

アルゴリズム(承前)

前編・中編で4×4オセロの完全解析を行うために、ビットボードというデータ構造と、
ビット演算を用いてビット数を高速にカウント・反転処理などのためのアルゴリズムを解説した。
本稿では、ビットボードを使ったアルゴリズムとして、完全解析を行うための深さ優先探索、および解析結果を解説する。

深さ優先探索

ゲーム木探索は、大きく言うと、深さ優先探索と幅優先探索の2種類がある。
深さ優先探索とは、先へ先へと局面を進めていき、末端に到達したらひとつ前に戻って、
次の手を探索するというアルゴリズムだ。
単純で高速なので、ゲーム木が小さく全探索可能であればこれで充分だ。

下記は深さ優先探索で全局面を探索し、双方最善手を打った場合の石数差を返すものだ。
双方最善という仮定で読み進めていくので、末端評価を常に黒から見た石数差だとすれば、
黒番では次のノードの最大値を、白番では最小値をそのノードの評価値として返す。
だが、そうすると白黒でコードを2つ書かなくてはいけないので、白番のときは白黒
を交換し、同じ negaMAx() をコールしている。
このように、白黒交換が非常に簡単で、ほぼコスト0で可能なのもビットボードの利点のひとつだ。

int negaMax(ushort black, ushort white)
{
    if( 末端局面 )
        return numOfBits(black) - numOfBits(white);
    int maxVal = INT_MIN;
    foreach(p すべての空欄) {
        ushort rev = get_rev(black, white, p);  //  反転パターン取得
        if( rev != 0 ) {                        //  石が返る場合
            doPut(black, white, p, rev);        //  石を返す
            int v = -negaMax(white, black);     //  白黒反転して自分自身を再帰コール
            if( v > maxVal ) max = v;
            doPut(black, white, p, rev);        //  反転した石を戻す
        }
    }
    return maxVal;
}

自分自身をコールする部分は以下のように最適化することもできる。
そうすれば、状態を元に戻す必要すらない。

        .....
        if( rev != 0 ) {                        //  石が返る場合
            //  白黒反転して自分自身を再帰コール
            int v = -negaMax(white ^ rev, black ^ rev ^ p);
            if( v > maxVal ) max = v;
        }
        .....

実は、上記コードは打つ箇所が無くてパスになった場合に対応していない。
以下に、パスに対応したコードを示す。
なお、双方パスになった場合は終局となるが、その場合、空欄部分は勝者のものと
なるよう2014年にルールが変更されているので、それにも対応している。

int negaMax(ushort black, ushort white, int nspc, bool pass)            //  黒番深さ優先探索
{
    if( nspc == 0 ) {
        return numOfBits(black) - numOfBits(white);
    }
    ushort space = ~(black | white);    //  空欄の部分だけビットを立てる
    int maxev = -9999;
    bool put = false;
    while( space != 0 ) {
        const ushort p = space & -space;      //  一番右のビットのみ取り出す
        ushort rev = getRev(black, white, p);   //  反転パターン取得
        if( rev != 0 ) {                                    //  石が返る場合
            int ev = -negaMax(white^rev, black|p|rev, nspc-1, false);
            maxev = max(maxev, ev);
            put = true;
        }
        space ^= p;                     //  一番右のビットをOFFにする
    }
    if( put )       //  パスでない場合
        return maxev;
    if( pass ) {    //  白黒双方がパスの場合
        //maxev = numOfBits(black) - numOfBits(white);      //  2014年以前の日本ルール
        //  2014年以降の新ルール
        const auto nb = numOfBits(black);
        const auto nw = numOfBits(white);
        maxev = nb - nw;
        if( nb > nw ) maxev += nspc;
        else if( nb < nw ) maxev -= nspc;
        return maxev;
    } else
        return -negaMax(white, black, nspc, true);;
}

課題

全局面を調査するのではなく、双方最善の場合の石数差のみを求めるのであれば
アルファベータ探索を行って、処理時間を短縮することができる。アルファベータ関数
またはネガアルファ関数を実装し、動作確認・処理時間比較をしてみなさい。

置換表(トランスポジションテーブル)

ある局面から、着手A, B, C と 着手C, B, A の様な場合に、異なる手順で同一局面になることがある。
このような場合に、同一局面を再度探索しないよう、局面を置換表に登録して回避し
探索局面を減らすことできる。

将棋やチェスの場合は、コマの種類、位置に乱数を割り当て、それらの排他的論理和をハッシュキー
とすることが多いが、 4×4オセロでビットボードを用いる場合、32ビット数値で局面を表現できるので、
32ビット数値をそのままハッシュキーに用いることができる。

本稿では下リストの様に std::unordered_map を利用した。局面の登録・検索は同クラスのメソッド・
演算子を利用する。
局面は (black<<16) | white をキーとし、手番は黒番固定とする。
同一局面の白番は白黒を入れ替え((white<<16) | black がキー)て登録する。

std::unordered_map<uint, char> g_tt;     //  局面→石差 トランスポジションテーブル

    g_tt[makeUint(black, white)] = v;      //  局面・石差を登録
    g_tt.find(makeUint(black, white))      //  局面登録チェック

置換表を用いたネガマックス関数のコードを以下に示す。

//  黒番深さ優先探索、トランスポジションテーブル使用版
int negaMaxTT(bitboard_t black, bitboard_t white, int nspc, bool pass)
{
    uint32 key = (black<<16) | white;
    auto itr = g_tt.find(key);      //  局面がトランスポジションテーブルに登録されているかチェック
    if( itr != g_tt.end() )         //  登録済みの場合
        return itr->second;
    if( nspc == 0 ) {
        //cout << boardText(black, white) << "\n";
        auto v = numOfBits(black) - numOfBits(white);
        g_tt[key] = v;          //  トランスポジションテーブルに追加
        g_tt[(white<<16) | black] = -v;     //  白の手番であっても終局なので、白黒反転も追加
        return v;
    }
    .....
    if( put ) {     //  パスでない場合
        //return maxev;
    } else if( pass ) { //  白黒双方がパスの場合
        //maxev = numOfBits(black) - numOfBits(white);      //  2014年以前の日本ルール
        //  2014年以降の新ルール
        const auto nb = numOfBits(black);
        const auto nw = numOfBits(white);
        maxev = nb - nw;
        if( nb > nw ) maxev += nspc;
        else if( nb < nw ) maxev -= nspc;
    } else {
        maxev = -negaMaxTT(white, black, nspc, true);;
    }
    g_tt[key] = maxev;      //  探索結果をトランスポジションテーブルに追加
    return maxev;
}

完全解析結果

以下に本プログラムで計算した双方最善手順を示す。
初手はどこに打っても対称なので、d3 に打つものとした。

4x4 オセロ完全解析【第3回】

2手目白は斜めが最善。4×4オセロでも序盤に角を取るのは有利なようだ。
5手目は「たぬき定石」というか黒も角を取るが、このあと白は意図的に相手に角を取らせる
有段者的打ち回しで、辺・中心部分を確定させ、2個空きで終局。黒3、白11個だが、残った
空欄は勝者のものなので、白の (11+2)-3 = 10石勝ちとなる。

最後に

データ構造としてビットボードを使用し、深さ優先探索で4×4オセロを完全解析するための
アルゴリズムについて解説した。ビット演算に馴染みがない人にはちょっと難しかった
かもしれないが、有効なプログラミングテクニックなのでぜひマスターしてただきたい。

NxNの盤面で各マスの状態数が限られている場合は、状態をビットで表すことで、
無条件分岐・演算並列化により高速化できる場合があるので、ビットボードを利用できないか
検討してみるといいかもしれない。

課題

4×6, 4×8, 6×6 オセロの完全解析に挑戦してみなさい

関連記事

投稿が見つかりません。

筆者:津田伸秀
プロフィール:テニス・オセロ・ボードゲーム・パズル類が趣味の年齢不詳のおじさん。 自宅研究員(主席)。vi と C++が好き。迷走中・・・ ボードゲーム・パズル系アプリ開発・リリースしてます。