最小树形图-朱刘算法详解 +例题解析

最小树形图

定义

对于有向图 G = ( V , E ) G = (V,E) G=(V,E),其中具有如下性质

  1. G G G中不包含有向环。
  2. 存在一个根结点 u u u,它不是任何弧的终点,而且 u u u的其他顶点都恰好是唯一的一条弧的终点

那么则称 G G G是以 u u u为根的树形图。

更笼统的讲,实际上就是有一颗不存在有向环的有向树,且除根结点 u u u外的每个顶点都只有一条入边。

而最小树形图则是权值最小的那个。

这是一个最小树形图的例子。

image-20210817184328177

和最小生成树的区别

最小生成树默认是针对无向图的最小生成树,我们通常也叫最小树形图为有向图的最小生成树。

那么对于最小生成树我们通常是使用Prim算法和Kruskal算法实现求解的,而对于最小树形图,这两种算法还适用嘛?

当然是不行的,因为边变成有向的,我们还需要考虑边的方向,而在最小生成树中我们是只在乎边的权值的,这是两者最主要的区别。所以这里解决最小树形图我们可以使用朱刘算法(也称Edmonds算法)和Tarjan的DMST算法,前者时间复杂度为 O ( V E ) O(VE) O(VE),后者时间复杂度为为 O ( E + V log ⁡ V ) O(E+V\log V) O(E+VlogV)。但由于后者算法较为复杂,且笔者只学了朱刘算法,故这里不提供后者的算法讲解,这里给出wiki的讲解blog

朱刘算法

思想

最小树形图基于贪心和缩点的思想。所谓贪心,就是根据最小树形图除根结点的每个顶点都需要有一条入边,那么我们这条入边即可选择权值最小的入边;那么缩点又是什么呢?基于此即是对于一个有向环,我们就看成一个点来方便处理,这样连到这个有向环的点都可以视为是连到了收缩点,而这些点连出的边都视为从缩点连出,通过这个操作我们可以处理有向环,那么最后我们当然是要展开收缩点得到有向图的。

步骤

  1. 对于最小树形图,是绝对不会存在自环的。所以我们需要删除自环,且删除了自环,我们才能保证时间复杂度为 O ( V E ) O(VE) O(VE)
  2. 求最短弧集合 E E E。==即我们需要选择除根结点之外的每个点都选定一条入边,这条入边一定是要所有入边最小的。==在这一步中我们就可以通过有无入边来判断是否可以形成最小树形图。
  3. 判断集合 E E E中有没有有向环,有就转到步骤4,否则转到步骤5.
  4. 收缩环。对于有向环,我们需要将其收缩成点,并且对图重新构造,即包括权值的改变和点的处理,然后再回到步骤2
  5. 展开收缩点得到最小树形图。即我们展开的有向环然后根据连缩点的入边和出缩点的入边比较去掉相同终点的弧。

流程展示

img
图来源于百度百科

算法实现

P4716 【模板】最小树形图

/**
  *@filename:最小树形图
  *@author: pursuit
  *@created: 2021-08-17 14:34
**/
#include <bits/stdc++.h>

#define debug(a) cout << "debug : " << (#a)<< " = " << a << endl

using namespace std;

typedef pair<int,int> pii;
typedef long long ll;
const int N = 1e4 + 10;
const int P = 1e9 + 7;
const int INF = 0x3f3f3f3f;

