用 Python 实现 Take-Away 游戏

87 阅读11分钟

背景

Great Theoretical Ideas in Computer Science 这门课程中提供了相关的 资源,其中包括 lecture02。在 lecture02 中,我们可以看到一个名为 Take-Away 的游戏

image.png

最初,有 2121chip\text{chip} (或者别的什么东西)。两位玩家轮流取走若干 chip\text{chip}(取走的 chip\text{chip} 的数量只能是 1,2,31,2,3 中的某一个)。取走最后一个 chip\text{chip} 的玩家获胜。我们可以将这个游戏一般化 ⬇️

  • 最初有 NN 个物品
  • 两位玩家 A,B\text{A,B} 轮流取走物品(每次取走的物品数量只能是 m1,m2,,mkm_1,m_2,\cdots,m_k 中的某一个,这 kk 个正整数需要提前约定好)
  • A\text{A} 是先手
  • 如果在某位玩家刚取走物品后,没有任何物品剩下,则这位玩家获胜
  • 如果某位玩家无法执行合法的操作,则这位玩家失败(那么,另一位玩家获胜)

那么 lecture02 中提到的 Take-Away 游戏就是如下的特例

  • N=21N=21
  • k=3k=3 并且 m1=1,m2=2,m3=3m_1=1,m_2=2,m_3=3

正文

如何判定自己是否有必胜策略?

以上文提到的 Take-Away 游戏的特例为例,我们想一想是否有“必胜”的策略(“必胜”是指无论对方如何操作,我们都有办法获胜)。假设我方是玩家 A\text{A}(即,先手方)。最初有 2121 个物品,感觉不好分析。那我们看看数字比较小的情况

  • 如果最初有 11 个物品,那么我方直接取走这个物品,立刻获胜
  • 如果最初有 22 个物品,那么我方直接取走这 22 个物品,立刻获胜
  • 如果最初有 33 个物品,那么我方直接取走这 33 个物品,立刻获胜

以上三种情况有点像中国象棋/国际象棋里的 一步杀。下面看更复杂的情况 ⬇️

如果最初有 44 个物品,按照规则我们可以在“取走 11 个物品”,“取走 22 个物品”,“取走 33 个物品” 之间挑一个

  • 如果我们取走 11 个物品,那么对方会看到 41=34-1=3 个物品,对方有“必胜”的策略
  • 如果我们取走 22 个物品,那么对方会看到 42=24-2=2 个物品,对方有“必胜”的策略
  • 如果我们取走 33 个物品,那么对方会看到 43=14-3=1 个物品,对方有“必胜”的策略

所以,只要对方认真玩,当我们开局遇到 44 个物品时,我方处于“必败”的局面。继续分析,可以总结出如下的表格 ⬇️

轮到我方时
剩余物品的数量(个)
我方是否“必胜”?解释
11全部取走即可
“一步杀”
22全部取走即可
“一步杀”
33全部取走即可
“一步杀”
44我们操作完之后,对方总是可以将我们“一步杀”
55移走 11 个物品,这样就能让对方看到 44 个物品
“两步杀”
66移走 22 个物品,这样就能让对方看到 44 个物品
“两步杀”
77移走 33 个物品,这样就能让对方看到 44 个物品
“两步杀”
88我们操作完之后,对方总是可以将我们“两步杀”
99移走 11 个物品,这样就能让对方看到 88 个物品
“三步杀”
1010移走 22 个物品,这样就能让对方看到 88 个物品
“三步杀”
1111移走 33 个物品,这样就能让对方看到 88 个物品
“三步杀”
1212我们操作完之后,对方总是可以将我们“三步杀”
.........

在上表中,我们关心的是,对我方遇到的一个特定局面,我们应该将它标记为 ✅ 还是 ❌。观察上表,不难猜测 ⬇️

  • 开局时,如果物品数量 NN 满足 4N4|N,那么玩家 A\text{A}(即,先手玩家)是“必败”的(玩家 B\text{B} 有办法让玩家 A\text{A} 输)
  • 开局时,如果物品数量 NN 不满足 4N4| N,那么玩家 A\text{A} 是“必胜”的(玩家 A\text{A} 可以构造出 xx 步杀,其中 x=N4+1x=\lfloor \frac{N}{4} \rfloor + 1)。用数学归纳法,可以证明这个猜测是正确的(证明 “33 个 ✅ 和 11 个 ❌ 会交替出现” 也是一个可行的思路)。

