每日算法 — 使用java实现麻将:和牌分解回溯与听牌有效张概率枚举

一、”听几张”这个问题,为什么比看上去难

麻将里最实用的判断是:我的牌听几张? 比如手里 13 张牌,摸到哪些牌能和?和了之后是”平和”还是”碰碰和”?剩下还有几张能摸到?

人脑算这个靠经验,但写程序会发现三个硬骨头:

  1. 一副牌可以有很多种拆法。同样是 123 456 789 11 22 33,既可以拆成 123/456/789 + 11 + 22 33(不成立,剩两张),也可以拆成别的组合。只有把全部拆法都枚举完,才能确定”能不能和”以及”最高番数是什么”。
  2. 听牌张需要枚举 34 种牌 × 每种剩余张数。34 种牌类型(万条筒各 9 种 + 风牌 4 + 箭牌 3),每种最多剩 4 张,要算出”总有效张 = 各有效牌剩余张数之和”。
  3. “有效张”不是简单的 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 个候选上重跑这个判定,并扣减可见张数得到真实期望。这套”回溯去重 + 外层枚举 + 概率扣减“的模式,可以平移到任何”有限组合拆分 + 剩余资源统计”问题——麻将只是它最典型的载体。