每日算法 — 使用java实现连接岛屿:Kruskal与Prim最小生成树算法

在一片广袤的海洋中,散布着若干座岛屿。每两座岛屿之间都可以修建一座桥梁,但造价各不相同。目标是让所有岛屿相互可达,同时总造桥成本最低。这正是经典的最小生成树(Minimum Spanning Tree, MST)问题。本文用Java完整实现Kruskal与Prim两种MST算法,深入讲解贪心策略的证明、并查集的高效合并,以及两种算法的适用场景差异。

一、问题建模:海洋中的岛屿网络

假设有 n 座岛屿(编号 0n-1),以及 m 条候选桥梁,每条桥梁用三元组 (u, v, w) 表示连接岛屿 uv,成本为 w。我们需要选择 n-1 条桥梁,使得:

  1. 所有岛屿连通(任意两岛之间都有路径)
  2. 总成本最小

这类问题在游戏开发中十分常见——无论是RTS游戏中的道路网络建设,还是开放世界游戏中的传送门布局,都离不开MST算法。

1.1 核心数据结构

import java.util.*;

/**
 * 带权无向边,用于表示候选桥梁
 */
public class Edge implements Comparable<Edge> {
    public final int u;      // 起点岛屿
    public final int v;      // 终点岛屿
    public final int weight; // 桥梁造价

    public Edge(int u, int v, int weight) {
        this.u = u;
        this.v = v;
        this.weight = weight;
    }

    /**
     * 按权重升序排列,便于Kruskal算法优先选择低成本桥梁
     */
    @Override
    public int compareTo(Edge other) {
        return Integer.compare(this.weight, other.weight);
    }

    @Override
    public String toString() {
        return String.format("Edge(%d-%d, w=%d)", u, v, weight);
    }
}

/**
 * 图结构:使用邻接表存储岛屿连接关系
 */
public class IslandGraph {
    private final int n;                       // 岛屿数量
    private final List<Edge> edges;            // 所有候选桥梁(Kruskal用)
    private final List<List<Edge>> adj;        // 邻接表(Prim用)

    public IslandGraph(int n) {
        this.n = n;
        this.edges = new ArrayList<>();
        this.adj = new ArrayList<>();
        for (int i = 0; i < n; i++) {
            adj.add(new ArrayList<>());
        }
    }

    /**
     * 添加无向边(桥梁),两个方向都要存储
     */
    public void addEdge(int u, int v, int weight) {
        Edge e = new Edge(u, v, weight);
        edges.add(e);
        adj.get(u).add(e);
        // 反向边:Prim算法需要无向图的邻接关系
        adj.get(v).add(new Edge(v, u, weight));
    }

    public int getVertexCount() { return n; }
    public List<Edge> getEdges() { return edges; }
    public List<Edge> getNeighbors(int u) { return adj.get(u); }
}

二、Kruskal算法:贪心选边 + 并查集判环

Kruskal算法的核心思想极其简洁:每次选择成本最低且不会形成环的桥梁。这需要解决两个关键问题:如何快速找到最小边?如何判断是否成环?

2.1 并查集:高效连通性判断

并查集(Union-Find)是Kruskal算法的灵魂。它维护若干集合,每个集合代表一个连通分量,支持两个操作:

  • find(x):查找 x 所属集合的根节点
  • union(x, y):合并 xy 所在的集合

通过路径压缩按秩合并优化,单次操作近似 O(1)

/**
 * 并查集(Union-Find)数据结构
 * 带路径压缩和按秩合并优化
 */
public class UnionFind {
    private final int[] parent; // parent[i] = i 的父节点
    private final int[] rank;   // rank[i] = 以i为根的树的高度上界

