每日算法 — 使用java实现连连看:网络流最大匹配与最小割消除策略

引言

连连看是一款广受欢迎的消除类益智游戏:玩家需要在一个布满图案的棋盘上,找出两个相同图案且能用不超过两个转弯的折线连接的方块,将它们消除。当棋盘上的图案成对且精心排布时,如何判断当前局面是否存在合法消除序列?如何在多步消除中避免陷入”死局”?这本质上是一个图匹配问题。本文用Java实现连连看的求解与判定算法,核心采用网络流最大匹配(Max-Flow)与最小割(Min-Cut)理论,将消除问题转化为二分图上的匹配问题,并通过残量网络分析判定死局。

问题建模

连连看规则的图论抽象

将棋盘上的每个图案视为图中的一个节点。对于当前局面上每一对相同图案,若它们之间存在合法路径(连线转角 ≤ 2),则在图中添加一条边。连连看的消除过程等价于:
– 选择一条边,移除其两个端点(消除一对图案)
– 重复上述过程,直到图中无边可选或所有节点被移除

转化为二分图匹配

将棋盘格子按坐标奇偶性分为两组(类似国际象棋棋盘的黑格与白格):
– 左部(L):(row + col) % 2 == 0 的格子
– 右部(R):(row + col) % 2 == 1 的格子

任何合法消除连接的两个格子必然分属不同部(因为每步移动改变行列和的奇偶性),因此消除边构成一个二分图。问题转化为:在该二分图中寻找一个最大匹配,使得消除的图案对数最多。

最小割与死局判定

根据Konig定理,二分图最大匹配的边数等于最小点覆盖的大小。而最小点覆盖与最大独立集互补。若最大匹配的边数 × 2 < 剩余图案总数,则存在无法被匹配的孤立图案,当前局面必然为死局。

更进一步,通过最小割(Minimum Cut)分析残量网络,可以精确定位哪些图案是导致死局的”瓶颈”节点。

核心算法:Dinic最大流求解二分图匹配

算法思路

  1. 建图:
  2. 源点 S 连接所有左部节点,容量为1
  3. 所有右部节点连接汇点 T,容量为1
  4. 每对可消除的相同图案之间建立有向边(左→右),容量为1
  5. Dinic算法:
  6. BFS分层:构建从源点到汇点的层次图
  7. DFS增广:在层次图上寻找阻塞流
  8. 重复直到无法增广
  9. 输出匹配:残量网络中容量为0的边即为匹配边
  10. 最小割恢复:从源点出发在残量网络中DFS/BFS,能到达的节点构成 S 侧,其余为 T 侧,跨割边即为最小割

复杂度分析

  • Dinic在二分图匹配上的时间复杂度为 O(E × √V),其中 V 为节点数,E 为边数
  • 对于 m × n 的棋盘,V = O(mn),E = O(mn),整体效率为 O((mn)^1.5)
  • 空间复杂度:O(V + E)

Java完整实现

import java.util.*;

/**
 * 连连看:网络流最大匹配与最小割消除策略
 * 
 * 核心思路:
 * 1. 将棋盘按坐标奇偶性分为二分图左右两部
 * 2. 对每对相同且可连通的图案建立二分图边
 * 3. 使用Dinic最大流算法求解最大匹配
 * 4. 通过残量网络的最小割定位死局瓶颈节点
 * 5. 提供路径搜索(BFS)验证两图案间是否可连通消除
 */
public class LinkGameMaxFlow {

    static final int[] DX = {-1, 1, 0, 0};
    static final int[] DY = {0, 0, -1, 1};
    static final int EMPTY = 0; // 空白格标记

    // ============ Dinic算法数据结构 ============
    static class Edge {
        int to, rev, cap;
        public Edge(int to, int rev, int cap) {
            this.to = to;
            this.rev = rev;
            this.cap = cap;
        }
    }

    static class Dinic {
        int N;
        List<List<Edge>> G;
        int[] level, iter;

