每日算法 — 使用java实现拱猪:双人零和求解与风险牌规避决策

一、拱猪是一个”负分游戏”

拱猪(Black Lady / Hearts 家族)是规避型纸牌游戏:所有分牌都是负分。红桃各 −1(或在某些规则里为 0),黑桃 Q(猪)−13,方块 J(羊)+10 等。游戏目标是让总分尽量接近 0,最好为正——也就是”别吃分牌”。

这带来了和传统得分型游戏完全相反的算法特性:

  1. 策略目标是最小化负分,而非最大化正分。如果直接把分数取反当”收益”,会导致”宁可让对手吃 26 分”这种极端打法,而这在实际规则里往往真的成立(”收全红”变体)。所以必须显式的做零和归一化。
  2. 每一张牌的边际风险是可计算的。给定剩余牌,每张牌的”被吃概率”可以从已知信息推断,因此期望损失最小化是可解析的。
  3. 残局可完全求解。牌数少时可以用精确的零和博弈求解(Alpha-Beta 或 Minimax),这是验证启发式策略质量的最佳手段。

本文实现:期望损失模型 + 精确残局求解器 + 两者对比。

二、规则简化与建模

为了聚焦算法,本文采用拱猪的核心双人残局规则:

  • 每轮两人各出一张牌,花色必须跟随(无该花色则可任意垫)。
  • 领出者出牌决定花色,另一方必须跟同花色;无法跟则任意出。
  • 同花色中更大者赢得本轮,赢家吃下本轮所有分牌,并领出下一轮。
  • 分牌:黑桃 Q = −13;红桃每张 = −1;其余 0 分。
  • 终局:所有牌出完,得分高者(负分少者)获胜。

关键约定:谁赢本轮谁吃分。这让”故意输掉本轮”成为核心策略——因为输家不吃分。

三、算法核心一:期望损失模型(中局决策)

3.1 风险的定义

对当前要出的一张牌 c,定义它的期望损失:

EV(c) = Σ_p  P(本轮我赢 | 我出 c, 对手出 p) × loss(p, c)

其中 loss 是本轮所有分牌的负分总和。难点在 P(本轮我赢):它取决于对手出什么牌,而对手的牌未知。

3.2 用”已知信息”替代”未知枚举”

与其枚举对手全部可能手牌(组合爆炸),不如利用三件事:

  1. 对手是否还有该花色:从已出牌记录可以精确推出(初始 13 张/花色,减去自己手里和已出的)。
  2. 对手手牌数:公开可见。
  3. 本花色的最大剩余牌:若自己出的牌是该花色当前最大,则只有对手持有更大牌时才可能输——而这个事件概率可从”未见牌”推断。

于是 EV(c) 可以写成两层决策:

EV(c) = P(对手无法跟花色) × EV(垫牌情形)
      + P(对手跟花色)     × E[本轮结果 | 对手跟牌]

其中第二项用”未见大牌密度”来估:

P(我赢) = 1 − P(对手持有更大的同花色牌)
       ≈ 1 − (未见更大牌数 / 未见该花色总数)^对手手牌数

这个近似在牌数多时准确,牌数少时会有偏差——这正是需要精确求解器的原因。

3.3 决策规则

选择 EV(c) 最大的牌(即期望损失最小)。但有一个硬约束优先级:

1. 若手中有"绝对安全牌"(该花色已无更大牌存活),优先出它
2. 否则选 EV 最优
3. 若所有牌都会吃分,选"吃分最少"的

这个分层比纯 EV 更稳定:安全牌是确定性零损失,不该被期望值的浮点噪声影响。

四、算法核心二:残局精确求解

当剩余牌数 ≤ 8 时,可以用 Alpha-Beta 精确求解(无近似):

  • 状态 = (剩余手牌集合 A, B, 已出牌记录, 当前轮状态, 领出方)。
  • 由于剩余牌少,状态空间可控(≤ 8 张牌,C(8,4) × 4! 级别)。
  • 求解返回精确的终局分差,可作为评估中局启发式策略的基准。

这给出一个很有价值的实验:把”精确求得的最优决策”与”启发式决策”逐手对比,量化启发式的失误率。

五、项目结构

