每日算法 — 使用java实现四川麻将:牌型组合评估与概率弃牌策略

引言

四川麻将是中国最受欢迎的麻将变体之一,以其独特的”缺一门””血战到底”等规则著称。与普通麻将不同,四川麻将要求玩家在打缺一门(筒、条、万中必须弃掉一门)后才能胡牌,且一家胡牌后游戏不结束,剩余玩家继续血战,极大增加了策略深度。

本文将用Java实现四川麻将的AI助手核心,重点讲解牌型组合数学评估、听牌概率计算以及贪心最优弃牌策略。

游戏规则与建模

牌型结构

四川麻将使用136张牌(无花牌):
– 筒子(1~9筒)、条子(1~9条)、万子(1~9万):各4张
– 风牌(东南西北):各4张
– 箭牌(中发白):各4张

核心规则

  1. 缺一门:手牌中只能保留两门花色,必须打缺一门才能胡牌
  2. 血战到底:一家胡牌后,其他未胡玩家继续游戏
  3. 胡牌条件:4组面子(顺子或刻子)+ 1对将牌
  4. 特殊牌型:七对、十三幺、龙七对等特殊胡牌方式

手牌表示

/**
 * 麻将牌枚举
 */
enum Tile {
    // 筒子 (0~8)
    T1, T2, T3, T4, T5, T6, T7, T8, T9,
    // 条子 (9~17)
    S1, S2, S3, S4, S5, S6, S7, S8, S9,
    // 万子 (18~26)
    W1, W2, W3, W4, W5, W6, W7, W8, W9,
    // 风牌 (27~30)
    EAST, SOUTH, WEST, NORTH,
    // 箭牌 (31~33)
    RED, GREEN, WHITE;

    static final int COUNT = 34;

    boolean isTong() { return ordinal() <= 8; }
    boolean isTiao() { return ordinal() >= 9 && ordinal() <= 17; }
    boolean isWan()  { return ordinal() >= 18 && ordinal() <= 26; }
    int suit() {
        if (isTong()) return 0;
        if (isTiao()) return 1;
        if (isWan()) return 2;
        return 3; // 字牌
    }
}

/**
 * 手牌状态
 */
class Hand {
    int[] tiles = new int[Tile.COUNT]; // 每种牌的数量(0~4)
    int total = 0; // 手牌总数

    void add(Tile t) { tiles[t.ordinal()]++; total++; }
    void remove(Tile t) { tiles[t.ordinal()]--; total--; }
}

核心算法设计

1. 牌型结构分解(递归搜索)

判断一手牌是否可胡,需将其分解为4组面子 + 1对将牌。采用递归搜索:

  1. 枚举所有可能的将牌(对子)
  2. 对剩余牌递归寻找顺子/刻子
  3. 若成功分解则判定为可胡
f(hand):
    for each tile t where count[t] >= 2:
        hand.removePair(t)
        if canFormFourGroups(hand):
            return true
        hand.addPair(t)
    return false

canFormFourGroups(hand):
    if hand.isEmpty(): return true
    for each tile t where count[t] > 0:
        // 尝试刻子
        if count[t] >= 3:
            hand.removeTriplet(t)
            if canFormFourGroups(hand): return true
            hand.addTriplet(t)
        // 尝试顺子(仅限序数牌)
        if t.isSequence() and count[t+1] > 0 and count[t+2] > 0:
            hand.removeSequence(t)
            if canFormFourGroups(hand): return true
            hand.addSequence(t)
    return false

2. 向听数(Shanten Number)计算

向听数表示手牌距离胡牌还差多少步(至少需要替换多少张牌)。计算方法:

$$Shanten = 8 – 2 \times Groups – Pairs – CompleteSequences$$

其中:
– $Groups$:已完成的刻子/顺子数
– $Pairs$:对子数(最多计1个)
– $CompleteSequences$:搭子数(两面搭、边搭、嵌搭)

向听数越小,手牌越接近胡牌。AI优先将手牌优化到向听数最小。

3. 牌效评估与最优弃牌

每张牌的价值取决于:
– 保留价值:该牌能组成的面子/搭子/对子数量
– 改良价值:摸到附近牌后手牌的提升潜力
– 危险价值:打出后点炮的风险(基于对手舍牌推断)

