每日算法 — 使用java实现找套牌(SET):GF(3)三重判定与枚举剪枝

一、游戏规则:一张牌有四个属性

找套牌(SET)是 1991 年由 Marsha Falco 设计的卡牌游戏,规则简单得可以用一句话说完,但它的数学结构却非常漂亮。

一副 SET 共 81 张牌,每张牌有四个属性,每个属性有三种取值:

属性 取值
形状 椭圆 / 菱形 / 波浪
颜色 红 / 绿 / 紫
数量 1 / 2 / 3
填充 实心 / 条纹 / 空心

因此总牌数 3⁴ = 81。三位牌构成一个 “套牌(SET)”,当且仅当对每一个属性,三张牌的取值要么完全相同,要么两两不同。也就是说,”恰好两张相同”是不允许的。

举例:三张牌形状分别为「椭圆、菱形、波浪」,颜色全为红色,数量为「1、1、1」,填充分别为「实心、条纹、空心」——这就是一个合法 SET。

玩家从 12 张明牌中找 SET,找到后取走并补牌。这个游戏的算法问题非常清晰:

  1. 如何表示一张牌,使得”是否为 SET”的判定变成一次运算?
  2. 如何从 N 张牌中高效枚举所有 SET,避免 O(N³) 的盲目三循环?
  3. 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,取哪一个?

几个可量化的策略:

  1. 优先取走会破坏其他 SET 的那一个——拿走后如果场上剩余的 SET 数量减少,说明自己吃掉了未来的机会;
  2. 优先取走”最晚被对手发现”的那个——可以用”属性差异度”近似:SET 三张牌的属性差异越分散,人类越难一眼看出;
  3. 留下对手容易看错的——反过来,让场上留着”不明显”的组合。

先用一个”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) 维度拼在一起。于是这个算法可以直接推广到:

  1. “找三条”类卡牌游戏——任意属性数为 m 的三值卡牌,都适用 O(N²) 枚举;
  2. 校验码/纠错码枚举——GF(3) 上的线性码中寻找满足校验关系的三元组,正是同一套逻辑;
  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;
}

这段代码体现了”值域小就用定址“的经典优化:哈希表再快也有哈希函数和链表/红黑树的常数,定址数组则是一次内存访问。

九、小结

本文从找套牌的规则出发,做了三件事:

  1. 编码:四个属性各占 2 bit,一张牌压缩成 8 bit 整数,判定 SET 只需 4 次模 3 求和;
  2. 降维枚举:利用 GF(3) 中”两点唯一确定第三点”的性质,把 O(N³) 的三重枚举降到 O(N²);
  3. 策略层:用”取走后剩余 SET 最少”的贪心策略做 AI 选牌,并用显著性评分近似人类视觉难度。

最值得记住的一句话是:游戏的规则描述是”要么全同要么全不同”,但它的代数本质是”模 3 求和为零”——找到这个映射,O(N³) 就自动变成了 O(N²)。