hearts/
├── pom.xml
└── src/main/java/com/example/hearts/
    ├── Suit.java           花色
    ├── Card.java           牌(花色 + 点数)
    ├── CardSet.java        牌集(位掩码)
    ├── Trick.java          一轮的状态
    ├── HeartsState.java    对局状态与转移
    ├── ExpectedLossAI.java 期望损失启发式
    ├── ExactSolver.java    残局 Alpha-Beta 精确解
    └── Main.java           自对弈 + 精度对比

六、完整实现

6.1 花色与牌

package com.example.hearts;

/** 花色。索引 0..3 固定,便于做位运算。 */
public enum Suit {
    CLUB(0, "梅花"), DIAMOND(1, "方块"), HEART(2, "红桃"), SPADE(3, "黑桃");

    public final int idx;
    public final String cn;

    Suit(int idx, String cn) {
        this.idx = idx;
        this.cn = cn;
    }

    public static Suit of(int idx) {
        return values()[idx];
    }
}
package com.example.hearts;

/** 一张牌。rank 1..13(A=1, K=13)。 */
public final class Card implements Comparable<Card> {

    public final Suit suit;
    public final int rank;

    public Card(Suit suit, int rank) {
        this.suit = suit;
        this.rank = rank;
    }

    /** 位编号:suit * 13 + (rank - 1),范围 0..51。 */
    public int bitIndex() {
        return suit.idx * 13 + (rank - 1);
    }

    public long bit() {
        return 1L << bitIndex();
    }

    public static Card fromBitIndex(int idx) {
        return new Card(Suit.of(idx / 13), idx % 13 + 1);
    }

    /** 是否分牌。 */
    public boolean isPenalty() {
        return suit == Suit.HEART || (suit == Suit.SPADE && rank == 12);
    }

    /** 罚分(负值)。黑桃Q = -13,红桃每张 = -1。 */
    public int penalty() {
        if (suit == Suit.SPADE && rank == 12) return -13;
        if (suit == Suit.HEART) return -1;
        return 0;
    }

    @Override
    public int compareTo(Card o) {
        if (suit != o.suit) return Integer.compare(suit.idx, o.suit.idx);
        return Integer.compare(rank, o.rank);
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Card c)) return false;
        return c.suit == suit && c.rank == rank;
    }

    @Override
    public int hashCode() {
        return bitIndex();
    }

    @Override
    public String toString() {
        String[] r = {"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"};
        return suit.cn + r[rank - 1];
    }
}

6.2 牌集(位掩码)

package com.example.hearts;

import java.util.ArrayList;
import java.util.List;

/** 用 long 的 52 位表示一组牌。集合运算全部是位运算,O(1)。 */
public final class CardSet {

    public static final long ALL = (1L << 52) - 1;
    public static final long HEARTS = 0x1FFFL << 26;    // suit 2 的 13 张
    public static final long SPADE_QUEEN = 1L << (3 * 13 + 11);

    private CardSet() {}

    /** 某花色的全部牌位掩码。 */
    public static long suitMask(Suit s) {
        return 0x1FFFL << (s.idx * 13);
    }

    public static int size(long set) {
        return Long.bitCount(set);
    }

    public static List<Card> toList(long set) {
        List<Card> out = new ArrayList<>();
        long s = set;
        while (s != 0) {
            int idx = Long.numberOfTrailingZeros(Long.lowestOneBit(s));
            s &= s - 1;
            out.add(Card.fromBitIndex(idx));
        }
        return out;
    }

    /** 某花色在集合中的牌。 */
    public static long ofSuit(long set, Suit suit) {
        return set & suitMask(suit);
    }

    /** 集合中所有分牌的罚分总和(负值)。 */
    public static int penaltyOf(long set) {
        int p = 0;
        long s = set;
        while (s != 0) {
            int idx = Long.numberOfTrailingZeros(Long.lowestOneBit(s));
            s &= s - 1;
            p += Card.fromBitIndex(idx).penalty();
        }
        return p;
    }

    /** 集合中的最大 rank(单花色集合上用)。显式遍历,最多 13 次,成本可忽略。 */
    public static int maxRank(long set) {
        int best = -1;
        long s = set;
        while (s != 0) {
            int idx = Long.numberOfTrailingZeros(Long.lowestOneBit(s));
            s &= s - 1;
            best = Math.max(best, Card.fromBitIndex(idx).rank);
        }
        return best;
    }

