每日算法 — 使用java实现接水管:并查集动态连通与最小生成树最优管道布局

引言

接水管(Pipe Mania / Pipe Dream)是一款经典的益智游戏,玩家需要在网格中旋转管道片段,使水源从起点顺利流向终点。本文将接水管的最优布局问题转化为图论中的最小生成树(Minimum Spanning Tree, MST)问题,并使用并查集(Union-Find / Disjoint Set Union, DSU)数据结构实现动态连通性检测,在管道逐步铺设的过程中实时判断连通状态并优化整体布局。

问题建模

网格与管道类型

游戏在一个 M×N 的网格上进行,每个格子可以放置以下六种管道之一:

管道类型 连接方向 旋转状态数
直管 上下 或 左右 2
弯管 上右、右下、下左、左上 4
十字管 上下左右 1
T型管 缺一个方向 4
起点 单向出水 4
终点 单向接水 4

连通图模型

将每个网格单元格视为图中的一个节点。若相邻两格的管道接口相互对接(即A格右侧开口且B格左侧开口),则在两节点之间建立一条无向边。游戏的胜利条件是:起点与终点之间存在一条连通的管道路径。

最优布局目标:在满足连通性的前提下,使用尽可能少的管道旋转次数完成布局(即最小化总操作代价)。

最小生成树视角

若将每个可能的管道放置视为带权边(权重 = 旋转次数 + 铺设代价),则整个网格的所有合法连接构成一个带权无向图。起点到终点的最优管道网络可以看作该图的一棵斯坦纳树(Steiner Tree)近似解。对于简化场景(无障碍、全连通),我们可以先用Kruskal算法求出MST,再检查起点与终点的连通性。

并查集动态连通

并查集是一种处理不相交集合合并与查询的数据结构,支持:

  • Find(x):查找x所在集合的代表元(根节点)
  • Union(x, y):合并x和y所在的集合

路径压缩和按秩合并优化后,单次操作均摊时间复杂度为 O(α(n)),其中α为阿克曼函数的反函数,实际应用中可视为常数。

在接水管中,每当一个管道的旋转状态改变,我们需要:
1. 断开该格子与四个邻居的旧连接
2. 根据新状态建立新连接
3. 实时查询起点与终点是否连通

Java实现

核心数据结构

import java.util.*;

/**
 * 接水管求解器
 * 并查集动态连通 + Kruskal最小生成树辅助布局
 */
public class PipeManiaSolver {

    // 四个方向:上、右、下、左
    static final int[] DX = {-1, 0, 1, 0};
    static final int[] DY = {0, 1, 0, -1};
    static final String[] DIR_NAMES = {"上", "右", "下", "左"};

    // 管道类型
    enum PipeType {
        STRAIGHT,   // 直管:位掩码 0101 (上下) 或 1010 (左右)
        ELBOW,      // 弯管:位掩码 0011, 0110, 1100, 1001
        CROSS,      // 十字管:位掩码 1111
        TEE,        // T型管:位掩码 0111, 1011, 1101, 1110
        SOURCE,     // 起点
        SINK        // 终点
    }

    // 每个方向的位掩码:上=8(1000), 右=4(0100), 下=2(0010), 左=1(0001)
    static final int UP = 8, RIGHT = 4, DOWN = 2, LEFT = 1;

    static class Pipe {
        PipeType type;
        int rotation;  // 0~3,表示顺时针旋转90度的次数
        int mask;      // 当前开口方向的位掩码

        Pipe(PipeType type, int rotation) {
            this.type = type;
            this.rotation = rotation;
            this.mask = computeMask(type, rotation);
        }

        // 根据类型和旋转计算开口掩码
        int computeMask(PipeType t, int rot) {
            int base = switch (t) {
                case STRAIGHT -> 0b1010;  // 左右开口
                case ELBOW -> 0b0110;     // 右下开口
                case CROSS -> 0b1111;     // 全部开口
                case TEE -> 0b1110;       // 上右下开口
                case SOURCE -> 0b0100;    // 向右出水
                case SINK -> 0b0001;      // 向左接水
            };
            // 顺时针旋转rot次
            for (int i = 0; i < rot; i++) {
                base = rotateClockwise(base);
            }
            return base;
        }

