🪄

棒消しゲームの必勝法

に公開

棒消しゲームとは

2人のプレイヤーがルールに沿って交互に棒を消していき、最後の棒を消したプレイヤーが勝ちというゲーム。

初期配置として、図のようにいくつかの棒が数段に分かれて並んでいる。

||| \\ ||||| \\ |||||||

棒を消すルール

  • 1本以上消さないといけない
  • 消せる棒は同じ行のもののみ
  • すでに消されている棒を超えて消すことはできない

例えば、下図のような盤面だと、2段目は左右の棒を同時に消すことはできない。

||| \\ |\sout{|||}| \\ |||||||

必勝法

この棒消しゲームには必勝法がある。今回はその必勝法を書いていく。

用語の定義

この記事で使う用語を定義していく。

グループ

一度に消すことができる棒の集まり。
図Aだと、 3, 1, 1, 7 本からなる4つのグループがあることになる。

||| \\ |\sout{|||}| \\ ||||||| \\ {\footnotesize 図A} \\

図Bだと、 1, 1, 1, 2, 2, 1 本からなる6つのグループがあることになる。

|\sout{||} \\ |\sout{|||}| \\ ||\sout{|}||\sout{|}| \\ {\footnotesize 図B} \\

評価値

盤面に対して、各グループの本数のビット列で排他的論理和をとったものを評価値という。
図Aだと 0b011 \oplus 0b001 \oplus 0b001 \oplus 0b111 = 0b100となり、図Bだと 0b01 \oplus 0b01 \oplus 0b01 \oplus 0b10 \oplus 0b10 \oplus 0b01 = 0b00 となる。
グループが1つもないときの評価値は 0 とする。

必勝法について

初期盤面の評価値が 0 であれば後手必勝、そうでないときは先手必勝である。
戦術としては、評価値が 0 であるような盤面を、常に相手に渡すことである。

その戦術で必勝法になることを示すには、以下の3つを示さないといけない。

  • A: 評価値が 0 となる盤面であれば、どのような消し方をしても必ず評価値が 0 でない盤面になること
  • B: 評価値が 0 でない盤面であれば、盤面の評価値を 0 にできる消し方が必ず存在すること
  • C: 棒が存在しない盤面の評価値は 0 であること

証明

A の証明

あるグループ G の棒を消すことにする。グループ G を省いた盤面の評価値は、元々の評価値が 0 であることを考慮すると、グループ G 単体と同じ評価値となる。
グループ G の本数を n とし、手番でのプレイとして k 本消して a 本と b 本 とで分けることを考える。 x + y = (x \oplus y) + 2 * (x \land y) が成り立つことに注意すると、

n - k = a + b = (a \oplus b) + 2 * (a \land b) \\

が成り立ち、式変形をして、

(a \oplus b) = n - k - 2 * (a \land b)

となる。ここで、手番プレイ後の盤面の評価値 n \oplus a \oplus b を考えると、

n \oplus a \oplus b = n \oplus (n - k - 2 * (a \land b))

となるが、ルール上最低でも1本は消さないといけないため k \geq 1 であり、 a \land b \geq 0 であることから、

n - k - 2 * (a \land b) \leq n - 1 \lt n

n にならないので、 n \oplus (n - k - 2 * (a \land b))0 とならない。つまり、盤面の評価値が 0 でないような消し方しかできない。

B の証明

評価値が x (\neq 0) の盤面を考える。 x をビット列で表した時の最上位ビットに着目する(それを 0 始まりの n 位とおく)。例えば x = 13 であれば 0b1101 から 3 位が最上位ビットになる。
評価値の計算方法から、 n 位にビットが立っている本数を持つグループ G が必ず存在する。そのグループ G が棒を消す対象となる。

アイデアとしては、2^n 本を犠牲にして、他のビットの 1 が消えるようにすることである。具体的には、 x - 2^n 本の棒を以下のようにうまく消す。 2^n にしか影響がないように、端からではなく真ん中から消すのがミソである。

\underbrace{\underbrace{\overbrace{|||| \cdots |||||}^{x - 2^n}\sout{|||| \cdots ||||}}_{2^n}||| \cdots |||||}_{G の本数} \\

