图论(6):图的最小生成树问题 - Prim和Kruskal算法

定义

关于最小生成树的定义,需要先了解如下这几个相关概念:

  • 连通图:在无向图中,若任意两个顶点vi与vj都有路径相通,则称该无向图为连通图。
  • 强连通图:在有向图中,若任意两个顶点vi与vj都有路径相通,则称该有向图为强连通图。
  • 连通网:在连通图中,若图的边具有一定的意义,每一条边都对应着一个数,称为权;权代表着连接两个顶点的代价,称这种连通图叫做连通网。
  • 生成树:一个连通图的生成树是指一个连通子图,它含有图中全部n个顶点,但只有足以构成一棵树的n-1条边。一颗有n个顶点的生成树有且仅有n-1条边,如果生成树中再添加一条边,则必定成环。
  • 最小生成树:在连通网的所有生成树中,所有边的代价和最小的生成树,称为最小生成树。

因此,最小生成树算法研究的问题是:在AOV网中找出串联n个顶点代价总和最小的边集。

构造最小生成树可以有多种算法。大多数算法都是利用了最小生成树的一种简称为MST的性质:假设N = (V, {E})是一个连通网,U是顶点集V的一个非空子集。若(u, v)是一条具有最小权值的边,其中,u ∈ U,v ∈ V - U,则必存在一棵包含边(u, v)的最小生成树。
下面我们要介绍的Prim(普里姆)算法和Kruskal(克鲁斯卡尔)算法就是两个利用了MST的性质构造最小生成树的算法。

Prim(普里姆)算法

该算法也称为“加点法”,每次迭代选择代价最小的边对应的节点加入到最小生成树中。算法从某个节点S出发,逐步覆盖整个连通网的所有顶点。

算法思想

假设G = (V, {E})是连通网,TE是G上的最小生成树中边的集合。算法从U = {u0}(u0 ∈ V),TE = {}开始,重复执行下述操作:在所有u ∈ U, v ∈ V - U的边(u, v) ∈ E中找一条代价最小的边(u0, v0)并入集合TE中,同时将v0并入U中,直到U == V为止。此时TE中必有n - 1条边,则T = (V, {TE})为G的最小生成树。

算法描述

假设现要求如下示例图所示的最小生成树(其中,红色线段为最终的最小生成树结果):


最小生成树示例图

我们使用Guava的ValueGraph作为该图的数据结构,每个顶点对应一个lowsestCost变量来表示节点当前迭代过程中的最小代价,当lowsestCost设为0时,表示该节点已并入了集合U中。初始时将节点V1并入集合U中,表示算法将从节点V1开始迭代,持续迭代n - 1次找出lowsestCost值最小的节点就能求出整个包含n个节点的连通网中的最小生成树。

第一步,我们通过ValueGraphBuilder构造图的实例,并输入示例图中的边集:


//undirected()构建无向图
MutableValueGraph<String, Integer> graph = ValueGraphBuilder.undirected()
        .nodeOrder(ElementOrder.<String>natural())
        .expectedNodeCount(10)
        .build();

graph.putEdgeValue(V1, V2, 6);
graph.putEdgeValue(V1, V3, 1);
graph.putEdgeValue(V1, V4, 5);
graph.putEdgeValue(V2, V3, 5);
graph.putEdgeValue(V2, V5, 3);
graph.putEdgeValue(V3, V4, 5);
graph.putEdgeValue(V3, V5, 6);
graph.putEdgeValue(V3, V6, 4);
graph.putEdgeValue(V4, V6, 2);
graph.putEdgeValue(V5, V6, 6);

初始输出结果如下:

nodes: [v1, v2, v3, v4, v5, v6],
edges: {[v4, v1]=5, [v2, v1]=6, [v3, v1]=1, [v5, v2]=3, [v3, v2]=5, 
[v5, v3]=6, [v4, v3]=5, [v6, v3]=4, [v6, v4]=2, [v6, v5]=6}

我们引入一个临时结构来记录每个节点运算的中间结果:

private static class CloseEdge {
    public String preNode; //标识该最小代价是从哪个顶点指向过来的
    public int lowsestCost; //最小代价
}

第二步,声明最小生成树的结果,并初始化每个节点的CloseEdge的信息:

//最小生成树的结果graph,通过拷贝原graph的属性
MutableValueGraph<String, Integer> result = ValueGraphBuilder.from(graph).build();

//节点的附加信息,用于保存计算的中间结果
Map<String, CloseEdge> closeEdges = new ArrayMap<>();

/**
 * 初始化节点的权重信息
 */