        // 顺时针旋转90度:上->右->下->左->上
        int rotateClockwise(int m) {
            int newMask = 0;
            if ((m & UP) != 0) newMask |= RIGHT;
            if ((m & RIGHT) != 0) newMask |= DOWN;
            if ((m & DOWN) != 0) newMask |= LEFT;
            if ((m & LEFT) != 0) newMask |= UP;
            return newMask;
        }

        boolean hasOpening(int dir) {
            return (mask & dir) != 0;
        }

        void rotate() {
            rotation = (rotation + 1) % 4;
            mask = rotateClockwise(mask);
        }

        @Override
        public String toString() {
            return type.name() + "(" + rotation + ")";
        }
    }

    int rows, cols;
    Pipe[][] grid;
    int sourceR, sourceC, sinkR, sinkC;

    // 并查集
    static class UnionFind {
        int[] parent;
        int[] rank;

        UnionFind(int n) {
            parent = new int[n];
            rank = new int[n];
            for (int i = 0; i < n; i++) parent[i] = i;
        }

        int find(int x) {
            if (parent[x] != x) {
                parent[x] = find(parent[x]);  // 路径压缩
            }
            return parent[x];
        }

        boolean union(int x, int y) {
            int rx = find(x), ry = find(y);
            if (rx == ry) return false;
            // 按秩合并
            if (rank[rx] < rank[ry]) {
                parent[rx] = ry;
            } else if (rank[rx] > rank[ry]) {
                parent[ry] = rx;
            } else {
                parent[ry] = rx;
                rank[rx]++;
            }
            return true;
        }

        boolean connected(int x, int y) {
            return find(x) == find(y);
        }

        void reset() {
            for (int i = 0; i < parent.length; i++) parent[i] = i;
            Arrays.fill(rank, 0);
        }
    }

    UnionFind uf;

    PipeManiaSolver(int rows, int cols) {
        this.rows = rows;
        this.cols = cols;
        this.grid = new Pipe[rows][cols];
        this.uf = new UnionFind(rows * cols);
    }

连通性检测与并查集更新

    /**
     * 将二维坐标转换为一维索引
     */
    int index(int r, int c) {
        return r * cols + c;
    }

    /**
     * 检查两个相邻格子是否管道连通
     */
    boolean isConnected(int r1, int c1, int r2, int c2) {
        Pipe p1 = grid[r1][c1];
        Pipe p2 = grid[r2][c2];
        if (p1 == null || p2 == null) return false;

        // 确定相对方向
        int dir1 = -1, dir2 = -1;
        if (r2 == r1 - 1 && c2 == c1) { dir1 = UP; dir2 = DOWN; }
        else if (r2 == r1 && c2 == c1 + 1) { dir1 = RIGHT; dir2 = LEFT; }
        else if (r2 == r1 + 1 && c2 == c1) { dir1 = DOWN; dir2 = UP; }
        else if (r2 == r1 && c2 == c1 - 1) { dir1 = LEFT; dir2 = RIGHT; }
        else return false;

        return p1.hasOpening(dir1) && p2.hasOpening(dir2);
    }

    /**
     * 根据当前网格状态重建并查集
     */
    void rebuildUnionFind() {
        uf.reset();
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                // 只检查右和下邻居,避免重复
                if (c + 1 < cols && isConnected(r, c, r, c + 1)) {
                    uf.union(index(r, c), index(r, c + 1));
                }
                if (r + 1 < rows && isConnected(r, c, r + 1, c)) {
                    uf.union(index(r, c), index(r + 1, c));
                }
            }
        }
    }

    /**
     * 旋转指定格子的管道并增量更新连通性
     */
    void rotatePipe(int r, int c) {
        Pipe p = grid[r][c];
        if (p == null || p.type == PipeType.CROSS) return;

        // 先断开该格子与所有邻居的连接
        // 然后旋转
        p.rotate();

        // 重建并查集(增量更新较复杂,小规模网格直接重建更高效)
        rebuildUnionFind();
    }

    /**
     * 检查水源是否到达终点
     */
    boolean isSolved() {
        return uf.connected(index(sourceR, sourceC), index(sinkR, sinkC));
    }

