每日算法 — 使用java实现记忆翻牌:信息论与最优决策策略

引言

记忆翻牌(Memory/Concentration)是一款考验记忆力的经典益智游戏。玩家面对一组背面朝上的卡牌,每次翻开两张,若图案相同则消除,不同则翻回。目标是消除所有卡牌。

当引入概率推理和信息论视角,这款看似纯记忆的游戏 reveals 出深刻的数学结构。本文将用Java实现一个具有最优决策能力的记忆翻牌AI,核心算法基于信息增益与概率推理。

游戏规则与信息模型

游戏规则

  • 棋盘有 $2N$ 张牌,组成 $N$ 对相同图案
  • 每轮玩家选择两张未消除的牌翻开
  • 若两张牌图案相同,则该对消除;否则翻回背面
  • 玩家可看到所有已翻开过的牌(直到被翻回)
  • 目标:消除所有牌对

信息模型

将游戏建模为部分可观察马尔可夫决策过程(POMDP):

  • 隐藏状态:每张牌的真实图案(共 $N$ 种,每种2张)
  • 观察:已翻开过的牌的位置和图案
  • 信念状态:对未知牌图案的概率分布

核心算法

1. 信念状态更新(贝叶斯推理)

每次翻牌后,根据观察更新对其他未知牌的概率估计。

假设已观察到某些牌的图案,剩余未知牌的图案分配需满足:
– 每种图案恰好还剩规定数量未匹配
– 所有未知牌的图案分布必须自洽

2. 信息增益策略

当存在已知的配对时(某图案的两张位置都已知),直接选择它们——确定性收益。

否则,选择能提供最大信息增益的翻牌组合:
– 优先翻开从未见过的牌(探索)
– 避免选择已知的单张(没有信息增益)
– 计算期望匹配概率,选择最优的翻牌对

3. 最优策略

理论最优策略混合了:
1. 利用(Exploitation):翻开已知的配对
2. 探索(Exploration):翻开新牌获取信息
3. 概率匹配:当有已知单张时,计算第二张在某位置的概率

Java实现

项目结构

memory-game/
├── src/
│   └── com/
│       └── memory/
│           ├── Card.java              // 卡牌表示
│           ├── BeliefState.java       // 信念状态
│           ├── MemoryAI.java          // AI决策器
│           ├── MemoryGame.java        // 游戏引擎
│           └── Main.java              // 入口

卡牌与游戏状态

package com.memory;

import java.util.*;

/**
 * 卡牌状态
 */
enum CardStatus {
    HIDDEN,     // 背面朝上,未见过
    SEEN,       // 曾翻开过,已知图案
    MATCHED     // 已配对消除
}

/**
 * 卡牌
 */
class Card {
    final int id;           // 卡牌ID(0到2N-1)
    final int pattern;      // 图案编号(0到N-1,每种图案2张)
    CardStatus status;

    Card(int id, int pattern) {
        this.id = id;
        this.pattern = pattern;
        this.status = CardStatus.HIDDEN;
    }
}

/**
 * 游戏状态
 */
class GameState {
    final int pairCount;          // 总对数
    final Card[] cards;           // 所有卡牌
    final int totalCards;

    int turnsTaken = 0;
    int pairsFound = 0;

    GameState(int pairCount) {
        this.pairCount = pairCount;
        this.totalCards = pairCount * 2;
        this.cards = new Card[totalCards];

        // 创建卡牌并洗牌
        List<Integer> patterns = new ArrayList<>();
        for (int i = 0; i < pairCount; i++) {
            patterns.add(i);
            patterns.add(i);
        }
        Collections.shuffle(patterns, new Random());

        for (int i = 0; i < totalCards; i++) {
            cards[i] = new Card(i, patterns.get(i));
        }
    }

    /**
     * 翻开两张牌
     * @return 是否配对成功
     */
    boolean flipPair(int pos1, int pos2) {
        turnsTaken++;
        Card c1 = cards[pos1];
        Card c2 = cards[pos2];

        // 标记为已见
        c1.status = CardStatus.SEEN;
        c2.status = CardStatus.SEEN;

        if (c1.pattern == c2.pattern) {
            c1.status = CardStatus.MATCHED;
            c2.status = CardStatus.MATCHED;
            pairsFound++;
            return true;
        }
        return false;
    }

    boolean isGameOver() {
        return pairsFound == pairCount;
    }

    /**
     * 获取所有可用(未消除)牌的位置
     */
    List<Integer> getAvailablePositions() {
        List<Integer> positions = new ArrayList<>();
        for (int i = 0; i < totalCards; i++) {
            if (cards[i].status != CardStatus.MATCHED) {
                positions.add(i);
            }
        }
        return positions;
    }
}

信念状态与概率推理

package com.memory;

import java.util.*;

/**
 * 信念状态
 * 维护对每个未知牌图案的概率分布
 */
class BeliefState {
    // knownPatterns[pattern] = 该图案已知但尚未配对的位置列表
    final Map<Integer, List<Integer>> knownPatterns = new HashMap<>();

