每日算法 — 使用java实现骨牌覆盖:二分图匹配与棋盘完美覆盖判定

问题背景

骨牌覆盖(Domino Tiling)是一个经典的组合数学问题:给定一个 $m \times n$ 的棋盘,能否用 $1 \times 2$ 的多米诺骨牌将其完全覆盖,且骨牌之间不重叠、不越界?如果棋盘有缺失格子,又该如何判定?

这个问题表面上是几何铺砌问题,本质上可以转化为二分图匹配问题。每个格子看作图中的一个顶点,相邻格子之间连边,求一个完美匹配即对应一种合法的骨牌覆盖方案。

核心算法思路

棋盘染色与二分图建模

将棋盘按国际象棋规则进行黑白染色:坐标 $(i,j)$ 的格子颜色为 $(i+j) \bmod 2$。相邻格子颜色必然不同,因此棋盘天然构成一个二分图:
– 黑色格子构成左部顶点集
– 白色格子构成右部顶点集
– 相邻格子之间连一条边

一个 $1 \times 2$ 的骨牌恰好覆盖一黑一白两个相邻格子,因此完美覆盖等价于二分图的完美匹配。

匈牙利算法求解最大匹配

使用匈牙利算法(Hungarian Algorithm for Maximum Bipartite Matching)在二分图中寻找最大匹配。若最大匹配数等于总格子数的一半,则存在完美覆盖;否则不存在。

匈牙利算法的核心思想是:不断寻找增广路来扩大匹配。增广路是一条交替路径,其起点和终点都是未匹配点,路径上的边交替地属于非匹配边和匹配边。将增广路上的匹配状态翻转,即可使匹配数增加1。

Java 完整实现

import java.util.*;

/**
 * 骨牌覆盖问题求解器
 * 将棋盘转化为二分图,使用匈牙利算法求最大匹配
 */
public class DominoTiling {

    // 棋盘行数和列数
    private int rows;
    private int cols;
    // 标记缺失的格子,true表示该格子存在,false表示缺失
    private boolean[][] available;
    // 每个格子的编号,-1表示缺失
    private int[][] id;
    // 总格子数
    private int totalCells;
    // 左部顶点数量(黑色格子)
    private int leftCount;
    // 邻接表:左部顶点 -> 相邻的右部顶点列表
    private List<Integer>[] graph;
    // 右部顶点的匹配对象,-1表示未匹配
    private int[] match;
    // DFS访问标记
    private boolean[] visited;

    /**
     * 构造函数
     * @param rows 棋盘行数
     * @param cols 棋盘列数
     * @param missing 缺失格子的坐标列表,每个元素为 int[]{row, col}
     */
    public DominoTiling(int rows, int cols, List<int[]> missing) {
        this.rows = rows;
        this.cols = cols;
        this.available = new boolean[rows][cols];
        for (int i = 0; i < rows; i++) {
            Arrays.fill(available[i], true);
        }
        if (missing != null) {
            for (int[] pos : missing) {
                available[pos[0]][pos[1]] = false;
            }
        }
        buildGraph();
    }

    /**
     * 构建二分图
     */
    @SuppressWarnings("unchecked")
    private void buildGraph() {
        id = new int[rows][cols];
        for (int i = 0; i < rows; i++) {
            Arrays.fill(id[i], -1);
        }

        // 给黑色格子((i+j)%2==0)编号作为左部顶点
        leftCount = 0;
        int rightCount = 0;
        for (int i = 0; i < rows; i++) {
            for (int j = 0; j < cols; j++) {
                if (available[i][j]) {
                    if ((i + j) % 2 == 0) {
                        id[i][j] = leftCount++;
                    } else {
                        id[i][j] = rightCount++;
                    }
                }
            }
        }

        totalCells = leftCount + rightCount;
        graph = new ArrayList[leftCount];
        for (int i = 0; i < leftCount; i++) {
            graph[i] = new ArrayList<>();
        }

        // 四个方向:上、下、左、右
        int[] dx = {-1, 1, 0, 0};
        int[] dy = {0, 0, -1, 1};

        // 为每个黑色格子建立邻接关系
        for (int i = 0; i < rows; i++) {
            for (int j = 0; j < cols; j++) {
                if (!available[i][j] || (i + j) % 2 != 0) continue;
                int u = id[i][j];
                for (int d = 0; d < 4; d++) {
                    int ni = i + dx[d];
                    int nj = j + dy[d];
                    if (ni >= 0 && ni < rows && nj >= 0 && nj < cols && available[ni][nj]) {
                        int v = id[ni][nj];
                        graph[u].add(v);
                    }
                }
            }
        }
    }

    /**
     * 匈牙利算法:寻找增广路
     * @param u 当前左部顶点
     * @return 是否找到增广路
     */
    private boolean findAugmentingPath(int u) {
        for (int v : graph[u]) {
            if (visited[v]) continue;
            visited[v] = true;
            // 如果v未匹配,或者v的匹配点可以找到新的匹配
            if (match[v] == -1 || findAugmentingPath(match[v])) {
                match[v] = u;
                return true;
            }
        }
        return false;
    }

    /**
     * 求解最大匹配数
     * @return 最大匹配数
     */
    public int maxMatching() {
        match = new int[rightCount()];
        Arrays.fill(match, -1);
        int result = 0;
        for (int u = 0; u < leftCount; u++) {
            visited = new boolean[rightCount()];
            if (findAugmentingPath(u)) {
                result++;
            }
        }
        return result;
    }

    /**
     * 获取右部顶点数量
     */
    private int rightCount() {
        int count = 0;
        for (int i = 0; i < rows; i++) {
            for (int j = 0; j < cols; j++) {
                if (available[i][j] && (i + j) % 2 == 1) {
                    count++;
                }
            }
        }
        return count;
    }