贪心策略:弃掉牌效最低且危险度最低的牌。

4. 听牌概率计算

给定手牌和剩余牌墙信息,计算听到某张牌的概率:

$$P(听t) = \frac{Remain(t)}{WallSize}$$

综合所有听牌的期望收益,选择收益最大化的打法。

Java实现

import java.util.*;

/**
 * 四川麻将AI核心
 * 牌型组合评估 + 向听数计算 + 概率弃牌策略
 */
public class SichuanMahjongAI {

    private static final int TILE_COUNT = 34;
    private static final int[] SUIT_START = {0, 9, 18, 27}; // 筒、条、万、字牌起始索引

    /**
     * 计算向听数:距离胡牌还差多少张牌
     */
    public int shanten(int[] hand) {
        int minShanten = 8; // 最大向听数

        // 标准形(4面子1将)
        for (int pair = 0; pair < TILE_COUNT; pair++) {
            if (hand[pair] < 2) continue;
            int[] temp = hand.clone();
            temp[pair] -= 2;
            int groups = maxGroups(temp);
            int pairs = 1;
            int shan = 8 - 2 * groups - pairs;
            if (shan < minShanten) minShanten = shan;
        }

        // 七对形
        int pairCount = 0, singleCount = 0;
        for (int i = 0; i < TILE_COUNT; i++) {
            if (hand[i] >= 2) pairCount++;
            if (hand[i] == 1) singleCount++;
        }
        int sevenPairsShanten = 6 - pairCount;
        if (sevenPairsShanten < minShanten) minShanten = sevenPairsShanten;

        // 十三幺
        int thirteenOrphansShanten = calcThirteenOrphansShanten(hand);
        if (thirteenOrphansShanten < minShanten) minShanten = thirteenOrphansShanten;

        return Math.max(0, minShanten);
    }

    /**
     * 计算手牌能构成的最大面子数(用于标准形向听数)
     */
    private int maxGroups(int[] hand) {
        return maxGroupsHelper(hand, 0);
    }

    private int maxGroupsHelper(int[] hand, int start) {
        // 找到第一个非零牌
        int first = start;
        while (first < TILE_COUNT && hand[first] == 0) first++;
        if (first >= TILE_COUNT) return 0;

        int max = 0;

        // 尝试刻子
        if (hand[first] >= 3) {
            hand[first] -= 3;
            int g = 1 + maxGroupsHelper(hand, first);
            if (g > max) max = g;
            hand[first] += 3;
        }

        // 尝试顺子(仅限序数牌)
        if (first < 27 && first % 9 <= 6 && hand[first + 1] > 0 && hand[first + 2] > 0) {
            hand[first]--; hand[first + 1]--; hand[first + 2]--;
            int g = 1 + maxGroupsHelper(hand, first);
            if (g > max) max = g;
            hand[first]++; hand[first + 1]++; hand[first + 2]++;
        }

        // 跳过这张牌
        int g = maxGroupsHelper(hand, first + 1);
        if (g > max) max = g;

        return max;
    }

    /**
     * 十三幺向听数
     */
    private int calcThirteenOrphansShanten(int[] hand) {
        int[] terminals = {0, 8, 9, 17, 18, 26, 27, 28, 29, 30, 31, 32, 33};
        int have = 0, havePair = 0;
        for (int t : terminals) {
            if (hand[t] > 0) have++;
            if (hand[t] >= 2) havePair = 1;
        }
        return 13 - have - havePair;
    }

    /**
     * 计算每张牌的保留价值(牌效)
     * 返回每张牌的效用分数,分数越低越应该弃掉
     */
    public double[] tileEfficiency(int[] hand, int[] wall) {
        double[] efficiency = new double[TILE_COUNT];

        for (int i = 0; i < TILE_COUNT; i++) {
            if (hand[i] == 0) {
                efficiency[i] = Double.NEGATIVE_INFINITY; // 手牌中没有,不考虑
                continue;
            }

            // 模拟弃掉这张牌后的向听数
            int[] temp = hand.clone();
            temp[i]--;
            int shanAfter = shanten(temp);

            // 基础向听数惩罚
            efficiency[i] = -shanAfter * 10;

            // 额外评估该牌的面子潜力
            efficiency[i] += calcTilePotential(i, hand, wall);

            // 孤张惩罚
            if (isIsolated(i, hand)) {
                efficiency[i] -= 5;
            }

            // 字牌单张惩罚(字牌难以组成顺子)
            if (i >= 27 && hand[i] == 1) {
                efficiency[i] -= 3;
            }
        }

        return efficiency;
    }