        public Dinic(int N) {
            this.N = N;
            this.G = new ArrayList<>();
            for (int i = 0; i < N; i++) G.add(new ArrayList<>());
            this.level = new int[N];
            this.iter = new int[N];
        }

        /** 添加有向边 */
        public void addEdge(int from, int to, int cap) {
            G.get(from).add(new Edge(to, G.get(to).size(), cap));
            G.get(to).add(new Edge(from, G.get(from).size() - 1, 0)); // 反向边
        }

        /** BFS构建层次图 */
        public boolean bfs(int s, int t) {
            Arrays.fill(level, -1);
            Queue<Integer> q = new LinkedList<>();
            level[s] = 0;
            q.offer(s);
            while (!q.isEmpty()) {
                int v = q.poll();
                for (Edge e : G.get(v)) {
                    if (e.cap > 0 && level[e.to] < 0) {
                        level[e.to] = level[v] + 1;
                        q.offer(e.to);
                    }
                }
            }
            return level[t] >= 0;
        }

        /** DFS在层次图上寻找增广路 */
        public int dfs(int v, int t, int f) {
            if (v == t) return f;
            for (int i = iter[v]; i < G.get(v).size(); i++) {
                Edge e = G.get(v).get(i);
                if (e.cap > 0 && level[v] < level[e.to]) {
                    int d = dfs(e.to, t, Math.min(f, e.cap));
                    if (d > 0) {
                        e.cap -= d;
                        G.get(e.to).get(e.rev).cap += d;
                        return d;
                    }
                }
                iter[v]++;
            }
            return 0;
        }

        /** 求解最大流 */
        public int maxFlow(int s, int t) {
            int flow = 0;
            final int INF = Integer.MAX_VALUE;
            while (bfs(s, t)) {
                Arrays.fill(iter, 0);
                int f;
                while ((f = dfs(s, t, INF)) > 0) {
                    flow += f;
                }
            }
            return flow;
        }

        /** 获取最小割的S侧节点(从源点在残量网络上BFS) */
        public boolean[] minCutS(int s) {
            boolean[] vis = new boolean[N];
            Queue<Integer> q = new LinkedList<>();
            vis[s] = true;
            q.offer(s);
            while (!q.isEmpty()) {
                int v = q.poll();
                for (Edge e : G.get(v)) {
                    if (e.cap > 0 && !vis[e.to]) {
                        vis[e.to] = true;
                        q.offer(e.to);
                    }
                }
            }
            return vis;
        }
    }

    // ============ 连连看游戏类 ============
    private int rows, cols;
    private int[][] board;        // 棋盘图案编号,0表示空白
    private int patternTypes;     // 图案种类数

    public LinkGameMaxFlow(int rows, int cols, int patternTypes) {
        this.rows = rows;
        this.cols = cols;
        this.patternTypes = patternTypes;
        this.board = new int[rows][cols];
    }

    public void setBoard(int[][] b) {
        for (int i = 0; i < rows; i++) {
            System.arraycopy(b[i], 0, board[i], 0, cols);
        }
    }

    public int[][] getBoard() { return board; }

    /** 将二维坐标映射为一维ID */
    private int nodeId(int r, int c) {
        return r * cols + c;
    }

