用 Python 实现 Take-Away 游戏

背景

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

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

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

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

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

正文

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

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

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

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

如果最初有 44 4 个物品,我们能做的事情,只有以下三种

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

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

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

观察上表,不难猜测, 4∣N4|N 4∣N 的这些开局,对我方来说,应该都是"必败"的(对方有办法让我们输)。而 4∤N4\nmid N 4∤N 的这些开局,对我方来说,应该都是"必胜"的(我们总是可以构造出 X\text{X} X 步杀)。用数学归纳法,不难证明这个猜测是正确的。这里不赘述证明过程。

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

我们只需要计算,对我方遇到的任意局面而言,我们应该将它标记为 ✅ 还是 ❌。lecture02 中提到了专门的术语

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

核心步骤的代码

基于以上分析,我们可以用 Python\text{Python} Python 来实现核心步骤(即,判定是否有必胜策略)的逻辑(请注意:以下代码只展示了 TakeAwayGame 中的部分逻辑,无法直接运行)

python 复制代码
class TakeAwayGame:
    def __init__(self):
        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):
        max_stone_cnt = max(self.candidate_nums) * 7 + 1 # 这里的 7 没有特别的含义,可以改为其他值
        self.has_win_strategy = [None] * max_stone_cnt
        
        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 # 
  • is_win_possible\text{is\_win\_possible} is_win_possible 方法用于判定在当前局面下,是否有必胜策略(也就是说,当前局面是不是 N-Position
  • calculate_win_strategy\text{calculate\_win\_strategy} calculate_win_strategy 方法用于遍历所有可能出现的局面,并判定每个局面是否有必胜策略(即,那个局面是不是 N-Position

完整的代码

其他的代码包括以下功能

  • 处理和用户的交互(包括展示必要的信息)
  • 驱动整个游戏的流程(玩家总是先手)
  • 保证在初始局面下,玩家有必胜策略(这样玩家在开局时,有胜利的可能)

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

完整的代码如下 ⬇️ (代码里用的是"石头(stone)"这个词,其实和上文的 chip\text{chip} chip、"物品"没有本质区别)

python 复制代码
import re

class TakeAwayGame:
    def __init__(self):
        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("Press 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):
        max_stone_cnt = max(self.candidate_nums) * 7 + 1
        self.has_win_strategy = [None] * max_stone_cnt

        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 not self.game_over:
            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

bash 复制代码
python3 take_away.py

运行效果如下图所示 ⬇️

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

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

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

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

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

参考资料

相关推荐
copyer_xyf2 小时前
Agent 流程编排
后端·python·agent
copyer_xyf2 小时前
Agent RAG
后端·python·agent
copyer_xyf2 小时前
【RAG】向量数据库:milvus
后端·python·agent
copyer_xyf3 小时前
Agent 记忆管理
后端·python·agent
星云穿梭18 小时前
用Python写一个带图形界面的学生管理系统——完整教程
python
金銀銅鐵18 小时前
用 Pygame 实现 15 puzzle
python·数学·游戏
黄忠1 天前
大模型之LangGraph技术体系
python·llm
hboot2 天前
AI工程师第二课 - 数据处理
人工智能·python·数据分析
用户8356290780512 天前
使用 Python 自动化 PowerPoint 形状布局与格式设置
后端·python