    /**
     * 计算单张牌的搭子潜力
     */
    private double calcTilePotential(int tile, int[] hand, int[] wall) {
        double potential = 0;

        // 刻子潜力
        if (hand[tile] >= 2) {
            potential += 5; // 已有对子,价值高
            if (wall[tile] > 0) potential += wall[tile]; // 剩余牌多,碰牌机会大
        }

        // 顺子潜力(仅限序数牌)
        if (tile < 27) {
            int suitStart = tile / 9 * 9;
            int rank = tile % 9;

            // 两面搭潜力(如3-4等5,或等2)
            if (rank >= 1 && hand[tile - 1] > 0) {
                int needed = tile + 1;
                if (needed < suitStart + 9) potential += wall[needed];
            }
            if (rank <= 7 && hand[tile + 1] > 0) {
                int needed = tile - 1;
                if (needed >= suitStart) potential += wall[needed];
            }

            // 嵌搭潜力(如3-5等4)
            if (rank >= 2 && hand[tile - 2] > 0) {
                potential += wall[tile - 1] * 0.5;
            }
            if (rank <= 6 && hand[tile + 2] > 0) {
                potential += wall[tile + 1] * 0.5;
            }
        }

        return potential;
    }

    /**
     * 判断是否为孤张(无相邻牌)
     */
    private boolean isIsolated(int tile, int[] hand) {
        if (hand[tile] > 1) return false; // 多张不算孤张
        if (tile >= 27) return true; // 字牌单张视为孤张

        int suitStart = tile / 9 * 9;
        int rank = tile % 9;
        boolean hasNeighbor = false;
        if (rank > 0 && hand[tile - 1] > 0) hasNeighbor = true;
        if (rank < 8 && hand[tile + 1] > 0) hasNeighbor = true;
        if (rank > 1 && hand[tile - 2] > 0) hasNeighbor = true;
        if (rank < 7 && hand[tile + 2] > 0) hasNeighbor = true;

        return !hasNeighbor;
    }

    /**
     * 选择最优弃牌
     */
    public int chooseDiscard(int[] hand, int[] wall, int[] discards) {
        double[] eff = tileEfficiency(hand, wall);

        // 加上危险度惩罚(基于对手舍牌推断)
        double[] danger = estimateDanger(hand, wall, discards);

        int bestTile = -1;
        double bestScore = Double.NEGATIVE_INFINITY;

        for (int i = 0; i < TILE_COUNT; i++) {
            if (hand[i] == 0) continue;
            double score = eff[i] - danger[i] * 2; // 平衡牌效与安全性
            if (score > bestScore) {
                bestScore = score;
                bestTile = i;
            }
        }

        return bestTile;
    }

    /**
     * 估计每张牌的危险度(被对手需要的概率)
     */
    private double[] estimateDanger(int[] hand, int[] wall, int[] discards) {
        double[] danger = new double[TILE_COUNT];

        // 基于已出牌统计:某家已打出某花色很多,可能在做另外两门
        int[] suitDiscards = new int[4];
        for (int i = 0; i < TILE_COUNT; i++) {
            suitDiscards[i / 9] += discards[i];
        }

        for (int i = 0; i < TILE_COUNT; i++) {
            int suit = i / 9;
            // 如果某家已打缺某门,则另外两门的牌更危险
            if (suit < 3) {
                // 简单启发:对手可能在做筒条万中的两门
                danger[i] = discards[i] * 0.5; // 已被打出越多,相对越安全
            }

            // 生张(未见过的牌)更危险
            if (discards[i] == 0 && wall[i] < 4) {
                danger[i] += 2;
            }
        }

        return danger;
    }

