每日算法 — 使用java实现梭哈:补牌计数Outs与隐含赔率期望决策

梭哈(Show Hand)是五张牌的比大小游戏。玩家手里有牌、桌上有公共牌,核心问题永远是同一个:跟注还是弃牌? 直觉回答靠”感觉牌运”,严格回答要靠数学——把”还剩多少张牌能让我赢”数清楚,再把它换算成赔率,与底池赔率比较。本文用 Java 实现一套完整的梭哈决策器:位运算牌型评估、Outs 精确组合计数、隐含赔率期望计算,以及基于阈值的决策判据。

一、牌型评估:用位运算一次算完

五张牌的牌型有九档,从高到低为:同花顺、四条、葫芦、同花、顺子、三条、两对、一对、高牌。用位运算可以做到”无分支”的高效判定。

关键数据结构:

  • rankMask:13 位掩码,第 r 位为 1 表示存在点数 r 的牌(r 取 0=A、1=2 … 12=K);
  • suitMask[4]:每个花色一个 13 位掩码;
  • counts[13]:每个点数出现的次数。

有了这两样,牌型可以这样判定:

牌型 判定条件
同花 某个 suitMask[s] 的 popcount == 5
顺子 rankMask 中存在连续 5 位;A 可作 1 需特判 A-2-3-4-5
四条/三条/两对/一对 由 counts 的分布(4/3/2+2/2)确定
同花顺 同花 且 该花色掩码中存在连续 5 位
package com.example.poker;

import java.util.*;

/** 五张牌的牌型评估:位运算实现,O(1) */
public class HandEvaluator {
    public static final int HIGH_CARD = 0, ONE_PAIR = 1, TWO_PAIR = 2,
            THREE_KIND = 3, STRAIGHT = 4, FLUSH = 5, FULL_HOUSE = 6,
            FOUR_KIND = 7, STRAIGHT_FLUSH = 8;

    /** 牌用 int 编码:高 4 位为点数(0..12),低 2 位为花色(0..3) */
    public static int card(int rank, int suit) { return (rank << 2) | suit; }
    public static int rankOf(int card) { return card >> 2; }
    public static int suitOf(int card) { return card & 3; }

    /** 评估结果:牌型 + 用于同型比较的 tiebreak 键 */
    public static class Result implements Comparable<Result> {
        public final int category;
        public final long tiebreak; // 高位为关键点数,便于直接比较

        public Result(int category, long tiebreak) {
            this.category = category;
            this.tiebreak = tiebreak;
        }

        @Override
        public int compareTo(Result o) {
            if (category != o.category) return Integer.compare(category, o.category);
            return Long.compare(tiebreak, o.tiebreak);
        }
    }

