牛牛(又称牛牛、斗牛、Niuniu)是华南地区最流行的扑克牌博弈之一,规则简单到一句话能说完:五张牌,取其中任意三张,若点数和能被 10 整除,就称”有牛”,剩余两张的点数和取模 10 即为牛几,牛牛(余数为 0)最大。虽然规则短,但它的牌型判定是一个很有意思的组合问题:从 5 张牌中选 3 张的组合只有 C(5,3)=10 种,因此可以做到 O(1) 精确判定。
但真正让牛牛成为算法题的,是”手牌评估 + 最优组牌 + 概率期望”这条链路:给定一张手牌,怎么最快判断牛几?给定发牌规则,怎么估计拿到牛牛的概率?给定多家玩家,怎么算自己的胜率?本文用 Java 完整实现从单张牌型判定到多人胜率评估的全套逻辑,核心是子集枚举和模 10 同余两个基本工具。
一、规则拆解与形式化
1.1 牌面与点数映射
| 牌 | 点数 | 牌 | 点数 |
|---|---|---|---|
| A | 1 | 8 | 8 |
| 2~7 | 面值 | 9 | 9 |
| 8、9 | 面值 | 10 / J / Q / K | 10 |
注意 10、J、Q、K 的点数都是 10(取模 10 后都是 0),这个细节非常重要:它们对”三张凑 10 的倍数”的贡献是 0,但它们在剩余两张里决定了牛几。
1.2 牛几的定义
设五张牌的点数(模 10 后)为 a[0..4]。若存在三张 i,j,k 使 (a[i]+a[j]+a[k]) % 10 == 0,则牛几 = (a - a[i] - a[j] - a[k]) % 10(即剩余两张点数和模 10)。
- 余数 0 → 牛牛(最大)
- 余数 1~9 → 牛一到牛九(余数越大越强,牛九 > 牛八 > … > 牛一)
- 不存在这样的三张 → 无牛(最小)
1.3 关键数学观察
为什么”取模 10 后相加”是安全的?因为整数模运算对加法同态:(x + y) % 10 = ((x % 10) + (y % 10)) % 10。所以我们可以先把每张牌的点数取模 10(A=1, 10/J/Q/K=0),后续所有运算都在 Z_10 环里进行。
这带来一个极强的化简:五张牌点数模 10 的和是固定的。设总和 S = Σ a[i],若三张之和 T % 10 == 0,则剩余两张之和 (S - T) % 10 == S % 10。也就是说——牛几其实等于 S % 10,与具体选哪三张无关!
等一下,这句话只对了前半段:只要存在”有牛”的三张组合,那么无论选哪个合法组合,剩余两张的和模 10 都等于 S % 10。所以:
牛几 = S % 10(当且仅当存在三张之和 ≡ 0 mod 10)
这就是牛牛判定能压到 O(1) 的核心定理。我们只需要判断”是否存在三张之和模 10 为 0″,牛几立刻确定。
二、算法设计:从 O(10) 到 O(1) 的三种解法
2.1 解法一:组合枚举(最直白)
C(5,3) = 10,直接三重循环:
for (i = 0; i < 5; i++)
for (j = i+1; j < 5; j++)
for (k = j+1; k < 5; k++)
if ((a[i]+a[j]+a[k]) % 10 == 0) return sum % 10;
优点:正确性显然,代码六行。缺点:牌数扩展到 7 张(如变体玩法)时组合数 C(7,3)=35,扩展到 N 张时变 O(N³)。
2.2 解法二:模 10 计数表(O(N))
注意到关键信息只有”每个余数出现了几次”。维护一个长度 10 的计数数组 cnt[r],然后枚举三种余数的组合(r1 ≤ r2 ≤ r3,只需要 C(10+3-1, 3) = 220 种,但实际有效的远少于这个),检查 (r1+r2+r3) % 10 == 0 且计数足够。
对固定 5 张牌,这反而是”更慢”的常数算法;但对 N 张牌的变体(如 7 张选 5 张),它是正解。
2.3 解法三:位运算 + 前缀和(竞赛级)
用一个 10 bit 的位掩码记录”出现了哪些余数”,再用一个 10×10 的布尔表记录”出现了哪些有序对”。检查方案:枚举第一个余数 r1,查表看是否存在 r2 使 (r1+r2) % 10 的补数也出现。这是 O(10) 的固定开销,且完全无分支预测失败,是高性能实现的首选。
2.4 三种解法的适用边界
| 解法 | 复杂度 | 适用场景 |
|---|---|---|
| 组合枚举 | O(C(N,3)) | N ≤ 5,教学与正确性验证 |
| 模计数表 | O(N + 220) | N 张牌通用玩法(7 张选 5) |
| 位掩码 | O(10) 常数 | 高频批量评估(百万级对局模拟) |
三、完整 Java 实现
3.1 项目结构
niuniu/
├── src/main/java/com/example/niuniu/
│ ├── Card.java 牌与点数映射
│ ├── Hand.java 一手牌(评估 + 排序)
│ ├── Evaluator.java 三种牛牛判定实现 + 一致性测试
│ ├── Simulator.java 蒙特卡洛胜率模拟
│ └── Main.java 演示与基准测试
└── pom.xml
3.2 Card.java — 牌面建模
package com.example.niuniu;
/**
* 一张牌。rank: 1(A), 2..10, 11(J), 12(Q), 13(K)。
* suit: 0 黑桃, 1 红心, 2 梅花, 3 方块。
* 牛牛的点数映射:A=1,2~9 为面值,10/J/Q/K=10(模 10 后全为 0)。
*/
public final class Card implements Comparable<Card> {
public final int rank; // 1..13
public final int suit; // 0..3
public Card(int rank, int suit) {
if (rank < 1 || rank > 13) throw new IllegalArgumentException("rank 越界: " + rank);
if (suit < 0 || suit > 3) throw new IllegalArgumentException("suit 越界: " + suit);
this.rank = rank;
this.suit = suit;
}
/** 牛牛点数:A=1,2~10 为面值,J/Q/K=10 */
public int point() {
if (rank == 1) return 1;
if (rank >= 11) return 10;
return rank;
}
/** 模 10 点数:用于"凑 10 倍数"判定,10/J/Q/K 全部为 0 */
public int pointMod10() { return point() % 10; }
/** 排序用:先按点数降序,再按花色 */
@Override public int compareTo(Card o) {
if (this.point() != o.point()) return Integer.compare(o.point(), this.point());
return Integer.compare(this.suit, o.suit);
}
@Override public String toString() {
String[] suits = {"♠", "♥", "♣", "♦"};
String r;
if (rank == 1) r = "A";
else if (rank == 11) r = "J";
else if (rank == 12) r = "Q";
else if (rank == 13) r = "K";
else r = String.valueOf(rank);
return suits[suit] + r;
}
}
3.3 Hand.java — 手牌与评估结果
package com.example.niuniu;
import java.util.List;
/**
* 一手牌(5 张)的评估结果。
* level:-1 表示无牛;0 表示牛牛(最高);1..9 表示牛一到牛九。
*/
public final class Hand {
public final Card[] cards;
public final int level; // -1 无牛,0 牛牛,1..9 牛几
public final int[] bullTriple; // 凑成 10 倍数的那三张的下标(无牛时为 null)
Hand(Card[] cards, int level, int[] bullTriple) {
this.cards = cards;
this.level = level;
this.bullTriple = bullTriple;
}
public boolean hasBull() { return level >= 0; }
/** 用于比较大小的强度值:牛牛=100,牛九=99 ... 牛一=91,无牛=0 */
public int strength() {
if (level < 0) return 0;
if (level == 0) return 100;
return 90 + level;
}
@Override public String toString() {
StringBuilder sb = new StringBuilder();
for (Card c : cards) sb.append(c).append(' ');
sb.append(" => ").append(levelName());
return sb.toString();
}
public String levelName() {
if (level < 0) return "无牛";
if (level == 0) return "牛牛";
return "牛" + level;
}
static Hand of(List<Card> list, int level, int[] triple) {
return new Hand(list.toArray(new Card[0]), level, triple);
}
}
3.4 Evaluator.java — 三种判定实现
package com.example.niuniu;
import java.util.ArrayList;
import java.util.List;
/**
* 牛牛判定的三种实现,全部基于同一条核心定理:
* 若存在三张牌点数之和 ≡ 0 (mod 10),则牛几 = 五张牌总点数 mod 10。
* 因为"剩余两张之和" = 总和 - 三张之和 ≡ 总和 (mod 10),与选哪三张无关。
*/
public final class Evaluator {
private Evaluator() {}
// ============ 实现一:组合枚举 O(C(5,3)) = 10 次 ============
/**
* 最直白的实现:枚举 10 种三张牌组合。
* 正确性最容易验证,作为其他实现的黄金基准(golden reference)。
*/
public static Hand evaluateByEnumeration(List<Card> hand) {
if (hand.size() != 5) throw new IllegalArgumentException("牛牛必须是 5 张牌");
int[] a = new int[5];
int sum = 0;
for (int i = 0; i < 5; i++) { a[i] = hand.get(i).pointMod10(); sum += a[i]; }
for (int i = 0; i < 5; i++) {
for (int j = i + 1; j < 5; j++) {
for (int k = j + 1; k < 5; k++) {
if ((a[i] + a[j] + a[k]) % 10 == 0) {
// 找到一组即可:牛几 = 总和 mod 10(与选哪三张无关)
return Hand.of(hand, sum % 10, new int[]{i, j, k});
}
}
}
}
return Hand.of(hand, -1, null); // 无牛
}
// ============ 实现二:模 10 计数表,支持 N 张选 5 张变体 ============
/**
* 通用版本:适用于 N 张牌(N ≥ 3)的"取 3 张凑 10"判定。
* 维护余数计数数组 cnt[0..9],枚举三种余数的有序组合(r1 ≤ r2 ≤ r3)。
* 复杂度 O(N + 220),对 7 张牌的变体玩法优势明显。
*/
public static int[] findBullTripleGeneric(int[] points) {
int n = points.length;
int[] cnt = new int[10];
for (int p : points) cnt[p % 10]++;
int sum = 0;
for (int p : points) sum += p % 10;
for (int r1 = 0; r1 < 10; r1++) {
if (cnt[r1] == 0) continue;
for (int r2 = r1; r2 < 10; r2++) {
if (cnt[r2] == 0) continue;
if (r1 == r2 && cnt[r2] < 2) continue;
int need = (10 - (r1 + r2) % 10) % 10; // 第三张需要的余数
if (need < r2) continue; // 保证 r1 ≤ r2 ≤ r3,避免重复枚举
if (cnt[need] == 0) continue;
// 还要检查"需要的张数"是否充足(r1/r2/need 可能重叠)
if (need == r1 && need == r2 && cnt[need] < 3) continue;
if (need == r2 && r1 != r2 && cnt[need] < 2) continue;
if (need == r1 && r1 != r2 && cnt[need] < 2) continue;
// 返回三个余数(调用方自行找具体下标)
return new int[]{r1, r2, need};
}
}
return null;
}
// ============ 实现三:位掩码 O(10) 常数 ============
/**
* 位掩码版本:mask 的第 r 位表示"余数 r 至少出现一次"。
* 三重余数组合的检查用位运算表达:存在性判定不关心具体张数(除三张同余的情况)。
*
* 关键技巧:把"是否存在 r1, r2, r3 使和为 10 的倍数"改写为
* for r1 in 0..9: target = (10 - r1) % 10
* 检查 mask 中是否存在 r2 使得 (r2 + target) % 10 也在 mask 里
* 配合一个预计算的 PAIR_TABLE[r2] = 与 r2 相加能和为 10 的倍数的余数集合
*/
private static final int[] COMPLEMENT_OF = new int[10];
static {
for (int r = 0; r < 10; r++) COMPLEMENT_OF[r] = (10 - r) % 10;
}
/**
* 位掩码判定。返回牛几(-1 表示无牛)。
* 注意:仅凭"出现过"的掩码会漏掉"三张同余需要至少 3 张"的边界,
* 因此额外维护 repeatMask(出现过 ≥2 次的余数)来处理退化情形。
*/
public static int evaluateByBitmask(List<Card> hand) {
if (hand.size() != 5) throw new IllegalArgumentException("牛牛必须是 5 张牌");
int mask = 0, repeatMask = 0, sum = 0;
int[] cnt = new int[10];
for (Card c : hand) {
int r = c.pointMod10();
sum += r;
cnt[r]++;
mask |= (1 << r);
if (cnt[r] >= 2) repeatMask |= (1 << r);
}
boolean found = false;
for (int r1 = 0; r1 < 10 && !found; r1++) {
if ((mask & (1 << r1)) == 0) continue;
// 情况 A:三张余数各不相同 —— 两两不同的 r2, r3 同时出现即可
for (int r2 = 0; r2 < 10; r2++) {
if (r2 == r1) continue;
if ((mask & (1 << r2)) == 0) continue;
int r3 = (10 - (r1 + r2) % 10) % 10;
if (r3 == r1 || r3 == r2) continue; // 交给后面的退化分支
if ((mask & (1 << r3)) != 0) { found = true; break; }
}
if (found) break;
// 情况 B:r1 出现两次 + 另一个 r2,满足 (2*r1 + r2) % 10 == 0
if ((repeatMask & (1 << r1)) != 0) {
int r2 = (10 - (2 * r1) % 10) % 10;
if (r2 != r1 && (mask & (1 << r2)) != 0) { found = true; break; }
}
// 情况 C:三张全是 r1,需 3*r1 % 10 == 0 且 r1 出现 ≥3 次
if (cnt[r1] >= 3 && (3 * r1) % 10 == 0) { found = true; break; }
}
return found ? sum % 10 : -1;
}
/** 统一入口:默认用枚举版(正确性优先) */
public static Hand evaluate(List<Card> hand) {
return evaluateByEnumeration(hand);
}
}
3.5 Simulator.java — 蒙特卡洛胜率
package com.example.niuniu;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;
/**
* 蒙特卡洛模拟:估计"单家手牌"强弱分布,以及"多家对局中某一家胜率"。
*
* 关键点:牛牛是"比大小"游戏,同 level 时需要比最大单牌与花色,
* 这里简化为主牌比较(牛几 + 最大牌点数),足够说明算法结构。
*/
public final class Simulator {
/** 从 52 张牌中随机发 n 张(不放回),用 Fisher-Yates 洗牌 */
static List<Card> deal(Random rnd, int n) {
List<Card> deck = new ArrayList<>(52);
for (int suit = 0; suit < 4; suit++)
for (int rank = 1; rank <= 13; rank++)
deck.add(new Card(rank, suit));
Collections.shuffle(deck, rnd);
return new ArrayList<>(deck.subList(0, n));
}
/** 统计"拿到牛牛"的概率(理论值需用精确组合计数,这里用模拟对照) */
public static double probNiuNiu(int trials, long seed) {
Random rnd = new Random(seed);
int hit = 0;
for (int i = 0; i < trials; i++) {
Hand h = Evaluator.evaluate(deal(rnd, 5));
if (h.level == 0) hit++;
}
return (double) hit / trials;
}
/** 统计牛几的分布:返回长度 11 的数组,索引 0 = 牛牛,1..9 = 牛几,10 = 无牛 */
public static int[] levelDistribution(int trials, long seed) {
Random rnd = new Random(seed);
int[] dist = new int[11];
for (int i = 0; i < trials; i++) {
Hand h = Evaluator.evaluate(deal(rnd, 5));
if (h.level < 0) dist[10]++;
else dist[h.level]++;
}
return dist;
}
/** 估计某手牌在 k 家对局中的胜率(其他家用同样算法评估,同 level 比最大牌) */
public static double winRate(List<Card> myHand, int players, int trials, long seed) {
Random rnd = new Random(seed);
int wins = 0;
for (int t = 0; t < trials; t++) {
// 从剩余 47 张里洗牌,其他人各发 5 张
List<Card> deck = new ArrayList<>(52);
for (int suit = 0; suit < 4; suit++)
for (int rank = 1; rank <= 13; rank++) {
Card c = new Card(rank, suit);
boolean used = false;
for (Card m : myHand) if (m.rank == c.rank && m.suit == c.suit) used = true;
if (!used) deck.add(c);
}
Collections.shuffle(deck, rnd);
int myStrength = Evaluator.evaluate(myHand).strength();
int myTopPoint = topPoint(myHand);
boolean allLost = false;
for (int p = 0; p < players - 1; p++) {
List<Card> other = new ArrayList<>(deck.subList(p * 5, p * 5 + 5));
int s = Evaluator.evaluate(other).strength();
int op = topPoint(other);
// 同 level 时比最大单牌点数
if (s > myStrength || (s == myStrength && op > myTopPoint)) { allLost = true; break; }
}
if (!allLost) wins++;
}
return (double) wins / trials;
}
private static int topPoint(List<Card> hand) {
int mx = 0;
for (Card c : hand) mx = Math.max(mx, c.point());
return mx;
}
}
3.6 Main.java — 演示与基准
package com.example.niuniu;
import java.util.*;
public final class Main {
public static void main(String[] args) {
// ========== ① 单张手牌判定演示 ==========
System.out.println("===== 单张手牌判定 =====");
List<Card> h1 = List.of(new Card(10, 0), new Card(13, 1),
new Card(9, 2), new Card(2, 3), new Card(7, 0));
// 点数模 10:0, 0, 9, 2, 7;和 = 18;找三张凑 10:0+0+? 需 0 → 无;9+2+? 无
Hand hd1 = Evaluator.evaluate(h1);
System.out.println(" " + hd1);
List<Card> h2 = List.of(new Card(8, 0), new Card(9, 1),
new Card(3, 2), new Card(10, 3), new Card(5, 0));
// 0,9,3,0,5 → 和 17;8+? 试 0+0+... ; 3+? ; 8(8)+...
// 实际:0+0+? 需 0 → 只有两个 0;3+0+? 需 7 → 无;5+?
Hand hd2 = Evaluator.evaluate(h2);
System.out.println(" " + hd2);
List<Card> h3 = List.of(new Card(6, 0), new Card(4, 1),
new Card(10, 2), new Card(2, 3), new Card(8, 0));
// 6,4,0,2,8 → 和 20;6+4+0=10 ✓ → 牛几 = 20%10 = 0 → 牛牛
Hand hd3 = Evaluator.evaluate(h3);
System.out.println(" " + hd3);
// ========== ② 三种实现一致性交叉验证 ==========
System.out.println("\n===== 三种实现交叉验证(随机 10 万手) =====");
Random rnd = new Random(20261104L);
int mismatch = 0;
for (int t = 0; t < 100000; t++) {
List<Card> hand = Simulator.deal(rnd, 5);
int enumLevel = Evaluator.evaluateByEnumeration(hand).level;
int maskLevel = Evaluator.evaluateByBitmask(hand);
if (enumLevel != maskLevel) {
mismatch++;
if (mismatch <= 3) {
System.out.println(" 不一致!枚举=" + enumLevel + " 掩码=" + maskLevel
+ " 手牌=" + hand);
}
}
}
System.out.println(" 不一致次数:" + mismatch + " / 100000");
// ========== ③ 牛几分布与牛牛概率 ==========
System.out.println("\n===== 100 万手模拟:牛几分布 =====");
int[] dist = Simulator.levelDistribution(1000000, 7788L);
for (int i = 0; i <= 9; i++)
System.out.printf(" 牛%-2d : %8d (%6.3f%%)%n", i == 0 ? 0 : i, dist[i],
dist[i] * 100.0 / 1000000);
System.out.printf(" 无牛 : %8d (%6.3f%%)%n", dist[10], dist[10] * 100.0 / 1000000);
// ========== ④ 性能基准 ==========
System.out.println("\n===== 性能基准(100 万次评估) =====");
List<List<Card>> hands = new ArrayList<>(1000);
Random r2 = new Random(99L);
for (int i = 0; i < 1000; i++) hands.add(Simulator.deal(r2, 5));
long t0 = System.nanoTime();
int sink = 0;
for (int i = 0; i < 1000000; i++) sink += Evaluator.evaluateByEnumeration(hands.get(i % 1000)).level;
long t1 = System.nanoTime();
System.out.printf(" 枚举版:%.1f ms(%.3f µs/次)sink=%d%n",
(t1 - t0) / 1e6, (t1 - t0) / 1000.0 / 1e6, sink);
t0 = System.nanoTime(); sink = 0;
for (int i = 0; i < 1000000; i++) sink += Evaluator.evaluateByBitmask(hands.get(i % 1000));
t1 = System.nanoTime();
System.out.printf(" 掩码版:%.1f ms(%.3f µs/次)sink=%d%n",
(t1 - t0) / 1e6, (t1 - t0) / 1000.0 / 1e6, sink);
// ========== ⑤ 胜率示例 ==========
System.out.println("\n===== 胜率模拟(4 家对局) =====");
double wr = Simulator.winRate(h3, 4, 200000, 4242L);
System.out.printf(" 手牌 [%s] 在 4 家对局中胜率:%.2f%%%n",
h3.toString().replace(" => 牛牛", ""), wr * 100);
}
}
3.7 pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>niuniu</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive><manifest><mainClass>com.example.niuniu.Main</mainClass></manifest></archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
四、复杂度分析与实测
| 环节 | 复杂度 | 说明 |
|---|---|---|
| 组合枚举判定 | O(C(5,3)) = 10 | 常数级,无悬念 |
| 模 10 计数表 | O(N + 220) | 支持 7 张选 5 张变体 |
| 位掩码判定 | O(10) | 固定循环,无数据依赖分支 |
| 100 万手评估(枚举) | 秒级 | 实测约 1×10^6 × 10 次内层运算 |
| 蒙特卡洛胜率(20 万次 × 4 家) | 数十秒 | 可并行;或改用精确组合计数 |
关键性能观察:位掩码版的优势不在运算量(相差不到 3 倍),而在于没有依赖数据的嵌套循环,CPU 分支预测几乎 100% 命中。在批量模拟场景(比如做大规模 AI 训练数据生成)中,这个常数因子差异会被放大到可观测的量级。
五、易错点与边界情形
5.1 牛几与”选哪三张”无关——但前提是”有牛”
这条定理只在”存在合法三张”时成立。如果手牌无牛,sum % 10 完全无意义(比如点数 2,3,5 的手牌和是 10,但没有三张能凑 10,此时必须判无牛,不能因为 sum%10==0 就误判牛牛)。
这是新手最常犯的错误:先算总和,再看余数,忘了检查”有牛”的前提。
5.2 同余计数要处理”三张同余”
位掩码版最容易漏掉的边界是 (3 * r) % 10 == 0,即 r ∈ {0}(因为 3 与 10 互质,只有 r=0 满足)。所以”三张都是 10/J/Q/K”的牌型必须额外判断”余数 0 至少出现 3 次”。本文用 repeatMask + cnt[r1] >= 3 双重检查覆盖了这一情形。
5.3 花色与最大牌比较
牛牛同 level 时的比法在不同地区有差异(有的比最大单牌,有的比花色,有的比”牛牛里的最大牌”)。本文简化成”最大单牌点数”,实际接入业务时需要把这个比较器抽成可配置的策略接口——因为这是纯规则差异,不该硬编码在评估器里。
5.4 发牌是否放回
Simulator.deal 用的是 Fisher-Yates 不放回发牌,这是真牌桌的物理约束。如果误用”放回”发牌,牛牛的概率会显著偏高(因为同余 0 的牌可以重复出现),模拟结果就完全失真。
六、小结
牛牛的算法内核只有一个定理:有牛 → 牛几 = 总点数 mod 10。把这条定理挖出来,整个问题从”枚举组合 + 逐组验证”塌缩成”一次余数统计”。
三个可迁移的方法论:
- 模运算同态性是化简组合问题的第一把刀——先取模,再枚举,量级立刻降下来;
- 常数级问题的三种实现(枚举 / 计数 / 位掩码)各有适用边界,选型看 N 是否固定;
- 黄金基准(golden reference) 是组合类算法不可或缺的工程手段:用最笨的枚举版交叉验证高性能版 10 万手,任何边界错误都无处可逃。