梭哈(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),因此正确做法是:
- 用对手的行动序列估计其手牌范围(例如”翻牌前加注 → 大概率是强牌”);
- 对范围内每一手牌计算 Outs;
- 用范围权重加权平均,得到期望 Outs;
- 按期望 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 |
正确性的关键在于两点:
- 牌型比较必须严格全序:
tiebreak拼接顺序要按”关键点数优先”排列,否则会出现”同牌型判错”。 - Outs 必须去重:混合听牌(同花 + 顺子)时,同时满足两种条件的牌只算一次。用
boolean[52]天然去重,这比”9 + 8″的加法安全得多。
八、常见误区
- “二四法则”直接乘 2/4:outs 多时严重高估,应该用补集精确式。
- 把”能改善牌型”当 Outs:Outs 的定义是”能让你赢的牌”,不是”能让你牌型变好的牌”。变成两对但对手已是三条,那不是 out。
- 忽略对手的 outs:你命中同花,对手可能同时命中葫芦。严格的算法要对”双方都抽牌”做联合枚举,复杂度升到
O(52^2)但正确性完整。 - 隐含赔率拍脑袋:
impliedWin应该基于对手范围与筹码深度估计,而不是固定常数。