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

最初,有 21 个 chip (或者别的什么东西)。两位玩家轮流取走若干 chip(取走的 chip 的数量只能是 1,2,3 中的某一个)。取走最后一个 chip 的玩家获胜。我们可以将这个游戏一般化 ⬇️
- 最初有 N 个物品
- 两位玩家 A,B 轮流取走物品(每次取走的物品数量只能是 m1,m2,⋯,mk 中的某一个,这 k 个正整数需要提前约定好)
- A 是先手
- 如果在某位玩家刚取走物品后,没有任何物品剩下,则这位玩家获胜
- 如果某位玩家无法执行合法的操作,则这位玩家失败(那么,另一位玩家获胜)
那么 lecture02 中提到的 Take-Away 游戏就是如下的特例
- N=21
- k=3 并且 m1=1,m2=2,m3=3
正文
如何判定自己是否有必胜策略?
以上文提到的 Take-Away 游戏的特例为例,我们想一想是否有"必胜"的策略("必胜"是指无论对方如何操作,我们都有办法获胜)。假设我方是玩家 A(即,先手方)。最初有 21 个物品,感觉不好分析。那我们看看数字比较小的情况
- 如果最初有 1 个物品,那么我方直接取走这个物品,立刻获胜
- 如果最初有 2 个物品,那么我方直接取走这 2 个物品,立刻获胜
- 如果最初有 3 个物品,那么我方直接取走这 3 个物品,立刻获胜
以上三种情况有点像中国象棋/国际象棋里的一步杀。下面看更复杂的情况 ⬇️
如果最初有 4 个物品,我们能做的事情,只有以下三种
- 如果我们取走 1 个物品,那么对方会看到 4−1=3 个物品,对方有"必胜"的策略
- 如果我们取走 2 个物品,那么对方会看到 4−2=2 个物品,对方有"必胜"的策略
- 如果我们取走 3 个物品,那么对方会看到 4−3=1 个物品,对方有"必胜"的策略
所以,只要对方认真玩,当我们开局遇到 4 个物品时,我方处于"必败"的局面。继续分析,可以总结出如下的表格 ⬇️
| 轮到我方时 剩余物品的数量(个) | 我方是否"必胜"? | 解释 |
|---|---|---|
| 1 | ✅ | 全部取走即可 "一步杀" |
| 2 | ✅ | 全部取走即可 "一步杀" |
| 3 | ✅ | 全部取走即可 "一步杀" |
| 4 | ❌ | 我们操作完之后,对方总是可以将我们"一步杀" |
| 5 | ✅ | 移走 1 个物品,这样就能让对方看到 4 个物品 "两步杀" |
| 6 | ✅ | 移走 2 个物品,这样就能让对方看到 4 个物品 "两步杀" |
| 7 | ✅ | 移走 3 个物品,这样就能让对方看到 4 个物品 "两步杀" |
| 8 | ❌ | 我们操作完之后,对方总是可以将我们"两步杀" |
| 9 | ✅ | 移走 1 个物品,这样就能让对方看到 8 个物品 "三步杀" |
| 10 | ✅ | 移走 2 个物品,这样就能让对方看到 8 个物品 "三步杀" |
| 11 | ✅ | 移走 3 个物品,这样就能让对方看到 8 个物品 "三步杀" |
| 12 | ❌ | 我们操作完之后,对方总是可以将我们"三步杀" |
| ... | ... | ... |
观察上表,不难猜测, 4∣N 的这些开局,对我方来说,应该都是"必败"的(对方有办法让我们输)。而 4∤N 的这些开局,对我方来说,应该都是"必胜"的(我们总是可以构造出 X 步杀)。用数学归纳法,不难证明这个猜测是正确的。这里不赘述证明过程。
按照这个逻辑,轮到我方操作时,如果面前是 0 个物品,应当认为我方失败(如果严格按照规则来的话,其实对方玩家取完物品后,对方就立刻获胜了),这与游戏规则是一致的。
我们只需要计算,对我方遇到的任意局面而言,我们应该将它标记为 ✅ 还是 ❌。lecture02 中提到了专门的术语
P-Position(上一步的玩家有必胜策略的局面): 相当于刚才我们标记 ❌ 的那些局面N-Position(当前玩家有必胜策略的局面): 相当于刚才我们标记 ✅ 的那些局面

核心步骤的代码
基于以上分析,我们可以用 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 方法用于判定在当前局面下,是否有必胜策略(也就是说,当前局面是不是
N-Position) - calculate_win_strategy 方法用于遍历所有可能出现的局面,并判定每个局面是否有必胜策略(即,那个局面是不是
N-Position)
完整的代码
其他的代码包括以下功能
- 处理和用户的交互(包括展示必要的信息)
- 驱动整个游戏的流程(玩家总是先手)
- 保证在初始局面下,玩家有必胜策略(这样玩家在开局时,有胜利的可能)
这些辅助功能都不复杂,就不赘述实现细节了。我在开发过程中,得到了 trae 的大力协助。
完整的代码如下 ⬇️ (代码里用的是"石头(stone)"这个词,其实和上文的 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,3 (即,每次可以取走 1 个石头或者 2 个石头或者 3 个石头)
我直接按回车了,效果如下图所示

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

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

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

参考资料
- Great Theoretical Ideas in Computer Science 这门课程中提供了相关的 资源,其中包括 lecture02。本文用到了 lecture02 中的内容