    // unknownPositions = 位置状态为HIDDEN的牌(从未见过)
    final List<Integer> unknownPositions = new ArrayList<>();

    // seenSingle = 已知图案但只见过一张的位置(等待配对)
    final Map<Integer, Integer> seenSingle = new HashMap<>();  // pattern -> position

    // 每种图案剩余未配对的数量(0, 1, 或 2)
    final Map<Integer, Integer> remainingCount = new HashMap<>();

    BeliefState(GameState state) {
        // 初始化:统计每种图案的状态
        Map<Integer, Integer> seenCount = new HashMap<>();
        Map<Integer, List<Integer>> seenPositions = new HashMap<>();

        for (Card card : state.cards) {
            if (card.status == CardStatus.MATCHED) continue;

            seenCount.put(card.pattern, seenCount.getOrDefault(card.pattern, 0) + 1);
            seenPositions.computeIfAbsent(card.pattern, k -> new ArrayList<>()).add(card.id);

            if (card.status == CardStatus.HIDDEN) {
                unknownPositions.add(card.id);
            }
        }

        for (var entry : seenCount.entrySet()) {
            int pattern = entry.getKey();
            int count = entry.getValue();
            remainingCount.put(pattern, count);

            if (count == 1) {
                // 只剩一张未配对,且已知位置
                List<Integer> pos = seenPositions.get(pattern);
                if (pos.size() == 1 && state.cards[pos.get(0)].status == CardStatus.SEEN) {
                    seenSingle.put(pattern, pos.get(0));
                }
            } else if (count == 2) {
                List<Integer> pos = seenPositions.get(pattern);
                boolean bothSeen = true;
                for (int p : pos) {
                    if (state.cards[p].status == CardStatus.HIDDEN) bothSeen = false;
                }
                if (bothSeen) {
                    knownPatterns.put(pattern, new ArrayList<>(pos));
                } else if (pos.size() == 1) {
                    // 一张seen一张hidden
                    seenSingle.put(pattern, pos.get(0));
                }
            }
        }
    }

    /**
     * 获取所有已知的完整配对
     */
    List<int[]> getKnownPairs() {
        List<int[]> pairs = new ArrayList<>();
        for (var entry : knownPatterns.entrySet()) {
            List<Integer> pos = entry.getValue();
            if (pos.size() == 2) {
                pairs.add(new int[]{pos.get(0), pos.get(1)});
            }
        }
        return pairs;
    }

    /**
     * 计算某未知位置是某图案的概率
     * 简化模型:假设未知牌的图案在所有可能的未知图案中均匀分布
     */
    double getProbability(int position, int pattern) {
        if (!unknownPositions.contains(position)) return 0.0;

        int remaining = remainingCount.getOrDefault(pattern, 0);
        if (remaining == 0) return 0.0;

        // 如果该图案已有seen单张,则未知牌中有1张是该图案
        // 否则有2张
        int hiddenCountOfPattern = remaining;
        for (int pos : seenSingle.values()) {
            // 检查这个单张是否是指定pattern
            // 简化:直接用remainingCount
        }

        // 简化概率模型
        int totalUnknown = unknownPositions.size();
        return (double) remaining / totalUnknown;
    }
}

AI决策器

package com.memory;

import java.util.*;

/**
 * 记忆翻牌AI
 * 基于信息论的最优决策
 */
public class MemoryAI {
    private final Random random = new Random(42);

    /**
     * 选择下一轮要翻开的两张牌
     * @return 两个位置索引
     */
    public int[] selectMove(GameState state) {
        BeliefState belief = new BeliefState(state);
        List<Integer> available = state.getAvailablePositions();

        if (available.size() < 2) {
            return new int[]{available.get(0), available.get(0)};
        }

        // 策略1:如果有已知的完整配对,直接选择(确定性最高)
        List<int[]> knownPairs = belief.getKnownPairs();
        if (!knownPairs.isEmpty()) {
            return knownPairs.get(0);
        }

        // 策略2:如果有已知的单张,尝试匹配
        if (!belief.seenSingle.isEmpty()) {
            // 选择一个已知单张,再选择最可能匹配的位置
            Map.Entry<Integer, Integer> entry = belief.seenSingle.entrySet().iterator().next();
            int knownPos = entry.getValue();
            int pattern = entry.getKey();

            // 在未知牌中寻找最可能匹配的
            int bestMatch = findBestMatch(state, belief, pattern, knownPos);
            if (bestMatch != -1) {
                return new int[]{knownPos, bestMatch};
            }
        }

        // 策略3:纯探索 - 翻开两张未知牌
        // 优先选择HIDDEN状态的牌(从未见过的)
        List<Integer> hidden = new ArrayList<>();
        List<Integer> seen = new ArrayList<>();
        for (int pos : available) {
            if (state.cards[pos].status == CardStatus.HIDDEN) hidden.add(pos);
            else seen.add(pos);
        }

        if (hidden.size() >= 2) {
            // 随机选择两张隐藏牌
            Collections.shuffle(hidden, random);
            return new int[]{hidden.get(0), hidden.get(1)};
        } else if (hidden.size() == 1) {
            // 一张隐藏 + 一张seen(尽量不选已知的单张)
            int hiddenPos = hidden.get(0);
            int seenPos = selectLeastInformativeSeen(state, seen);
            return new int[]{hiddenPos, seenPos};
        } else {
            // 只有seen牌,随机选择
            Collections.shuffle(seen, random);
            return new int[]{seen.get(0), seen.get(1)};
        }
    }

