20x20 Chompの解析

20x20 Chompの解析

2025/07/08 投稿

はじめに

Chompは、二人で行う単純なルールのゲームですが、その必勝法は未だ完全には解明されていません。特に、盤面が大きくなると計算量が爆発的に増加するため、大規模な盤面の解析は困難とされてきました。現状、数学者Doron Zeilberger氏による14x14盤面までの探索が知られています。

今回、私は20x20までのすべての長方形盤面について、先手必勝となる初手をすべて列挙するChompソルバーを開発しました。本記事では、効率的なメモ化盤面のビット表現について解説します。

Chompと計算の壁

Chompの盤面状態は、左と上を揃えたフェラーズ図形として表現できます。ゲームの探索は、この盤面状態を頂点とするグラフ上の探索問題とみなせます。しかし、盤面のサイズが大きくなるにつれて状態の数は組み合わせ的に増加します。

例えば、N x Mの盤面の状態数は、組み合わせの計算 C(N+M, N) で求められます。20x20の盤面では、その状態数は C(40, 20) となり、これは膨大な数になります。すべての状態を探索するには、計算時間だけでなく、各状態の評価結果(勝ち・負け)を記録するためのメモリも大きな課題となります。

探索アルゴリズム

探索の基本戦略として、メモ化を利用したNegamax法(αβ枝刈り込み) を採用しました。一度探索した局面の結果を保存し、再利用することで計算量を削減します。しかし、前述の通り、20x20盤面の状態をすべて記録するには、単純な方法ではメモリが不足します。この問題を解決したのが、本プログラムの核心であるメモ化の実装方法です。

高効率なメモ化テーブル

20x20盤面の探索を実現するため、メモリ使用量を極限まで削減するメモ化テーブルを設計しました。キーとなるアイデアは「盤面状態の完全ハッシュ化」と「状態の2ビット化」です。

1. 盤面状態を37ビットの数値に変換する (完全ハッシュ)

Chompの盤面(フェラーズ図形)が C(N+M, N) 通りあることを利用し、各盤面を0から C(N+M, N) - 1 までの整数値に一意に対応させます(これをランク付け完全ハッシュと呼びます)。
具体的には、盤面の形状を{0, 1}のステップで表現される格子路上の経路とみなし、その経路が辞書順で何番目に来るかを計算することでキーを生成します。この計算には二項係数(組み合わせの数)が用いられます。
// src/ferrers_key.cpp より uint128_t make_key_sub(const uint8_t *rows, uint8_t height) { // (抜粋) const int L = N_GRID_SIZE + M_GRID_SIZE; // 40 int remT = L; int remR = M_GRID_SIZE; // 20 uint128_t rank = 0; // ... 盤面の形状に応じて二項係数を足し合わせ、ランクを計算 ... return rank; }
20x20盤面では、状態数 C(40, 20) を表現するために log2(C(40, 20))、すなわち約37ビットが必要となります。この37ビットの整数値が、各盤面を一意に識別するキー(メモ化テーブルのアドレス)となります。

2. 1つの局面を2ビットで表現

各局面の状態は「勝ち (Win)」「負け (Lose)」「未探索 (Unexplored)」の3種類です。これらを表現するには2ビットあれば十分です(例: 00: 未探索, 01: 勝ち, 10: 負け)。

そこで、巨大な uint8_t の配列を確保し、各局面のキー(アドレス)に基づいて、その中の2ビットだけを読み書きするように実装しました。
2bitあれば実際には4通りの状態が表現できるので、より圧縮することで1つの局面を最小log2(3) ≈ 1.585ビットで表現できますが、アクセスの効率を考慮して、2ビットで表現することにしました。
// src/memo_table.cpp より // キーに対応する2ビットの状態を取得 uint8_t get_state(uint128_t key) { uint128_t bit = key * STATE_BITS; // STATE_BITS = 2 uint128_t idx = bit >> 3; // 8で割ってバイト位置を計算 uint8_t off = bit & 7; // 8の剰余でビットオフセットを計算 return (memo[idx] >> off) & 0x3; // 該当する2ビットを読み出す } // キーに対応する2ビットの状態を設定 void set_state(uint128_t key, uint8_t v) { uint128_t bit = key * STATE_BITS; uint128_t idx = bit >> 3; uint8_t off = bit & 7; memo[idx] &= ~(0x3u << off); // 対象の2ビットをクリア memo[idx] |= ((v & 0x3u) << off); // 新しい値を書き込む }

なぜハッシュマップではダメなのか?

一般的な std::unordered_map のようなハッシュマップを使う場合、キー(盤面)と値(勝ち/負け)を格納するために、1つのエントリあたり最低でも8バイト(64ビット)以上は必要になるでしょう。また、キー自体を格納する必要もあり、メモリ効率が悪化します。
一方、本手法ではキーをアドレスとして直接利用し、値を2ビットに圧縮しているため、1局面あたり 2/8 = 0.25 バイトのメモリ効率を達成しています。20x20の探索には約34GBのメモリが必要です。

探索結果

本プログラムを用いて20x20までの長方形盤面を解析した結果、この範囲にも先手必勝手が高々2つの初期盤面しか存在しないという事実が確認できました。多くの盤面で必勝手はただ1つでした。

まとめ

本プロジェクトは、Chompという古典的なゲームの20x20盤面の探索を行いました。

ソースコードはGitHubで公開しています。個人の開発によるものであり、正確性は保証できません。誤りを発見された場合はぜひご報告ください。