    /**
     * BFS判断两个格子是否可通过不超过2个转弯的路径连接
     * 连连看规则:路径只能经过空白格(0),且转角次数≤2
     */
    public boolean canConnect(int r1, int c1, int r2, int c2) {
        if (board[r1][c1] != board[r2][c2] || board[r1][c1] == EMPTY) {
            return false;
        }
        if (r1 == r2 && c1 == c2) return false;

        // BFS状态: (r, c, direction, turns)
        // direction: 0=上,1=下,2=左,3=右,4=起始
        boolean[][][][] vis = new boolean[rows][cols][5][3]; // turns最多2
        ArrayDeque<int[]> q = new ArrayDeque<>();
        q.offer(new int[]{r1, c1, 4, 0}); // 起点方向为4(无方向),转角0
        vis[r1][c1][4][0] = true;

        while (!q.isEmpty()) {
            int[] cur = q.poll();
            int r = cur[0], c = cur[1], dir = cur[2], turns = cur[3];

            for (int d = 0; d < 4; d++) {
                int nr = r + DX[d], nc = c + DY[d];
                int nTurns = turns;
                if (dir != 4 && dir != d) nTurns++; // 转弯
                if (nTurns > 2) continue;

                while (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
                    if (nr == r2 && nc == c2) {
                        return true; // 到达目标
                    }
                    if (board[nr][nc] != EMPTY) break; // 遇到非空白格阻挡
                    if (!vis[nr][nc][d][nTurns]) {
                        vis[nr][nc][d][nTurns] = true;
                        q.offer(new int[]{nr, nc, d, nTurns});
                    }
                    nr += DX[d];
                    nc += DY[d];
                }
            }
        }
        return false;
    }