    public static String describe(long set) {
        return toList(set).toString();
    }
}

这里没有用”位索引取最高位”的技巧。位索引是全局 suit*13 + rank-1,跨花色时最高位对应的 rank 会被花色偏移干扰,直接算会出错。显式遍历虽然慢一点,但正确性无歧义——在这类位掩码结构里,把”跨花色的全局索引”当”花色内 rank”用,是最常见的隐性 bug 来源。

6.3 一轮与对局状态

package com.example.hearts;

import java.util.ArrayList;
import java.util.List;

/** 一轮(trick)的状态。 */
public final class Trick {

    public final Suit leadSuit;
    public final int leader;       // 领出方 0/1
    public Card firstCard = null;  // 领出牌
    public Card secondCard = null; // 跟随牌

    public Trick(int leader) {
        this.leader = leader;
        this.leadSuit = null;   // 花色由第一张牌决定,见 leadSuitOf
    }

    public boolean isComplete() {
        return firstCard != null && secondCard != null;
    }

    public long cardsMask() {
        long m = 0;
        if (firstCard != null) m |= firstCard.bit();
        if (secondCard != null) m |= secondCard.bit();
        return m;
    }

    /** 本轮谁赢。 */
    public int winner() {
        if (!isComplete()) return -1;
        Suit s = firstCard.suit;
        boolean secondFollows = secondCard.suit == s;
        if (secondFollows) {
            return secondCard.rank > firstCard.rank ? (1 - leader) : leader;
        }
        // 未跟花色,领出方直接赢
        return leader;
    }

    /** 本轮吃下的罚分(负值),由赢家承受。 */
    public int penalty() {
        return firstCard.penalty() + secondCard.penalty();
    }
}
package com.example.hearts;

import java.util.ArrayList;
import java.util.List;

/** 对局状态:双方手牌、已完成轮次的吃分记录、当前轮。 */
public final class HeartsState {

    public long[] hands = new long[2];
    public int[] penalties = new int[2];
    public int nextLeader = 0;
    public Trick trick = null;
    /** 已打出的所有牌(公开信息)。 */
    public long played = 0;

    public HeartsState() {}

    public HeartsState copy() {
        HeartsState s = new HeartsState();
        s.hands[0] = hands[0];
        s.hands[1] = hands[1];
        s.penalties[0] = penalties[0];
        s.penalties[1] = penalties[1];
        s.nextLeader = nextLeader;
        s.played = played;
        if (trick != null) {
            Trick t = new Trick(trick.leader);
            t.firstCard = trick.firstCard;
            t.secondCard = trick.secondCard;
            s.trick = t;
        }
        return s;
    }

    /** 某方是否还有指定花色。 */
    public boolean hasSuit(int player, Suit suit) {
        return (hands[player] & CardSet.suitMask(suit)) != 0;
    }

    /**
     * 某方在给定局面下的合法出牌。
     * 花色跟随规则:若有领出花色则必须跟,否则任意。
     */
    public List<Card> legalCards(int player) {
        List<Card> out = new ArrayList<>();
        long hand = hands[player];
        if (hand == 0) return out;

        if (trick != null && trick.firstCard != null) {
            Suit lead = trick.firstCard.suit;
            long follow = hand & CardSet.suitMask(lead);
            if (follow != 0) return CardSet.toList(follow);
        }
        return CardSet.toList(hand);
    }

    /** 出牌并推进状态。 */
    public void play(int player, Card card) {
        hands[player] &= ~card.bit();
        played |= card.bit();

        if (trick == null) {
            trick = new Trick(player);
            trick.firstCard = card;
        } else {
            trick.secondCard = card;
            int w = trick.winner();
            penalties[w] += trick.penalty();
            nextLeader = w;
            trick = null;
        }
    }

    public boolean isTerminal() {
        return hands[0] == 0 && hands[1] == 0;
    }

    public int totalPenalty() {
        return penalties[0] + penalties[1];
    }

    /** 分差:正数表示玩家 0 领先。 */
    public int diff() {
        return penalties[1] - penalties[0];
    }
}

6.4 期望损失启发式

package com.example.hearts;

import java.util.List;