int n,m,root;//n个结点m条有向边,以root为根。
int u,v,w;
int g[N][N],inCost[N],pre[N];//inCost[u]存储u的最小入边花费。pre[u]存储u的前驱结点。
int id[N],vis[N];//id[u]表示u重新编号的点,这个数组是为了处理有向环的。vis[u]表示u是通过vis[u]来访问的。
struct node{
    int u,v,w;
}edges[N];
int zhuliu(){
    int res = 0;//res统计边权和。
    while(true){
        for(int i = 1; i <= n; ++ i)inCost[i] = INF, id[i] = vis[i] = -1;//初始化入边消耗等。
        for(int i = 0; i < m; ++ i){
            u = edges[i].u, v = edges[i].v, w = edges[i].w;
            if(u != v && w < inCost[v]){
                inCost[v] = w;
                pre[v] = u;
            }
        }
        for(int i = 1; i <= n; ++ i){
            if(i != root && inCost[i] == INF){
                //说明不存在入边,无法构成树形图。
                return -1;
            }
        }
        inCost[root] = 0;
        int tn = 0;//新的编号。
        for(int i = 1; i <= n; ++ i){
            res += inCost[i];//统计入边消耗。
            v = i;
            while(vis[v] != i && id[v] == -1 && v != root){
                vis[v] = i;
                v = pre[v];//不断迭代前驱结点,知道更新到根结点。若是环,当处于vis[v] = i时即会退出,即回到起点。
            }
            if(v != root && id[v] == -1){
                //说明不是以root为根结点。即出现了有向环
                id[v] = ++tn;
                for(u = pre[v]; u != v; u = pre[u])id[u] = tn;//重新给有向环编号,便于之后缩点。
            }
        }
        if(tn == 0)break;//说明没有重新编号,即不存在有向环。
        for(int i = 1; i <= n; ++ i){
            if(id[i] == -1){
                id[i] = ++tn;//给未重新编号的也重新编号,便于处理。
            }
        }
        int i = 0;
        while(i < m){
            int vv = edges[i].v;//由于入边还存着是原来的编号,所以这里取出入边终点。
            //获取重新的编号。
            edges[i].u = id[edges[i].u], edges[i].v = id[edges[i].v];
            if(edges[i].u != edges[i].v){
                //这里看做是环外一点入环内的v点,所以我们需要减去环内入边权。
                //这样可以避免重复计算。
                edges[i ++].w -= inCost[vv];//更新权值,因为这个已经被累加了。
            }
            else{
                //将有向环环边抛出。
                swap(edges[i],edges[-- m]);
            }
        }
        n = tn;//更新新的结点数量。
        root = id[root];//更新根结点编号。
    }
    return res;
}
void solve(){
    printf("%d\n", zhuliu());
}
int main(){	
    scanf("%d%d%d", &n, &m, &root);
    for(int i = 1; i <= n; ++ i)for(int j = 1; j <= n; ++ j)g[i][j] = INF;
    for(int i = 0; i < m; ++ i){
        scanf("%d%d%d", &u, &v, &w);
        //去除自环。
        if(u == v)continue;
        //去除重边。
        g[u][v] = min(g[u][v],w);
    }
    m = 0;
    for(int i = 1; i <= n; ++ i){
        for(int j = 1; j <= n; ++ j){
            //将边存储起来。
            if(g[i][j] != INF){
                edges[m].u = i,edges[m].v = j,edges[m ++].w = g[i][j];
            }
        }
    }
    solve();
    return 0;
}

例题

POJ3164_Command_Network

  • 题意
    给你 n n n个点的坐标值,其中给出 m m m条边,每条边给出 u , v u,v u,v代表 u − > v u->v u>v的权值,为它们的欧几里得距离。求最小树形图。

  • 解题思路
    此题我们只需要作个转换,其实将边权值设置为两点之间的距离即可。其他照常处理。注意POJ有个坑点就是对于printf要使用%.2ff而不是%.2lf输出。

  • AC代码

/**
  *@filename:POJ3164
  *@author: pursuit
  *@created: 2021-08-17 19:47
**/
//#include <bits/stdc++.h>
#include<iostream>
#include<cstdio>
#include<cstring>
#include<cmath>
#define debug(a) cout << "debug : " << (#a)<< " = " << a << endl
#define x first 
#define y second
using namespace std;

typedef pair<double, double> pdd;
typedef long long ll;
const int N = 100 + 10;
const int P = 1e9 + 7;
const int INF = 0x3f3f3f3f;