Kruskal辅助布局

    /**
     * 边:连接两个格子的潜在管道连接
     */
    static class Edge implements Comparable<Edge> {
        int r1, c1, r2, c2;
        int weight;  // 铺设代价

        Edge(int r1, int c1, int r2, int c2, int w) {
            this.r1 = r1; this.c1 = c1;
            this.r2 = r2; this.c2 = c2;
            this.weight = w;
        }

        @Override
        public int compareTo(Edge other) {
            return Integer.compare(this.weight, other.weight);
        }
    }

    /**
     * 使用Kruskal算法构建辅助布局
     * 目标:用最少的管道旋转将起点和终点连入同一连通块
     */
    List<Edge> kruskalLayout() {
        List<Edge> edges = new ArrayList<>();

        // 收集所有相邻格子的潜在连接及其代价
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                if (c + 1 < cols) {
                    int cost = minRotationCost(r, c, r, c + 1);
                    edges.add(new Edge(r, c, r, c + 1, cost));
                }
                if (r + 1 < rows) {
                    int cost = minRotationCost(r, c, r + 1, c);
                    edges.add(new Edge(r, c, r + 1, c, cost));
                }
            }
        }

        Collections.sort(edges);

        UnionFind kruskalUF = new UnionFind(rows * cols);
        List<Edge> mst = new ArrayList<>();

        for (Edge e : edges) {
            int u = index(e.r1, e.c1);
            int v = index(e.r2, e.c2);
            if (!kruskalUF.connected(u, v)) {
                kruskalUF.union(u, v);
                mst.add(e);
            }
        }

        return mst;
    }

    /**
     * 计算使两格连通所需的最小旋转次数
     */
    int minRotationCost(int r1, int c1, int r2, int c2) {
        Pipe p1 = grid[r1][c1];
        Pipe p2 = grid[r2][c2];
        if (p1 == null || p2 == null) return Integer.MAX_VALUE;

        int dir1 = -1, dir2 = -1;
        if (r2 == r1 - 1 && c2 == c1) { dir1 = UP; dir2 = DOWN; }
        else if (r2 == r1 && c2 == c1 + 1) { dir1 = RIGHT; dir2 = LEFT; }
        else if (r2 == r1 + 1 && c2 == c1) { dir1 = DOWN; dir2 = UP; }
        else if (r2 == r1 && c2 == c1 - 1) { dir1 = LEFT; dir2 = RIGHT; }
        else return Integer.MAX_VALUE;

        int minCost = Integer.MAX_VALUE;
        int origMask1 = p1.mask;
        int origMask2 = p2.mask;
        int origRot1 = p1.rotation;
        int origRot2 = p2.rotation;

        // 枚举所有旋转组合
        for (int rot1 = 0; rot1 < 4; rot1++) {
            for (int rot2 = 0; rot2 < 4; rot2++) {
                p1.rotation = rot1;
                p1.mask = p1.computeMask(p1.type, rot1);
                p2.rotation = rot2;
                p2.mask = p2.computeMask(p2.type, rot2);

                if (p1.hasOpening(dir1) && p2.hasOpening(dir2)) {
                    int cost = rot1 + rot2;  // 简化代价模型
                    if (cost < minCost) minCost = cost;
                }
            }
        }

        // 恢复原状态
        p1.rotation = origRot1;
        p1.mask = origMask1;
        p2.rotation = origRot2;
        p2.mask = origMask2;

        return minCost;
    }

BFS求解最短路径布局

    /**
     * BFS搜索最少旋转次数的解法
     * 状态 = 整个网格的旋转配置
     */
    List<int[]> solveBFS() {
        // 状态编码:每个格子的旋转状态组成一个字符串
        Queue<String> queue = new LinkedList<>();
        Map<String, String> parent = new HashMap<>();
        Map<String, int[]> parentMove = new HashMap<>();

        String initial = encodeState();
        queue.offer(initial);
        parent.put(initial, null);

        while (!queue.isEmpty()) {
            String state = queue.poll();
            decodeState(state);
            rebuildUnionFind();

            if (isSolved()) {
                // 回溯路径
                List<int[]> moves = new ArrayList<>();
                String cur = state;
                while (parent.get(cur) != null) {
                    moves.add(0, parentMove.get(cur));
                    cur = parent.get(cur);
                }
                return moves;
            }

            // 尝试旋转每个可旋转的管道
            for (int r = 0; r < rows; r++) {
                for (int c = 0; c < cols; c++) {
                    Pipe p = grid[r][c];
                    if (p == null || p.type == PipeType.CROSS) continue;

                    for (int rot = 1; rot < 4; rot++) {
                        decodeState(state);  // 恢复当前状态
                        p = grid[r][c];
                        for (int i = 0; i < rot; i++) p.rotate();

                        String next = encodeState();
                        if (!parent.containsKey(next)) {
                            parent.put(next, state);
                            parentMove.put(next, new int[]{r, c, rot});
                            queue.offer(next);
                        }
                    }
                }
            }
        }

        return null;  // 无解
    }

    String encodeState() {
        StringBuilder sb = new StringBuilder();
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                if (grid[r][c] != null) {
                    sb.append(grid[r][c].rotation);
                } else {
                    sb.append('X');
                }
            }
        }
        return sb.toString();
    }

    void decodeState(String state) {
        int idx = 0;
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                char ch = state.charAt(idx++);
                if (ch != 'X' && grid[r][c] != null) {
                    grid[r][c].rotation = ch - '0';
                    grid[r][c].mask = grid[r][c].computeMask(grid[r][c].type, grid[r][c].rotation);
                }
            }
        }
    }