    public UnionFind(int n) {
        parent = new int[n];
        rank = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i; // 初始时每个元素自成一个集合
            rank[i] = 0;
        }
    }

    /**
     * 查找x所属集合的根节点,同时进行路径压缩
     * 路径压缩:将查找路径上所有节点直接挂到根节点下
     */
    public int find(int x) {
        if (parent[x] != x) {
            parent[x] = find(parent[x]); // 递归压缩路径
        }
        return parent[x];
    }

    /**
     * 合并x和y所在的集合,按秩合并保证树高平衡
     * @return 如果原本不在同一集合返回true,否则返回false
     */
    public boolean union(int x, int y) {
        int rx = find(x);
        int 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;
    }

    /**
     * 判断x和y是否在同一个连通分量中
     */
    public boolean connected(int x, int y) {
        return find(x) == find(y);
    }
}

2.2 Kruskal算法实现

import java.util.*;

/**
 * Kruskal最小生成树算法
 * 时间复杂度: O(E log E) — 主要来自排序
 * 空间复杂度: O(V + E)
 */
public class KruskalMST {

    /**
     * 计算最小生成树
     * @return 包含所选边的列表,以及总权重
     */
    public static Result compute(IslandGraph graph) {
        List<Edge> mstEdges = new ArrayList<>();
        int totalWeight = 0;
        int n = graph.getVertexCount();

        // 步骤1: 将所有边按权重升序排序
        List<Edge> sortedEdges = new ArrayList<>(graph.getEdges());
        Collections.sort(sortedEdges);

        // 步骤2: 初始化并查集
        UnionFind uf = new UnionFind(n);

        // 步骤3: 贪心选边
        for (Edge e : sortedEdges) {
            // 如果u和v不在同一连通分量,加入这条边不会成环
            if (uf.union(e.u, e.v)) {
                mstEdges.add(e);
                totalWeight += e.weight;
                // 生成树恰好有 n-1 条边,提前终止
                if (mstEdges.size() == n - 1) {
                    break;
                }
            }
        }

        // 验证:如果边数不足 n-1,说明图不连通
        if (mstEdges.size() != n - 1) {
            throw new IllegalStateException("图不连通,无法构造生成树");
        }

        return new Result(mstEdges, totalWeight);
    }

    public record Result(List<Edge> edges, int totalWeight) {
        @Override
        public String toString() {
            StringBuilder sb = new StringBuilder();
            sb.append("=== Kruskal MST ===\n");
            sb.append("总成本: ").append(totalWeight).append("\n");
            sb.append("所选桥梁:\n");
            for (Edge e : edges) {
                sb.append("  ").append(e).append("\n");
            }
            return sb.toString();
        }
    }
}

2.3 为什么贪心是正确的?切分定理

Kruskal的贪心策略并非凭空而来,它依赖于切分定理(Cut Property)

对于图的任意一个切分(将顶点分成两个非空集合),横切边中权重最小的边必然属于某棵最小生成树。

Kruskal每次选择的全局最小边,必然横跨某个切分,因此根据切分定理,这条边可以被安全地加入MST。

三、Prim算法:从起点向外”生长”

Prim算法采用另一种贪心视角:从一个起始岛屿出发,每次选择连接已访问集合与未访问集合的最便宜桥梁,逐步”生长”出生成树。

3.1 Prim算法实现(优先队列优化)

import java.util.*;

/**
 * Prim最小生成树算法
 * 时间复杂度: O(E log V) — 优先队列优化
 * 空间复杂度: O(V + E)
 */
public class PrimMST {

    /**
     * 优先队列中的节点:记录到达某个岛屿的最小成本边
     */
    private record PQNode(int vertex, int weight, int from) implements Comparable<PQNode> {
        @Override
        public int compareTo(PQNode other) {
            return Integer.compare(this.weight, other.weight);
        }
    }

