每日算法 — 使用java实现多米诺骨牌:图论匹配与最长链搜索

多米诺骨牌是一种经典的双数字 tiles 游戏,每张骨牌由两个0-6的数字组成。游戏的核心玩法之一是”接龙”:将骨牌首尾相连形成最长的链,要求相邻骨牌接触端的数字相同。本文用 Java 实现多米诺骨牌的最长链求解系统,核心算法包括图论建模、欧拉路径判定、DFS 回溯搜索,以及动态规划优化。

一、问题建模:从骨牌到图

将每张骨牌 [a|b] 建模为图中的一条无向边,连接顶点 a 和 b。接龙问题转化为:
– 最长链问题:在多重图中寻找最长的路径(允许重复访问顶点,但不重复访问边)
– 完整环问题:判断是否存在欧拉回路(使用所有骨牌恰好一次的环)
– 完整链问题:判断是否存在欧拉路径(使用所有骨牌恰好一次的链)

对于 n 张骨牌,图有 7 个顶点(0-6),n 条边(允许重边和自环)。

二、核心算法

2.1 欧拉路径判定

无向图存在欧拉路径的充要条件:
– 图连通(忽略孤立点)
– 奇度顶点数为 0(欧拉回路)或 2(欧拉路径)

2.2 最长链搜索

当不存在欧拉路径时,需要寻找使用最多骨牌的路径。这是一个 NP-hard 问题(类似最长路径问题)。本文采用带剪枝的 DFS 回溯:
– 从每个顶点出发尝试所有未使用的边
– 记录当前最优解
– 剪枝:若剩余边数 + 当前链长 ≤ 最优解,则回溯

2.3 动态规划优化(小规模)

对于特定结构(如链状、树状),可用状态压缩 DP,但骨牌数量大时状态空间爆炸。

三、Java 完整实现

import java.util.*;

/**
 * 多米诺骨牌最长链求解器
 * 核心算法:图论建模 + DFS回溯 + 欧拉路径判定
 */
public class DominoSolver {

    public static class Domino {
        public final int a, b;
        public final int id;

        public Domino(int a, int b, int id) {
            this.a = Math.min(a, b);
            this.b = Math.max(a, b);
            this.id = id;
        }

        public boolean hasNumber(int n) {
            return a == n || b == n;
        }

        public int otherEnd(int n) {
            return a == n ? b : a;
        }

        @Override
        public String toString() {
            return String.format("[%d|%d]", a, b);
        }
    }

    private List<Domino> dominoes;
    private int n;
    private List<int[]> adj; // adj[v] = list of [neighbor, edgeId]
    private boolean[] used;
    private int bestLength;
    private List<Domino> bestChain;
    private List<Domino> currentChain;

    public DominoSolver(List<Domino> dominoes) {
        this.dominoes = dominoes;
        this.n = dominoes.size();
        this.adj = new ArrayList<>();
        for (int i = 0; i < 7; i++) adj.add(new ArrayList<>());

        for (Domino d : dominoes) {
            adj.get(d.a).add(new int[]{d.b, d.id});
            if (d.a != d.b) {
                adj.get(d.b).add(new int[]{d.a, d.id});
            }
        }

        this.used = new boolean[n];
        this.currentChain = new ArrayList<>();
    }

    /**
     * 欧拉路径判定:返回起始顶点,若不存在返回 -1
     */
    public int eulerPathStart() {
        int[] degree = new int[7];
        for (Domino d : dominoes) {
            degree[d.a]++;
            if (d.a != d.b) degree[d.b]++;
        }

        // 检查连通性
        boolean[] visited = new boolean[7];
        int startVertex = -1;
        for (int i = 0; i < 7; i++) {
            if (degree[i] > 0) {
                startVertex = i;
                break;
            }
        }
        if (startVertex == -1) return 0; // 空图

        dfsConnectivity(startVertex, visited);
        for (int i = 0; i < 7; i++) {
            if (degree[i] > 0 && !visited[i]) return -1; // 不连通
        }

        // 统计奇度顶点
        int oddCount = 0;
        int oddVertex = startVertex;
        for (int i = 0; i < 7; i++) {
            if (degree[i] % 2 == 1) {
                oddCount++;
                oddVertex = i;
            }
        }

        if (oddCount == 0 || oddCount == 2) {
            return oddVertex;
        }
        return -1;
    }

    private void dfsConnectivity(int v, boolean[] visited) {
        visited[v] = true;
        for (int[] edge : adj.get(v)) {
            int u = edge[0];
            if (!visited[u]) dfsConnectivity(u, visited);
        }
    }

    /**
     * Hierholzer 算法求欧拉路径
     */
    public List<Domino> findEulerPath(int start) {
        List<Domino> path = new ArrayList<>();
        Stack<Integer> stack = new Stack<>();
        Stack<Integer> edgeStack = new Stack<>();
        boolean[] localUsed = new boolean[n];

        stack.push(start);

        while (!stack.isEmpty()) {
            int v = stack.peek();
            boolean found = false;

            for (int[] edge : adj.get(v)) {
                int u = edge[0];
                int eid = edge[1];
                if (!localUsed[eid]) {
                    localUsed[eid] = true;
                    edgeStack.push(eid);
                    stack.push(u);
                    found = true;
                    break;
                }
            }

            if (!found) {
                stack.pop();
                if (!edgeStack.isEmpty()) {
                    int eid = edgeStack.pop();
                    path.add(dominoes.get(eid));
                }
            }
        }

        Collections.reverse(path);
        return path;
    }

    /**
     * DFS 回溯搜索最长链(带剪枝)
     */
    public void findLongestChain() {
        bestLength = 0;
        bestChain = new ArrayList<>();

        // 尝试从每个顶点出发
        for (int start = 0; start < 7; start++) {
            if (adj.get(start).isEmpty()) continue;
            Arrays.fill(used, false);
            currentChain.clear();
            dfsLongest(start);
        }
    }

