每日算法 — 使用java实现FreeCell:A*搜索与空位约束传播

一、FreeCell游戏规则与算法目标

FreeCell(空当接龙)是经典的纸牌益智游戏。一副牌52张随机分8列,右上角4个空位为”Home Cell”(按花色从A到K顺序收纳),左上角4个空位为”Free Cell”(临时存放单张牌)。玩家可将牌在列之间移动(必须按交替颜色、降序排列),或放入Free Cell/Home Cell。

本文目标是实现一个自动求解器,核心算法为:
– A*搜索:在巨大的状态空间中寻找最短解路径
– 空位约束传播:利用Free Cell数量限制进行剪枝
– 死锁检测:提前识别无法推进的死局状态

二、状态表示与哈希编码

FreeCell状态包含:8列牌堆、4个Free Cell、4个Home Cell。为支持A*的closed set去重,需要高效的状态哈希。

import java.util.*;

/**
 * FreeCell游戏状态
 * 使用字符串编码实现快速哈希与去重
 */
public class FreeCellState implements Comparable<FreeCellState> {
    // 8列牌堆,每列是一个栈(底到顶)
    List<Deque<Card>> columns;
    // 4个FreeCell,null表示空
    Card[] freeCells;
    // 4个HomeCell,记录当前已放置的最大牌面(0表示空)
    int[] homeCells; // 0~13,分别对应4种花色

    // A*搜索所需字段
    int g; // 已走步数
    int h; // 启发式估计值
    FreeCellState parent; // 父状态,用于路径回溯
    String move; // 到达此状态的移动描述

    public FreeCellState() {
        columns = new ArrayList<>(8);
        for (int i = 0; i < 8; i++) columns.add(new ArrayDeque<>());
        freeCells = new Card[4];
        homeCells = new int[4];
        g = 0;
        h = 0;
    }

    /**
     * 核心:状态编码为唯一字符串,用于HashSet去重
     * 格式:FreeCell|HomeCell|每列牌序列
     */
    public String encode() {
        StringBuilder sb = new StringBuilder();
        // FreeCell编码(按花色排序保证唯一性)
        Card[] sortedFree = Arrays.copyOf(freeCells, 4);
        Arrays.sort(sortedFree, Comparator.nullsLast(Comparator.comparingInt(c -> c.id)));
        for (Card c : sortedFree) sb.append(c == null ? "0" : c.id).append(',');
        sb.append('|');
        // HomeCell编码
        for (int h : homeCells) sb.append(h).append(',');
        sb.append('|');
        // 列编码:每列从底到顶的牌ID序列
        for (Deque<Card> col : columns) {
            for (Card c : col) sb.append(c.id).append(',');
            sb.append(';');
        }
        return sb.toString();
    }

    @Override
    public int compareTo(FreeCellState o) {
        return Integer.compare(this.g + this.h, o.g + o.h);
    }

    @Override
    public int hashCode() {
        return encode().hashCode();
    }

    @Override
    public boolean equals(Object obj) {
        if (!(obj instanceof FreeCellState)) return false;
        return encode().equals(((FreeCellState) obj).encode());
    }
}

/**
 * 纸牌定义:13点数 × 4花色 = 52张牌
 * id范围1~52,便于编码
 */
class Card {
    int id;       // 1~52
    int rank;     // 1(A)~13(K)
    int suit;     // 0=♠,1=♥,2=♣,3=♦
    boolean red;  // 红色为true(♥♦)

    public Card(int id) {
        this.id = id;
        this.rank = (id - 1) % 13 + 1;
        this.suit = (id - 1) / 13;
        this.red = (suit == 1 || suit == 3);
    }

    @Override
    public String toString() {
        String[] ranks = {"A","2","3","4","5","6","7","8","9","10","J","Q","K"};
        String[] suits = {"♠","♥","♣","♦"};
        return ranks[rank-1] + suits[suit];
    }
}

三、A*搜索与启发式函数

FreeCell求解本质是状态空间搜索。A*算法需要设计一个可采纳的启发式函数(admissible heuristic),即永远不高估实际代价。

public class FreeCellSolver {

    /**
     * 启发式函数 h(state)
     * 组合多个可采纳启发式,取最大值保持可采纳性
     */
    int heuristic(FreeCellState s) {
        int h1 = cardsNotInHome(s);      // 未归位牌数(最松上界)
        int h2 = blockedCards(s);        // 被压住无法移动的牌数
        int h3 = sequenceBreaks(s);      // 列内序列断裂数
        return Math.max(Math.max(h1, h2), h3);
    }

