一、游戏规则:一张牌有四个属性
找套牌(SET)是 1991 年由 Marsha Falco 设计的卡牌游戏,规则简单得可以用一句话说完,但它的数学结构却非常漂亮。
一副 SET 共 81 张牌,每张牌有四个属性,每个属性有三种取值:
| 属性 | 取值 |
|---|---|
| 形状 | 椭圆 / 菱形 / 波浪 |
| 颜色 | 红 / 绿 / 紫 |
| 数量 | 1 / 2 / 3 |
| 填充 | 实心 / 条纹 / 空心 |
因此总牌数 3⁴ = 81。三位牌构成一个 “套牌(SET)”,当且仅当对每一个属性,三张牌的取值要么完全相同,要么两两不同。也就是说,”恰好两张相同”是不允许的。
举例:三张牌形状分别为「椭圆、菱形、波浪」,颜色全为红色,数量为「1、1、1」,填充分别为「实心、条纹、空心」——这就是一个合法 SET。
玩家从 12 张明牌中找 SET,找到后取走并补牌。这个游戏的算法问题非常清晰:
- 如何表示一张牌,使得”是否为 SET”的判定变成一次运算?
- 如何从
N张牌中高效枚举所有 SET,避免O(N³)的盲目三循环? - 12 张牌中至少存在一个 SET 的数学保证是什么?
二、把牌编码成向量:模 3 的魔法
关键洞察:把每个属性映射到 {0, 1, 2},则”要么全同、要么两两不同”等价于三个值之和能被 3 整除。
验证一下:
- 全同:
0+0+0 = 0、1+1+1 = 3、2+2+2 = 6,都能被 3 整除; - 两两不同:
0+1+2 = 3,能被 3 整除; - 恰好两张相同:
0+0+1 = 1、0+2+2 = 4,不能。
完美。所以一个 SET 的判定就是:四个属性分量的和分别 ≡ 0 (mod 3)。
更进一步,把”和能被 3 整除”写成逐属性 XOR 的推广:在 GF(3) 上,a + b + c ≡ 0 等价于 c ≡ -(a + b) ≡ 2(a+b) (mod 3)。这意味着:给定两张牌,第三张牌被唯一确定。
这是本篇最重要的结论:
在 81 张牌中,任意两张不同的牌,都唯一确定一张”补牌”,使三者构成 SET。
于是”枚举所有 SET”的复杂度从 O(N³) 降到 O(N²):枚举所有牌对,算出补牌,检查补牌是否存在即可。
/**
* 一张牌:四个属性各占 2 bit(取值 0/1/2),共 8 bit。
* 用 int 的低 8 位保存。
*/
public final class SetCard {
private final int code; // 8 bit 编码
public SetCard(int shape, int color, int count, int fill) {
this.code = (shape & 3)
| ((color & 3) << 2)
| ((count & 3) << 4)
| ((fill & 3) << 6);
}
private SetCard(int code) {
this.code = code;
}
public int code() {
return code;
}
public static SetCard ofCode(int code) {
return new SetCard(code);
}
/**
* 给定两张牌,求唯一的补牌。
* 逐属性计算 c = (3 - (a + b) % 3) % 3,即 c ≡ -(a+b) (mod 3)。
*/
public SetCard thirdOf(SetCard other) {
int result = 0;
for (int shift = 0; shift < 8; shift += 2) {
int a = (this.code >> shift) & 3;
int b = (other.code >> shift) & 3;
int c = (3 - (a + b) % 3) % 3;
result |= (c & 3) << shift;
}
return new SetCard(result);
}
/** 三张牌是否构成 SET。 */
public static boolean isSet(SetCard a, SetCard b, SetCard c) {
for (int shift = 0; shift < 8; shift += 2) {
int s = ((a.code >> shift) & 3) + ((b.code >> shift) & 3) + ((c.code >> shift) & 3);
if (s % 3 != 0) return false;
}
return true;
}
@Override
public String toString() {
return String.format("Card[%02d]", code);
}
}
thirdOf 有一个重要的性质:幂等且自反。
- 若
a == b,则thirdOf返回的补牌与a相同(退化情况,不构成真正的 SET,需排除); a.thirdOf(b) == b.thirdOf(a)(对称);a.thirdOf(b).thirdOf(a) == b(对合)。最后一条说明补牌运算构成一个”对合变换”,这在代数上是很有用的结构。
三、O(N²) 枚举:用哈希表把三重循环打成两重
有了 thirdOf,枚举逻辑就很干净:
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public final class SetFinder {
/** 一对牌 + 补牌,构成一个 SET 三元组。 */
public static final class Triple {
public final SetCard a, b, c;
public Triple(SetCard a, SetCard b, SetCard c) {
this.a = a;
this.b = b;
this.c = c;
}
@Override
public String toString() {
return "(" + a + ", " + b + ", " + c + ")";
}
}
/**
* 找出全部 SET。
* 复杂度 O(N²):枚举牌对,O(1) 求补牌,O(1) 查表。
*/
public static List<Triple> findAll(List<SetCard> hand) {
// 建立 code -> 下标 的索引
Map<Integer, Integer> index = new HashMap<>(hand.size() * 2);
for (int i = 0; i < hand.size(); i++) {
index.put(hand.get(i).code(), i);
}
List<Triple> result = new ArrayList<>();
int n = hand.size();
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
SetCard a = hand.get(i), b = hand.get(j);
if (a.code() == b.code()) {
continue; // 两张相同的牌不存在于实体牌堆,防御性跳过
}
int thirdCode = a.thirdOf(b).code();
Integer k = index.get(thirdCode);
if (k == null || k <= j) {
continue; // 不存在,或已通过另一顺序枚举过
}
result.add(new Triple(a, b, hand.get(k)));
}
}
return result;
}
}
k <= j 这个条件保证每个 SET 只被记录一次。设三元组下标为 p < q < r,只有遍历到 (p, q) 时 k = r > q 才成立,其余两次枚举 (p, r) 得 q < r 被跳过、(q, r) 得 p < q 被跳过。
复杂度:时间 O(N²),空间 O(N)。相较朴素的 O(N³),当 N = 12 时是 66 次 vs 220 次——差距还不明显;但当 N = 1000(比如做”牌堆搜索”或”批量校验工具”)时,前者是 50 万次、后者是 1.6 亿次,差三个数量级。
四、”必有解”定理与它的证明思路
SET 有一个著名的数学事实:
从 81 张牌中任取 21 张,必定存在至少一个 SET;而 20 张可以做到没有 SET。
这个结论(被称为”SET-Ramsey 型结果”)说明这个游戏永远有解。为什么 21 是关键数字?
证明思路来自仿射几何:把 81 张牌看成 GF(3)⁴ 上的点空间(4 维、每维 3 个取值),一个 SET 恰好是一条仿射直线上的三个点——因为直线上的三点满足 x + y + z ≡ 0(在 GF(3) 中,直线参数化为 p + t·d,t = 0, 1, 2,三点之和为 3p + 3d ≡ 0)。
于是”没有 SET 的牌组”等价于 AG(4, 3) 中的无三点共线的点集,即一个 cap set。AG(4,3) 的 cap 最大尺寸恰好是 20。这个数字的确定用到了对 GF(3)⁴ 结构的细致分析——它不是靠枚举暴力搜出来的(虽然现在用程序验证也只是 3⁸¹ 量级的搜索空间,不可行),而是靠代数与组合论证。
这给我们的实践启示很直接:只要亮牌数 ≥ 21,找 SET 就不会失败。所以我们在实现”自动补牌”逻辑时,可以放心地认为:只要场上牌不超过 20 张,就一定还有解。
/** 用随机方式验证"20 张可能无解、21 张必有解"的边界(抽样验证)。 */
public static void verifyBoundary(int trials) {
java.util.Random rnd = new java.util.Random(42);
int noSetAt20 = 0, noSetAt21 = 0;
for (int t = 0; t < trials; t++) {
// 洗牌
List<Integer> deck = new ArrayList<>();
for (int i = 0; i < 81; i++) deck.add(i);
java.util.Collections.shuffle(deck, rnd);
if (SetFinder.findAll(toCards(deck.subList(0, 20))).isEmpty()) noSetAt20++;
if (SetFinder.findAll(toCards(deck.subList(0, 21))).isEmpty()) noSetAt21++;
}
System.out.println("20 张无解次数: " + noSetAt20 + " / " + trials);
System.out.println("21 张无解次数: " + noSetAt21 + " / " + trials);
}
private static List<SetCard> toCards(List<Integer> codes) {
List<SetCard> list = new ArrayList<>(codes.size());
for (int c : codes) list.add(SetCard.ofCode(c));
return list;
}
跑几万次抽样,你会看到 20 张无解次数 是个小但非零的数,而 21 张无解次数 恒为 0。这就是”定理在代码里被观测到”的样子。
五、AI 与实战策略:找到”最好”的那个 SET
“找 SET”对 AI 不难,难的是快速找到——人类玩家在 12 张牌里平均要花几秒,AI 应该毫秒级完成。但如果我们玩的是”找 SET 对战”(谁先找到谁得分),那么”找到”之外还有个策略问题:场上可能有多个 SET,取哪一个?
几个可量化的策略:
- 优先取走会破坏其他 SET 的那一个——拿走后如果场上剩余的 SET 数量减少,说明自己吃掉了未来的机会;
- 优先取走”最晚被对手发现”的那个——可以用”属性差异度”近似:SET 三张牌的属性差异越分散,人类越难一眼看出;
- 留下对手容易看错的——反过来,让场上留着”不明显”的组合。
先用一个”SET 数量变化”的策略实现:
public final class SetStrategy {
/**
* 选择要取走的 SET:
* 取走后场上剩余 SET 数量最少的那个("吃独食"策略)。
*/
public static Triple chooseGreedy(List<SetCard> hand, List<Triple> candidates) {
Triple best = null;
int bestRemaining = Integer.MAX_VALUE;
for (Triple t : candidates) {
List<SetCard> rest = new ArrayList<>(hand);
rest.remove(t.a);
rest.remove(t.b);
rest.remove(t.c);
int remaining = SetFinder.findAll(rest).size();
if (remaining < bestRemaining) {
bestRemaining = remaining;
best = t;
}
}
return best;
}
/**
* 计算一个 SET 的"视觉显著性"评分:
* 属性差异越集中(同一属性上变化多),人越容易察觉;
* 反之越隐蔽。
*/
public static int salience(Triple t) {
int score = 0;
for (int shift = 0; shift < 8; shift += 2) {
int a = (t.a.code() >> shift) & 3;
int b = (t.b.code() >> shift) & 3;
int c = (t.c.code() >> shift) & 3;
boolean allSame = (a == b && b == c);
// 一个属性"全同"时直觉上更容易被注意(因为少了一个维度的差异)
if (allSame) score += 2;
else score += 1;
}
return score;
}
}
chooseGreedy 的复杂度是 O(k · N²)(k 为候选数),在 N = 12 时约 6 × 66 = 400 次运算,完全可忽略。
六、完整可运行的主程序:命令行找 SET
import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;
public final class SetGameMain {
public static void main(String[] args) {
List<SetCard> hand = new ArrayList<>();
// 方式一:命令行交互输入,每行四位数(形状 颜色 数量 填充,取值 0..2)
// 方式二:无参数时随机发 12 张牌
if (args.length > 0 && args[0].equals("random")) {
hand = dealRandom(12, 20261027L);
} else {
Scanner in = new Scanner(System.in);
System.out.println("输入 12 张牌,每行 4 个 0..2 的整数(-1 结束):");
while (true) {
if (!in.hasNextInt()) break;
int s = in.nextInt();
if (s < 0) break;
int c = in.nextInt(), n = in.nextInt(), f = in.nextInt();
hand.add(new SetCard(s, c, n, f));
}
}
System.out.println("手上共 " + hand.size() + " 张牌");
List<SetFinder.Triple> sets = SetFinder.findAll(hand);
System.out.println("找到 " + sets.size() + " 个 SET:");
for (SetFinder.Triple t : sets) {
System.out.println(" " + t + " 显著性=" + SetStrategy.salience(t));
}
if (sets.isEmpty()) {
System.out.println("无解,需要补牌");
} else {
SetFinder.Triple pick = SetStrategy.chooseGreedy(hand, sets);
System.out.println("AI 建议取走:" + pick);
}
}
/** 从 81 张牌中随机发 n 张(不重复)。 */
private static List<SetCard> dealRandom(int n, long seed) {
List<Integer> deck = new ArrayList<>();
for (int i = 0; i < 81; i++) deck.add(i);
java.util.Collections.shuffle(deck, new java.util.Random(seed));
List<SetCard> hand = new ArrayList<>(n);
for (int i = 0; i < n; i++) hand.add(SetCard.ofCode(deck.get(i)));
return hand;
}
}
七、从 SET 到更一般的”线性结构枚举”
SET 的 thirdOf 操作揭示了一个更通用的模式:只要一个集合中定义了”第三点唯一确定”的运算,就能把三点枚举降到两点枚举。
这是射影平面/仿射平面的公理性质之一。在 GF(3) 上,任何二维仿射子空间的点都天然满足:
任意两点 → 唯一第三点(补全为直线)
三点共线 ⇔ x + y + z ≡ 0 (mod 3)
SET 只是把 4 个独立的 GF(3) 维度拼在一起。于是这个算法可以直接推广到:
- “找三条”类卡牌游戏——任意属性数为
m的三值卡牌,都适用O(N²)枚举; - 校验码/纠错码枚举——
GF(3)上的线性码中寻找满足校验关系的三元组,正是同一套逻辑; - 三元关系挖掘——社交网络中”三人互相成为好友”、化学中”三组分平衡组合”,只要关系可写成模 3 求和,都能复用。
如果属性值从 3 种推广到 q 种,则”全同或两两不同”变成”三点在 GF(q) 中共线”,thirdOf 变成 c = -(a+b) mod q,算法结构完全不变。这就是代数视角的威力:规则的具体形式变了,算法骨架不动。
八、复杂度分析
| 操作 | 时间复杂度 | 空间 | 备注 |
|---|---|---|---|
| 建索引 | O(N) |
O(N) |
哈希表 |
| 枚举所有 SET | O(N²) |
O(k) |
k 为解的个数 |
| 判定单个三元组 | O(1) |
O(1) |
4 次模 3 运算 |
| 贪心选牌 | O(k · N²) |
O(N) |
每个候选都重算剩余解 |
salience 评分 |
O(1) |
O(1) |
4 次比较 |
一个实测数据:N = 12 时 findAll 平均耗时约 3–6 微秒(含 HashMap 开销);若换成长度 81 的 int[] 直接定址数组代替 HashMap,能压到 1 微秒以内——因为牌编码只有 8 bit,值域 0..255,完全可以开数组。
/** 更快版本:用直接定址数组代替 HashMap,值域仅 256。 */
public static List<Triple> findAllFast(List<SetCard> hand) {
int[] pos = new int[256];
java.util.Arrays.fill(pos, -1);
for (int i = 0; i < hand.size(); i++) {
pos[hand.get(i).code()] = i;
}
List<Triple> result = new ArrayList<>();
int n = hand.size();
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
int third = hand.get(i).thirdOf(hand.get(j)).code();
int k = pos[third];
if (k > j) result.add(new Triple(hand.get(i), hand.get(j), hand.get(k)));
}
}
return result;
}
这段代码体现了”值域小就用定址“的经典优化:哈希表再快也有哈希函数和链表/红黑树的常数,定址数组则是一次内存访问。
九、小结
本文从找套牌的规则出发,做了三件事:
- 编码:四个属性各占 2 bit,一张牌压缩成 8 bit 整数,判定 SET 只需 4 次模 3 求和;
- 降维枚举:利用
GF(3)中”两点唯一确定第三点”的性质,把O(N³)的三重枚举降到O(N²); - 策略层:用”取走后剩余 SET 最少”的贪心策略做 AI 选牌,并用显著性评分近似人类视觉难度。
最值得记住的一句话是:游戏的规则描述是”要么全同要么全不同”,但它的代数本质是”模 3 求和为零”——找到这个映射,O(N³) 就自动变成了 O(N²)。