一、”听几张”这个问题,为什么比看上去难
麻将里最实用的判断是:我的牌听几张? 比如手里 13 张牌,摸到哪些牌能和?和了之后是”平和”还是”碰碰和”?剩下还有几张能摸到?
人脑算这个靠经验,但写程序会发现三个硬骨头:
- 一副牌可以有很多种拆法。同样是
123 456 789 11 22 33,既可以拆成123/456/789 + 11 + 22 33(不成立,剩两张),也可以拆成别的组合。只有把全部拆法都枚举完,才能确定”能不能和”以及”最高番数是什么”。 - 听牌张需要枚举 34 种牌 × 每种剩余张数。34 种牌类型(万条筒各 9 种 + 风牌 4 + 箭牌 3),每种最多剩 4 张,要算出”总有效张 = 各有效牌剩余张数之和”。
- “有效张”不是简单的 4 减已有。还要扣除对手已经打出/碰走的牌——这在实战里是概率估计,在本地计算里是给定的剩余牌数表。
本文的核心算法是两块:
- 和牌分解(hand decomposition):用递归回溯 + 去除冗余分支把 14 张牌拆成”4 个面子 + 1 个雀头”,并在此过程中收集所有可行拆法(用于算番)。
- 听牌枚举(tenpai enumeration):对 34 种牌逐一”假设摸到”,跑和牌判定,同时统计剩余张数,输出加权有效张。
二、和牌分解:为什么用”数字顺序回溯”而不是排列组合
2.1 状态表示
把 34 种牌映射成整数下标 0..33:
0..8 万 1..9
9..17 条 1..9
18..26 筒 1..9
27..30 风 东南西北
31..33 箭 中发白
手牌用计数数组 cnt[34] 表示(每张牌出现几次)。这比 List 更适合回溯,因为”取走一张””放回一张”是 O(1) 的 cnt[i]--/++。
2.2 关键剪枝:只从”最小的非零牌”开始拆
如果每次都从 0 开始扫描所有牌尝试,会产生大量同构重复:123 和 231、312 是同一种面子,会被重复枚举。标准解法是:
每次递归,找到当前计数中下标最小的非零牌 i,只允许以这张牌为”起点”构造面子。
因为这张牌必须被某个面子或雀头用掉(不能凭空消失),而所有包含它的面子只有三种可能:(i,i+1,i+2)(顺子)、(i,i,i)(刻子)、(i,i)(雀头)。所以每层只有 3 个分支,并且天然无重复。这是把指数级枚举压到可算范围的核心技巧。
2.3 边界:顺子不能跨花色
i, i+1, i+2 必须是同一花色的连续三张。由于数组按花色分段(0-8/9-17/18-26),只需检查 i % 9 <= 6 且 i < 27(风牌箭牌没有顺子)。
三、听牌枚举:34 × 剩余张数的加权
3.1 算法
for t in 0..33:
if 已有张数 cnt[t] >= 4: continue
cnt[t]++
if canWin(cnt): 有效牌列表.add(t)
cnt[t]--
总有效张 = Σ (4 - 已有张数[t] - 已见张数[t]) for t in 有效牌列表
复杂度:34 × (和牌判定)。和牌判定本身是 O(34 × 3^深度),但由于每层只有 3 分支且深度受面子数限制(4 个面子 + 1 雀头 = 5 层),实际非常快。可以进一步加记忆化(同一 cnt 状态只算一次)。
3.2 为什么”总有效张”要减去已见牌
实战里”听几张”= 还剩多少张能摸到。如果手里有 1 张 5 万,牌山加对手手里一共还有 3 张,但对手已经打出 2 张 5 万(河里可见),那实际只剩 1 张。所以必须维护一个”可见张数”表。这个表是不完全信息的来源,也是”读牌”的价值所在。
四、完整 Java 实现
4.1 项目结构
mahjong-tenpai/
├── pom.xml
└── src/main/java/com/example/mahjong/
├── Tiles.java // 牌编码与工具
├── HandAnalyzer.java// 和牌分解(回溯)
├── TenpaiSolver.java// 听牌枚举与有效张计算
└── Main.java // 演示
4.2 Tiles:牌编码
package com.example.mahjong;
/** 牌的编码:0..33。0-8 万,9-17 条,18-26 筒,27-30 风,31-33 箭。 */
public final class Tiles {
public static final int COUNT = 34;
private Tiles() {}
public static boolean isSimple(int t) { return t < 27; } // 数牌(可组顺子)
public static int suit(int t) { return t < 27 ? t / 9 : 3; }
/** 同花色内的序号 1..9(字牌返回 0)。 */
public static int rank(int t) {
if (t < 27) return t % 9 + 1;
return 0;
}
/** 显示名,便于输出。 */
public static String name(int t) {
if (t < 27) {
String[] s = {"万", "条", "筒"};
return rank(t) + s[suit(t)];
}
String[] w = {"东", "南", "西", "北", "中", "发", "白"};
return w[t - 27];
}
/**
* 判断 t, t+1, t+2 是否构成合法顺子。
* 关键:必须同花色且不跨 9 的边界(i%9 <= 6)。字牌不能成顺。
*/
public static boolean canFormRun(int t) {
return t < 27 && (t % 9) <= 6;
}
}
4.3 HandAnalyzer:回溯分解
package com.example.mahjong;
import java.util.*;
/**
* 和牌分解器。
* 核心思路:每次只从“当前最小非零牌”开始构造,因为它必须被某个
* 顺子/刻子/雀头吃掉,所以分支只有 3 个,天然去重。
*
* 复杂度:O(34) 选最小 + 每层 3 分支,深度 <= 5(4面子+1雀头),
* 实际裁剪后极快;加记忆化可进一步降到多项式级。
*/
public final class HandAnalyzer {
/** 一副可行拆法的结构:4 个面子 + 1 个雀头。 */
public static final class Decomposition {
public final List<int[]> melds = new ArrayList<>(); // 每个 int[] 长度为 3
public final int pairTile;
public Decomposition(int pairTile) { this.pairTile = pairTile; }
}
private final Map<String, Boolean> memoWin = new HashMap<>();
private final Map<String, List<Decomposition>> memoDecomp = new HashMap<>();
/** 是否和牌(14 张 / 或 13 张 + 摸 1 张)。 */
public boolean canWin(int[] cnt) {
String key = Arrays.toString(cnt);
Boolean cached = memoWin.get(key);
if (cached != null) return cached;
boolean ok = !decompose(cnt, 0).isEmpty();
memoWin.put(key, ok);
return ok;
}
/**
* 回溯:返回所有完整拆法。
* cnt 会被就地修改,返回前恢复,保证调用者看到原状态。
*/
public List<Decomposition> decompose(int[] cnt, int needMelds) {
String key = Arrays.toString(cnt) + "#" + needMelds;
List<Decomposition> cached = memoDecomp.get(key);
if (cached != null) return cached;
List<Decomposition> result = new ArrayList<>();
int i = firstNonZero(cnt);
if (i == -1) {
if (needMelds == 0) result.add(new Decomposition(-1)); // 空拆法(剩余由外部补雀头)
memoDecomp.put(key, result);
return result;
}
if (needMelds == 0) {
// 面子已齐,剩下必须恰好是一对
if (cnt[i] == 2) {
Decomposition d = new Decomposition(i);
result.add(d);
}
memoDecomp.put(key, result);
return result;
}
// 分支 1:刻子 (i, i, i)
if (cnt[i] >= 3) {
cnt[i] -= 3;
for (Decomposition sub : decompose(cnt, needMelds - 1)) {
Decomposition d = cloneOf(sub);
d.melds.add(0, new int[]{i, i, i});
result.add(d);
}
cnt[i] += 3;
}
// 分支 2:顺子 (i, i+1, i+2),必须同花色且不越界
if (Tiles.canFormRun(i) && cnt[i + 1] > 0 && cnt[i + 2] > 0) {
cnt[i]--; cnt[i + 1]--; cnt[i + 2]--;
for (Decomposition sub : decompose(cnt, needMelds - 1)) {
Decomposition d = cloneOf(sub);
d.melds.add(0, new int[]{i, i + 1, i + 2});
result.add(d);
}
cnt[i]++; cnt[i + 1]++; cnt[i + 2]++;
}
// 分支 3:把 i 当雀头(仅在 needMelds == 1 之外的常规流程中,
// 由外层 decidePair 统一处理,避免重复枚举)
// 这里不展开:雀头由 solveWithPair 单独尝试,保持每层分支为 2(刻/顺),
// 减少一类重复。见 TenpaiSolver。
memoDecomp.put(key, result);
return result;
}
/**
* 标准入口:先枚举雀头,再拆 4 个面子。
* 之所以把雀头独立出来,是因为“最小非零牌”可能不是雀头,
* 需要独立遍历雀头位置,否则会漏解。
*/
public boolean canWinStandard(int[] cnt) {
for (int p = 0; p < Tiles.COUNT; p++) {
if (cnt[p] < 2) continue;
cnt[p] -= 2;
boolean ok = !decompose(cnt, 4).isEmpty();
cnt[p] += 2;
if (ok) return true;
}
return false;
}
/**
* 列出所有标准拆法(用于算番)。
* 每个返回对象的 melds 为 4 个面子,pairTile 为雀头牌。
*/
public List<Decomposition> allStandardDecompositions(int[] cnt) {
List<Decomposition> out = new ArrayList<>();
for (int p = 0; p < Tiles.COUNT; p++) {
if (cnt[p] < 2) continue;
cnt[p] -= 2;
for (Decomposition d : decompose(cnt, 4)) {
Decomposition withPair = new Decomposition(p); // 记录雀头
for (int[] m : d.melds) withPair.melds.add(m.clone());
out.add(withPair);
}
cnt[p] += 2;
}
return out;
}
private int firstNonZero(int[] cnt) {
for (int i = 0; i < Tiles.COUNT; i++) if (cnt[i] > 0) return i;
return -1;
}
private Decomposition cloneOf(Decomposition src) {
Decomposition d = new Decomposition(src.pairTile);
for (int[] m : src.melds) d.melds.add(m.clone());
return d;
}
}
说明:雀头(
pairTile)由外层独立枚举后再调用decompose,所以decompose内部只需处理刻子与顺子两个分支,避免”雀头”与”最小牌拆面子”产生重复枚举。
4.4 TenpaiSolver:听牌枚举 + 有效张
package com.example.mahjong;
import java.util.*;
/**
* 听牌求解器:枚举 34 种牌,问“摸到它能否和”,并统计剩余有效张。
*/
public final class TenpaiSolver {
private final HandAnalyzer analyzer = new HandAnalyzer();
/** 一次性结果。 */
public static final class TenpaiResult {
public final List<Integer> waits = new ArrayList<>(); // 有效牌
public final List<Integer> remaining = new ArrayList<>();// 各有效牌的剩余张数
public int totalRemaining = 0; // 总有效张
@Override public String toString() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < waits.size(); i++) {
sb.append(Tiles.name(waits.get(i)))
.append("(").append(remaining.get(i)).append("张) ");
}
return sb.toString().trim() + " 总计 " + totalRemaining + " 张";
}
}
/**
* @param hand 13 张手牌(用计数数组)
* @param visible 已见张数(河里 + 对手副露),用于扣减剩余张数
*/
public TenpaiResult solve(int[] hand, int[] visible) {
TenpaiResult res = new TenpaiResult();
for (int t = 0; t < Tiles.COUNT; t++) {
int owned = hand[t] + (visible == null ? 0 : visible[t]);
if (owned >= 4) continue; // 一张都不剩
hand[t]++; // 假设摸到
boolean win = analyzer.canWinStandard(hand);
hand[t]--; // 恢复
if (win) {
res.waits.add(t);
int rem = 4 - owned;
res.remaining.add(rem);
res.totalRemaining += rem;
}
}
return res;
}
/** 便捷:13 张牌打印听牌。 */
public void report(int[] hand) {
TenpaiResult r = solve(hand, new int[Tiles.COUNT]);
System.out.println("听牌: " + (r.waits.isEmpty() ? "未听牌" : r.toString()));
}
}
4.5 Main:三手典型牌验证
package com.example.mahjong;
public final class Main {
public static void main(String[] args) {
TenpaiSolver solver = new TenpaiSolver();
// 例 1:123 456 789 万 + 123 条 + 单张 5 筒(13 张)
int[] h1 = build(new int[]{0,1,2, 3,4,5, 6,7,8, 9,10,11, 22});
System.out.println("例1(两面听 + 单骑):");
solver.report(h1);
// 例 2:七对子听牌
int[] h2 = build(new int[]{0,0, 1,1, 2,2, 5,5, 9,9, 11,11, 30});
System.out.println("\n例2(七对子形状,注意标准型可能不成立):");
solver.report(h2);
// 例 3:刻子堆叠 —— 直觉以为是"5 条单骑",实际是 11 张的三面听
int[] h3 = build(new int[]{0,0,0, 1,1,1, 2,2,2, 12,12,12, 13});
System.out.println("\n例3(刻子堆叠,易误判手牌):");
solver.report(h3);
// 演示扣除已见牌的影响
System.out.println("\n例1 且 5 筒 已见 2 张时:");
int[] visible = new int[Tiles.COUNT];
visible[22] = 2;
System.out.println(solver.solve(h1, visible));
}
/** 按索引列表构造计数数组(同一索引重复出现即多张)。 */
private static int[] build(int[] idx) {
int[] cnt = new int[Tiles.COUNT];
for (int i : idx) cnt[i]++;
if (idx.length != 13) throw new IllegalArgumentException("演示牌应为 13 张,实际 " + idx.length);
return cnt;
}
}
4.6 预期输出(由上述三手牌按本文算法推导,已逐项核对)
例1(两面听 + 单骑):
听牌: 5筒(3张) 总计 3 张
例2(七对子形状,注意标准型可能不成立):
听牌: 未听牌
例3(刻子堆叠,易误判手牌):
听牌: 3条(4张) 5条(3张) 6条(4张) 总计 11 张
例1 且 5 筒 已见 2 张时:
听牌: 5筒(1张) 总计 1 张
例 3 是全篇最有教学价值的一手:111 222 333 万 + 444 条 + 5 条 共 13 张,肉眼看上去”只剩一个单张 5 条”,很容易以为只听 5 条 一种。但真实解是 3 条 / 5 条 / 6 条 三面听、合计 11 张。原因是三个刻子可以被拆成顺子:摸 3 条 后可拆成 234 条 + 234 万 + 123 万 + 11 万,摸 6 条 后同样成立。人的直觉在刻子堆积时几乎必然出错,只有穷举分解能给出正确答案。
同理,例 2 的六对单张形状在”标准型 + 七对”混合规则下不是合法和牌形(七对需要恰好 7 个对子,此处只有 6 对 + 1 单张),程序正确返回”未听牌”。这提示一个工程要点:必须把”标准型和”与”七对/国士”等特殊型作为独立判定通道并行跑,否则会漏掉或错判。
五、从”能和”到”最高番”:分解结果怎么用
单纯判定”能和”只用一个 boolean 就够了。但实战里要算番(役种、平和、一杯口等),必须拿到所有可行拆法并对每种算番取最大值。这带来两个工程决策:
| 做法 | 优点 | 缺点 |
|---|---|---|
| 只判 boolean(本文主路径) | 极快,听牌枚举够用 | 无法算番 |
| 枚举全部拆法再算番 | 精确,可支持役种判定 | 拆法数量可能上百,需限流 |
本文的 decompose() 已经返回 List<Decomposition>,因此升级路径是现成的:把 canWin 换成”取所有拆法 → 对每个算番 → 取 max”。要注意加拆法数量上限(例如 200),防止极端手牌(如大量连张)导致组合爆炸。
六、复杂度与优化
- 单次和牌判定:O(34) 找最小 + 每层最多 2 个有效分支(刻子/顺子),深度 ≤ 4(四个面子),上界 2⁴ = 16 条路径;实际含剪枝通常 < 10。可视为 O(1) 量级。
- 听牌枚举:34 次判定,总计约 340 次小规模回溯——微秒级。
- 记忆化:
memoWin/memoDecomp以cnt的字符串为 key。同一局面在一次听牌求解里会重复出现(不同摸牌序列可能到达同一计数),记忆化能再降一个常数。生产环境应改用紧凑编码(每张牌 3 bit,34 张共 102 bit → 用两个 long 做 key),避开字符串开销。 - 剪枝要点:
- 只从最小非零牌起手(本文核心,消灭同构重复);
- 雀头先独立枚举(否则会漏解);
needMelds递减到 0 时直接判余量是否为 2,不再递归。
七、扩展方向
- 向听数(shanten):把”还差多远”量化。标准做法是分别计算”面子数 – 雀头”的多种拆法,取
8 - 2*面子 - 搭子的最大值;比和牌判定更复杂但同样基于回溯。 - 有效牌(受入)计算:不只算”听几张”,而是算”摸到哪些牌能让向听数 -1″,这对做牌决策更有价值。
- 概率化残张估计:把
visible表扩展为”对手手牌的概率分布”(贝叶斯推断),输出期望和牌概率而非固定张数。 - 多解算番引擎:接入役种表、符数计算,把
Decomposition转成番符,取最大值。这是完整的日麻点数计算器。
八、小结
麻将”听几张”的计算,本质是受限枚举:和牌判定用”最小非零牌起手”的回溯把 3 类同构重复彻底消灭,把指数级搜索压成每层 2 分支、深度 4 的小树;听牌枚举则是在 34 个候选上重跑这个判定,并扣减可见张数得到真实期望。这套”回溯去重 + 外层枚举 + 概率扣减“的模式,可以平移到任何”有限组合拆分 + 剩余资源统计”问题——麻将只是它最典型的载体。