    public static Result evaluate(int[] cards) {
        int rankMask = 0;
        int[] counts = new int[13];
        int[] suitMask = new int[4];

        for (int c : cards) {
            int r = rankOf(c), s = suitOf(c);
            rankMask |= 1 << r;
            counts[r]++;
            suitMask[s] |= 1 << r;
        }

        // 同花:某个花色出现 5 次
        int flushSuit = -1;
        for (int s = 0; s < 4; s++)
            if (Integer.bitCount(suitMask[s]) == 5) flushSuit = s;

        // 顺子:rankMask 中连续 5 位
        int straightHigh = -1;
        for (int top = 12; top >= 4; top--) {
            int need = 0b11111 << (top - 4);
            if ((rankMask & need) == need) { straightHigh = top; break; }
        }
        // A-2-3-4-5(轮子):A 作 1,最高牌按 5 计
        if (straightHigh < 0) {
            int wheel = (1 << 12) | 0b1111; // A,2,3,4,5
            if ((rankMask & wheel) == wheel) straightHigh = 3; // 最高牌为 5(索引3)
        }

        // 同花顺
        if (flushSuit >= 0 && straightHigh >= 0) {
            int sm = suitMask[flushSuit];
            int sfHigh = -1;
            for (int top = 12; top >= 4; top--) {
                int need = 0b11111 << (top - 4);
                if ((sm & need) == need) { sfHigh = top; break; }
            }
            if (sfHigh < 0) {
                int wheel = (1 << 12) | 0b1111;
                if ((sm & wheel) == wheel) sfHigh = 3;
            }
            if (sfHigh >= 0) return new Result(STRAIGHT_FLUSH, sfHigh);
        }

        // 按出现次数分组,次数多的优先比较
        int four = -1, three = -1;
        List<Integer> pairs = new ArrayList<>();
        for (int r = 12; r >= 0; r--) {
            if (counts[r] == 4) four = r;
            else if (counts[r] == 3) three = r;
            else if (counts[r] == 2) pairs.add(r);
        }

        if (four >= 0) {
            int kicker = 0;
            for (int r = 12; r >= 0; r--) if (counts[r] == 1) { kicker = r; break; }
            return new Result(FOUR_KIND, ((long) four << 4) | kicker);
        }
        if (three >= 0 && !pairs.isEmpty())
            return new Result(FULL_HOUSE, ((long) three << 4) | pairs.get(0));
        if (flushSuit >= 0) {
            long key = 0;
            for (int r = 12; r >= 0; r--)
                if ((suitMask[flushSuit] & (1 << r)) != 0) key = (key << 4) | r;
            return new Result(FLUSH, key);
        }
        if (straightHigh >= 0) return new Result(STRAIGHT, straightHigh);
        if (three >= 0) {
            long key = (long) three << 8;
            int shift = 4;
            for (int r = 12; r >= 0 && shift >= 0; r--)
                if (counts[r] == 1) { key |= (long) r << shift; shift -= 4; }
            return new Result(THREE_KIND, key);
        }
        if (pairs.size() >= 2) {
            int hi = pairs.get(0), lo = pairs.get(1);
            int kicker = 0;
            for (int r = 12; r >= 0; r--) if (counts[r] == 1) { kicker = r; break; }
            return new Result(TWO_PAIR, ((long) hi << 8) | ((long) lo << 4) | kicker);
        }
        if (pairs.size() == 1) {
            long key = (long) pairs.get(0) << 12;
            int shift = 8;
            for (int r = 12; r >= 0 && shift >= 0; r--)
                if (counts[r] == 1) { key |= (long) r << shift; shift -= 4; }
            return new Result(ONE_PAIR, key);
        }
        long key = 0;
        for (int r = 12; r >= 0; r--)
            if ((rankMask & (1 << r)) != 0) key = (key << 4) | r;
        return new Result(HIGH_CARD, key);
    }
}

suitMask 用 13 位整数而非 64 位长整型,是因为单花色只有 13 个点数,Integer.bitCount 足够。tiebreak 的构造方式保证同牌型之间的比较只需一次 Long.compare——把关键点数按重要性左移拼接即可。

二、Outs 计数:剩下的牌里有几张能救你

Outs(出路)指”还未出现的牌中,能让当前手牌反超对手的手牌数量”。这是梭哈决策里最核心的量。

精确计算不能靠手数,必须用组合枚举:把剩余牌(52 – 已知牌数)逐一尝试加入,评估新手牌,再与对手手牌的评估结果比较。

package com.example.poker;

import java.util.*;

public class OutsCounter {
    /**
     * 精确计算 Outs:遍历所有"未知牌",逐一模拟抽到它后的手牌与对手比较。
     *
     * @param myCards   我方已知的牌(已含公共牌)
     * @param oppCards  对手已知的牌(已含公共牌,仅示范用;实战中为估计范围)
     * @param known     所有已知牌(双方手牌 + 公共牌 + 已弃牌)
     * @param drawSlots 还需要抽几张(如转牌后还有 1 张河牌则为 1)
     * @return 长度为 52 的数组,下标为牌编码,1 表示该牌是 out
     */
    public static boolean[] computeOuts(int[] myCards, int[] oppCards,
                                        int[] known, int drawSlots) {
        boolean[] isKnown = new boolean[52];
        for (int c : known) isKnown[c] = true;

        boolean[] outs = new boolean[52];
        HandEvaluator.Result opp = HandEvaluator.evaluate(oppCards);

        for (int c = 0; c < 52; c++) {
            if (isKnown[c]) continue;
            int[] next = new int[myCards.length + 1];
            System.arraycopy(myCards, 0, next, 0, myCards.length);
            next[myCards.length] = c;
            // 若还需要多次抽牌,此处应递归;单张抽牌时直接评估
            if (drawSlots == 1) {
                HandEvaluator.Result mine = HandEvaluator.evaluate(next);
                if (mine.compareTo(opp) > 0) outs[c] = true;
            }
        }
        return outs;
    }

    /** 多轮抽牌的精确 Outs:递归枚举所有后续组合 */
    public static int countOutsMultiStreet(int[] myCards, int[] oppCards,
                                           int[] known, int drawSlots) {
        boolean[] isKnown = new boolean[52];
        for (int c : known) isKnown[c] = true;
        HandEvaluator.Result opp = HandEvaluator.evaluate(oppCards);
        int[] acc = new int[]{0};

        Deque<Integer> stack = new ArrayDeque<>();
        recurse(myCards, isKnown, drawSlots, opp, stack, acc);
        return acc[0];
    }