按照这个逻辑,轮到我方操作时,如果面前是 00 个物品,应当认为我方失败(如果严格按照规则来的话,其实对方玩家取完物品后,对方就立刻获胜了),这与游戏规则是一致的。

如果每次可以取走的物品的数量 m1,m2,,mkm_1,m_2,\cdots,m_k 刚好是 1,2,,k1,2,\cdots,k 的一个排列,那么用类似的方法,可以分析出 ⬇️

  • 开局时,如果物品数量 NN 满足 kNk|N,那么玩家 A\text{A}(即,先手玩家)是“必败”的(玩家 B\text{B} 有办法让玩家 A\text{A} 输)
  • 开局时,如果物品数量 NN 不满足 kNk| N,那么玩家 A\text{A} 是“必胜”的(玩家 A\text{A} 可以构造出 xx 步杀,其中 x=Nk+1x=\lfloor \frac{N}{k} \rfloor + 1)。用数学归纳法,可以证明这个猜测是正确的(证明 “k1k-1 个 ✅ 和 11 个 ❌ 会交替出现” 也是一个可行的思路)。

如果 m1,m2,,mkm_1,m_2,\cdots,m_k 之间没有特别的关系,例如 k=3k=3m1=2,m2=3,m3=5m_1=2,m_2=3,m_3=5,我不知道有没有巧妙的办法可以快速计算出各种局面下是否有必胜策略。但我们仍旧可以用上述的朴素思路来进行分析,即,从遇到 00 个物品的局面开始(此时先手失败),逐步分析遇到 11 个物品,22 个物品,33 个物品等情况下,是否有必胜策略。

k=3k=3m1=2,m2=3,m3=5m_1=2,m_2=3,m_3=5 为例,我们可以这样分析 ⬇️

轮到我方时
剩余物品的数量(个)
我方是否“必胜”?解释
00相当于对方取走了最后的物品,我方失败
11我方没有合法的操作,我方失败
22全部取走即可
33全部取走即可
44我们可以取走 33 个物品,让对方陷入面对 11 个物品的必败局面
55全部取走即可
66我们可以取走 55 个物品,让对方陷入面对 11 个物品的必败局面
77无论我方如何操作,都会让对方看到“必胜”局面,所以我方“必败”
88无论我方如何操作,都会让对方看到“必胜”局面,所以我方“必败”
99我们可以取走 22 个物品,让对方陷入面对 77 个物品的必败局面
1010我们可以取走 33 个物品,让对方陷入面对 77 个物品的必败局面
1111我们可以取走 33 个物品,让对方陷入面对 88 个物品的必败局面
1212我们可以取走 55 个物品,让对方陷入面对 77 个物品的必败局面
1313我们可以取走 55 个物品,让对方陷入面对 88 个物品的必败局面
1414无论我方如何操作,都会让对方看到“必胜”局面,所以我方“必败”
1515无论我方如何操作,都会让对方看到“必胜”局面,所以我方“必败”
1616我们可以取走 22 个物品,让对方陷入面对 1414 个物品的必败局面
.........

就这个特例而言,我们可以猜测出,遇到以下两类局面时,先手“必败”,其他情况则是先手“必胜”。

  • N0(mod7)N\equiv 0 \pmod 7
  • N1(mod7)N\equiv 1 \pmod 7

不难证明 55 个 ✅ 和 22 个 ❌ 会交替出现,所以上方的猜测是正确的。

lecture02 中提到了专门的术语

  • P-Position(上一步的玩家有必胜策略的局面): 相当于刚才我们标记 ❌ 的那些局面
  • N-Position(当前玩家有必胜策略的局面): 相当于刚才我们标记 ✅ 的那些局面

image.png

核心步骤的代码

基于以上分析,我们可以用 Python\text{Python} 来实现核心步骤(即,判定是否有必胜策略)的逻辑。为了让游戏具有一般性,我们允许用户在开始游戏时,指定“可以移走的物品的数量”。例如用户可以指定,只能移走 3,4,53,4,5 个物品。那么如果某个玩家遇到了当前物品数为 22 的局面,这位玩家就会因为无法操而失败。(请注意:以下代码只展示了 TakeAwayGame 中的部分逻辑,无法直接运行,完整的代码在下一小节)