    /**
     * 计算听牌列表及期望收益
     */
    public List<int[]> calcTenpai(int[] hand, int[] wall) {
        List<int[]> tenpai = new ArrayList<>();

        for (int i = 0; i < TILE_COUNT; i++) {
            if (wall[i] == 0) continue;
            int[] temp = hand.clone();
            temp[i]++;
            if (shanten(temp) == -1) { // 注意:实际应判断是否为胡牌
                // 简化:这里用向听数<=0表示可胡
                tenpai.add(new int[]{i, wall[i]});
            }
        }

        return tenpai;
    }

    /**
     * 判断是否需要打缺一门
     */
    public int chooseSuitToDiscard(int[] hand) {
        int[] suitCount = new int[3];
        for (int i = 0; i < 27; i++) {
            suitCount[i / 9] += hand[i];
        }

        // 选择数量最少的一门打缺
        int minSuit = 0;
        for (int s = 1; s < 3; s++) {
            if (suitCount[s] < suitCount[minSuit]) minSuit = s;
        }

        return minSuit;
    }

    public static void main(String[] args) {
        SichuanMahjongAI ai = new SichuanMahjongAI();

        // 构造测试手牌:3筒3筒, 4筒, 5筒, 6条, 7条, 8条, 1万, 1万, 3万, 3万, 3万, 东, 东
        int[] hand = new int[TILE_COUNT];
        hand[2] = 2;  // 3筒
        hand[3] = 1;  // 4筒
        hand[4] = 1;  // 5筒
        hand[14] = 1; // 6条
        hand[15] = 1; // 7条
        hand[16] = 1; // 8条
        hand[18] = 2; // 1万
        hand[20] = 3; // 3万
        hand[27] = 2; // 东

        int[] wall = new int[TILE_COUNT];
        Arrays.fill(wall, 4);
        for (int i = 0; i < TILE_COUNT; i++) wall[i] -= hand[i];

        int[] discards = new int[TILE_COUNT];

        System.out.println("向听数: " + ai.shanten(hand));
        System.out.println("建议打缺: " + (ai.chooseSuitToDiscard(hand) == 0 ? "筒" : ai.chooseSuitToDiscard(hand) == 1 ? "条" : "万"));

        int discard = ai.chooseDiscard(hand, wall, discards);
        System.out.println("建议弃牌: " + tileToString(discard));
    }

    private static String tileToString(int tile) {
        String[] names = {
            "1筒","2筒","3筒","4筒","5筒","6筒","7筒","8筒","9筒",
            "1条","2条","3条","4条","5条","6条","7条","8条","9条",
            "1万","2万","3万","4万","5万","6万","7万","8万","9万",
            "东","南","西","北","中","发","白"
        };
        return names[tile];
    }
}

算法复杂度分析

时间复杂度

  • 向听数计算:递归搜索,最坏 $O(TILE_COUNT^k)$,其中 $k$ 为面子数。实际中通过剪枝和记忆化,单次计算在毫秒级
  • 牌效评估:对每张牌模拟弃牌并计算向听数,$O(TILE_COUNT \cdot ShantenTime)$
  • 最优弃牌:综合牌效与危险度,$O(TILE_COUNT)$

空间复杂度

  • 手牌表示:$O(TILE_COUNT)$
  • 递归栈:$O(k)$,$k$ 为面子数(最大4)

扩展与改进

1. 牌河分析

通过分析对手的舍牌顺序和花色分布,使用贝叶斯推断估计对手的手牌结构,从而更精确地计算危险度。

2. 番数计算

四川麻将存在多种番数规则(如清一色、碰碰胡、杠上花等)。将番数期望纳入评估函数,在追求速度的同时最大化收益。

3. 深度强化学习

使用Self-Play DQN或PPO训练端到端的麻将AI,如Suphx等系统,可达到人类高手水平。

总结

四川麻将AI的核心在于组合数学与概率推理的融合。通过向听数计算量化手牌价值,通过牌效评估指导最优弃牌,结合危险度估计平衡进攻与防守。这套框架不仅适用于四川麻将,其牌型分解、搭子评估等方法也可推广至所有麻将变体及类似的组合牌类游戏。