测试入口

    void initGrid() {
        // 4x4 测试用例
        grid[0][0] = new Pipe(PipeType.SOURCE, 0);
        grid[0][1] = new Pipe(PipeType.ELBOW, 0);
        grid[0][2] = new Pipe(PipeType.STRAIGHT, 0);
        grid[0][3] = new Pipe(PipeType.ELBOW, 0);
        grid[1][0] = new Pipe(PipeType.ELBOW, 0);
        grid[1][1] = new Pipe(PipeType.TEE, 0);
        grid[1][2] = new Pipe(PipeType.ELBOW, 0);
        grid[1][3] = new Pipe(PipeType.STRAIGHT, 0);
        grid[2][0] = new Pipe(PipeType.STRAIGHT, 0);
        grid[2][1] = new Pipe(PipeType.ELBOW, 0);
        grid[2][2] = new Pipe(PipeType.CROSS, 0);
        grid[2][3] = new Pipe(PipeType.ELBOW, 0);
        grid[3][0] = new Pipe(PipeType.ELBOW, 0);
        grid[3][1] = new Pipe(PipeType.STRAIGHT, 0);
        grid[3][2] = new Pipe(PipeType.ELBOW, 0);
        grid[3][3] = new Pipe(PipeType.SINK, 0);

        sourceR = 0; sourceC = 0;
        sinkR = 3; sinkC = 3;
        rebuildUnionFind();
    }

    public static void main(String[] args) {
        PipeManiaSolver solver = new PipeManiaSolver(4, 4);
        solver.initGrid();

        System.out.println("初始状态连通: " + solver.isSolved());

        // Kruskal辅助分析
        List<Edge> mst = solver.kruskalLayout();
        System.out.println("Kruskal MST边数: " + mst.size());
        for (Edge e : mst) {
            System.out.printf("  (%d,%d)-(%d,%d) cost=%d%n", e.r1, e.c1, e.r2, e.c2, e.weight);
        }

        // BFS求解
        List<int[]> moves = solver.solveBFS();
        if (moves != null) {
            System.out.println("找到解法,共 " + moves.size() + " 步:");
            for (int[] m : moves) {
                System.out.printf("  旋转 grid[%d][%d] %d 次%n", m[0], m[1], m[2]);
            }
        } else {
            System.out.println("无解");
        }
    }
}

算法分析

指标 数值
并查集单次操作 O(α(V)) ≈ O(1)
全网格重建并查集 O(V + E) = O(MN)
Kruskal算法 O(E log E) = O(MN log(MN))
BFS状态空间 4^(MN)(最坏情况)
BFS实际可用 对于 4×4~6×6 规模可秒级求解

并查集负责动态连通性维护,Kruskal负责全局最优布局指导,BFS负责精确最短解搜索。三者分工明确,构成了从实时响应到全局最优的完整算法体系。

扩展方向

  1. A*启发式搜索:用曼哈顿距离+开口匹配度作为启发函数,加速BFS
  2. 多源多汇网络流:处理有多个水源和出水口的复杂关卡
  3. 时间轴动态管道:处理水流随时间推进的时序约束

总结

本文将接水管游戏抽象为图论连通问题,使用并查集实现动态连通性检测,用Kruskal最小生成树辅助评估布局代价,并以BFS精确搜索最少旋转次数的解。读者可以在此基础上扩展更复杂的管道类型、障碍物机制和多目标布局优化。