int n,m,u,v,pre[N];
double w,inCost[N];
int id[N],vis[N],tot;
pdd a[N];
struct node{
    int u,v;
    double w;
}edges[N * N];
double getDist(int u,int v){
    return sqrt((a[u].x - a[v].x) * (a[u].x - a[v].x) + (a[u].y - a[v].y) * (a[u].y - a[v].y));
}
void addEdge(int u,int v){
    edges[++tot].u = u;
    edges[tot].v = v;
    edges[tot].w = getDist(u,v);
}
double zhuliu(int root){
    double res = 0;//res统计边权和。
    while(true){
        for(int i = 1; i <= n; ++ i)inCost[i] = INF, id[i] = vis[i] = -1;//初始化入边消耗等。
        for(int i = 1; i <= m; ++ i){
            u = edges[i].u, v = edges[i].v, w = edges[i].w;
            if(u != v && w < inCost[v]){
                //cout << u << "->" << v << endl;
                inCost[v] = w;
                pre[v] = u;
            }
        }
        for(int i = 1; i <= n; ++ i){
            if(i != root && inCost[i] == INF){
                //说明不存在入边,无法构成树形图。
                return -1;
            }
        }
        inCost[root] = 0;
        int tn = 0;//新的编号。
        for(int i = 1; i <= n; ++ i){
            res += inCost[i];//统计入边消耗。
            v = i;
            while(vis[v] != i && id[v] == -1 && v != root){
                vis[v] = i;
                v = pre[v];//不断迭代前驱结点,知道更新到根结点。若是环,当处于vis[v] = i时即会退出,即回到起点。
            }
            if(v != root && id[v] == -1){
                //说明不是以root为根结点。即出现了有向环
                id[v] = ++tn;
                for(u = pre[v]; u != v; u = pre[u])id[u] = tn;//重新给有向环编号,便于之后缩点。
            }
        }
        if(tn == 0)break;//说明没有重新编号,即不存在有向环。
        for(int i = 1; i <= n; ++ i){
            if(id[i] == -1){
                id[i] = ++tn;//给未重新编号的也重新编号,便于处理。
            }
        }
        int i = 1;
        while(i <= m){
            int vv = edges[i].v;//由于入边还存着是原来的编号,所以这里取出入边终点。
            //获取重新的编号。
            edges[i].u = id[edges[i].u], edges[i].v = id[edges[i].v];
            if(edges[i].u != edges[i].v){
                //这里看做是环外一点入环内的v点,所以我们需要减去环内入边权。
                //这样可以避免重复计算。
                edges[i ++].w -= inCost[vv];//更新权值,因为这个已经被累加了。
            }
            else{
                //将有向环环边抛出。
                swap(edges[i],edges[m --]);
            }
        }
        n = tn;//更新新的结点数量。
        root = id[root];//更新根结点编号。
    }
    return res;
}
void solve(){
    double ans = zhuliu(1);
    if(ans == -1){
        puts("poor snoopy");
    }
    else{
        printf("%.2f\n", ans);
    }
}
int main(){	
    while(~scanf("%d%d", &n, &m)){
        tot = 0;
        for(int i = 1; i <= n; ++ i){
            scanf("%lf%lf", &a[i].x, &a[i].y);
        }
        for(int i = 0; i < m; ++ i){
            scanf("%d%d", &u, &v);
            if(u == v)continue;
            addEdge(u,v);
        }
        m = tot;
        solve();
    }
    return 0;
}

HDU2121_Ice_cream’s_world_II

  • 题意
    给你n个点和m条有向边,问最少能花费多少将它们连起来,如果能连起来输出需要的权值和根的序号,不能的话输出impossible

  • 解题思路
    由于此题是无根的最小树形图,所以我们就要建立一个虚点,也称超级源点,并使源点到其他所有点都有一条单向路径,且权值为所有路径的权值之和 + 1。那么最后求得的值为路径的总权值+超级源点到实根的权值。这里做一个sum += 1,这正是为了区分超过最大限度的2 * 路径总权值 + 1的情况,即当超过了说明多了一个实根,这样代表最小树形图不成立。
    最后我们在定根的时候,我们需要通过边的下标来减去边数 m m m获得,即减去偏移量。这样我们保证了获取的根一定是最小的编号的。

  • AC代码

/**
  *@filename:HDU_2121
  *@author: pursuit
  *@created: 2021-08-17 19:42
**/
#include <bits/stdc++.h>
#define debug(a) cout << "debug : " << (#a)<< " = " << a << endl

using namespace std;

typedef pair<int,int> pii;
typedef long long ll;
const int N = 1e3 + 10;
const int P = 1e9 + 7;
const int INF = 0x3f3f3f3f;