    /**
     * 为已知单张寻找最佳匹配位置
     */
    private int findBestMatch(GameState state, BeliefState belief, int pattern, int excludePos) {
        List<Integer> candidates = new ArrayList<>();
        for (int pos : belief.unknownPositions) {
            if (pos != excludePos) {
                candidates.add(pos);
            }
        }

        if (candidates.isEmpty()) {
            // 没有未知牌了,只能从seen牌中选
            for (int i = 0; i < state.totalCards; i++) {
                if (state.cards[i].status == CardStatus.SEEN && i != excludePos) {
                    candidates.add(i);
                }
            }
        }

        if (candidates.isEmpty()) return -1;

        // 在未知牌中均匀随机选择(所有未知牌等概率是目标图案)
        return candidates.get(random.nextInt(candidates.size()));
    }

    /**
     * 选择信息量最小的seen牌(避免浪费已知信息)
     */
    private int selectLeastInformativeSeen(GameState state, List<Integer> seenPositions) {
        // 优先选择那些"已知不可能配对"的seen牌(即另一个同图案已匹配)
        // 简化:随机选择
        return seenPositions.get(random.nextInt(seenPositions.size()));
    }
}

游戏引擎与演示

package com.memory;

/**
 * 记忆翻牌游戏引擎与演示
 */
public class Main {
    public static void main(String[] args) {
        // 8对牌(16张)
        int pairCount = 8;
        GameState state = new GameState(pairCount);
        MemoryAI ai = new MemoryAI();

        System.out.println("=== 记忆翻牌 AI 演示 ===\n");
        System.out.println("牌面图案映射(仅供演示):");
        for (int i = 0; i < state.totalCards; i++) {
            System.out.printf("位置%2d: 图案%d  ", i, state.cards[i].pattern);
            if ((i + 1) % 4 == 0) System.out.println();
        }
        System.out.println();

        // AI自动游戏
        while (!state.isGameOver()) {
            int[] move = ai.selectMove(state);
            int pos1 = move[0];
            int pos2 = move[1];

            boolean matched = state.flipPair(pos1, pos2);
            int p1 = state.cards[pos1].pattern;
            int p2 = state.cards[pos2].pattern;

            System.out.printf("第%2d轮: 翻开位置%d(图案%d)和位置%d(图案%d) -> %s\n",
                state.turnsTaken, pos1, p1, pos2, p2,
                matched ? "配对成功!" : "不匹配");
        }

        System.out.println("\n游戏结束!");
        System.out.println("总轮数:" + state.turnsTaken);
        System.out.println("理论最优(完美记忆):" + pairCount + "轮");
        System.out.println("效率:" + String.format("%.1f", (double)state.turnsTaken / pairCount * 100) + "%");
    }
}

理论分析:最优策略下界

信息论视角

记忆翻牌可视为一个信息获取过程。每翻开一张新牌,获得的信息量取决于该牌图案的先验不确定性。

  • 初始时有 $N$ 对牌,$(2N)! / (2^N)$ 种可能的排列
  • 每次翻开一张未知牌,约获得 $\log_2(2N) – \log_2(2) = \log_2(N)$ 比特信息
  • 确定性地找到一对需要消除该对的不确定性

最优轮数分析

对于完美记忆的玩家:
– 最坏情况:$2N – 1$ 轮(每次只找到一对)
– 平均情况:约 $N \log N$ 轮量级(信息论下界)

对于概率推理AI(非完美记忆):
– 性能取决于探索-利用的平衡
– 当未知牌较多时,随机匹配成功率约为 $1/(2N-1)$
– 随着信息积累,成功率逐渐提升

扩展与变体

1. 多玩家竞争模式

引入玩家轮次和得分机制,AI需要同时考虑阻碍对手获取信息。

2. 带惩罚的错误匹配

错误配对有代价时,AI需要更保守的策略,提高匹配概率阈值。

3. 不完美记忆模型

模拟人类记忆的遗忘曲线,为每张已见牌分配”记忆强度”,随时间衰减。

4. 更大牌组与多副牌

使用更复杂的概率模型处理多种图案和不同数量的重复。

总结

记忆翻牌揭示了信息在决策中的核心作用。从全信息(完美记忆)到部分信息(概率推理),不同信息条件下的最优策略截然不同。这种信息-决策框架在搜索算法、机器学习、经济学博弈等领域具有普适性价值。