    /** 统计尚未放入HomeCell的牌数 */
    int cardsNotInHome(FreeCellState s) {
        int count = 0;
        for (int h : s.homeCells) count += h;
        return 52 - count;
    }

    /**
     * 统计被"锁住"的牌:上方有牌且无法直接放入HomeCell的牌
     * 一张牌被锁住当且仅当:
     * 1. 它在某列中且上方有牌,或
     * 2. 它在FreeCell中但其rank不是当前HomeCell+1
     */
    int blockedCards(FreeCellState s) {
        int blocked = 0;
        // 列中被压住的牌
        for (Deque<Card> col : s.columns) {
            boolean above = false;
            for (Card c : col) {
                if (above && !canMoveToHome(s, c)) blocked++;
                above = true;
            }
        }
        // FreeCell中无法直接归位的牌
        for (Card c : s.freeCells) {
            if (c != null && !canMoveToHome(s, c)) blocked++;
        }
        return blocked;
    }

    /** 判断一张牌是否可以直接放入HomeCell */
    boolean canMoveToHome(FreeCellState s, Card c) {
        return s.homeCells[c.suit] == c.rank - 1;
    }

    /**
     * 列内序列断裂数:连续递减交替序列越长,越容易移动
     * 断裂点 = 颜色相同 或 非连续递减的位置数
     */
    int sequenceBreaks(FreeCellState s) {
        int breaks = 0;
        for (Deque<Card> col : s.columns) {
            Card prev = null;
            for (Card c : col) {
                if (prev != null) {
                    if (prev.red == c.red || prev.rank != c.rank + 1) {
                        breaks++;
                    }
                }
                prev = c;
            }
        }
        return breaks;
    }

    /**
     * A*搜索主算法
     */
    public List<String> solve(FreeCellState initial) {
        PriorityQueue<FreeCellState> open = new PriorityQueue<>();
        Set<String> closed = new HashSet<>();

        initial.h = heuristic(initial);
        open.add(initial);
        closed.add(initial.encode());

        int explored = 0;
        while (!open.isEmpty()) {
            FreeCellState cur = open.poll();
            explored++;

            // 终止条件:所有牌归位
            if (isGoal(cur)) {
                return reconstructPath(cur);
            }

            // 生成所有合法移动
            for (FreeCellState next : generateMoves(cur)) {
                String enc = next.encode();
                if (closed.contains(enc)) continue;

                next.h = heuristic(next);
                next.g = cur.g + 1;
                next.parent = cur;

                // 死锁检测剪枝
                if (isDeadlock(next)) continue;

                open.add(next);
                closed.add(enc);
            }

            // 搜索深度限制,防止无限搜索
            if (explored > 5_000_000) {
                System.out.println("Search limit exceeded, no solution found within bound.");
                return null;
            }
        }
        return null; // 无解
    }

    boolean isGoal(FreeCellState s) {
        for (int h : s.homeCells) if (h != 13) return false;
        return true;
    }

    List<String> reconstructPath(FreeCellState goal) {
        List<String> path = new ArrayList<>();
        FreeCellState cur = goal;
        while (cur != null && cur.move != null) {
            path.add(cur.move);
            cur = cur.parent;
        }
        Collections.reverse(path);
        return path;
    }
}

四、移动生成与约束传播

移动生成是A*的性能瓶颈。需要高效枚举所有合法移动,并利用超级移动(supermove)将多个步骤压缩为一次状态转移。

public class FreeCellSolver {

