公司动态
Python多人石头剪刀布模拟:从循环赛到策略博弈的实战指南
1. 项目缘起从单人对战到多人博弈的思维跃迁“石头剪刀布”这个游戏几乎刻在了每个人的童年记忆里。作为程序员当我们学习Python时也常常用它来练习条件判断、随机数生成和用户交互。一个典型的入门练习是写一个“人机对战”的石头剪刀布程序用户输入电脑随机出拳然后判断胜负。这确实能很好地巩固if-elif-else、random和input的基础用法。但今天我想带你跳出这个“舒适区”思考一个更有趣的场景模拟一场多人同时参与的石头剪刀布比赛。这不仅仅是把两个玩家的代码复制粘贴成N份那么简单。它背后涉及的是从“线性流程”到“并发或批量处理”的思维转变是数据结构从简单变量到列表、字典乃至更复杂容器的升级是逻辑判断从两两比较到循环遍历和规则聚合的深化。想象一下这些应用场景你需要测试一个游戏服务器的匹配逻辑你想为班级活动快速生成一个随机分组对抗结果或者你正在设计一个智能体需要观察在简单规则下群体行为的涌现比如某种出拳策略是否会成为主流。这时一个能灵活模拟多人对战的程序就非常有用。它不再是一个玩具而是一个可以承载更多想法的小型实验平台。本文将手把手带你实现这个程序并深入探讨其中的设计抉择、性能考量以及那些教程里不会告诉你的“坑”。2. 核心设计如何定义一场“多人”对局在动手写代码之前我们必须先厘清“模拟多人石头剪刀布”的具体含义。这直接决定了我们程序的数据结构和核心算法。根据不同的规则主要有两种主流模式它们的复杂度和实现方式截然不同。2.1 模式一循环锦标赛制Round Robin这是最直观的模式。假设有N个玩家每个玩家都要与其他所有玩家分别进行一场一对一的“石头剪刀布”对决。就像足球联赛一样每两支队伍之间都会踢一场。设计思路与数据结构我们需要记录每个玩家的身份比如一个ID或名字和他们的积分。比赛过程是一个双重循环外层循环遍历每一个玩家i内层循环遍历i之后的所有玩家j避免重复比赛例如玩家1对玩家2和玩家2对玩家1是同一场比赛。对于每一对(i, j)模拟一次对战并根据结果更新双方的积分。数据存储选择使用字典dict是再合适不过的了。键Key可以是玩家ID或名字值Value可以是一个列表或另一个字典用于存储积分、胜场、负场等数据。例如players { “Alice”: {“score”: 0, “wins”: 0}, “Bob”: {“score”: 0, “wins”: 0}, “Charlie”: {“score”: 0, “wins”: 0} }每场比赛后我们可以通过players[“Alice”][“score”] 1来更新数据非常清晰。为什么选择字典而不是列表列表当然也可以比如scores [0, 0, 0]。但列表依赖于索引当我们需要频繁地通过玩家名字来查找和更新数据时字典O(1)的查找效率远高于列表的遍历查找。代码的可读性也更强players[“Alice”]比scores[player_index_dict[“Alice”]]要直观得多。2.2 模式二大乱斗制Battle Royale这是一种更刺激、也更复杂的模式。所有N个玩家在同一轮次中同时出拳然后我们需要根据一套规则一次性判定本轮的所有胜负关系可能还会直接淘汰部分玩家。规则设计的复杂性这是本模式的核心难点。简单的“两两比较”在这里不适用因为可能产生循环克制A赢BB赢CC赢A。常见的判定规则有少数派淘汰统计本轮出拳的种类。找出出拳数量最少的那种假设是“剪刀”那么所有出“剪刀”的玩家被淘汰。因为“剪刀”被“石头”克而出“石头”的玩家在本轮最多。这是一种模拟“物竞天择”的规则。多数派胜利与上相反找出出拳数量最多的那种该阵营的所有玩家晋级/得分。阵营对抗积分分别计算“石头”、“剪刀”、“布”三个阵营的人数。然后根据克制关系石头赢剪刀剪刀赢布布赢石头给每个阵营计算积分。例如石头阵营的积分等于“剪刀”阵营的人数因为石头赢了所有剪刀以此类推。最后根据阵营积分给阵营内每个玩家加分。实现挑战这种模式要求我们在一轮开始前收集所有玩家的选择然后进行集中式的统计和判断。数据结构上我们可能需要两个列表一个存玩家对象一个存他们本轮对应的出拳选择。或者用一个字典列表每个字典代表一个玩家及其当轮状态。# 一轮开始前 round_choices [] for player in players: choice player.make_choice() # 玩家做出选择 round_choices.append({“name”: player.name, “choice”: choice}) # 一轮结束后根据round_choices列表进行统计和判定其算法核心是collections.Counter类它可以快速统计列表中各元素出现的次数是处理此类问题的利器。注意在真正开始编码前务必和需求方或者你自己确认清楚到底需要哪种模式。本文后续将主要以循环锦标赛制为例进行详细实现因为它逻辑清晰更适合初学者理解多人模拟的骨架。在文章最后我们会探讨如何将其扩展到大乱斗制。3. 基础实现构建循环锦标赛的核心引擎我们选择实现循环锦标赛制。目标是输入一个玩家名单程序自动完成所有两两对决并输出最终的排名。3.1 玩家与选择的表示首先我们要定义如何表示“玩家”和“出拳选择”。出拳选择的编码用字符串‘rock’‘scissors’‘paper’是最直观的但判断胜负时需要写一长串的if-elif。更高效的方法是使用数字编码0: 石头 (rock)1: 剪刀 (scissors)2: 布 (paper)这样做有一个绝妙的好处可以利用取模运算来判断胜负。规则是石头(0)赢剪刀(1)剪刀(1)赢布(2)布(2)赢石头(0)。观察一下如果我们将两个选择记为a和b那么(a - b) % 3的结果可以揭示胜负结果为 1:a赢b(例如: (0-2)%31? 不对我们重新审视。) 让我们列举一下 a0(石头), b1(剪刀): (0-1)%3 2 - 这个规则不对。 更通用的公式是(a - b) % 3a0, b1: (0-1)%3 2 (平局不对是a赢)a1, b0: (1-0)%3 1 (平局不对是b赢)看来这个公式不直接。实际上一个经典的胜负判断条件是if a b: result “平局” elif (a - b) % 3 1: result “a赢” else: result “b赢”让我们验证a0(石), b1(剪): (0-1)%32不等于1所以进入else判定为“b赢”错了应该是a赢。 所以这个公式不对。我查阅了常见的算法正确的判断是def judge(a, b): if a b: return 0 # 平局 elif (a - b) % 3 1: return 1 # a胜 else: return -1 # b胜验证a0(石), b1(剪): (0-1)%3 2不等于1所以判定为b胜依然错误。 看来我陷入了思维定式。最可靠且易读的方式还是使用字典映射胜负关系winning_rules { 0: 1, # 石头(0) 赢 剪刀(1) 1: 2, # 剪刀(1) 赢 布(2) 2: 0 # 布(2) 赢 石头(0) } def judge(a, b): if a b: return 0 elif winning_rules[a] b: return 1 # a赢b else: return -1 # b赢a这种方式一目了然避免了魔数公式代码可维护性更高。在性能不是极端瓶颈的情况下清晰永远比巧妙更重要。玩家类的设计我们可以创建一个简单的Player类。初期它可能只需要名字和积分属性。出拳行为可以作为一个方法。class Player: def __init__(self, name): self.name name self.score 0 # 总积分 self.wins 0 # 胜场数用于更细致的排名 def make_random_choice(self): 随机出拳 return random.randint(0, 2) def __repr__(self): return f“Player({self.name}, score{self.score})”3.2 胜负判定与积分逻辑有了玩家和选择接下来是实现单场对决的函数。这个函数需要获取两个玩家的选择。根据规则判定胜负。更新玩家的积分和胜场。积分规则可以自定义。一个常见的规则是胜者得2分平局双方各得1分负者得0分。这模仿了许多体育联赛的积分制。def play_match(player_a, player_b, choice_a, choice_b): 模拟一场比赛更新玩家状态 result judge(choice_a, choice_b) if result 0: # 平局 player_a.score 1 player_b.score 1 elif result 1: # a胜 player_a.score 2 player_a.wins 1 # player_b.score 0 else: # b胜 player_b.score 2 player_b.wins 1 # player_a.score 0注意这里我们只给胜者增加了wins。在最终排名时如果积分相同我们可以用wins胜场数作为第二排序关键字这比比较“净胜局”更简单。3.3 组织整个锦标赛循环这是整个模拟的驱动逻辑。我们需要创建玩家列表然后使用双重循环组织所有比赛。import random def simulate_tournament(player_names): 模拟循环赛返回排序后的玩家列表 # 1. 初始化玩家对象 players [Player(name) for name in player_names] n len(players) # 2. 双重循环进行所有比赛 for i in range(n): for j in range(i1, n): # j从i1开始避免重复和自对阵 player_a players[i] player_b players[j] # 获取选择 choice_a player_a.make_random_choice() choice_b player_b.make_random_choice() # 进行比赛 play_match(player_a, player_b, choice_a, choice_b) # 可选打印每场结果 # choices_map {0: ‘石头‘ 1: ‘剪刀‘ 2: ‘布’} # print(f“{player_a.name}({choices_map[choice_a]}) vs {player_b.name}({choices_map[choice_b]})”) # 3. 排序并返回结果 # 按积分降序、胜场降序排序 sorted_players sorted(players, keylambda p: (p.score, p.wins), reverseTrue) return sorted_players一个关键细节内层循环for j in range(i1, n)确保了每对玩家只比赛一次。这是循环赛的标准实现方式时间复杂度是 O(n²)。对于少量玩家比如少于1000人完全足够。4. 功能增强从随机到策略从模拟到分析基础版本已经可以运行了但它只是随机出拳的简单重复。我们可以让它变得更有用、更强大。4.1 引入不同的出拳策略让所有玩家都随机出拳结果虽然公平但缺乏趣味性和分析价值。我们可以为Player类赋予不同的“策略”大脑。策略模式设计我们可以定义一个“策略”基类然后派生出各种具体策略。Player对象在初始化时绑定一个策略。class Strategy: 策略基类 def make_choice(self, player, opponent, history): 根据玩家自身信息、对手信息和历史对战记录做出选择。 history: 一个列表记录了与该对手过往的对战信息例如 [(choice_self, choice_opponent, result), ...] raise NotImplementedError class RandomStrategy(Strategy): 完全随机策略 def make_choice(self, player, opponent, history): return random.randint(0, 2) class FixedStrategy(Strategy): 固定出拳策略比如只出石头 def __init__(self, fixed_choice): self.choice fixed_choice def make_choice(self, player, opponent, history): return self.choice class CopycatStrategy(Strategy): 模仿者策略第一局随机之后每一局都出对手上一局出的拳 def make_choice(self, player, opponent, history): if not history: # 没有历史记录第一局 return random.randint(0, 2) else: # history[-1] 是最近一局(自己的选择对手的选择结果) return history[-1][1] # 返回对手上一局的选择然后修改Player类class Player: def __init__(self, name, strategyNone): self.name name self.score 0 self.wins 0 self.strategy strategy if strategy else RandomStrategy() # 默认随机策略 self.match_history {} # 键为对手名字值为历史记录列表 def make_choice(self, opponent): 根据策略做出选择 history_with_opponent self.match_history.get(opponent.name, []) choice self.strategy.make_choice(self, opponent, history_with_opponent) return choice def record_match(self, opponent, my_choice, opp_choice, result): 记录一场比赛的历史 if opponent.name not in self.match_history: self.match_history[opponent.name] [] self.match_history[opponent.name].append((my_choice, opp_choice, result))相应地play_match和simulate_tournament函数也需要修改在比赛前后调用make_choice和record_match。这样设计的好处开闭原则很容易添加新的策略如“反模仿策略”、“统计对手偏好策略”而无需修改Player或比赛主逻辑。可测试性可以轻松让两个特定策略对战成千上万轮分析哪种策略在长期更优。更真实的模拟为后续模拟“智能体”群体行为打下基础。4.2 数据收集与可视化分析模拟完成后我们得到了一堆数据。如何从这些数据中提取洞察简单的打印排名不够直观。数据收集我们可以在模拟过程中不仅记录积分还可以记录更多元的数据比如每个玩家的总比赛场数、胜、平、负场次。每个玩家出“石头”、“剪刀”、“布”的频率。玩家之间的胜负关系矩阵谁是谁的“克星”。我们可以创建一个TournamentResult类来封装这些数据。可视化展示使用matplotlib库可以生成直观的图表。积分榜柱状图一目了然地看到所有玩家的积分排名。出拳分布饼图展示整个锦标赛中三种出拳的总比例或者某个特定策略玩家的出拳偏好。胜负关系热力图用矩阵形式展示玩家A对玩家B的胜率颜色越深表示胜率越高。这能清晰揭示“食物链”关系。import matplotlib.pyplot as plt import numpy as np def plot_ranking(players): 绘制积分榜柱状图 names [p.name for p in players] scores [p.score for p in players] plt.figure(figsize(10, 6)) bars plt.barh(names, scores, color‘skyblue’) plt.xlabel(‘积分’) plt.title(‘循环锦标赛积分榜’) # 在条形末端显示积分 for bar, score in zip(bars, scores): plt.text(score, bar.get_y() bar.get_height()/2, f’ {score}‘, va‘center’) plt.gca().invert_yaxis() # 积分高的在上方 plt.tight_layout() plt.show()通过可视化抽象的模拟数据变成了直观的图形无论是用于分析、报告还是演示都极具价值。4.3 处理大规模模拟与性能考量当玩家数量上升到数千或者我们需要重复模拟数万次来验证策略的长期期望收益时性能就成为问题。我们的基础双重循环是O(n²)对于1000个玩家就需要进行约50万场比赛模拟。优化思路向量化计算使用NumPy如果所有玩家都采用随机策略且我们只关心最终积分分布那么可以不用模拟每一场细节。我们可以利用概率论和矩阵运算一次性计算出期望积分。但这失去了对策略和每场比赛过程的模拟。并行化如果模拟是独立的比如重复10000次锦标赛看某个策略的平均排名可以使用multiprocessing或concurrent.futures模块进行多进程/多线程并行充分利用多核CPU。算法微调在Python层面避免在深层循环中进行不必要的属性访问、函数调用。例如将choices_map字典查找、judge函数调用等移出最内层循环或者使用局部变量引用。使用更高效的数据结构对于简单的积分记录如果玩家ID是连续的整数用列表可能比字典更快。但对于大多数情况字典的易用性优势更大。一个实用的建议除非玩家数量真的非常大5000否则O(n²)的算法在Python中运行一次锦标赛仍然是瞬间完成的毫秒级。真正的性能瓶颈往往出现在重复模拟上。此时使用numba进行JIT编译或者将核心循环用Cython重写是更彻底的解决方案。但对于学习和大多数应用场景清晰的代码结构比极致的性能更重要。5. 避坑指南与实战心得在实际编码和扩展功能的过程中我踩过一些坑也总结了一些经验。5.1 随机数的种子与可复现性模拟程序的一个关键需求是可复现性。你希望今天运行的程序和明天运行的程序在输入相同的情况下输出完全一致的结果。这对于调试和分享结果至关重要。import random # 在程序开始时设置随机种子 random.seed(42) # 42是一个经典种子你可以用任何整数 # 现在每次运行程序random.randint()等函数产生的序列都将完全相同切记如果你使用了numpy也需要设置np.random.seed(seed)。在多线程/多进程环境下随机种子的管理会更复杂需要为每个工作进程设置不同的种子例如seed base_seed process_id以避免产生完全相同的随机序列。5.2 策略实现中的历史记录陷阱在实现CopycatStrategy模仿者策略时我最初犯了一个错误我让策略直接读取了Player对象中存储的全局历史。但在锦标赛循环中玩家A和玩家B的比赛是交错进行的。当玩家A在第5轮遇到玩家B时玩家B的“上一局”可能并不是与玩家A的对局而是与玩家C的对局。这会导致策略逻辑错误。正确的做法正如我在4.1节代码中所示Player的match_history应该是一个嵌套字典以对手名字为键存储仅与该对手的对战历史列表。策略在决策时接收的history参数也应该是这段特定的历史。这样才能确保模仿者策略是“模仿这个特定对手上一局的出拳”。5.3 对象引用与深拷贝的微妙之处这是一个更进阶但容易踩坑的点。假设我想保存模拟开始前所有玩家的初始状态然后在模拟后进行比较。我可能会这样做initial_players players_list # 错误这只是创建了一个引用 simulate_tournament(players_list) # 此时 initial_players 和 players_list 指向同一个列表里面的Player对象状态已经被修改了正确的做法是使用copy模块进行深拷贝因为Player对象内部还有属性。import copy initial_players copy.deepcopy(players_list)但深拷贝有时会带来性能开销。另一种更轻量的模式是在Player类中实现一个reset()方法将积分、历史等状态清零而不是保存整个对象的副本。5.4 扩展至“大乱斗”模式的思考文章开头提到了“大乱斗”模式。基于我们已构建的框架如何扩展核心在于修改比赛的组织和判定逻辑。修改主循环不再是双重循环两两对战而是变成一个单循环代表多个“轮次”round。每轮逻辑 a.出拳阶段遍历所有存活玩家调用其make_choice方法。这里make_choice方法可能需要修改因为对手不再是单个玩家而是一个“环境”。策略可能需要调整比如“观察上一轮全体玩家的出拳分布”。 b.判定阶段收集所有玩家的选择使用collections.Counter进行统计。根据预设的规则如“少数派淘汰”判定哪些玩家被淘汰并更新存活玩家列表和积分。 c.循环直到存活玩家少于等于1人或者达到最大轮次限制。策略适配需要为策略基类增加新的上下文信息比如上一轮全体玩家的出拳分布Counter对象。CopycatStrategy可能就失效了需要设计新的策略如“出上一轮人数最多的那种拳的克星”如果上一轮布多本轮就出剪刀。这实际上是将项目复杂度提升了一个维度从“确定的N*(N-1)/2场比赛”变成了“动态的、多轮次的淘汰赛”非常适合作为本项目的一个高级挑战。6. 项目总结与代码整合让我们把所有的核心组件整合到一个完整的、可执行的脚本中。这个脚本实现了带有策略的循环锦标赛并包含了简单的数据可视化。import random import matplotlib.pyplot as plt from collections import defaultdict import copy # ---------- 1. 策略定义 ---------- class Strategy: def make_choice(self, player, opponent, history): raise NotImplementedError class RandomStrategy(Strategy): def make_choice(self, player, opponent, history): return random.randint(0, 2) class FixedStrategy(Strategy): def __init__(self, choice): self.choice choice def make_choice(self, player, opponent, history): return self.choice class CopycatStrategy(Strategy): def make_choice(self, player, opponent, history): if not history: return random.randint(0, 2) return history[-1][1] # 对手上一局的选择 # ---------- 2. 玩家类 ---------- class Player: def __init__(self, name, strategyNone): self.name name self.score 0 self.wins 0 self.strategy strategy if strategy else RandomStrategy() self.match_history defaultdict(list) # 使用defaultdict简化代码 def make_choice(self, opponent): history self.match_history[opponent.name] return self.strategy.make_choice(self, opponent, history) def record_match(self, opponent, my_choice, opp_choice, result): self.match_history[opponent.name].append((my_choice, opp_choice, result)) def reset(self): 重置玩家状态用于多次模拟 self.score 0 self.wins 0 self.match_history.clear() def __repr__(self): return f“{self.name}(积分:{self.score}, 胜场:{self.wins})” # ---------- 3. 胜负判定 ---------- WINNING_RULES {0: 1, 1: 2, 2: 0} # 克制关系 CHOICE_MAP {0: “石头” 1: “剪刀” 2: “布”} def judge(choice_a, choice_b): if choice_a choice_b: return 0 elif WINNING_RULES[choice_a] choice_b: return 1 else: return -1 def play_match(player_a, player_b, verboseFalse): choice_a player_a.make_choice(player_b) choice_b player_b.make_choice(player_a) result judge(choice_a, choice_b) # 更新积分和记录 if result 0: player_a.score 1 player_b.score 1 elif result 1: player_a.score 2 player_a.wins 1 else: player_b.score 2 player_b.wins 1 # 记录历史 player_a.record_match(player_b, choice_a, choice_b, result) player_b.record_match(player_a, choice_b, choice_a, -result) # 注意结果的对称性 if verbose: res_text [“平局” f“{player_a.name}胜” f“{player_b.name}胜”][result] print(f“{player_a.name}({CHOICE_MAP[choice_a]}) vs {player_b.name}({CHOICE_MAP[choice_b]}) - {res_text}”) # ---------- 4. 锦标赛模拟 ---------- def simulate_tournament(players, verboseFalse): 模拟循环赛返回排序后的玩家列表 n len(players) for i in range(n): for j in range(i1, n): play_match(players[i], players[j], verbose) # 按积分、胜场排序 sorted_players sorted(players, keylambda p: (p.score, p.wins), reverseTrue) return sorted_players def plot_results(players): names [p.name for p in players] scores [p.score for p in players] plt.figure(figsize(12, 5)) plt.subplot(1, 2, 1) bars plt.barh(names, scores, color‘lightcoral’) plt.xlabel(‘积分’) plt.title(‘锦标赛最终积分榜’) for bar, score in zip(bars, scores): plt.text(score, bar.get_y() bar.get_height()/2, f’ {score}‘, va‘center’) plt.gca().invert_yaxis() # 绘制出拳分布所有玩家的总选择 plt.subplot(1, 2, 2) choice_count {‘石头’:0, ‘剪刀’:0, ‘布’:0} for p in players: for opp_history in p.match_history.values(): for my_choice, _, _ in opp_history: choice_count[CHOICE_MAP[my_choice]] 1 labels list(choice_count.keys()) values list(choice_count.values()) plt.pie(values, labelslabels, autopct‘%1.1f%%’, startangle90, colors[‘lightblue’, ‘lightgreen’, ‘pink’]) plt.title(‘全体玩家出拳分布’) plt.tight_layout() plt.show() # ---------- 5. 主程序 ---------- if __name__ “__main__”: # 设置随机种子确保结果可复现 random.seed(2024) # 创建带有不同策略的玩家 players [ Player(“随机者A”, RandomStrategy()), Player(“随机者B”, RandomStrategy()), Player(“石头人”, FixedStrategy(0)), Player(“剪刀手”, FixedStrategy(1)), Player(“布道者”, FixedStrategy(2)), Player(“模仿猫”, CopycatStrategy()), ] print(“开始模拟循环锦标赛...”) final_ranking simulate_tournament(players, verboseFalse) # 设为True可查看每场比赛 print(“\n最终排名”) for i, player in enumerate(final_ranking, 1): print(f“第{i}名: {player}”) # 可视化结果 plot_results(final_ranking)运行这段代码你会看到一场6名不同策略玩家参与的锦标赛结果以及直观的积分榜和出拳分布图。你可以通过修改随机种子、增加玩家数量、创造新的策略类来探索更多可能性。这个项目就像一个乐高底座你已经拥有了所有基础模块接下来可以自由地搭建更复杂、更有趣的模拟世界了。