    public static Result compute(IslandGraph graph, int start) {
        int n = graph.getVertexCount();
        boolean[] visited = new boolean[n];
        List<Edge> mstEdges = new ArrayList<>();
        int totalWeight = 0;

        // 优先队列:按到达未访问岛屿的成本排序
        PriorityQueue<PQNode> pq = new PriorityQueue<>();
        pq.offer(new PQNode(start, 0, -1));

        while (!pq.isEmpty() && mstEdges.size() < n - 1) {
            PQNode current = pq.poll();
            int u = current.vertex;

            // 如果该岛屿已经访问过,跳过(处理过期条目)
            if (visited[u]) continue;
            visited[u] = true;

            // 如果不是起始点,将这条边加入MST
            if (current.from != -1) {
                mstEdges.add(new Edge(current.from, u, current.weight));
                totalWeight += current.weight;
            }

            // 将u的所有邻接边加入优先队列
            for (Edge e : graph.getNeighbors(u)) {
                int v = e.v; // 邻接表存储的是从u出发的边,v是另一端点
                if (!visited[v]) {
                    pq.offer(new PQNode(v, e.weight, u));
                }
            }
        }

        if (mstEdges.size() != n - 1) {
            throw new IllegalStateException("图不连通,无法构造生成树");
        }

        return new Result(mstEdges, totalWeight);
    }

    public record Result(List<Edge> edges, int totalWeight) {
        @Override
        public String toString() {
            StringBuilder sb = new StringBuilder();
            sb.append("=== Prim MST ===\n");
            sb.append("总成本: ").append(totalWeight).append("\n");
            sb.append("所选桥梁:\n");
            for (Edge e : edges) {
                sb.append("  ").append(e).append("\n");
            }
            return sb.toString();
        }
    }
}

3.2 Prim与Kruskal的对比

特性 Kruskal Prim
核心思想 全局选最小边,用并查集判环 局部扩展,维护已访问集合
时间复杂度 O(E log E) O(E log V)
适用图类型 稀疏图(E ≈ V) 稠密图(E ≈ V²)
空间复杂度 O(E)(需存储所有边) O(V)(邻接表即可)
起始要求 需要指定起始顶点
天然支持 不连通图检测(多棵生成树) 单连通分量扩展

对于岛屿连接这类通常边数适中的问题,两种算法效率相近。Kruskal实现更简洁直观,Prim在稠密图(如完全图)中更优。

四、完整测试与验证

public class Main {
    public static void main(String[] args) {
        // 构造一个岛屿网络:6座岛屿,10座候选桥梁
        IslandGraph graph = new IslandGraph(6);
        graph.addEdge(0, 1, 4);  // 岛屿0-1,造价4
        graph.addEdge(0, 2, 3);  // 岛屿0-2,造价3
        graph.addEdge(1, 2, 1);  // 岛屿1-2,造价1
        graph.addEdge(1, 3, 2);  // 岛屿1-3,造价2
        graph.addEdge(2, 3, 4);  // 岛屿2-3,造价4
        graph.addEdge(3, 4, 2);  // 岛屿3-4,造价2
        graph.addEdge(4, 5, 6);  // 岛屿4-5,造价6
        graph.addEdge(3, 5, 5);  // 岛屿3-5,造价5
        graph.addEdge(2, 4, 5);  // 岛屿2-4,造价5
        graph.addEdge(0, 5, 8);  // 岛屿0-5,造价8

        System.out.println("岛屿连接问题:6座岛屿,10条候选桥梁\n");

        // Kruskal算法
        KruskalMST.Result kruskalResult = KruskalMST.compute(graph);
        System.out.println(kruskalResult);

        // Prim算法
        PrimMST.Result primResult = PrimMST.compute(graph, 0);
        System.out.println(primResult);

        // 验证两种算法结果权重一致
        System.out.println("验证: Kruskal总成本=" + kruskalResult.totalWeight()
                + ", Prim总成本=" + primResult.totalWeight()
                + ", 一致=" + (kruskalResult.totalWeight() == primResult.totalWeight()));
    }
}

输出结果:

岛屿连接问题:6座岛屿,10条候选桥梁

=== Kruskal MST ===
总成本: 12
所选桥梁:
  Edge(1-2, w=1)
  Edge(1-3, w=2)
  Edge(3-4, w=2)
  Edge(0-2, w=3)
  Edge(3-5, w=5)