/**
 * 期望损失 AI。
 * 核心思想:估算每张牌的期望罚分,选最小者。
 * 三层结构:安全牌优先 → 期望值决策 → 兜底最少损失。
 */
public final class ExpectedLossAI {

    private final int me;

    public ExpectedLossAI(int me) {
        this.me = me;
    }

    /** 选择要出的牌。 */
    public Card choose(HeartsState st) {
        int opp = 1 - me;
        List<Card> legal = st.legalCards(me);
        if (legal.size() == 1) return legal.get(0);

        // 第一层:绝对安全牌。若该牌是本花色当前最大,且更大牌已全部出现,则零风险。
        for (Card c : legal) {
            if (isAbsolutelySafe(st, c, opp)) return c;
        }

        // 第二层:期望损失最小
        Card best = legal.get(0);
        double bestEv = Double.NEGATIVE_INFINITY;
        for (Card c : legal) {
            double ev = expectedValue(st, c, opp);
            if (ev > bestEv) {
                bestEv = ev;
                best = c;
            }
        }
        return best;
    }

    /**
     * 绝对安全:本花色内不存在"仍存活且比我大的牌"。
     * 存活 = 不在我手里、不在已出牌里。
     */
    private boolean isAbsolutelySafe(HeartsState st, Card c, int opp) {
        long unknown = CardSet.ALL & ~st.hands[me] & ~st.played;
        long bigger = 0;
        for (long s = unknown; s != 0; ) {
            int idx = Long.numberOfTrailingZeros(Long.lowestOneBit(s));
            s &= s - 1;
            Card u = Card.fromBitIndex(idx);
            if (u.suit == c.suit && u.rank > c.rank) bigger |= u.bit();
        }
        if (bigger != 0) return false;

        // 还要确认对手确实跟不了更大牌:若对手手里没有该花色,则必定安全
        // (本实现里 bigger == 0 已足够:场上已无更大牌)
        return true;
    }

    /**
     * 期望值(越大越好,因为是负分游戏)。
     *
     * EV = P(对手跟不出花色) * E[情况A] + P(对手跟得出) * E[情况B]
     *
     * 情况A:对手垫牌,我必赢本轮,吃下本轮全部罚分 + 对手垫的分牌
     * 情况B:对手跟牌,胜负取决于牌面比较
     */
    private double expectedValue(HeartsState st, Card c, int opp) {
        Suit lead = st.trick != null && st.trick.firstCard != null
                ? st.trick.firstCard.suit : c.suit;

        long oppHand = st.hands[opp];
        long oppSuit = oppHand & CardSet.suitMask(lead);
        int oppSize = CardSet.size(oppHand);
        int oppSuitCount = CardSet.size(oppSuit);

        // 若我是领出方,对手必然"面对"我的花色
        boolean iLead = (st.trick == null || st.trick.firstCard == null);
        double pFollow = oppSize == 0 ? 0 : (double) oppSuitCount / oppSize;
        if (iLead && oppSuitCount > 0) pFollow = 1.0;   // 有该花色就必须跟

        // 计算两种情形下的罚分承受
        int myPenaltyIfWin = c.penalty();
        int oppPenaltyIfWin = 0;

        // 对手的牌未知,用"未见牌"的平均罚分估计
        long unknown = CardSet.ALL & ~st.hands[0] & ~st.hands[1] & ~st.played;
        double avgUnknownPenalty = unknown == 0 ? 0
                : (double) CardSet.penaltyOf(unknown) / CardSet.size(unknown);

        // 情况 B:对手跟牌。我赢的概率取决于是否有更大的同花色牌
        double pIWin;
        if (iLead) {
            // 我领出 c,对手若跟则必然有同花色牌,比较大小
            long biggerInOpp = oppSuit & biggerMask(c);
            double pOppHasBigger = oppSuitCount == 0 ? 0
                    : Math.min(1.0, (double) CardSet.size(biggerInOpp) / oppSuitCount);
            pIWin = 1.0 - pOppHasBigger;
        } else {
            // 我跟牌(或垫牌),当前领出牌为 trick.firstCard
            Card leadCard = st.trick.firstCard;
            if (c.suit != leadCard.suit) {
                pIWin = 0.0;   // 垫牌必输
            } else {
                pIWin = c.rank > leadCard.rank ? 1.0 : 0.0;
            }
        }

        double lossIfIWin = myPenaltyIfWin + avgUnknownPenalty;
        double lossIfILose = c.penalty() * 0.0;   // 我不吃分

        double evWin = -lossIfIWin;
        double evLose = 0.0;

        double evFollow = pIWin * evWin + (1 - pIWin) * evLose;
        // 对手跟不出:我只能赢(领出方)或必然输(跟牌方垫牌)
        double evNoFollow = iLead ? evWin : evLose;

        return pFollow * evFollow + (1 - pFollow) * evNoFollow;
    }