    private static void recurse(int[] myCards, boolean[] isKnown, int remain,
                                HandEvaluator.Result opp, Deque<Integer> stack, int[] acc) {
        if (remain == 0) {
            int[] full = new int[myCards.length + stack.size()];
            System.arraycopy(myCards, 0, full, 0, myCards.length);
            int i = myCards.length;
            for (int c : stack) full[i++] = c;
            if (HandEvaluator.evaluate(full).compareTo(opp) > 0) acc[0]++;
            return;
        }
        for (int c = 0; c < 52; c++) {
            if (isKnown[c]) continue;
            isKnown[c] = true;
            stack.push(c);
            recurse(myCards, isKnown, remain - 1, opp, stack, acc);
            stack.pop();
            isKnown[c] = false;
        }
    }
}

常见场景的 Outs 数量可以心算验证:

手牌形态 需要的牌 典型 Outs
同花听牌(4 张同花) 任意同花色 9 张
顺子听牌(开口两端) 两端点数各 4 张 8 张
顺子听牌(卡顺) 中间点数 4 张 4 张
三条 → 葫芦/四条 剩余同名 1 + 对子来源 约 7~10 张
两对 → 葫芦 4 张(两个点数各 2) 4 张

同花听牌的 9 张 = 13 – 4(已知同花色)= 9,这个减法比枚举更快,但枚举能同时处理”混合听牌”(同花 + 顺子,Outs 去重后为 15 张而非 9+8=17 张)。去重是枚举法的关键优势。

三、把 Outs 换算成概率

单张抽牌的命中率:

P(hit) = outs / remaining

remaining = 52 – 已知牌数。转牌到河牌共两张待抽时,命中至少一次的概率用补集计算最稳:

P(至少命中一次) = 1 - C(remaining - outs, 2) / C(remaining, 2)

注意不是 outs/remaining * 2——那个近似式(俗称”二四法则”)在 outs 较大时高估明显。精确式子:

/** 两张待抽时"至少命中一次"的精确概率 */
public static double twoStreetProbability(int outs, int remaining) {
    if (remaining < 2) return outs > 0 ? 1.0 : 0.0;
    double miss = (double) (remaining - outs) * (remaining - outs - 1)
                / ((double) remaining * (remaining - 1));
    return 1.0 - miss;
}

对比一下:outs = 15、remaining = 46 时

  • 近似式:15/46 * 2 ≈ 0.652
  • 精确式:1 - (31*30)/(46*45) ≈ 1 - 0.4493 = 0.5507

近似式高估了近 10 个百分点。梭哈里每次决策的筹码量都不小,这种误差足以让”该弃的牌跟到底”。

四、隐含赔率与期望值

底池赔率(pot odds)是”跟注需要的投入”与”跟注后能拿到的总底池”之比:

potOdds = callAmount / (pot + callAmount)

决策判据很简单:命中概率 > 底池赔率 → 跟注有正期望。

但真实梭哈还有隐含赔率(implied odds):命中后还能从对手那里多赢到的筹码。于是完整判据是:

EV(跟注) = P(hit) * (pot + impliedWin) - (1 - P(hit)) * callAmount
EV(弃牌) = 0
跟注 ⟺ EV(跟注) > 0
package com.example.poker;

public class OddsCalculator {
    /**
     * 计算跟注的期望值。
     *
     * @param outs           出路数量
     * @param remaining      未知牌数量
     * @param callAmount     跟注需要投入
     * @param pot            当前底池(不含本次跟注)
     * @param impliedWin     命中后预计还能额外赢到的筹码
     * @param streetsLeft    还剩几条街可抽(1 或 2)
     */
    public static double expectedValue(int outs, int remaining,
                                       double callAmount, double pot,
                                       double impliedWin, int streetsLeft) {
        double pHit = streetsLeft >= 2
                ? twoStreetProbability(outs, remaining)
                : (double) outs / remaining;
        double winAmount = pot + impliedWin;
        return pHit * winAmount - (1.0 - pHit) * callAmount;
    }

    public static double twoStreetProbability(int outs, int remaining) {
        if (remaining < 2) return outs > 0 ? 1.0 : 0.0;
        double miss = (double) (remaining - outs) * (remaining - outs - 1)
                / ((double) remaining * (remaining - 1));
        return 1.0 - miss;
    }

    /** 盈亏平衡所需的最小 Outs 数量 */
    public static int breakEvenOuts(double callAmount, double pot,
                                    double impliedWin, int remaining, int streetsLeft) {
        for (int o = 0; o <= remaining; o++) {
            if (expectedValue(o, remaining, callAmount, pot, impliedWin, streetsLeft) > 0)
                return o;
        }
        return -1;
    }
}