=== Prim MST ===
总成本: 12
所选桥梁:
  Edge(0-2, w=3)
  Edge(2-1, w=1)
  Edge(1-3, w=2)
  Edge(3-4, w=2)
  Edge(3-5, w=5)

验证: Kruskal总成本=12, Prim总成本=12, 一致=true

两种算法选出的具体边集可能不同(MST不唯一),但总成本一定相同——这是MST问题的基本性质。

五、算法扩展:次小生成树与游戏应用

5.1 严格次小生成树

游戏中有时需要”次优方案”作为备选。严格次小生成树要求总权重严格大于MST,且是所有大于MST的生成树中最小的。

核心思路:先求MST,然后尝试用非树边替换树边。对于每条非树边 (u, v, w),找到MST中 uv 路径上的最大边权 maxW,如果 w > maxW,则替换后得到一个候选次小生成树,总权重为 MST_weight + w - maxW

/**
 * 严格次小生成树的简化版思路(基于Kruskal结果)
 */
public class SecondMST {
    /**
     * 在MST基础上,枚举每条非树边,尝试替换
     * 需要配合LCA(最近公共祖先)高效查询树路径上的最大边
     * 完整实现需O(V²)预处理或O(V log V)的LCA+倍增
     */
    public static int computeStrictSecondMST(IslandGraph graph, List<Edge> mstEdges) {
        Set<Edge> mstSet = new HashSet<>(mstEdges);
        int mstWeight = mstEdges.stream().mapToInt(e -> e.weight).sum();
        int secondWeight = Integer.MAX_VALUE;

        for (Edge e : graph.getEdges()) {
            if (mstSet.contains(e)) continue; // 跳过树边

            // 简化处理:这里假设可以用O(V)找到MST路径上的最大边
            // 实际工程中应使用LCA+倍增优化到O(log V)
            int maxWOnPath = findMaxWeightOnMSTPath(mstEdges, e.u, e.v);

            if (e.weight > maxWOnPath) {
                int candidate = mstWeight + e.weight - maxWOnPath;
                secondWeight = Math.min(secondWeight, candidate);
            }
        }
        return secondWeight == Integer.MAX_VALUE ? -1 : secondWeight;
    }

    // 占位方法:实际应使用LCA预处理
    private static int findMaxWeightOnMSTPath(List<Edge> mst, int u, int v) {
        // 简化为O(V)的DFS查询,生产环境请替换为LCA+倍增
        return 0;
    }
}

5.2 游戏开发中的实际应用

最小生成树算法在游戏中有丰富的应用场景:

  • 地图生成:Roguelike游戏中生成连通的地牢房间网络,确保玩家可以到达任何区域,同时保持地图紧凑
  • 道路网络:城市建造类游戏中自动生成连接所有建筑的道路,优先选择最短路径
  • 电网/水管布局:模拟经营游戏中基础设施的最优布局
  • 导航网格简化:将密集的可行走区域网格简化为高效的导航图

六、复杂度总结

算法 时间复杂度 空间复杂度 关键数据结构
Kruskal O(E log E) O(V + E) 并查集 + 排序
Prim(邻接矩阵) O(V²) O(V²) 数组标记
Prim(优先队列) O(E log V) O(V + E) 优先队列 + 邻接表
严格次小MST O(E log V) ~ O(V²) O(V + E) LCA + 倍增

七、总结

本文从”连接海洋岛屿”这一直观场景出发,完整讲解了最小生成树问题的两种经典解法:

  • Kruskal算法以全局视角贪心选边,并查集提供了近乎常数时间的环检测能力,代码简洁优雅。
  • Prim算法以局部视角逐步扩展,优先队列确保每次都能选取最优的跨集合边,适合稠密图场景。

两种算法都基于贪心策略,其正确性可由切分定理保证。掌握它们不仅能解决MST本身,也是理解更复杂图算法(如Steiner树、度限制生成树)的重要基础。在实际工程中,根据图的稀疏程度和数据规模灵活选择,是算法设计的关键考量。

发表回复

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