    /**
     * 生成所有合法后继状态
     * 包含:单牌移动、序列移动、自动归位
     */
    List<FreeCellState> generateMoves(FreeCellState s) {
        List<FreeCellState> moves = new ArrayList<>();

        // 1. 自动归位:任何可以直接放入HomeCell的牌立即移动
        // 这是FreeCell的确定性规则,减少分支因子
        boolean moved = true;
        while (moved) {
            moved = false;
            // 检查列顶牌
            for (int i = 0; i < 8; i++) {
                Deque<Card> col = s.columns.get(i);
                if (col.isEmpty()) continue;
                Card top = col.peekLast();
                if (canMoveToHome(s, top)) {
                    s = cloneAndMoveToHome(s, i, -1); // -1表示来自列
                    moved = true;
                    break;
                }
            }
            // 检查FreeCell
            if (!moved) {
                for (int i = 0; i < 4; i++) {
                    Card c = s.freeCells[i];
                    if (c != null && canMoveToHome(s, c)) {
                        s = cloneAndMoveToHome(s, -1, i);
                        moved = true;
                        break;
                    }
                }
            }
        }

        // 2. 列顶牌 → FreeCell
        for (int i = 0; i < 8; i++) {
            if (s.columns.get(i).isEmpty()) continue;
            for (int j = 0; j < 4; j++) {
                if (s.freeCells[j] == null) {
                    moves.add(cloneAndMoveToFreeCell(s, i, j));
                }
            }
        }

        // 3. 列顶牌 → 其他列(可移动整个合法序列)
        for (int from = 0; from < 8; from++) {
            Deque<Card> src = s.columns.get(from);
            if (src.isEmpty()) continue;

            // 计算从该列可移动的最大序列长度
            int maxMove = maxMovableSequence(s);
            List<Card> seq = getTopSequence(src, maxMove);

            for (int to = 0; to < 8; to++) {
                if (from == to) continue;
                Deque<Card> dst = s.columns.get(to);
                Card dstTop = dst.isEmpty() ? null : dst.peekLast();

                // 尝试移动长度为1~seq.size()的序列
                for (int len = 1; len <= seq.size(); len++) {
                    Card moveCard = seq.get(len - 1);
                    if (canPlaceOn(moveCard, dstTop)) {
                        moves.add(cloneAndMoveSequence(s, from, to, len));
                        break; //  longer sequences handled by recursive supermoves
                    }
                }
            }
        }

        // 4. FreeCell → 列
        for (int i = 0; i < 4; i++) {
            Card c = s.freeCells[i];
            if (c == null) continue;
            for (int to = 0; to < 8; to++) {
                Deque<Card> dst = s.columns.get(to);
                Card dstTop = dst.isEmpty() ? null : dst.peekLast();
                if (canPlaceOn(c, dstTop)) {
                    moves.add(cloneAndMoveFreeToCol(s, i, to));
                }
            }
        }

        return moves;
    }

    /**
     * 计算当前状态下可移动的最大序列长度
     * 公式:空FreeCell数 + 1 的 空列数 + 1 次方
     * 经典FreeCell理论:可移动序列长度 = (f+1) * 2^e,其中f=空FreeCell数,e=空列数
     */
    int maxMovableSequence(FreeCellState s) {
        int freeCellsEmpty = 0;
        for (Card c : s.freeCells) if (c == null) freeCellsEmpty++;
        int emptyCols = 0;
        for (Deque<Card> col : s.columns) if (col.isEmpty()) emptyCols++;

        // 保守估计:(freeCellsEmpty + 1) * (1 << emptyCols)
        return (freeCellsEmpty + 1) * (1 << emptyCols);
    }

    /** 获取列顶的可移动序列(最长连续交替降序) */
    List<Card> getTopSequence(Deque<Card> col, int maxLen) {
        List<Card> seq = new ArrayList<>();
        Card prev = null;
        for (Card c : col) {
            if (prev == null || (prev.red != c.red && prev.rank == c.rank + 1)) {
                seq.add(c);
                if (seq.size() >= maxLen) break;
            } else {
                break;
            }
            prev = c;
        }
        return seq;
    }

    boolean canPlaceOn(Card src, Card dst) {
        if (dst == null) return true; // 空列可放
        return src.red != dst.red && src.rank == dst.rank - 1;
    }

    // 以下为cloneAndMoveXxx辅助方法,实现状态拷贝与移动...
    // (为节省篇幅,此处省略具体实现,完整代码包含深拷贝与移动逻辑)
}

五、死锁检测

死锁检测能提前剪掉大量无效分支。常见死锁场景包括:

  1. 同色压牌:同一列中两张同花色、rank相邻的牌按错误顺序堆叠
  2. King陷阱:所有空列被King占据且无法引出
  3. FreeCell满载:4个FreeCell都被非HomeCell-ready牌占据,且列顶无法互相移动
public class FreeCellSolver {