int n,m,u,v,w,sum,minRoot;
int id[N],vis[N],tot,inCost[N],pre[N];
struct node{
    int u,v,w;
}edges[N * N];
void addEdge(int u,int v,int w){
    edges[tot].u = u;
    edges[tot].v = v;
    edges[tot++].w = w;
}
int zhuliu(int root,int n){
    int res = 0;//res统计边权和。
    while(true){
        for(int i = 0; i < n; ++ i)inCost[i] = INF, id[i] = vis[i] = -1;//初始化入边消耗等。
        for(int i = 0; i < tot; ++ i){
            u = edges[i].u, v = edges[i].v, w = edges[i].w;
            if(u != v && w < inCost[v]){
                //cout << u << "->" << v << endl;
                inCost[v] = w;
                pre[v] = u;
                //注意这里,由于前面已经为其余的点分配了入点。剩下的就是可能的实根了。
                if(u == root)minRoot = i;//
                //cout << u << "->" << v << "minRoot:" << minRoot << endl;
            }
        }
        for(int i = 0; i < n; ++ i){
            if(i != root && inCost[i] == INF){
                //说明不存在入边,无法构成树形图。
                return -1;
            }
        }
        inCost[root] = 0;
        int tn = 0;//新的编号。
        //判断是否存在环。
        for(int i = 0; i < n; ++ i){
            res += inCost[i];//统计入边消耗。
            v = i;
            while(vis[v] != i && id[v] == -1 && v != root){
                vis[v] = i;
                v = pre[v];//不断迭代前驱结点,知道更新到根结点。若是环,当处于vis[v] = i时即会退出,即回到起点。
            }
            if(v != root && id[v] == -1){
                //说明不是以root为根结点。即出现了有向环
                for(u = pre[v]; u != v; u = pre[u])id[u] = tn;//重新给有向环编号,便于之后缩点。
                id[v] = tn ++;
            }
        }
        if(tn == 0)break;//说明没有重新编号,即不存在有向环。
        for(int i = 0; i < n; ++ i){
            if(id[i] == -1){
                id[i] = tn ++;//给未重新编号的也重新编号,便于处理。
            }
        }
        int i = 0;
        while(i < tot){
            int vv = edges[i].v;//由于入边还存着是原来的编号,所以这里取出入边终点。
            //获取重新的编号。
            edges[i].u = id[edges[i].u], edges[i].v = id[edges[i].v];
            if(edges[i].u != edges[i].v){
                //这里看做是环外一点入环内的v点,所以我们需要减去环内入边权。
                //这样可以避免重复计算。
                edges[i].w -= inCost[vv];//更新权值,因为这个已经被累加了。
            }
            i ++;
        }
        n = tn;//更新新的结点数量。
        root = id[root];//更新根结点编号。
    }
    return res;
}
void solve(){
    sum ++;
    //添加虚点。
    for(int i = 0; i < n; ++ i){
        addEdge(n,i,sum);
    }
    //更新边数。
    int ans = zhuliu(n,n + 1);
    if(ans == -1 || ans >= 2 * sum){
        puts("impossible");
    }
    else{
        printf("%d %d\n", ans - sum, minRoot - m);
    }
    puts("");
}
int main(){	
    while(~scanf("%d%d", &n, &m)){
        tot = sum = 0;
        for(int i = 0; i < m; ++ i){
            scanf("%d%d%d", &u, &v, &w);
            addEdge(u, v,w);
            sum += w;
        }
        solve();
    }
    return 0;
}

CF240E_Road_Repairs

  • 题意
    有n个城市,1为该国的首都,一些城市对之间有单向道路,但并非都处于良好状态,没有维修的是使用不了的。
    需要使得首都到达其他任何一座城市,找出必须修复的最少道路数。这里还需输出路径编号。

  • 解题思路
    这是最小树形图模板题,只不过这道题稍微有点复杂的是要输出我们选择的维修道路编号。即是我们需要将缩点的有向环打开,这篇blog有详细的解释,可以作为参考。

  • AC代码

/**
  *@filename:E_Road_Repairs
  *@author: pursuit
  *@created: 2021-08-18 15:36
**/
#include <bits/stdc++.h>
#define debug(a) cout << "debug : " << (#a)<< " = " << a << endl

using namespace std;

typedef pair<int,int> pii;
typedef long long ll;
const int N = 1e6 + 10;
const int P = 1e9 + 7;
const int INF = 0x3f3f3f3f;