for (String node : graph.nodes()) {
    CloseEdge extra = new CloseEdge();
    extra.preNode = startNode; //前一个节点为startNode节点
    //有边连接时,lowsestCost为其边上的权值;没有边连接时,则为无穷大
    extra.lowsestCost = graph.edgeValueOrDefault(startNode, node, Integer.MAX_VALUE);
    closeEdges.put(node, extra);
}

第三步,开始迭代前,首先将startNode首先并入U集合中,即lowsestCost赋为0,并定义查找最小权值的方法:

//初始化时,先将startNode并入U集合(lowsestCost = 0)
closeEdges.get(startNode).lowsestCost = 0; 

/**
 * 找到一条{U, V - U}中权值最小的边,minCostNode是V - U集合中的顶点
 */
String minCostNode = ""; //存放权值最小边的节点
int min = Integer.MAX_VALUE;
for (String node : closeEdges.keySet()) {
    CloseEdge value = closeEdges.get(node);

    /**
     * lowsestCost == 0时,表示该边已经并入了U集合,不在查找范围
     */
    if (value.lowsestCost > 0 && value.lowsestCost < min) {
        min = value.lowsestCost;
        minCostNode = node;
    }
}

第四步,迭代更新lowsestCost值,并将结果放入ValueGraph类型的result中:

for (int i = 0; i < closeEdges.size() - 1; i++) { //循环n -1次
    /**
     * 找到一条{U, V - U}中权值最小的边,minCostNode是V - U集合中的顶点
     */
    String minCostNode = findMinCostNode(); //上述定义方法

    if (!TextUtils.isEmpty(minCostNode)) {
        CloseEdge minEdge = closeEdges.get(minCostNode);
        /**
         * 将最小的权重边放入结果Graph中
         */
        result.putEdgeValue(minEdge.preNode, minCostNode, minEdge.lowsestCost);

        minEdge.lowsestCost = 0; //将找到的最小的边并入U集合中

        /**
         * 并入minCostNode后,更新minCostNode到其他节点的lowsestCost信息,作为下次循环查找的条件
         */
        for (String node : graph.adjacentNodes(minCostNode)) { //优化:只遍历邻接点即可
            final int cost = graph.edgeValueOrDefault(minCostNode, node, Integer.MAX_VALUE);
            CloseEdge current = closeEdges.get(node);
            if (current.lowsestCost > 0 && cost < current.lowsestCost) {
                current.lowsestCost = cost; //更新权值

                //该最小权值是从minCostNode指向过来的
                current.preNode = minCostNode; 
            }
        }
    }
}

最终程序运行的最小生成树的结果如下(对应开始时示例图中的红色线段):

nodes: [v1, v2, v3, v4, v5, v6], 
edges: {[v3, v1]=1, [v5, v2]=3, [v3, v2]=5, [v6, v3]=4, [v6, v4]=2}

总结

Prim算法涉及到两层循环(第一层为迭代更新lowsestCost,第二层为找权值最小的边),由此,Prim算法的时间复杂度为O(n²)。更重要的是,这里可以看出算法的时间复杂度仅仅与图中节点的数量有关,而与图中边的数量无关,因此适用于求边稠密图的最小生成树。而下面要介绍的Kruskal算法的时间复杂度为O(e * log e)(e为图中边的数目),因此它相对于Prim算法而言,更适合于求边稀疏的最小生成树。

具体Prim算法的示例demo实现,请参考:https://github.com/Jarrywell/GH-Demo/blob/master/app/src/main/java/com/android/test/demo/graph/Prim.java

Kruskal(克鲁斯卡尔)算法

该算法也称为“加边法”,初始时最小生成树的边数为0,没迭代一次选择一条满足条件的最小代价边加入到最小生成树的边集合中。

算法思想

假设连通网G=(V, {E}),令最小生成树的初始状态为只有n个顶点而无边的非连通图T=(V, {}),图中每个顶点自成一个连通分量。在E中选择权值最小的边,若该边依附的顶点落在T中不同的连通分量中,则将此边加入到T中;否则,舍去此边而选下一条权值最小的边。依次类推,直到T中所有顶点都在同一个连通分量上(此时含有n-1边)为止,这时的T就是一棵最小的生成树。

算法描述

假设现要求如下示例图所示的最小生成树(其中,红色线段为最终的最小生成树结果):


最小生成树示例图

我们使用Guava的ValueGraph作为该图的数据结构,每个顶点对应一个vest变量来表示节点当前属于哪个连通分量(初始化是每个顶点自成一个连通分量,即没有任何边连接;vest为节点的序号),并将边按照边权值大小递增排序,每次选择一条不在同一连通分量中的边并入最小生成树的集合中,直到所有节点都在同一连通分量中。