五、把一切串起来:决策主流程

package com.example.poker;

import java.util.*;

public class ShowHandDecider {
    public static void main(String[] args) {
        // 示例:转牌圈。手牌 A♠ K♠,公共牌 Q♠ J♠ 2♥ → 同花 + 顺子双重听牌
        int as = HandEvaluator.card(12, 0); // A♠
        int ks = HandEvaluator.card(11, 0); // K♠
        int qs = HandEvaluator.card(10, 0); // Q♠
        int js = HandEvaluator.card(9, 0);  // J♠
        int two = HandEvaluator.card(0, 1); // 2♥

        int[] mine = {as, ks, qs, js, two};
        int[] known = mine.clone();

        // 对手手牌(示范:一对 2)
        int[] opp = {HandEvaluator.card(0, 2), HandEvaluator.card(0, 3),
                     qs, js, two};
        int[] knownAll = new int[known.length + 3];
        System.arraycopy(known, 0, knownAll, 0, known.length);
        knownAll[known.length] = opp[0];
        knownAll[known.length + 1] = opp[1];

        boolean[] outs = OutsCounter.computeOuts(mine, opp, knownAll, 1);
        int n = 0;
        for (boolean b : outs) if (b) n++;

        int remaining = 52 - knownAll.length;
        double pot = 120, call = 40, implied = 80;

        double ev = OddsCalculator.expectedValue(n, remaining, call, pot, implied, 1);
        double potOdds = call / (pot + call);

        System.out.printf("Outs = %d, 剩余牌 = %d%n", n, remaining);
        System.out.printf("命中概率 = %.3f, 底池赔率 = %.3f%n",
                (double) n / remaining, potOdds);
        System.out.printf("EV(跟注) = %.2f → %s%n", ev, ev > 0 ? "跟注" : "弃牌");

        // 推荐:算一下盈亏平衡点
        int need = OddsCalculator.breakEvenOuts(call, pot, implied, remaining, 1);
        System.out.println("盈亏平衡所需 Outs = " + need);
    }
}

六、从单点决策到范围对抗

上面假设”对手手牌已知”。实战中对手手牌是一个范围(range),因此正确做法是:

  1. 用对手的行动序列估计其手牌范围(例如”翻牌前加注 → 大概率是强牌”);
  2. 对范围内每一手牌计算 Outs;
  3. 用范围权重加权平均,得到期望 Outs;
  4. 按期望 Outs 做赔率决策。

这一步把单点决策变成了范围对抗,代码上只需在上述流程外包一层加权循环:

/** 范围加权 Outs:对对手每种可能手牌求 Outs,按权重汇总 */
public static double weightedOuts(int[] mine, List<int[]> oppRange,
                                  List<Double> weights, int[] known, int slots) {
    double sum = 0, wsum = 0;
    for (int i = 0; i < oppRange.size(); i++) {
        boolean[] o = OutsCounter.computeOuts(mine, oppRange.get(i), known, slots);
        int cnt = 0;
        for (boolean b : o) if (b) cnt++;
        sum += cnt * weights.get(i);
        wsum += weights.get(i);
    }
    return wsum == 0 ? 0 : sum / wsum;
}

七、复杂度与正确性

环节 复杂度 说明
牌型评估 O(1) 13 位掩码 + popcount,无循环依赖
单张 Outs 枚举 O(52) 每张未知牌评估一次
两张 Outs 枚举 O(52^2) 最多 2704 次评估,毫秒级
范围加权 O(R × 52^2) R 为范围大小,通常 ≤ 200

正确性的关键在于两点:

  1. 牌型比较必须严格全序:tiebreak 拼接顺序要按”关键点数优先”排列,否则会出现”同牌型判错”。
  2. Outs 必须去重:混合听牌(同花 + 顺子)时,同时满足两种条件的牌只算一次。用 boolean[52] 天然去重,这比”9 + 8″的加法安全得多。

八、常见误区

  • “二四法则”直接乘 2/4:outs 多时严重高估,应该用补集精确式。
  • 把”能改善牌型”当 Outs:Outs 的定义是”能让你赢的牌”,不是”能让你牌型变好的牌”。变成两对但对手已是三条,那不是 out。
  • 忽略对手的 outs:你命中同花,对手可能同时命中葫芦。严格的算法要对”双方都抽牌”做联合枚举,复杂度升到 O(52^2) 但正确性完整。
  • 隐含赔率拍脑袋:impliedWin 应该基于对手范围与筹码深度估计,而不是固定常数。