/*
题意:
*/
int n,m,u,v,flag,pre[N],preId[N];
int inCost[N];
int id[N],vis[N],use[N];
struct node{
    int u,v,cost,flag,id;//flag代表道路的状况,若为1,则需要对其进行修复。
}edges[N];
struct Cancel{
    int pre,cur;//pre为可能被取消的那条边的id,而cur保存的是可能新增的那条边的更新前的id。
}cancel[N];
void addEdge(int u,int v,int flag,int id){
    edges[id].u = u;
    edges[id].v = v;
    edges[id].flag = edges[id].cost = flag;
    edges[id].id = id;
}
int zhuliu(int root){
    int res = 0,total = m;//res统计边权和。total为下一条新建边的id。
    while(true){
        for(int i = 1; i <= n; ++ i)inCost[i] = INF, id[i] = vis[i] = -1,pre[i] = -1;//初始化入边消耗等。
        for(int i = 0; i < m; ++ i){
            u = edges[i].u, v = edges[i].v, flag = edges[i].cost;
            if(u != v && flag < inCost[v]){
                //cout << u << "->" << v << endl;
                inCost[v] = flag;
                pre[v] = u;
                //更新加入到边集的那条边的id。
                preId[v] = edges[i].id;
            }
        }
        for(int i = 1; i <= n; ++ i){
            if(i != root && inCost[i] == INF){
                //说明不存在入边,无法构成树形图。
                return -1;
            }
        }
        inCost[root] = 0;
        int tn = 0;//新的编号。
        for(int i = 1; i <= n; ++ i){
            res += inCost[i];//统计入边消耗。
            v = i;
            //将新图中用到的边保存起来。
            if(i != root)use[preId[v]] ++;
            while(vis[v] != i && id[v] == -1 && v != root){
                vis[v] = i;
                v = pre[v];//不断迭代前驱结点,知道更新到根结点。若是环,当处于vis[v] = i时即会退出,即回到起点。
            }
            if(v != root && id[v] == -1){
                //说明不是以root为根结点。即出现了有向环
                id[v] = ++tn;
                for(u = pre[v]; u != v; u = pre[u])id[u] = tn;//重新给有向环编号,便于之后缩点。
            }
        }
        if(tn == 0)break;//说明没有重新编号,即不存在有向环。
        for(int i = 1; i <= n; ++ i){
            if(id[i] == -1){
                id[i] = ++tn;//给未重新编号的也重新编号,便于处理。
            }
        }
        for(int i = 0; i < m; ++ i){
            int vv = edges[i].v;//由于入边还存着是原来的编号,所以这里取出入边终点。
            //获取重新的编号。
            edges[i].u = id[edges[i].u], edges[i].v = id[edges[i].v];
            if(edges[i].u != edges[i].v){
                //这里看做是环外一点入环内的v点,所以我们需要减去环内入边权。
                //这样可以避免重复计算。
                edges[i].cost -= inCost[vv];//更新权值,因为这个已经被累加了。
                //将这条边的更新信息保存起来。
                cancel[total].pre = preId[vv];//原本指向v的边就取消了。
                cancel[total].cur = edges[i].id;//保留更新前的id。
                edges[i].id = total ++;
            }
        }
        n = tn;//更新新的结点数量。
        root = id[root];//更新根结点编号。
    }
    //正常。
    for(int i = total - 1; i >= m; -- i){
        if(use[i]){
            use[cancel[i].pre] --;
            use[cancel[i].cur] ++;
        }
    }
    return res;
}
void solve(){
    int ans = zhuliu(1);
    printf("%d\n", ans);
    if(ans > 0){
        //找到需要修复的路径编号。
        for(int i = 0; i < m; ++ i){
            if(use[i] && edges[i].flag)printf("%d ", i + 1);
            //cout << i << ":" << use[i] << endl;
        }
        puts("");
    }
}
int main(){	
    freopen("input.txt","r",stdin);
    freopen("output.txt", "w", stdout); 
    scanf("%d%d", &n, &m);
    for(int i = 0; i < m; ++ i){
        scanf("%d%d%d", &u, &v, &flag);
        addEdge(u,v,flag,i);
    }
    solve();
    return 0;
}
posted @   unique_pursuit  阅读(353)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 没有源码,如何修改代码逻辑?
· NetPad:一个.NET开源、跨平台的C#编辑器
· PowerShell开发游戏 · 打蜜蜂
· 凌晨三点救火实录:Java内存泄漏的七个神坑,你至少踩过三个!
点击右上角即可分享
微信分享提示