class TakeAwayGame:
    def __init__(self):
        # 下一行的 20 没有特别的含义,可以将其调整为其他值
        self.candidate_num_upper_bound = 20
        self.candidate_nums = []
        self.has_win_strategy = []
        self.curr_stone_cnt = 0
        self.game_over = False

        self.setup_candidate_nums()
        self.display_candidate_nums()
        self.calculate_win_strategy()
        self.pick_start_num()

    def calculate_win_strategy(self):
        # 下一行的 7 没有特别的含义,可以将其调整为其他值(如果 self.candidate_nums 是 [1, 2, 3],则 max_stone_cnt = 21)
        max_stone_cnt = max(self.candidate_nums) * 7
        self.has_win_strategy = [None] * (max_stone_cnt + 1)

        for i in range(len(self.has_win_strategy)):
            self.has_win_strategy[i] = self.is_win_possible(i)

    def is_win_possible(self, curr_stone_cnt):
        for candidate_num in self.candidate_nums:
            if curr_stone_cnt < candidate_num:
                break
            if not self.has_win_strategy[curr_stone_cnt - candidate_num]:
                return True
        return False

代码里用的是“石头(stone)”这个词,其实和上文的 chip\text{chip}、“物品”没有本质区别。

  • self.candidate_nums\text{self.candidate\_nums} 中会保存用户指定的 m1,m2,,mkm_1,m_2,\cdots,m_k
    • 为了避免 m1,m2,,mkm_1,m_2,\cdots,m_k 中出现很大的数字,我用 self.candidate_num_upper_bound\text{self.candidate\_num\_upper\_bound} 来表示 mim_i 的上限
  • is_win_possible\text{is\_win\_possible} 方法用于判定在当前局面下,是否有必胜策略(也就是说,当前局面是不是 N-Position
  • calculate_win_strategy\text{calculate\_win\_strategy} 方法用于遍历所有可能出现的局面,并判定每个局面是否有必胜策略(即,那个局面是不是 N-Position

完整的代码

除了核心逻辑外,还需要支持以下功能

  • 处理和用户的交互(包括展示必要的信息)
  • 驱动整个游戏的流程(玩家总是先手)
  • 保证在初始局面下,玩家有必胜策略(这样玩家在开局时,有胜利的可能)
  • 特殊情况的处理(例如当前剩余 44 个石头,但是只允许取走 55 个石头或者 77 个石头,那么此时当前玩家失败)

这些功能都不复杂,就不赘述实现细节了。我在开发过程中,得到了 trae 的大力协助。

完整的代码如下 ⬇️

import re

class TakeAwayGame:
    def __init__(self):
        # 下一行的 20 没有特别的含义,可以将其调整为其他值
        self.candidate_num_upper_bound = 20
        self.candidate_nums = []
        self.has_win_strategy = []
        self.curr_stone_cnt = 0
        self.game_over = False

        self.setup_candidate_nums()
        self.display_candidate_nums()
        self.calculate_win_strategy()
        self.pick_start_num()

    def get_user_input(self):
        nums = input("Please input the allowed number of stones to remove\n[Press enter to use default values (i.e. 1, 2, 3)]\n>>> ")
        if not nums:
            nums = "1, 2, 3"
        return nums

    def parse_input(self, user_input):
        try:
            return [int(num.strip()) for num in re.split(r'[, \t]', user_input) if num.strip()]
        except ValueError:
            print("Invalid input: ", user_input)
            return []

    def validate_nums(self, nums):
        for num in nums:
            if num <= 0:
                print("Number (%d) should be greater than 0" % (num))
                return False
            if num >= self.candidate_num_upper_bound:
                print("Number (%d) should be less than the upper bound (%d)" % (num, self.candidate_num_upper_bound))
                return False
        return True

    def setup_candidate_nums(self):
        while True:
            user_input = self.get_user_input()
            nums = self.parse_input(user_input)
            if not nums:
                continue
            if not self.validate_nums(nums):
                continue
            self.candidate_nums = sorted(nums)
            break

    def display_candidate_nums(self):
        print("The allowed number of stones to remove are listed as follows:")
        for candidate_num in self.candidate_nums:
            print(candidate_num)
        print()

    def calculate_win_strategy(self):
        # 下一行的 7 没有特别的含义,可以将其调整为其他值(如果 self.candidate_nums 是 [1, 2, 3],则 max_stone_cnt = 21)
        max_stone_cnt = max(self.candidate_nums) * 7
        self.has_win_strategy = [None] * (max_stone_cnt + 1)

        for i in range(len(self.has_win_strategy)):
            self.has_win_strategy[i] = self.is_win_possible(i)

    def is_win_possible(self, curr_stone_cnt):
        for candidate_num in self.candidate_nums:
            if curr_stone_cnt < candidate_num:
                break
            if not self.has_win_strategy[curr_stone_cnt - candidate_num]:
                return True
        return False

    def pick_start_num(self):
        init_stone_cnt = len(self.has_win_strategy) - 1

        while True:
            if self.has_win_strategy[init_stone_cnt]:
                break
            init_stone_cnt -= 1

        self.curr_stone_cnt = init_stone_cnt
        print("In the beginning, there are %d stones" % init_stone_cnt)
        self.display_stones()

    def get_player_move(self):
        while True:
            try:
                raw_num = input("Press enter the number to remove to continue ...\n>>> ")
                specified_num = int(raw_num)
                if specified_num not in self.candidate_nums:
                    print("The number (%d) is not in the allowed number list: %s" % (specified_num, self.candidate_nums))
                    continue
                if specified_num > self.curr_stone_cnt:
                    print("The number (%d) should be less than or equal to the current number of stones (%d)" % (specified_num, self.curr_stone_cnt))
                    continue
                return specified_num
            except ValueError:
                print("Invalid input: ", raw_num)
                continue

    def process_player_move(self, specified_num):
        self.curr_stone_cnt -= specified_num
        print("You removed %d stone(s) (%s)" %  (specified_num, "🪨" * specified_num))
        print()
        print("Current number of stones: %d" % self.curr_stone_cnt)
        self.display_stones()
        self.game_over = self.curr_stone_cnt == 0
        if self.game_over:
            print("You win!")

    def get_computer_choice(self):
        if self.has_win_strategy[self.curr_stone_cnt]:
            return self.find_a_good_num()
        min_candidate_num = self.find_min_candidate_num()
        if min_candidate_num <= self.curr_stone_cnt:
            return min_candidate_num
        return None

    def apply_computer_move(self, choice):
        self.curr_stone_cnt -= choice
        print("Computer removed %d stone(s) (%s)" % (choice, "🪨" * choice))
        print()
        print("Current number of stones: %d" % self.curr_stone_cnt)
        self.display_stones()
        self.game_over = self.curr_stone_cnt == 0
        if self.game_over:
            print("Computer win!")

    def process_computer_move(self):
        choice = self.get_computer_choice()
        if choice is None:
            print("Computer is unable to remove any stone and lose!")
            self.game_over = True
            return
        self.apply_computer_move(choice)

    def find_min_candidate_num(self):
        return self.candidate_nums[0]

    def play(self):
        while True:
            if self.find_min_candidate_num() > self.curr_stone_cnt:
                print("You are unable to remove any stone and lose the game!")
                return

            specified_num = self.get_player_move()
            self.process_player_move(specified_num)
            if self.game_over:
                return
            self.process_computer_move()
            if self.game_over:
                return

    def find_a_good_num(self):
        for candidate_num in self.candidate_nums:
            if self.curr_stone_cnt < candidate_num:
                break
            if not self.has_win_strategy[self.curr_stone_cnt - candidate_num]:
                return candidate_num
        return None

    def display_stones(self):
        if self.curr_stone_cnt == 0:
            return
        batch_size = 10
        batch_cnt = self.curr_stone_cnt // batch_size
        if batch_cnt > 0:
            print("\n".join(["🪨" * batch_size] * batch_cnt))
        if self.curr_stone_cnt % batch_size != 0:
            print("🪨" * (self.curr_stone_cnt % batch_size))
        print()

if __name__ == "__main__":
    game = TakeAwayGame()
    game.play()

运行效果

请将完整的代码(上一小节已提供)保存为 take_away.py。使用下方的命令可以运行 take_away.py

python3 take_away.py

运行效果如下图所示 ⬇️

image.png

如果不想指定数字,直接按回车就会使用默认的 1,2,31,2,3 (即,每次可以取走 11 个石头或者 22 个石头或者 33 个石头)

我直接按回车了,效果如下图所示 ⬇️

image.png

最初有 2121 个石头。我们按照前文的分析,知道只要始终让对方处于石头数 N=4×kN=4\times kk\text{k} 是自然数)的局面,我们就会胜利。那我们现在取走 11 个石头 ⬇️

image.png

我玩了几轮之后,只剩下 33 个石头了 ⬇️

image.png

此时将 33 个石头全部取走即可 ⬇️

image.png

结果符合预期

参考资料