    private void dfsLongest(int currentVertex) {
        // 剪枝:剩余可能的最大长度
        int remaining = 0;
        for (boolean u : used) if (!u) remaining++;
        if (currentChain.size() + remaining <= bestLength) return;

        // 更新最优解
        if (currentChain.size() > bestLength) {
            bestLength = currentChain.size();
            bestChain = new ArrayList<>(currentChain);
        }

        // 尝试所有未使用的邻接边
        for (int[] edge : adj.get(currentVertex)) {
            int nextVertex = edge[0];
            int eid = edge[1];

            if (used[eid]) continue;

            used[eid] = true;
            currentChain.add(dominoes.get(eid));

            dfsLongest(nextVertex);

            currentChain.remove(currentChain.size() - 1);
            used[eid] = false;
        }
    }

    /**
     * 打印骨牌链
     */
    public void printChain(List<Domino> chain) {
        if (chain.isEmpty()) {
            System.out.println("空链");
            return;
        }

        StringBuilder sb = new StringBuilder();
        int current = chain.get(0).a;
        for (Domino d : chain) {
            if (d.a == current) {
                sb.append(d);
                current = d.b;
            } else {
                sb.append("[").append(d.b).append("|").append(d.a).append("]");
                current = d.a;
            }
            sb.append(" -> ");
        }
        sb.setLength(sb.length() - 4);
        System.out.println(sb.toString());
        System.out.println("长度: " + chain.size() + " 张骨牌");
    }

    /**
     * 生成随机骨牌集合
     */
    public static List<Domino> generateRandom(int count) {
        List<Domino> list = new ArrayList<>();
        Random rand = new Random();
        for (int i = 0; i < count; i++) {
            int a = rand.nextInt(7);
            int b = rand.nextInt(7);
            list.add(new Domino(a, b, i));
        }
        return list;
    }

    /**
     * 生成标准双六骨牌全集合(28张)
     */
    public static List<Domino> generateFullSet() {
        List<Domino> list = new ArrayList<>();
        int id = 0;
        for (int i = 0; i <= 6; i++) {
            for (int j = i; j <= 6; j++) {
                list.add(new Domino(i, j, id++));
            }
        }
        return list;
    }

    public static void main(String[] args) {
        // 测试1:标准28张骨牌集合
        System.out.println("=== 测试1:标准双六骨牌集合 ===");
        List<Domino> fullSet = generateFullSet();
        DominoSolver solver1 = new DominoSolver(fullSet);

        int eulerStart = solver1.eulerPathStart();
        if (eulerStart != -1) {
            System.out.println("存在欧拉路径,起点: " + eulerStart);
            List<Domino> eulerPath = solver1.findEulerPath(eulerStart);
            solver1.printChain(eulerPath);
        } else {
            System.out.println("不存在欧拉路径,搜索最长链...");
            solver1.findLongestChain();
            solver1.printChain(solver1.bestChain);
        }

        // 测试2:随机骨牌集合
        System.out.println("\n=== 测试2:随机15张骨牌 ===");
        List<Domino> randomSet = generateRandom(15);
        System.out.println("骨牌列表: " + randomSet);
        DominoSolver solver2 = new DominoSolver(randomSet);

        eulerStart = solver2.eulerPathStart();
        if (eulerStart != -1) {
            System.out.println("存在欧拉路径");
            List<Domino> eulerPath = solver2.findEulerPath(eulerStart);
            solver2.printChain(eulerPath);
        } else {
            System.out.println("搜索最长链...");
            solver2.findLongestChain();
            solver2.printChain(solver2.bestChain);
        }

        // 测试3:小规模精确解 vs 欧拉路径
        System.out.println("\n=== 测试3:存在欧拉回路的构造数据 ===");
        List<Domino> cycleSet = Arrays.asList(
            new Domino(0, 1, 0), new Domino(1, 2, 1),
            new Domino(2, 3, 2), new Domino(3, 0, 3)
        );
        DominoSolver solver3 = new DominoSolver(cycleSet);
        eulerStart = solver3.eulerPathStart();
        System.out.println("欧拉起点: " + eulerStart);
        List<Domino> path = solver3.findEulerPath(eulerStart);
        solver3.printChain(path);
    }
}

四、复杂度分析

算法 时间复杂度 空间复杂度 适用场景
欧拉路径判定 O(V + E) O(V + E) 快速判定是否存在完整链
Hierholzer O(E) O(E) 求欧拉路径(存在时)
DFS回溯 O(E!) 最坏 O(E) 最长链(精确解)
带剪枝DFS O(α^E), α < E O(E) 最长链(实践中大幅优化)

其中 V=7(固定),E 为骨牌数。由于顶点数极少,DFS 剪枝效果极佳,对于 E ≤ 30 可秒级求解。

五、算法优化技巧

  1. 重边优先:在 DFS 中优先尝试连接更多未使用边的顶点,增加找到长链的概率
  2. 双向搜索:从两端同时扩展链,在中间汇合
  3. 度数启发:优先从奇度顶点出发(若存在),因为欧拉路径必然从奇度顶点开始
  4. 连通分量分解:若图不连通,在各连通分量分别求最长链后拼接

六、扩展应用

多米诺骨牌的图论建模思想可推广至:
– DNA 序列拼接:将 reads 建模为 de Bruijn 图的边,寻找覆盖所有 reads 的最短超串
– 旅行商问题(TSP)变体:在特定图结构下的路径优化
– 电路板走线:寻找连接所有焊点的最短路径
– 笔画问题:判断汉字能否一笔写成(欧拉路径应用)