そうすることで、グループ G から n 位のビットが消え、 x - 2^n のビットが新たに立つ。結果的に x \oplus 2^n \oplus (x - 2^n) = 0 となり、盤面の評価値を 0 にする消し方が存在することになる。

C の証明

棒がないということは、グループが1つもないということなので、その盤面の評価値は 0 となる。

対戦ゲーム

以上の証明で、棒消しゲームは必勝法があるゲームということが分かった。
対戦相手のコンピュータがその必勝法を使ってくる、対戦プログラムを書いてみた。

対戦プログラム
from dataclasses import dataclass
import random
import sys


# 入力エラーの例外
class InputError(Exception):
    pass

# グループの本数を管理するクラス
@dataclass(frozen=True)
class Group:
    count: int

    def __init__(self, count: int) -> None:
        if count <= 0:
            raise ValueError("Non positive number.")

        object.__setattr__(self, "count", count)

    def __str__(self) -> str:
        return str(self.count)

# ゲーム状態を管理するクラス
class Game:
    groups: list[Group]

    def __init__(self, groupCount: int, minCount: int, maxCount: int) -> None:
        """
        Args:
            groupCount int: グループの数
            minCount int: 各グループの最小本数
            maxCount int: 各グループの最大本数
        """

        if groupCount <= 0 or minCount <= 0 or maxCount <= 0:
            raise ValueError("Game init args are wrong.")

        # ランダムに盤面を作成する
        groups = []
        for _ in range(groupCount):
            groups.append(Group(random.randint(minCount, maxCount)))

        self.groups = groups

    # プレイを実行する
    def play(self, before: int, afterA: int, afterB: int) -> None:
        """
        Args:
            before int: 削除対象のグループの本数
            afterA int: 新規にできるグループの本数
            afterB int: 新規にできるグループの本数
        """

        # 入力値が不正の時
        if before < 1 or afterA < 0 or afterB < 0 or before <= afterA + afterB:
            raise InputError(f"Value is wrong.")

        # 削除対象グループの探索
        for index, group in enumerate(self.groups):
            if group.count == before:
                # 削除対象グループの削除
                del self.groups[index]
                break
        else:
            # 削除対象グループが鳴ければ例外を出す
            raise InputError(f"No exist {before}.")

        # 新規にできたグループの追加
        if afterA > 0:
            self.groups.append(Group(afterA))
        if afterB > 0:
            self.groups.append(Group(afterB))

    # 評価値の計算
    def evaluete(self) -> int:
        e = 0
        for group in self.groups:
            e ^= group.count

        return e

    # 勝敗判定
    def isWin(self) -> bool:
        return len(self.groups) == 0

    def __str__(self) -> str:
        return str(list(map(lambda x: str(x), self.groups)))

# 必勝法通りプレイする関数
def getBestStrategy(game: Game) -> (int, int, int):
    x = game.evaluete()
    n = x.bit_length() - 1

    # 必勝盤面でないときはランダムで棒を消す
    if x == 0:
        target = game.groups[random.randint(0, len(game.groups) - 1)].count
        afterA = random.randint(0, target - 1)
        afterB = random.randint(0, target - afterA - 1)
        return target, afterA, afterB

    # 必勝法通り棒を消す
    target = 0
    for group in game.groups:
        if group.count & 2 ** n != 0:
            target = group.count
            break
    else:
        raise ValueError(f"No exist.")

    return target, target - 2 ** n, x - 2 ** n