    /**
     * 判断是否存在完美覆盖
     * @return true 如果存在完美覆盖
     */
    public boolean hasPerfectTiling() {
        // 首先,总格子数必须是偶数
        if (totalCells % 2 != 0) return false;
        // 黑色和白色格子数必须相等
        if (leftCount != rightCount()) return false;
        return maxMatching() == leftCount;
    }

    /**
     * 获取具体的覆盖方案
     * @return 每个黑色格子的配对坐标,int[]{blackRow, blackCol, whiteRow, whiteCol}
     */
    public List<int[]> getTilingSolution() {
        List<int[]> solution = new ArrayList<>();
        if (!hasPerfectTiling()) return solution;

        // match[v] = u 表示右部顶点v匹配了左部顶点u
        // 需要反向查找坐标
        int[][] leftPos = new int[leftCount][2];
        int[][] rightPos = new int[rightCount()][2];

        for (int i = 0; i < rows; i++) {
            for (int j = 0; j < cols; j++) {
                if (!available[i][j]) continue;
                int idx = id[i][j];
                if ((i + j) % 2 == 0) {
                    leftPos[idx] = new int[]{i, j};
                } else {
                    rightPos[idx] = new int[]{i, j};
                }
            }
        }

        for (int v = 0; v < match.length; v++) {
            if (match[v] != -1) {
                int u = match[v];
                solution.add(new int[]{
                    leftPos[u][0], leftPos[u][1],
                    rightPos[v][0], rightPos[v][1]
                });
            }
        }
        return solution;
    }

    /**
     * 打印覆盖方案的可视化结果
     */
    public void printSolution() {
        List<int[]> sol = getTilingSolution();
        if (sol.isEmpty()) {
            System.out.println("不存在完美覆盖方案");
            return;
        }

        // 为每个骨牌分配一个编号
        int[][] dominoId = new int[rows][cols];
        for (int i = 0; i < rows; i++) {
            Arrays.fill(dominoId[i], -1);
        }
        for (int k = 0; k < sol.size(); k++) {
            int[] d = sol.get(k);
            dominoId[d[0]][d[1]] = k;
            dominoId[d[2]][d[3]] = k;
        }

        System.out.println("骨牌覆盖方案(相同数字表示同一块骨牌):");
        for (int i = 0; i < rows; i++) {
            for (int j = 0; j < cols; j++) {
                if (!available[i][j]) {
                    System.out.print(" X ");
                } else {
                    System.out.printf("%-3d", dominoId[i][j]);
                }
            }
            System.out.println();
        }
    }

    // ==================== 主程序 ====================

    public static void main(String[] args) {
        // 示例1:4x4 完整棋盘,存在完美覆盖
        System.out.println("=== 示例1:4x4 完整棋盘 ===");
        DominoTiling tiling1 = new DominoTiling(4, 4, null);
        System.out.println("是否存在完美覆盖: " + tiling1.hasPerfectTiling());
        tiling1.printSolution();

        // 示例2:4x4 棋盘缺失对角两个格子,不存在完美覆盖
        System.out.println("\n=== 示例2:4x4 缺失(0,0)和(3,3) ===");
        List<int[]> missing2 = Arrays.asList(new int[]{0, 0}, new int[]{3, 3});
        DominoTiling tiling2 = new DominoTiling(4, 4, missing2);
        System.out.println("是否存在完美覆盖: " + tiling2.hasPerfectTiling());

        // 示例3:3x4 棋盘,存在完美覆盖
        System.out.println("\n=== 示例3:3x4 完整棋盘 ===");
        DominoTiling tiling3 = new DominoTiling(3, 4, null);
        System.out.println("是否存在完美覆盖: " + tiling3.hasPerfectTiling());
        tiling3.printSolution();

        // 示例4:经典问题 8x8 棋盘缺失对角,判定
        System.out.println("\n=== 示例4:8x8 缺失对角(0,0)和(7,7) ===");
        List<int[]> missing4 = Arrays.asList(new int[]{0, 0}, new int[]{7, 7});
        DominoTiling tiling4 = new DominoTiling(8, 8, missing4);
        System.out.println("是否存在完美覆盖: " + tiling4.hasPerfectTiling());
        System.out.println("(原因:缺失的两个格子同色,导致黑白数量不平衡)");
    }
}

算法复杂度分析

时间复杂度:匈牙利算法的时间复杂度为 $O(V \cdot E)$,其中 $V$ 是左部顶点数,$E$ 是边数。对于 $m \times n$ 的棋盘,$V \approx mn/2$,$E \approx 2mn$,因此总复杂度约为 $O(m^2 n^2)$。对于一般规模的棋盘(如 $8 \times 8$)可以瞬间完成。

空间复杂度:$O(mn)$,用于存储图的邻接表和匹配数组。

关键结论与扩展

  1. 必要条件:棋盘总格子数必须为偶数,且黑白格子数相等。缺失同色格子必然导致无法完美覆盖。
  2. 充分条件:对于矩形棋盘,上述必要条件也是充分的(可通过构造证明)。
  3. 扩展问题:可以进一步求解覆盖方案的总数,这需要使用转移矩阵法或轮廓线动态规划(Plug DP),时间复杂度更高。
  4. 实际应用:骨牌覆盖的模型在电路板设计、瓷砖铺设、蛋白质折叠等领域都有应用。

总结

本文将骨牌覆盖问题转化为二分图完美匹配问题,使用匈牙利算法进行求解。通过棋盘染色技巧,我们将几何问题巧妙地转化为图论问题,这是算法设计中”建模转换”思想的经典体现。掌握这种转换思维,能够帮助你在面对复杂问题时找到更简洁的解决路径。