一、把卡牌变成 F₃ 上的向量
形色(对应英文卡牌游戏 Set)的牌面由四个独立属性构成,每个属性恰好三种取值:
| 属性 | 取值 |
|---|---|
| 数量 | 1 / 2 / 3 |
| 形状 | 菱形 / 波浪 / 椭圆 |
| 填充 | 实心 / 条纹 / 空心 |
| 颜色 | 红 / 绿 / 紫 |
一副牌共 3⁴ = 81 张,恰好是四维三值网格的全部格点。一组”形色”(Set)的定义是:三个属性,每张牌在该属性上要么三者全同、要么三者全不同。
这个定义有一个极其漂亮的代数等价形式。把每张牌编码为向量 v ∈ F₃⁴(每个坐标取 0/1/2),三个牌 a, b, c 构成 Set 当且仅当
a + b + c ≡ (0, 0, 0) (mod 3)
原因是:坐标上 {0,0,0} 和为 0、{1,1,1} 和为 3 ≡ 0、{2,2,2} 和为 6 ≡ 0,而任何”两同一异”的组合和都不为 0(例如 1+1+2 = 4 ≡ 1)。于是“全同或全不同” ⟺ “模三和为零”,判定从 4 个属性的分支判断塌缩成一次向量加法。
更妙的是:给定任意两张不同的牌 a, b,第三张牌 c 是唯一确定的——c = (-a-b) mod 3,且必然是一张合法牌(坐标必在 0..2 内)。这条性质意味着任意两张不同的牌恰好属于唯一一个 Set,也解释了为什么一副 81 张牌里 Set 的总数恰好是 C(81,2)/3 = 1080。
二、编码与判定
public final class Card {
public static final int ATTRS = 4, VALUES = 3;
/** 用四维三值向量编码,各分量范围 0..2 */
public final int a, b, c, d;
public Card(int a, int b, int c, int d) {
this.a = a; this.b = b; this.c = c; this.d = d;
}
/** 编码成 0..80 的整数(三位一组) */
public int code() {
return a * 27 + b * 9 + c * 3 + d;
}
public static Card fromCode(int code) {
return new Card(code / 27, (code / 9) % 3, (code / 3) % 3, code % 3);
}
/** 给定两张牌,推出第三张:c = (-a-b) mod 3,逐分量 */
public static Card third(Card x, Card y) {
int na = (6 - x.a - y.a) % 3; // 等价于 (3 - (x+y)%3) % 3
int nb = (6 - x.b - y.b) % 3;
int nc = (6 - x.c - y.c) % 3;
int nd = (6 - x.d - y.d) % 3;
return new Card(na, nb, nc, nd);
}
/** 判定三张牌是否构成 Set */
public static boolean isSet(Card x, Card y, Card z) {
return (x.a + y.a + z.a) % 3 == 0
&& (x.b + y.b + z.b) % 3 == 0
&& (x.c + y.c + z.c) % 3 == 0
&& (x.d + y.d + z.d) % 3 == 0;
}
@Override public String toString() {
return "(" + a + "," + b + "," + c + "," + d + ")";
}
}
third 里的 (6 - x - y) % 3 值得说明:x, y ∈ [0,2],故 x+y ∈ [0,4],6-x-y ∈ [2,6],取模 3 后必落在 [0,2]——Java 的 % 对正数不会返回负数,所以这一步是安全的。若坐标允许负数,就必须补一次 +3 再取模。
三、枚举全部 Set:哈希 O(n²) 而非三重循环
桌面上有 n 张牌(通常 12 张,最多可到 15~18)。三重循环 O(n³) 对 12 张只是 220 次判定,随便写都行;但若做”牌局可行性分析”(枚举 81 张全牌的所有 Set),O(81³) = 531441 也不算大。真正需要优化的是“给定牌面,找出所有 Set” 在移动端每帧调用的场景,以及“找出至少包含某张牌的所有 Set” 这类子问题。
标准解法是 O(n²) 哈希:
public final class SetFinder {
/** 返回所有构成 Set 的三元组(下标) */
public static java.util.List<int[]> findAll(java.util.List<Card> cards) {
java.util.List<int[]> result = new java.util.ArrayList<>();
int n = cards.size();
java.util.Map<Integer, Integer> present = new java.util.HashMap<>();
for (int i = 0; i < n; i++) present.put(cards.get(i).code(), i);
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
Card need = Card.third(cards.get(i), cards.get(j));
Integer k = present.get(need.code());
// 必须要求 k > j,保证每组只报一次,且不会出现 i=j=k
if (k != null && k > j) {
result.add(new int[]{i, j, k});
}
}
}
return result;
}
}
这里 k > j 是去重的关键。由于第三张牌唯一,”任意两张 → 唯一第三张”的映射保证了三元组只会被枚举到一次(从其最小的两个下标出发),不需要额外的 Set<String> 去重,节省了对象分配开销。
顺带可得一条硬结论:若桌面有 n 张牌且牌不重复,则 Set 数量上界为 C(n,2)/3。12 张时上界 22,实际通常个位数——这就是为什么规则要求”12 张无解则补牌”,无解本身就是大概率事件。
四、最大不交集:为什么贪心会输
多人对局中,每次找到 Set 就把三张牌取走、补三张新牌。于是产生一个真问题:当前牌面最多能连取几组 Set? ——即把卡牌集合划分成尽可能多的互不相交的 Set。
这是一个 3-Set Packing 问题,属于 NP-hard(可归约到三维匹配 3DM)。贪心在这里会稳定地输:优先选”最容易找到”的 Set,往往会把关键的公共牌烧掉,导致后续崩盘。反例很容易构造——一个被 3 个 Set 共享的”枢纽牌”,贪心若先用掉它,剩下的牌就再也拼不出 Set。
位压缩 DP:n ≤ 20 时的精确解
当桌面上限 12~15 张(或做离线最优分析时到 20 张),用位掩码 DP 可以拿到精确最优:
dp[mask] = max{ 1 + dp[mask \ triple] | triple ⊆ mask 且 triple 是 Set }
这里 mask 是”还未取的牌”集合。状态数 2^n,n = 15 时是 32768,配上前置枚举出的 Set 列表,整体毫秒级。
public final class PackingDP {
/** 返回最大可连取的 Set 组数 */
public static int maxDisjointSets(java.util.List<Card> cards) {
int n = cards.size();
if (n < 3) return 0;
// 1. 先把所有 Set 的具体三元组枚举成位掩码
java.util.List<Integer> triples = new java.util.ArrayList<>();
java.util.Map<Integer, Integer> present = new java.util.HashMap<>();
for (int i = 0; i < n; i++) present.put(cards.get(i).code(), i);
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
Card need = Card.third(cards.get(i), cards.get(j));
Integer k = present.get(need.code());
if (k != null && k > j) {
triples.add((1 << i) | (1 << j) | (1 << k));
}
}
}
if (triples.isEmpty()) return 0;
// 2. dp[mask] = 在 mask 这张"剩余牌"集合上还能再取几组
int[] dp = new int[1 << n];
java.util.Arrays.fill(dp, -1);
dp[(1 << n) - 1] = 0; // 从满集出发,向后取
// 3. 逆序推进:mask 从大到小,确保子状态先被算好
for (int mask = (1 << n) - 1; mask >= 0; mask--) {
if (dp[mask] < 0) continue;
for (int t : triples) {
if ((mask & t) != t) continue; // 三元组必须完整落在 mask 内
int rest = mask ^ t;
if (dp[rest] < dp[mask] + 1) {
dp[rest] = dp[mask] + 1;
}
}
}
// 空集(或不含任何完整三元组的状态)中的最大值即答案
int best = 0;
for (int v : dp) best = Math.max(best, v);
return best;
}
}
这段实现有一个易错点:由于 rest = mask ^ t 必然小于 mask(异或只会把置位清零),必须让 mask 从大到小迭代,才能保证写入 dp[rest] 时它的所有上游来源都已处理过。若误用升序,dp[mask] 在被读取时还是初始的 -1,整条链会断掉、答案系统性偏小甚至为 0。最后的 max 扫描则是为了兼容”剩余牌里再也凑不出三元组”的状态——此时 dp[rest] 停在某个中间值,而那才是真实可取组数。
O(2^n · T) 的复杂度在 n = 15、T ≈ 20 时约 65 万次操作,完全可接受。若 n = 20(2²⁰ = 1048576 状态 × 最多 100 个三元组 ≈ 1 亿次),在移动端需要拆成多帧、或者改用”按块分治 + 记忆化”。
五、实时校验:玩家点选的最优反馈
玩家点选三张牌时,游戏需要即时给出”是否合法 + 错在哪”。朴素做法是逐个属性比较,四次分支;用向量法可以做得更优雅:先算 s = (a+b+c) mod 3,再检查 s 是否全零。若不为零,s 的非零分量恰好就是违反的属性,可以直接高亮提示——这比”逐个属性判 4 次”省掉一轮循环,也让错误反馈的语义精确到属性级。
public final class Validator {
/** 返回 null 表示合法,否则返回违反的属性下标 */
public static int[] violated(Card x, Card y, Card z) {
int[] s = {
(x.a + y.a + z.a) % 3,
(x.b + y.b + z.b) % 3,
(x.c + y.c + z.c) % 3,
(x.d + y.d + z.d) % 3
};
int cnt = 0;
for (int v : s) if (v != 0) cnt++;
if (cnt == 0) return null;
int[] bad = new int[cnt];
int p = 0;
for (int i = 0; i < 4; i++) if (s[i] != 0) bad[p++] = i;
return bad;
}
}
六、完整工程结构
set-game/
├── pom.xml
└── src/main/java/com/example/setgame/
├── Card.java // 四维三值编码与判定
├── SetFinder.java // 哈希枚举全部 Set
├── PackingDP.java // 位掩码 DP 最大不交集
├── Validator.java // 实时合法性反馈
└── Demo.java
public final class Demo {
public static void main(String[] args) {
// 1. 全牌 81 张,验证 Set 总数应为 C(81,2)/3 = 1080
java.util.List<Card> all = new java.util.ArrayList<>();
for (int code = 0; code < 81; code++) all.add(Card.fromCode(code));
int total = SetFinder.findAll(all).size();
System.out.println("全副 81 张的 Set 总数 = " + total); // 1080
// 2. 12 张手牌:找出所有 Set
java.util.List<Card> hand = new java.util.List.of(
Card.fromCode(0), Card.fromCode(1), Card.fromCode(2),
Card.fromCode(3), Card.fromCode(4), Card.fromCode(5),
Card.fromCode(9), Card.fromCode(10), Card.fromCode(11),
Card.fromCode(13), Card.fromCode(26), Card.fromCode(40));
java.util.List<int[]> sets = SetFinder.findAll(hand);
System.out.println("手牌 Set 数量 = " + sets.size());
// 3. 最大不交集
System.out.println("最大可连取组数 = " + PackingDP.maxDisjointSets(hand));
// 4. 实时校验
int[] bad = Validator.violated(hand.get(0), hand.get(1), hand.get(5));
System.out.println("校验结果(null 为合法)= " +
(bad == null ? "合法" : java.util.Arrays.toString(bad)));
}
}
七、复杂度与三条结论
| 环节 | 复杂度 | 备注 |
|---|---|---|
| 单组判定 | O(1) |
4 次模加,无分支 |
| 全部 Set 枚举 | O(n²) |
哈希查表,无需去重 |
| 最大不交集 | O(2ⁿ·T) |
n ≤ 15 时毫秒级 |
| 实时校验 | O(1) |
同时定位违规属性 |
结论一,模三表示是本问题的”正确的坐标系”。 用四个布尔属性分别判”全同/全不同”,代码量至少翻三倍,而且错误提示只能笼统说”不合法”;换成 F₃ 向量后,”和为零”既是判定又是诊断,这是抽象带来的直接收益。
结论二,”任意两牌唯一确定第三牌”这条性质支撑了几乎所有高效算法。 O(n²) 枚举、C(n,2)/3 的计数上界、以及”无解即大概率”的规则设计,全部由它推出。识别出这种”闭包性质”,往往比优化常数更重要。
结论三,连取阶段必须上精确算法。 贪心在 3-Set Packing 上会稳定地输,而玩家对”明明还有 Set 却提示补牌”的容忍度为零。12~15 张的规模下位掩码 DP 成本极低,没有理由退而求其次用启发式。
补充一个工程细节:present 哈希表要求牌不重复。若业务允许重复牌(某些变体允许牌面同构、仅图案镜像不同),Map<Integer,Integer> 会覆盖同码牌,导致漏解。此时应改成 Map<Integer, List<Integer>>,并在枚举时对每个候选下标逐一生成三元组,代价从 O(n²) 升到 O(n²·k)(k 为同码牌数),但正确性无虞。