def main(game: Game):
    print(game)

    # 手番の決定
    print("Are you first move? [y/n]: ")
    move = input()
    if move == "y":
        turn = 0
    elif move == "n":
        turn = 1
    else:
        exit()

    # ゲームの実行
    while True:
        # 挑戦者のターン
        if turn == 0:
            if game.evaluete() == 0:
                # 負けが決まっているときのメッセージ
                print("----- player turn (YOU LOSE!!!) -----")
            else:
                print("----- player turn -----")

            print(game)
            try:
                before, afterA, afterB = map(int, input().split())
            except KeyboardInterrupt:
                break
            except:
                print("input error!")
                continue

            try:
                game.play(before, afterA, afterB)
            except InputError:
                print("input error!")
                continue
            turn = 1

            if game.isWin():
                print("you win")
                break

        # 必勝法を使ってくるコンピュータのターン
        elif turn == 1:
            print("----- computer turn -----")
            print(game)

            before, afterA, afterB = getBestStrategy(game)
            print(str(before) + " " + str(afterA) + " " + str(afterB))

            game.play(before, afterA, afterB)
            turn = 0

            if game.isWin():
                print("computer win")
                break

        else:
            raise ValueError(f"Something wrong.")


# ゲーム盤面の作成
args = sys.argv
game = Game(
    int(args[1]), # グループの数
    int(args[2]), # 各グループの最小本数
    int(args[3]), # 各グループの最大本数
)

# ゲーム開始
main(game)

使い方

実行は引数に3つを指定する。

  • 第1引数: グループの数
  • 第2引数: 各グループの最小本数
  • 第3引数: 各グループの最大本数

例では、初期盤面として4から6本からなるグループが3つ作られる。

$ python3 main.py 3 4 6

初期盤面が表示され、最初に先攻か後攻かを決められる。先攻なら y 、後攻なら n を指定する。

['4', '4', '5']
Are you first move? [y/n]:
y

手番時にプレイ内容を以下のように指定する。

  • 1つ目の数字: 削除対象にするグループの本数
  • 2つ目の数字: 削除後にできる1つ目のグループの本数
  • 3つ目の数字: 削除後にできる2つ目のグループの本数

例では、5本からなるグループを 0 本と 0 本のグループに分ける(全消し)するプレイになる。

----- player turn -----
['4', '4', '5']
5 0 0

コンピュータがプレイする。必勝法が使える盤面なら必勝法を、そうでないときはランダムでプレイする。

----- computer turn -----
['4', '4']
4 1 0
----- player turn -----
['4', '1']
4 1 0

最終的に、全部のグループがなくなったら勝敗が決まる。

----- player turn -----
['4', '1']
4 1 0
----- computer turn -----
['1', '1']
1 0 0
----- player turn -----
['1']
1 0 0
you win

必勝法への挑戦

もしコンピュータの方に必勝盤面が渡ると、下記の表示に変わって勝てないことを示される。
その表示が出ないように、最初の手番選択と、各手番のプレイを間違えないように進めること(必勝法で進めていくこと)に挑戦してほしい。

----- player turn (YOU LOSE!!!) -----

亜種ルール

最後の棒を消したプレイヤーが勝ちというルールにしたが、最後の棒を消したプレイヤーが負けというルールも存在する。実はその場合でも、前述した方法をほぼ同じ方法で必勝法があることが示せる。

1つのグループを除いて全てのグループの本数が1本になるまでは、前述した必勝法でプレイすればいい。その状況になれば、2本以上あるグループを 0 本か 1 本を残して消せばいい。その本数の選択は、消した後のグループ数が奇数になるようにする。

1つのグループを除いて全てのグループの本数が1本になる、という状況は盤面の評価値が 0 でないときに限られるので、必勝法の手番の人にしかこない。

おわりに

このゲームの存在自体は中学生の頃に知り、どこかのサイトでこの記事と同じ必勝法が解説されていた。そのサイトでは方針だけで、数学的な証明がなかったので、ちゃんと証明したいなと思っていたら、めちゃくちゃ時が経ってしまっていた。

証明だけではなく対戦プログラムも書いたので、是非皆さん必勝法を使って対戦してみてもらいたい。

余談

A の証明がパッと思いつかなかったので Claude Sonnet 4 に聞いてみたら、証明の概要を書いてくれた。プロンプトとしては、この記事の「棒消しゲームとは」と「必勝法」という章をそのまま渡してやっただけ。最初出されたものが8割り程度の出来栄えだったが、部分的に再度聞き返すことで詳細の説明もしてくれた。どうせ無理だろうと高を括っていたが、想像以上にできていたので動揺した。

Discussion