第一步,我们通过ValueGraphBuilder构造图的实例,并输入示例图中的边集。(由于这里使用的就是Prim算法中同一个示例图,故此处不再贴图的初始化代码了)

第二步,初始化最小生成树的结果集合以及辅助变量vest的初始值:

//最小生成树的结果graph
ValueGraph<String, Integer> result = ValueGraphBuilder.from(graph).build();

/**
 * 辅助变量,用于判断两个顶点是否在两个连通分量中
 */
Map<String, Integer> vest = new ArrayMap<>();

/**
 * 初始化辅助数组vest,一开始每个节点单独成一个连通分量
 */
int index = 0;
for (String node : graph.nodes()) {
    vest.put(node, index++); //使用节点需要作为连通分量的序号
}

第三步,将图中边集合按其权值大小增序排列:

/**
 * 将图中所有的边,按权值从小到大排序,便于后面一次循环就能从小到大遍历
 * EndpointPair数据结构用于存储边的两个顶点U和V
 */
List<EndpointPair<String>> edges = sortEdges(graph);

/**
 * 将图中的边按其权值大小排序
 * @param graph
 * @return
 */
List<EndpointPair<String>> sortEdges(final ValueGraph<String, Integer> graph){
    List<EndpointPair<String>> edges = new ArrayList<>();
    edges.addAll(graph.edges()); //Set转List
    /**
     * 使用Collections的sort函数进行排序,compare()比较的是边权值
     */
    Collections.sort(edges, new Comparator<EndpointPair<String>>() {
        @Override
        public int compare(EndpointPair<String> endPoint1,
                           EndpointPair<String> endPoint2) {
            final int edge1 = graph.edgeValueOrDefault(endPoint1.nodeU(),
                    endPoint1.nodeV(), 0);
            final int edge2 = graph.edgeValueOrDefault(endPoint2.nodeU(),
                    endPoint2.nodeV(), 0);
            return edge1 - edge2;
        }
    });
    return edges;
}

排序输出:

v3 -> v1 = 1
v6 -> v4 = 2
v5 -> v2 = 3
v6 -> v3 = 4
v4 -> v1 = 5
v3 -> v2 = 5
v4 -> v3 = 5
v2 -> v1 = 6
v5 -> v3 = 6
v6 -> v5 = 6

第四步,增序遍历一遍图中所有的边,将满足条件的边(边的节点处于不同的连通分量)放入最小生成树中:

/**
 * 按增序遍历图中所有边
 */
for (EndpointPair<String> edge : edges) {
    /**
     * 获取两个节点所代表的连通分量的sn值
     */
    final int nodeUSn = vest.get(edge.nodeU());
    final int nodeVSn = vest.get(edge.nodeV());
    /**
     * 判断一条边的两个顶点是否对应不同的连通分量;若不相同,则将该边加入最小生成树的图中
     */
    if (nodeUSn != nodeVSn) {
        int value = graph.edgeValueOrDefault(edge.nodeU(), edge.nodeV(), 0);
        result.putEdgeValue(edge.nodeU(), edge.nodeV(), value); //最小生成树
        Log.i(TAG, edge.nodeU() + " ->" + edge.nodeV() + ": " + value);

        /**
         * 更新加入最小生成树中边对应的整个连通分量的sn值(后一个连通分量并入
         * 前一个连通分量),以此作为下一次遍历的依据
         */
        for (String node : vest.keySet()) {
            if (vest.get(node) == nodeVSn) {
                vest.put(node, nodeUSn);
            }
        }
    }
}

最终程序运行的最小生成树的结果如下(对应开始时示例图中的红色线段):

nodes: [v1, v2, v3, v4, v5, v6], 
edges: {[v3, v1]=1, [v5, v2]=3, [v3, v2]=5, [v6, v3]=4, [v6, v4]=2}

总结

Kruskal算法因此至多对e条边各扫描一次,且对边排序的最优时间复杂度为O(log e),因此其时间复杂度为O(e * log e)。由于该算法仅与图中边的数量有关,因此Kruskal算法适用于求边稀疏的最小生成树。

具体Prim算法的示例demo实现,请参考:https://github.com/Jarrywell/GH-Demo/blob/master/app/src/main/java/com/android/test/demo/graph/Kruskal.java

参考文档

《数据结构》(严蔚敏版)
https://www.cnblogs.com/wxdjss/p/5503023.html

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,784评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,745评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,702评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,229评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,245评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,376评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,798评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,471评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,655评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,485评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,535评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,235评论 3 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,793评论 3 304
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,863评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,096评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,654评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,233评论 2 341

推荐阅读更多精彩内容