    private long biggerMask(Card c) {
        long m = 0;
        for (int r = c.rank + 1; r <= 13; r++) {
            m |= new Card(c.suit, r).bit();
        }
        return m;
    }
}

6.5 残局精确求解

package com.example.hearts;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * 残局精确求解器(Alpha-Beta)。
 * 状态空间小(剩余牌 ≤ 8),可完全求解,用作启发式的基准。
 * 返回的是"从 me 视角的精确终局分差"。
 */
public final class ExactSolver {

    private final int me;
    private long nodes = 0;
    private final Map<Long, Integer> tt = new HashMap<>();

    public ExactSolver(int me) {
        this.me = me;
    }

    public long nodes() { return nodes; }

    public int solve(HeartsState st) {
        return search(st, Integer.MIN_VALUE + 1, Integer.MAX_VALUE - 1);
    }

    /** 返回分差(玩家0 视角),内部用玩家0 最大化。 */
    private int search(HeartsState st, int alpha, int beta) {
        nodes++;

        if (st.isTerminal()) {
            return st.penalties[1] - st.penalties[0];
        }

        long key = hashKey(st);
        Integer cached = tt.get(key);
        if (cached != null) return cached;

        int player = st.trick == null || st.trick.firstCard == null
                ? st.nextLeader
                : 1 - st.trick.leader;

        List<Card> legal = st.legalCards(player);
        int best = player == 0 ? Integer.MIN_VALUE + 1 : Integer.MAX_VALUE - 1;

        for (Card c : legal) {
            HeartsState next = st.copy();
            next.play(player, c);
            int value = search(next, alpha, beta);

            if (player == 0) {
                if (value > best) best = value;
                if (value > alpha) alpha = value;
            } else {
                if (value < best) best = value;
                if (value < beta) beta = value;
            }
            if (alpha >= beta) break;
        }

        tt.put(key, best);
        return best;
    }

    /**
     * 状态键:双手牌 + 已出牌 + 当前轮的两张牌 + 领出方。
     * 用一个 long 拼接(手牌各 52 位放不下,故用哈希组合)。
     */
    private long hashKey(HeartsState st) {
        long h = st.hands[0] * 0x9E3779B97F4A7C15L;
        h ^= Long.rotateLeft(st.hands[1], 17) * 0xC2B2AE3D27D4EB4FL;
        h ^= Long.rotateLeft(st.played, 31);
        if (st.trick != null) {
            h ^= st.trick.leader * 0x85EBCA6BL;
            if (st.trick.firstCard != null) h ^= st.trick.firstCard.bit() * 0x27D4EB2FL;
            if (st.trick.secondCard != null) h ^= st.trick.secondCard.bit() * 0x165667B1L;
        } else {
            h ^= st.nextLeader * 0xA24BAED4963EE407L;
        }
        return h;
    }
}

6.6 入口:启发式 vs 精确解

package com.example.hearts;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Random;

public class Main {

    /** 从全副牌中随机发 2×n 张,构成一个残局。 */
    static HeartsState randomEndgame(int cardsPerPlayer, long seed) {
        Random rnd = new Random(seed);
        List<Card> all = CardSet.toList(CardSet.ALL);
        Collections.shuffle(all, rnd);
        HeartsState st = new HeartsState();
        for (int i = 0; i < cardsPerPlayer; i++) {
            st.hands[0] |= all.get(i * 2).bit();
            st.hands[1] |= all.get(i * 2 + 1).bit();
        }
        st.nextLeader = rnd.nextInt(2);
        return st;
    }