    /**
     * 死锁检测:判断当前状态是否已进入死局
     */
    boolean isDeadlock(FreeCellState s) {
        // 检测1:FreeCell全满且列顶无法互相移动、无法归位
        int freeFull = 0;
        for (Card c : s.freeCells) if (c != null) freeFull++;
        if (freeFull == 4) {
            boolean anyMove = false;
            // 检查列顶牌是否可以归位或移动到别的列
            for (int i = 0; i < 8; i++) {
                Deque<Card> col = s.columns.get(i);
                if (col.isEmpty()) { anyMove = true; break; }
                Card top = col.peekLast();
                if (canMoveToHome(s, top)) { anyMove = true; break; }
                for (int j = 0; j < 8; j++) {
                    if (i == j) continue;
                    Deque<Card> dst = s.columns.get(j);
                    Card dstTop = dst.isEmpty() ? null : dst.peekLast();
                    if (canPlaceOn(top, dstTop)) { anyMove = true; break; }
                }
                if (anyMove) break;
            }
            if (!anyMove) return true; // 完全锁死
        }

        // 检测2:某列中低rank牌被同花色的高rank牌压住,且中间无交替颜色牌
        for (Deque<Card> col : s.columns) {
            List<Card> list = new ArrayList<>(col);
            for (int i = 0; i < list.size(); i++) {
                for (int j = i + 1; j < list.size(); j++) {
                    Card a = list.get(i), b = list.get(j);
                    if (a.suit == b.suit && a.rank > b.rank) {
                        // 检查i到j之间是否有合法序列允许b被移出
                        boolean validSequence = true;
                        for (int k = i + 1; k <= j; k++) {
                            Card prev = list.get(k - 1), cur = list.get(k);
                            if (prev.red == cur.red || prev.rank != cur.rank + 1) {
                                validSequence = false; break;
                            }
                        }
                        if (!validSequence) return true; // b被同色大rank牌锁住
                    }
                }
            }
        }

        return false;
    }
}

六、主程序与复杂度分析

public class FreeCellMain {
    public static void main(String[] args) {
        // 初始化一个随机牌局(Microsoft FreeCell #1为经典测试用例)
        FreeCellState initial = dealGame(1);

        FreeCellSolver solver = new FreeCellSolver();
        long start = System.currentTimeMillis();
        List<String> solution = solver.solve(initial);
        long time = System.currentTimeMillis() - start;

        if (solution != null) {
            System.out.println("Solution found in " + solution.size() + " moves!");
            System.out.println("Time: " + time + "ms");
            for (int i = 0; i < Math.min(solution.size(), 20); i++) {
                System.out.println((i+1) + ". " + solution.get(i));
            }
            if (solution.size() > 20) System.out.println("... (" + (solution.size()-20) + " more moves)");
        } else {
            System.out.println("No solution found.");
        }
    }

    /** 发牌:使用确定性的伪随机序列模拟经典FreeCell牌局 */
    static FreeCellState dealGame(int seed) {
        FreeCellState s = new FreeCellState();
        List<Card> deck = new ArrayList<>();
        for (int i = 1; i <= 52; i++) deck.add(new Card(i));

        // 线性同余发牌器(Microsoft FreeCell标准)
        long state = seed;
        for (int i = 51; i >= 0; i--) {
            state = (state * 214013 + 2531011) & 0xFFFFFFFFL;
            int j = (int)((state >> 16) % (i + 1));
            Collections.swap(deck, i, j);
        }

        // 8列发牌:前4列7张,后4列6张
        int idx = 0;
        for (int col = 0; col < 8; col++) {
            int count = (col < 4) ? 7 : 6;
            for (int i = 0; i < count; i++) {
                s.columns.get(col).addLast(deck.get(idx++));
            }
        }
        return s;
    }
}

复杂度分析:
– 状态空间:FreeCell约52!/(8!·4!·4!)量级,实际可达状态约10^20
– A*分支因子:平均约5~10(自动归位大幅降低分支)
– 时间复杂度:依赖启发式质量,实际通常在数秒内求解
– 空间复杂度:O(b^d),其中b为分支因子,d为解深度(典型解60~100步)

七、总结

本文从FreeCell的状态编码出发,实现了完整的A*自动求解器。核心收获:
1. 可采纳启发式的设计原则:组合多个下界取最大值
2. 超级移动压缩搜索深度:利用空位公式一次移动长序列
3. 约束传播与死锁检测:自动归位和死锁剪枝将分支因子降低一个数量级
4. 状态哈希:排序无关编码保证HashSet去重效率

FreeCell已被数学证明几乎所有牌局可解(约99.999%),是研究启发式搜索的经典案例。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注