背景
Great Theoretical Ideas in Computer Science 这门课程中提供了相关的 资源,其中包括 lecture02。在 lecture02 中,我们可以看到一个名为 Take-Away 的游戏
最初,有 个 (或者别的什么东西)。两位玩家轮流取走若干 (取走的 的数量只能是 中的某一个)。取走最后一个 的玩家获胜。我们可以将这个游戏一般化 ⬇️
- 最初有 个物品
- 两位玩家 轮流取走物品(每次取走的物品数量只能是 中的某一个,这 个正整数需要提前约定好)
- 是先手
- 如果在某位玩家刚取走物品后,没有任何物品剩下,则这位玩家获胜
- 如果某位玩家无法执行合法的操作,则这位玩家失败(那么,另一位玩家获胜)
那么 lecture02 中提到的 Take-Away 游戏就是如下的特例
- 并且
正文
如何判定自己是否有必胜策略?
以上文提到的 Take-Away 游戏的特例为例,我们想一想是否有“必胜”的策略(“必胜”是指无论对方如何操作,我们都有办法获胜)。假设我方是玩家 (即,先手方)。最初有 个物品,感觉不好分析。那我们看看数字比较小的情况
- 如果最初有 个物品,那么我方直接取走这个物品,立刻获胜
- 如果最初有 个物品,那么我方直接取走这 个物品,立刻获胜
- 如果最初有 个物品,那么我方直接取走这 个物品,立刻获胜
以上三种情况有点像中国象棋/国际象棋里的 一步杀。下面看更复杂的情况 ⬇️
如果最初有 个物品,按照规则我们可以在“取走 个物品”,“取走 个物品”,“取走 个物品” 之间挑一个
- 如果我们取走 个物品,那么对方会看到 个物品,对方有“必胜”的策略
- 如果我们取走 个物品,那么对方会看到 个物品,对方有“必胜”的策略
- 如果我们取走 个物品,那么对方会看到 个物品,对方有“必胜”的策略
所以,只要对方认真玩,当我们开局遇到 个物品时,我方处于“必败”的局面。继续分析,可以总结出如下的表格 ⬇️
| 轮到我方时 剩余物品的数量(个) | 我方是否“必胜”? | 解释 |
|---|---|---|
| ✅ | 全部取走即可 “一步杀” | |
| ✅ | 全部取走即可 “一步杀” | |
| ✅ | 全部取走即可 “一步杀” | |
| ❌ | 我们操作完之后,对方总是可以将我们“一步杀” | |
| ✅ | 移走 个物品,这样就能让对方看到 个物品 “两步杀” | |
| ✅ | 移走 个物品,这样就能让对方看到 个物品 “两步杀” | |
| ✅ | 移走 个物品,这样就能让对方看到 个物品 “两步杀” | |
| ❌ | 我们操作完之后,对方总是可以将我们“两步杀” | |
| ✅ | 移走 个物品,这样就能让对方看到 个物品 “三步杀” | |
| ✅ | 移走 个物品,这样就能让对方看到 个物品 “三步杀” | |
| ✅ | 移走 个物品,这样就能让对方看到 个物品 “三步杀” | |
| ❌ | 我们操作完之后,对方总是可以将我们“三步杀” | |
| ... | ... | ... |
在上表中,我们关心的是,对我方遇到的一个特定局面,我们应该将它标记为 ✅ 还是 ❌。观察上表,不难猜测 ⬇️
- 开局时,如果物品数量 满足 ,那么玩家 (即,先手玩家)是“必败”的(玩家 有办法让玩家 输)
- 开局时,如果物品数量 不满足 ,那么玩家 是“必胜”的(玩家 可以构造出 步杀,其中 )。用数学归纳法,可以证明这个猜测是正确的(证明 “ 个 ✅ 和 个 ❌ 会交替出现” 也是一个可行的思路)。
按照这个逻辑,轮到我方操作时,如果面前是 个物品,应当认为我方失败(如果严格按照规则来的话,其实对方玩家取完物品后,对方就立刻获胜了),这与游戏规则是一致的。
如果每次可以取走的物品的数量 刚好是 的一个排列,那么用类似的方法,可以分析出 ⬇️
- 开局时,如果物品数量 满足 ,那么玩家 (即,先手玩家)是“必败”的(玩家 有办法让玩家 输)
- 开局时,如果物品数量 不满足 ,那么玩家 是“必胜”的(玩家 可以构造出 步杀,其中 )。用数学归纳法,可以证明这个猜测是正确的(证明 “ 个 ✅ 和 个 ❌ 会交替出现” 也是一个可行的思路)。
如果 之间没有特别的关系,例如 ,,我不知道有没有巧妙的办法可以快速计算出各种局面下是否有必胜策略。但我们仍旧可以用上述的朴素思路来进行分析,即,从遇到 个物品的局面开始(此时先手失败),逐步分析遇到 个物品, 个物品, 个物品等情况下,是否有必胜策略。
以 , 为例,我们可以这样分析 ⬇️
| 轮到我方时 剩余物品的数量(个) | 我方是否“必胜”? | 解释 |
|---|---|---|
| ❌ | 相当于对方取走了最后的物品,我方失败 | |
| ❌ | 我方没有合法的操作,我方失败 | |
| ✅ | 全部取走即可 | |
| ✅ | 全部取走即可 | |
| ✅ | 我们可以取走 个物品,让对方陷入面对 个物品的必败局面 | |
| ✅ | 全部取走即可 | |
| ✅ | 我们可以取走 个物品,让对方陷入面对 个物品的必败局面 | |
| ❌ | 无论我方如何操作,都会让对方看到“必胜”局面,所以我方“必败” | |
| ❌ | 无论我方如何操作,都会让对方看到“必胜”局面,所以我方“必败” | |
| ✅ | 我们可以取走 个物品,让对方陷入面对 个物品的必败局面 | |
| ✅ | 我们可以取走 个物品,让对方陷入面对 个物品的必败局面 | |
| ✅ | 我们可以取走 个物品,让对方陷入面对 个物品的必败局面 | |
| ✅ | 我们可以取走 个物品,让对方陷入面对 个物品的必败局面 | |
| ✅ | 我们可以取走 个物品,让对方陷入面对 个物品的必败局面 | |
| ❌ | 无论我方如何操作,都会让对方看到“必胜”局面,所以我方“必败” | |
| ❌ | 无论我方如何操作,都会让对方看到“必胜”局面,所以我方“必败” | |
| ✅ | 我们可以取走 个物品,让对方陷入面对 个物品的必败局面 | |
| ... | ... | ... |
就这个特例而言,我们可以猜测出,遇到以下两类局面时,先手“必败”,其他情况则是先手“必胜”。
不难证明 个 ✅ 和 个 ❌ 会交替出现,所以上方的猜测是正确的。
lecture02 中提到了专门的术语
P-Position(上一步的玩家有必胜策略的局面): 相当于刚才我们标记 ❌ 的那些局面N-Position(当前玩家有必胜策略的局面): 相当于刚才我们标记 ✅ 的那些局面
核心步骤的代码
基于以上分析,我们可以用 来实现核心步骤(即,判定是否有必胜策略)的逻辑。为了让游戏具有一般性,我们允许用户在开始游戏时,指定“可以移走的物品的数量”。例如用户可以指定,只能移走 个物品。那么如果某个玩家遇到了当前物品数为 的局面,这位玩家就会因为无法操而失败。(请注意:以下代码只展示了 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)”这个词,其实和上文的 、“物品”没有本质区别。
- 中会保存用户指定的
- 为了避免 中出现很大的数字,我用 来表示 的上限
- 方法用于判定在当前局面下,是否有必胜策略(也就是说,当前局面是不是
N-Position) - 方法用于遍历所有可能出现的局面,并判定每个局面是否有必胜策略(即,那个局面是不是
N-Position)
完整的代码
除了核心逻辑外,还需要支持以下功能
- 处理和用户的交互(包括展示必要的信息)
- 驱动整个游戏的流程(玩家总是先手)
- 保证在初始局面下,玩家有必胜策略(这样玩家在开局时,有胜利的可能)
- 特殊情况的处理(例如当前剩余 个石头,但是只允许取走 个石头或者 个石头,那么此时当前玩家失败)
这些功能都不复杂,就不赘述实现细节了。我在开发过程中,得到了 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
运行效果如下图所示 ⬇️
如果不想指定数字,直接按回车就会使用默认的 (即,每次可以取走 个石头或者 个石头或者 个石头)
我直接按回车了,效果如下图所示 ⬇️
最初有 个石头。我们按照前文的分析,知道只要始终让对方处于石头数 ( 是自然数)的局面,我们就会胜利。那我们现在取走 个石头 ⬇️
我玩了几轮之后,只剩下 个石头了 ⬇️
此时将 个石头全部取走即可 ⬇️
结果符合预期
参考资料
- Great Theoretical Ideas in Computer Science 这门课程中提供了相关的 资源,其中包括 lecture02。本文用到了 lecture02 中的内容