    public static void main(String[] args) {
        int cardsPerPlayer = 6;    // 12 张牌的残局,规模够小,可精确求解
        int trials = 200;

        int heuristicDiffSum = 0;
        int exactDiffSum = 0;
        long totalNodes = 0;
        int agree = 0;

        for (int t = 0; t < trials; t++) {
            HeartsState base = randomEndgame(cardsPerPlayer, t);

            // 1) 启发式对局:双方都用期望损失 AI
            HeartsState h = base.copy();
            ExpectedLossAI ai0 = new ExpectedLossAI(0);
            ExpectedLossAI ai1 = new ExpectedLossAI(1);
            while (!h.isTerminal()) {
                int player = h.trick == null ? h.nextLeader : 1 - h.trick.leader;
                Card c = (player == 0 ? ai0 : ai1).choose(h);
                h.play(player, c);
            }
            heuristicDiffSum += h.diff();

            // 2) 精确求解同一初始局面
            ExactSolver solver = new ExactSolver(0);
            exactDiffSum += solver.solve(base);
            totalNodes += solver.nodes();

            // 3) 比较首手决策是否一致
            int firstPlayer = base.trick == null ? base.nextLeader : 1 - base.trick.leader;
            Card heurPick = (firstPlayer == 0 ? ai0 : ai1).choose(base);
            Card exactPick = bestBySolver(base, solver, firstPlayer);
            if (heurPick.equals(exactPick)) agree++;
        }

        System.out.println("=== 残局实验:" + cardsPerPlayer + " 张/人,共 " + trials + " 局 ===");
        System.out.printf("启发式平均分差(玩家0视角): %.2f%n", heuristicDiffSum * 1.0 / trials);
        System.out.printf("精确解平均分差(玩家0视角): %.2f%n", exactDiffSum * 1.0 / trials);
        System.out.printf("首手决策一致率: %.1f%%%n", agree * 100.0 / trials);
        System.out.printf("精确求解平均节点数: %.0f%n", totalNodes * 1.0 / trials);
    }

    /**
     * 用求解器遍历首手合法牌,选出对当前玩家最优的那张。
     *
     * 注意视角不变量:solver.solve() 永远返回"玩家 0 视角的分差"。
     * 因此玩家 0 要挑"分差最大",玩家 1 要挑"分差最小"——两个方向必须相反。
     * 若写成同向,AI 会稳定地选错牌,而且不会抛任何异常,极难排查。
     */
    static Card bestBySolver(HeartsState st, ExactSolver solver, int player) {
        List<Card> legal = st.legalCards(player);
        Card best = legal.get(0);
        int bestVal = player == 0 ? Integer.MIN_VALUE : Integer.MAX_VALUE;

        for (Card c : legal) {
            HeartsState next = st.copy();
            next.play(player, c);
            int v = solver.solve(next);   // 始终是"玩家0 视角"的分差
            if (player == 0) {
                if (v > bestVal) { bestVal = v; best = c; }   // 玩家0 最大化分差
            } else {
                if (v < bestVal) { bestVal = v; best = c; }   // 玩家1 最小化分差
            }
        }
        return best;
    }
}

七、复杂度分析

  • 期望损失决策:对每张合法牌(≤ 13)计算一次 EV,每次 EV 需要扫一遍未见牌(≤ 52)。总计 O(13 × 52) = O(1) 常数级。
  • 精确求解:状态数上界约为 C(2n, n) × 2^n(n 为每人手牌数),n=6 时约 924 × 64 ≈ 6 万,配合 Alpha-Beta 与置换表实测平均节点数在数千量级。
  • 牌集运算:所有集合操作(取花色、求罚分、取最高牌)都是 64 位位运算,单次 O(1);位掩码让”是否还有某花色”的判定从遍历变成一次 &。
  • 空间:状态仅 2 个 long 手牌 + 若干整数,置换表为 HashMap<Long,Integer>,n=6 时不超过几万条目。

八、小结

拱猪的算法价值在于它把”负面目标”处理得极为干净:

  • 零和归一是必须的。负分游戏里直接取反做最大化,会让 AI 追求”对手灾难”而不是”自己安全”,这类偏差在训练或对弈中很难被发现。
  • 分层决策比纯期望更稳。安全牌是确定性零损失,不该被浮点期望的噪声左右;先判安全、再算期望,是工程上更可靠的顺序。
  • 残局求解器是最好的验证工具。它给你一个精确基准,让”启发式的失误率”变成可量化的数字,而不是靠手感判断好坏。