    /**
     * 构建二分图并求解最大匹配
     * @return 包含最大匹配数、匹配对列表、是否可完全消除、死局节点的结果
     */
    public MatchResult solveMaxMatching() {
        // 统计非空节点并分配左右部编号
        int totalNodes = rows * cols;
        int[] leftId = new int[totalNodes];  // 原始节点ID -> 左部局部ID(-1表示不在左部)
        int[] rightId = new int[totalNodes]; // 原始节点ID -> 右部局部ID(-1表示不在右部)
        Arrays.fill(leftId, -1);
        Arrays.fill(rightId, -1);

        List<int[]> leftNodes = new ArrayList<>();  // (r, c)
        List<int[]> rightNodes = new ArrayList<>(); // (r, c)

        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                if (board[r][c] == EMPTY) continue;
                int id = nodeId(r, c);
                if ((r + c) % 2 == 0) {
                    leftId[id] = leftNodes.size();
                    leftNodes.add(new int[]{r, c});
                } else {
                    rightId[id] = rightNodes.size();
                    rightNodes.add(new int[]{r, c});
                }
            }
        }

        int L = leftNodes.size();
        int R = rightNodes.size();
        int S = L + R;
        int T = S + 1;
        Dinic dinic = new Dinic(T + 1);

        // 源点->左部
        for (int i = 0; i < L; i++) {
            dinic.addEdge(S, i, 1);
        }
        // 右部->汇点
        for (int i = 0; i < R; i++) {
            dinic.addEdge(L + i, T, 1);
        }
        // 左部->右部的可消除边
        for (int i = 0; i < L; i++) {
            int[] a = leftNodes.get(i);
            for (int j = 0; j < R; j++) {
                int[] b = rightNodes.get(j);
                if (board[a[0]][a[1]] == board[b[0]][b[1]] && canConnect(a[0], a[1], b[0], b[1])) {
                    dinic.addEdge(i, L + j, 1);
                }
            }
        }

        int maxMatch = dinic.maxFlow(S, T);

        // 恢复匹配对:检查左部到右部的边,cap为0表示被使用
        List<int[]> matches = new ArrayList<>();
        for (int i = 0; i < L; i++) {
            for (Dinic.Edge e : dinic.G.get(i)) {
                if (e.to >= L && e.to < L + R && e.cap == 0) {
                    int[] a = leftNodes.get(i);
                    int[] b = rightNodes.get(e.to - L);
                    matches.add(new int[]{nodeId(a[0], a[1]), nodeId(b[0], b[1])});
                }
            }
        }

        // 最小割定位死局节点
        boolean[] sSide = dinic.minCutS(S);
        List<int[]> bottleneck = new ArrayList<>();
        int totalPatterns = L + R;
        boolean solvable = (maxMatch * 2 == totalPatterns);

        if (!solvable) {
            // S侧中未匹配的左部节点 + T侧中未匹配的右部节点 = 瓶颈
            for (int i = 0; i < L; i++) {
                if (sSide[i]) { // 在S侧
                    boolean matched = false;
                    for (Dinic.Edge e : dinic.G.get(i)) {
                        if (e.to >= L && e.to < L + R && e.cap == 0) { matched = true; break; }
                    }
                    if (!matched) bottleneck.add(leftNodes.get(i));
                }
            }
            for (int j = 0; j < R; j++) {
                if (!sSide[L + j]) { // 在T侧
                    boolean matched = false;
                    for (Dinic.Edge e : dinic.G.get(L + j)) {
                        if (e.to == T && e.cap == 0) { matched = true; break; }
                    }
                    if (!matched) {
                        // 需要检查是否确实有连向S侧的边但容量为1(未匹配)
                        boolean hasUnsatEdge = false;
                        for (Dinic.Edge e : dinic.G.get(L + j)) {
                            if (e.to < L && e.cap == 1) { hasUnsatEdge = true; break; }
                        }
                        if (hasUnsatEdge) bottleneck.add(rightNodes.get(j));
                    }
                }
            }
        }

        return new MatchResult(maxMatch, matches, solvable, bottleneck, totalPatterns);
    }

    /** 执行一次消除:移除匹配对中的两个图案 */
    public void eliminate(int id1, int id2) {
        int r1 = id1 / cols, c1 = id1 % cols;
        int r2 = id2 / cols, c2 = id2 % cols;
        board[r1][c1] = EMPTY;
        board[r2][c2] = EMPTY;
    }

    /** 打印棋盘 */
    public void printBoard() {
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                if (board[r][c] == EMPTY) System.out.print("  . ");
                else System.out.printf("%3d ", board[r][c]);
            }
            System.out.println();
        }
    }

    // ============ 结果类 ============
    static class MatchResult {
        int maxMatch;              // 最大匹配数
        List<int[]> matches;       // 匹配对列表
        boolean fullySolvable;     // 是否能完全消除
        List<int[]> bottlenecks;   // 死局瓶颈节点
        int totalPatterns;         // 总图案数

        public MatchResult(int maxMatch, List<int[]> matches, boolean fullySolvable,
                           List<int[]> bottlenecks, int totalPatterns) {
            this.maxMatch = maxMatch;
            this.matches = matches;
            this.fullySolvable = fullySolvable;
            this.bottlenecks = bottlenecks;
            this.totalPatterns = totalPatterns;
        }

        @Override
        public String toString() {
            StringBuilder sb = new StringBuilder();
            sb.append("最大匹配数: ").append(maxMatch).append(" / ").append(totalPatterns / 2).append("\n");
            sb.append("可完全消除: ").append(fullySolvable).append("\n");
            sb.append("匹配对数: ").append(matches.size()).append("\n");
            if (!fullySolvable) {
                sb.append("死局瓶颈节点: ").append(bottlenecks.size()).append(" 个\n");
            }
            return sb.toString();
        }
    }

    // ============ 主程序 ============
    public static void main(String[] args) {
        // 6×6棋盘,使用6种图案
        int[][] initBoard = {
            {1, 2, 3, 4, 5, 6},
            {2, 1, 4, 3, 6, 5},
            {3, 4, 5, 6, 1, 2},
            {4, 3, 6, 5, 2, 1},
            {5, 6, 1, 2, 3, 4},
            {6, 5, 2, 1, 4, 3}
        };

        LinkGameMaxFlow game = new LinkGameMaxFlow(6, 6, 6);
        game.setBoard(initBoard);

        System.out.println("=== 连连看:网络流最大匹配与最小割 ===\n");
        System.out.println("初始棋盘:");
        game.printBoard();
        System.out.println();

        // 求解当前局面的最大匹配
        MatchResult result = game.solveMaxMatching();
        System.out.println(result);

        // 演示消除过程
        int step = 1;
        while (!result.fullySolvable && result.maxMatch > 0) {
            // 执行一个匹配对的消除
            int[] pair = result.matches.get(0);
            int r1 = pair[0] / 6, c1 = pair[0] % 6;
            int r2 = pair[1] / 6, c2 = pair[1] % 6;
            System.out.printf("步骤%d: 消除 (%d,%d)[%d] 与 (%d,%d)[%d]\n",
                step++, r1, c1, initBoard[r1][c1], r2, c2, initBoard[r2][c2]);
            game.eliminate(pair[0], pair[1]);

            result = game.solveMaxMatching();
            if (result.maxMatch == 0) break;
        }

        System.out.println("\n消除后剩余棋盘:");
        game.printBoard();
        System.out.println("\n最终状态: " + (result.fullySolvable ? "全部消除!" : "存在死局,无法继续消除。"));

        // 演示路径连通性检查
        System.out.println("\n【路径连通性测试】");
        int[][] testBoard = {
            {1, 0, 0, 0, 0, 1},
            {0, 0, 0, 0, 0, 0},
            {0, 0, 0, 0, 0, 0},
            {0, 0, 0, 0, 0, 0},
            {0, 0, 0, 0, 0, 0},
            {0, 0, 0, 0, 0, 0}
        };
        game.setBoard(testBoard);
        boolean connected = game.canConnect(0, 0, 0, 5);
        System.out.printf("(0,0)与(0,5)的1号图案可连通: %s (直线,0个转弯)\n", connected);

        int[][] testBoard2 = {
            {1, 0, 0, 0, 0, 0},
            {0, 0, 0, 0, 0, 0},
            {0, 0, 0, 0, 0, 0},
            {0, 0, 0, 0, 0, 0},
            {0, 0, 0, 0, 0, 0},
            {0, 0, 0, 0, 0, 1}
        };
        game.setBoard(testBoard2);
        boolean connected2 = game.canConnect(0, 0, 5, 5);
        System.out.printf("(0,0)与(5,5)的1号图案可连通: %s (需要2个转弯的折线)\n", connected2);
    }
}

