
1. 项目概述从零构建一个带核心算法的麻将游戏最近几年身边不少朋友和同事都开始琢磨自己动手做点小游戏一来是兴趣使然二来也是想验证一下自己的技术栈。其中麻将游戏因为规则复杂、逻辑性强成了很多开发者跃跃欲试的“硬骨头”。我自己也花了几个月时间从零开始完整地实现了一套麻将游戏软件不仅包含了完整的客户端界面和网络对战功能更重要的是我把整个游戏最核心、最复杂的算法部分——包括胡牌判定、听牌计算、AI出牌逻辑——都独立封装成了一个可复用的算法库。这个项目远不止是画个界面、发发牌那么简单。它真正的挑战在于如何用代码精准地模拟人类打麻将时的复杂决策过程尤其是那个让无数新手头疼的“胡牌算法”。市面上很多开源或简单的麻将游戏其胡牌判定可能只支持最基本的牌型或者逻辑有漏洞。而一个健壮的核心算法需要能处理所有通用规则下的特殊情况比如七对、十三幺、清一色等还要能高效计算当前手牌的听牌状态听哪几张牌能胡。这背后涉及大量的状态枚举、优化剪枝和逻辑判断。如果你也对游戏开发、算法设计或者单纯对麻将的逻辑实现感兴趣那么这篇文章会非常对你胃口。我会抛开那些花哨的UI框架聚焦于最本质的“算法内核”带你一步步拆解我是如何设计并实现这套麻将核心算法的。无论你是想自己写一个麻将游戏还是单纯想学习如何处理这类复杂的规则型游戏逻辑相信都能从中获得可以直接“抄作业”的干货。2. 核心算法模块设计与思路拆解一个麻将游戏的核心算法可以看作是一个独立的“规则引擎”。它的输入是牌局状态包括玩家手牌、已出牌、杠碰状态等输出则是各种判定结果能否胡牌、听什么牌、推荐出哪张牌等。在设计之初我就明确要将这个引擎与具体的界面、网络通信彻底解耦使其成为一个纯逻辑的、无状态的算法库。2.1 核心算法模块的职责划分我将核心算法划分为四个相对独立又相互关联的模块牌型与状态表示模块这是所有算法的基础。如何用代码高效地表示一张麻将牌、一手牌以及整个牌局我放弃了直接用字符串如“一万”、“东风”来表示而是采用了“枚举值位运算”的方案。每张牌被赋予一个唯一的整数ID这个ID同时编码了它的花色万、条、筒、字和点数。一手牌则用一个长度为牌型总数如34种的整数数组来表示数组下标对应牌ID值表示该牌拥有的张数。这种表示法在后续进行顺子、刻子查找以及计算牌型组合时效率远高于操作字符串列表。胡牌判定模块这是算法的“心脏”。它的任务是给定一手牌通常14张判断其是否符合胡牌牌型。经典的判定算法是“回溯法”尝试将手牌分解为若干个“面子”顺子或刻子加上一对“将”对子。我在此基础上进行了大量优化比如优先处理字牌因为它们不能组成顺子以及利用牌的张数信息进行快速剪枝避免无谓的回溯将判定时间复杂度控制在极低的水平即使是在AI需要每秒计算成千上万次胡牌可能性的场景下也游刃有余。听牌计算模块这是胡牌判定的“逆向工程”。给定一手牌通常13张计算出所有打出一张牌后能够等待哪些牌来达成胡牌状态。实现上我会遍历所有可能打出的牌然后遍历所有可能摸进的牌共34种对每一种“打出-摸进”的组合调用胡牌判定。这里的关键优化是“对称性剪枝”和“记忆化搜索”避免重复计算完全相同的牌型状态使得听牌计算能在毫秒级内完成。AI决策模块基于以上模块构建的上层应用。一个简单的AI可以随机出牌但一个有点水平的AI需要模拟简单的决策树。我的AI核心是一个“风险-收益”评估模型。对于每一张可打出的手牌AI会评估打出这张牌后自己手牌的“向听数”距离听牌还差几步减少了多少收益同时这张牌是否为“危险牌”即很可能点炮评估危险牌时AI会结合已打出的牌和推测的他人手牌非常简化的模型来估算点炮概率。最终AI会选择“收益/风险比”最高的那张牌打出。2.2 为什么选择自研算法而非使用现有库在项目初期我确实调研过一些开源的麻将算法库。但发现它们要么耦合了特定的游戏框架难以剥离要么只实现了基础规则对国标、日本麻将等变种的扩展性不好要么代码晦涩难以调试和定制。自研算法虽然前期投入大但带来了几个决定性的优势绝对的控制力任何规则调整、性能优化、Bug修复我都能第一时间完成不依赖第三方。深度理解通过亲手实现我对麻将算法的每一个细节都了如指掌这在调试和设计AI时是无价之宝。可定制性我可以轻松地为算法库添加新的牌型如地方麻将的“飘胡”、新的判定规则或者集成更复杂的AI策略。3. 核心细节解析与实操要点3.1 胡牌判定算法的深度优化基础的“回溯面子分解”算法伪代码如下但直接实现效率很低function 是否可以胡牌(手牌数组) { if (手牌总数 ! 14) return false; return 回溯尝试分解(手牌数组 是否需要将牌); }优化点一字牌优先处理。字牌东南西北中发白只能组成刻子不能组成顺子。因此在开始复杂的回溯前先单独检查字牌。如果某种字牌只有1张或2张且无法通过后续的“杠”或“补花”解决那么这手牌绝对不可能胡可以立即返回false。这能提前过滤掉大量无效情况。优化点二利用“向听数”快速剪枝。“向听数”是一个衡量手牌距离听牌还有多远的指标。在回溯分解过程中我们可以实时计算当前部分分解状态下的最小可能向听数。如果发现即使做最理想的后续分解向听数也大于0即无法胡牌就可以立即终止当前分支的回溯。这需要预先设计一个高效的向听数估算函数。优化点三状态记忆化Memoization。在回溯过程中不同的分解路径可能会遇到相同的“手牌状态”指忽略牌顺序的牌型组合。我们可以用一个哈希表或字典来记录已经计算过的状态及其结果能否胡牌。当再次遇到相同状态时直接查表返回结果避免重复计算。这对于听牌计算和AI模拟中大量重复的判定调用性能提升是数量级的。注意实现记忆化时关键是如何生成状态的唯一键。我采用的方法是将手牌数组34个整数转换成一个长字符串或一个64位整数通过每位表示某种牌的张数确保相同牌型必然产生相同的键。3.2 听牌计算中的效率陷阱与解决听牌计算最朴素的实现是双重循环外层循环尝试打出手牌中的每一张牌假设有13张内层循环尝试摸进剩余的每一种牌最多34种然后调用胡牌判定。13 * 34 442次判定。看起来不多但在AI实时决策或需要同时计算多个玩家听牌状态时这个计算量会成倍增长。我的解决方案是“增量计算”。核心思想是很多不同的“打出一张牌A”的状态其实共享大量相同的子状态。具体做法是首先计算当前13张手牌的“基础牌型”。当计算打出某张牌A后的听牌时我不再从头开始双重循环而是基于“基础牌型”快速生成“打出A后的牌型”然后在这个新牌型上只遍历可能摸进的牌。更进一步的优化是我发现如果两张牌是“同花色且相邻点数”那么打出它们后的牌型在计算听牌时有很多重叠部分。我可以预先计算并缓存这些关联关系。通过这套组合优化我将单次听牌计算的平均耗时降低了70%以上。3.3 AI决策模型的设计心得让AI像人一样打牌是终极目标但作为第一阶段我设计了一个实用且有效的“基于牌效的防守型AI”。它的决策流程如下牌效评估对于每一张手牌计算其“孤立度”和“有效进张数”。例如一张孤立的“五万”可能不如一张与“四万”、“六万”组成搭子的“五万”有价值因为后者更容易形成顺子。我会给每张牌一个基础分数。危险度评估这是一个简化的模型。AI会记录所有玩家打出的牌。如果某张牌特别是字牌和幺九牌从未被打出过且已到牌局中后期则其危险度增加。同时如果某张牌是“现物”即同一张牌其他玩家刚打过则其危险度极低根据规则通常不能胡“现物”。综合决策AI有一个目标函数得分 牌效提升分数 - 危险度系数 * 点炮预估损失。它会选择使这个得分最高的牌打出。其中“点炮预估损失”是一个根据牌局阶段动态调整的权重早期权重低鼓励进攻后期权重高鼓励防守。这个模型虽然远不及职业选手但已经能打出有模有样的牌局不会犯“随便打生张点炮”的低级错误并且会尝试向听牌方向改进手牌。4. 实操过程与核心环节实现4.1 数据结构定义与牌型初始化一切从定义一张牌开始。我选择用8位字节byte来编码一张牌高4位表示花色低4位表示点数。class MahjongTile: SUIT_WAN 1 # 万 SUIT_TIAO 2 # 条 SUIT_TONG 3 # 筒 SUIT_ZI 4 # 字 def __init__(self, suit: int, value: int): self.suit suit self.value value # 对于字牌1-7分别代表东南西北中发白 self.id (suit 4) | value # 生成唯一ID def __str__(self): # 转换为“一万”、“东风”这样的字符串方便调试 suits {1: 万, 2: 条, 3: 筒, 4: 字} values [, 一,二,三,四,五,六,七,八,九] zi_values [, 东,南,西,北,中,发,白] if self.suit self.SUIT_ZI: return zi_values[self.value] else: return values[self.value] suits[self.suit]一手牌我用一个TileCounter类来管理它内部就是一个长度为34的数组。class TileCounter: TOTAL_TILE_TYPES 34 # 万条筒各9种字牌7种 def __init__(self): self.counts [0] * self.TOTAL_TILE_TYPES def add_tile(self, tile_id: int): self.counts[tile_id] 1 def remove_tile(self, tile_id: int): if self.counts[tile_id] 0: self.counts[tile_id] - 1 return True return False def to_vector(self): 转换为可用于哈希和比较的元组 return tuple(self.counts)4.2 胡牌判定算法的代码实现以下是经过优化的胡牌判定核心函数。它先处理特殊牌型如七对、十三幺再处理通用牌型。class MahjongChecker: def __init__(self): self.memo {} # 用于记忆化搜索 def is_winning_hand(self, counter: TileCounter) - bool: 判断牌组是否构成和牌形 total_tiles sum(counter.counts) if total_tiles ! 14: return False # 1. 检查特殊牌型如七对、国士无双 if self._is_seven_pairs(counter): return True if self._is_thirteen_orphans(counter): return True # 2. 通用牌型检查4个面子1个将 return self._check_standard_win(counter) def _check_standard_win(self, counter: TileCounter, has_pairFalse) - bool: 回溯检查标准牌型递归实现 state_key counter.to_vector() if state_key in self.memo: return self.memo[state_key] # 递归基所有牌用完且已有将牌 if sum(counter.counts) 0: result has_pair self.memo[state_key] result return result # 优化优先尝试抽取刻子三张相同 for tile_id in range(counter.TOTAL_TILE_TYPES): if counter.counts[tile_id] 3: # 尝试移除一个刻子 counter.counts[tile_id] - 3 if self._check_standard_win(counter, has_pair): counter.counts[tile_id] 3 # 回溯 self.memo[state_key] True return True counter.counts[tile_id] 3 # 回溯 # 尝试抽取顺子仅限万、条、筒且点数连续 for suit in [MahjongTile.SUIT_WAN, MahjongTile.SUIT_TIAO, MahjongTile.SUIT_TONG]: for start_value in range(1, 8): # 顺子起始点数最大为7 tile_id1 MahjongTile(suit, start_value).id tile_id2 MahjongTile(suit, start_value 1).id tile_id3 MahjongTile(suit, start_value 2).id if (counter.counts[tile_id1] 0 and counter.counts[tile_id2] 0 and counter.counts[tile_id3] 0): # 尝试移除一个顺子 counter.counts[tile_id1] - 1 counter.counts[tile_id2] - 1 counter.counts[tile_id3] - 1 if self._check_standard_win(counter, has_pair): # 回溯 counter.counts[tile_id1] 1 counter.counts[tile_id2] 1 counter.counts[tile_id3] 1 self.memo[state_key] True return True # 回溯 counter.counts[tile_id1] 1 counter.counts[tile_id2] 1 counter.counts[tile_id3] 1 # 尝试抽取将牌对子但只能抽一次 if not has_pair: for tile_id in range(counter.TOTAL_TILE_TYPES): if counter.counts[tile_id] 2: counter.counts[tile_id] - 2 if self._check_standard_win(counter, has_pairTrue): counter.counts[tile_id] 2 self.memo[state_key] True return True counter.counts[tile_id] 2 self.memo[state_key] False return False def _is_seven_pairs(self, counter: TileCounter) - bool: 判断是否为七对 pair_count 0 for count in counter.counts: if count 2: pair_count 1 elif count ! 0: return False # 有不是0或2的张数肯定不是七对 return pair_count 7 def _is_thirteen_orphans(self, counter: TileCounter) - bool: 判断是否为十三幺国士无双 # 定义13种幺九牌和字牌的ID orphan_ids [ ... ] # 具体ID列表省略 has_pair False for tile_id in orphan_ids: c counter.counts[tile_id] if c 0: return False # 缺任何一种都不行 elif c 2: if has_pair: return False # 只能有一对 has_pair True elif c 2: return False # 检查总牌数13种幺九牌各一张其中一种有两张共14张 return has_pair and sum(counter.counts) 144.3 听牌计算功能的实现听牌计算函数会返回一个列表列出所有“打出一张牌后能胡哪些牌”的组合。def calculate_ready_hand(self, counter: TileCounter): 计算当前手牌通常13张的听牌状态。 返回一个列表每个元素为 (打出的牌ID, [能胡的牌ID列表]) ready_hand_info [] total_tiles sum(counter.counts) if total_tiles ! 13: # 也可能是杠后或者别的状态这里简化处理为13张标准听牌 return ready_hand_info # 遍历每一张可能打出的牌 for discard_id in range(self.TOTAL_TILE_TYPES): if counter.counts[discard_id] 0: continue # 没有这张牌无法打出 # 模拟打出这张牌 counter.counts[discard_id] - 1 winning_tiles [] # 遍历所有可能摸进的牌 for draw_id in range(self.TOTAL_TILE_TYPES): # 如果牌池里这种牌已经没了假设4张为上限则不能摸进 # 这里需要接入游戏的总牌墙状态为简化我们假设任何牌都至少还能摸一张 if self._is_tile_available(draw_id): # 需要实现这个方法 counter.counts[draw_id] 1 if self.is_winning_hand(counter): winning_tiles.append(draw_id) counter.counts[draw_id] - 1 # 回溯 if winning_tiles: # 如果打出这张牌后能听牌 ready_hand_info.append((discard_id, winning_tiles)) # 恢复状态准备计算下一张打出牌 counter.counts[discard_id] 1 return ready_hand_info5. 常见问题与排查技巧实录在开发和调试这套算法的过程中我踩过不少坑也总结出一些排查问题的有效方法。5.1 胡牌判定结果异常问题现象明明应该是胡牌的牌型算法返回false或者不应该胡的牌算法却判定为胡牌。排查思路单元测试是生命线必须建立庞大的测试用例库。包括所有基本胡牌牌型平胡、碰碰胡、清一色等、所有特殊牌型七对、十三幺、全不靠等以及大量边缘案例如含有杠的牌型、多种解法的牌型。我最初就是靠一个包含上百个测试用例的脚本才揪出了回溯算法中一个关于字牌处理的逻辑错误。可视化调试不要只盯着数组看。写一个辅助函数将TileCounter对象以人类可读的方式如“一万2 二万1 三万1 东风3...”打印出来。对比输入的手牌和算法中间步骤分解出的“面子”和“将”能直观地发现逻辑错误。检查特殊牌型优先级我的算法中先检查特殊牌型七对、十三幺再检查通用牌型。要确保这个顺序是合理的并且特殊牌型的判定条件绝对准确。例如七对必须恰好是7个对子不能有碰或杠。5.2 听牌计算效率低下或结果不全问题现象计算听牌时程序卡顿或者计算出的听牌选项明显遗漏。排查技巧性能分析使用性能分析工具如Python的cProfile找到耗时最长的函数。往往是is_winning_hand被调用了太多次。这时就要检查记忆化缓存memo是否正常工作命中率如何。我遇到过因为状态键生成函数有Bug导致缓存几乎永不命中性能直接回到解放前。验证听牌结果对于一手牌手动推理出听牌结果然后与程序计算结果对比。如果遗漏问题可能出在_is_tile_available函数逻辑错误错误地排除了某些可摸的牌。听牌计算循环中discard_id和draw_id的遍历范围不正确。手牌初始状态不对比如传入的牌不是13张。边界条件检查特别注意“九莲宝灯”这种特殊听牌听同花色的所有九张牌。用这种极端牌型测试能很好地检验算法完备性。5.3 AI表现愚蠢常打危险牌或错过听牌问题现象AI在牌局后期打出明显的生张导致点炮或者手牌明明已经听牌AI却还在换牌。解决与优化强化危险度模型最初的危险度模型只考虑了“是否现物”和“是否字牌”。后来我加入了“牌河分析”即统计每种牌被打出的总张数。如果一种牌一张都没出现过生张且已进入中后期其危险度会呈指数级上升。还可以引入“筋牌理论”例如如果4万和6万都被打光了那么5万是相对安全的但这需要更复杂的模型。引入“听牌感知”在AI决策循环的最开始先调用calculate_ready_hand。如果发现当前手牌已经听牌则立刻进入“听牌后模式”此时出牌策略应转变为优先打绝对安全牌现物没有安全牌则打危险度最低的牌坚决不拆听口。我最初的AI就是因为缺少这个检查在听牌后还会去优化牌型结果打了不该打的牌。调整牌效评估参数牌效评估中的“有效进张数”计算需要精细调整。例如边张如一万、九万和中张如五万的价值是不同的孤立的字牌和成对的字牌价值也天差地别。我通过让AI自我对弈数千局观察不同参数下的胜率来微调这些评估权重。5.4 算法与游戏状态同步问题问题现象算法库计算出的结果与游戏实际状态不符。例如算法认为可以胡牌但游戏规则里因为“没下叫”或“过水”等原因不能胡。核心原则算法库只提供纯粹的、与规则无关的逻辑计算能力。胡牌判定算法只回答“这组牌是否符合麻将牌型组合规则”。至于“此刻能否胡这张牌”这是一个游戏规则问题它依赖于更多上下文是否报听过是否过水是否满足起胡番数这些规则判断应该放在游戏逻辑层服务端算法库只作为一个工具被调用。我的做法是算法库提供一系列原子APIcan_win(hand_tiles, new_tile)、get_ready_tiles(hand_tiles)等。游戏逻辑层负责收集所有状态玩家手牌、刚摸的牌、杠碰状态、规则配置然后调用合适的API并结合规则进行最终裁决。这样保持了算法库的纯净和可复用性。