关键代码解析

1. BFS路径连通性验证

while (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
    if (nr == r2 && nc == c2) return true;
    if (board[nr][nc] != EMPTY) break;
    // ...
    nr += DX[d]; nc += DY[d];
}

连连看的核心规则是”直线延伸+最多两次转弯”。代码采用多源BFS,每个状态记录当前方向和已用转弯数。沿当前方向直线滑动直到遇到边界或非空白格,在每个可停留的空白格处尝试向其他方向转弯(若转弯次数未超限)。

2. Dinic最大流算法

while (bfs(s, t)) {
    Arrays.fill(iter, 0);
    while ((f = dfs(s, t, INF)) > 0) flow += f;
}

Dinic通过BFS分层+DFS阻塞流的组合,在二分图匹配场景下达到 O(E × √V) 的优秀复杂度。相比匈牙利算法的 O(VE),Dinic在大规模棋盘上具有显著速度优势。

3. 最小割死局判定

boolean[] sSide = dinic.minCutS(S);

最大流完成后,在残量网络中从源点进行BFS,所有可达节点属于最小割的 S 侧。根据最大流最小割定理,跨割的边即为限制匹配大小的瓶颈。未匹配且位于错误割侧的节点,就是导致死局的关键图案。

扩展思考

  1. 带权匹配:若不同图案的消除分值不同,可将边容量设为图案分值,转化为最大权匹配问题,需用费用流(Min-Cost Max-Flow)求解
  2. 多步前瞻:当前算法只评估当前局面的最大匹配,实际游戏中可通过递归消除评估多步后的死局概率,引入蒙特卡洛树搜索(MCTS)进行长程规划
  3. 提示系统设计:当玩家请求提示时,计算当前最大匹配并随机返回一条匹配边,保证提示一定有效(不会引入死局)

总结

本文以连连看游戏为载体,将消除问题转化为二分图最大匹配,并使用Dinic网络流算法高效求解。核心贡献包括:
– 基于坐标奇偶性的二分图建模方法
– BFS验证不超过2个转弯的连通路径
– 利用最大流-最小割定理进行死局判定与瓶颈定位

该框架适用于所有基于配对消除的益智游戏(如麻将消消乐、动物连连看),也可扩展至资源分配、任务调度等经典二分图匹配场景。