【Acwing算法基础课 第三章 搜索与图论(三)】最小生成树+二分图(java版详细注解!!你疑惑的点可能都写到了)

本文围绕最小生成树和二分图展开,介绍Prim和Kruskal算法求最小生成树,包括思路分析与代码实现。还阐述二分图定义,用染色法判定二分图,以及匈牙利算法求二分图最大匹配,结合形象例子和图解说明算法过程,并对代码中易疑惑点进行解释。

最小生成树+二分图
Acwing算法基础课 第三章 搜索与图论(三)的内容,最小生成树和二分图。最小生成树比较简单,和迪杰斯特拉比较像;然后二分图的匈牙利算法挺有趣的(例子生动),有几个点可能会产生疑惑,文章都提及到了,希望能帮到同样疑惑的小伙伴~(图借的acwing题解的海绵宝宝and其他大佬)

最小生成树

Prim

858.Prim算法求最小生成树

给定一个 n 个点 m 条边的无向图,图中可能存在重边和自环,边权可能为负数。求最小生成树的树边权重之和,如果最小生成树不存在则输出 impossible。给定一张边带权的无向图 G=(V,E),其中 V 表示图中点的集合,E表示图中边的集合,n=|V|,m=|E|。

由 V 中的全部 n 个顶点和 E 中 n−1 条边构成的无向连通子图被称为 G 的一棵生成树,其中边的权值之和最小的生成树被称为无向图 G 的最小生成树。

输入格式:第一行包含两个整数 n 和 m。接下来 m 行,每行包含三个整数 u,v,w,表示点 u 和点 v 之间存在一条权值为 w 的边。

输出格式:共一行,若存在最小生成树,则输出一个整数,表示最小生成树的树边权重之和,如果最小生成树不存在则输出 impossible

数据范围:1≤n≤500,1≤m≤10^5,图中涉及边的边权的绝对值均不超过 10000。

输入样例:

4 5
1 2 1
1 3 2
1 4 3
2 3 2
3 4 4

输出样例:

6
思路分析

根据以前学过的数据结构,Prim就是“加点”,即每次选一个点加入最小生成树,直到所有点都加入树。代码实际的总体思路和迪杰斯特拉有点像,只是Prim的每一轮是从集合外中选一点 t,t是到集合内的距离最近的点,然后用 t 更新其他点;迪的 dist[i] 是 i 到起点的最短路径,而这里的 dist[i] 是 i 到集合的最短路径。

代码实现
public class _858 {
    static final int INF = 0x3f3f3f3f; //定义无穷
    static int N = 510;
    static int n,m;  //输入的图的点数和边数
    static int g[][] = new int[N][N];  //用邻接矩阵存储图
    static int dist[] = new int[N];  //存储某点到集合的最短路径的值
    static boolean st[] = new boolean[N];  //标记是否已经确定到 某个点的最短路径

    public static int Prim(){
        int result = 0;
        Arrays.fill(dist,INF);
        //循环n次,每次树中多一个点,直到n个点全部在树中
        for (int i = 0; i < n; i++) {
            int t = -1;
            for (int j = 1; j <= n ; j++) {
                if (!st[j] && (t == -1 || dist[t]>dist[j]))
                    t=j;
            }
            // 找到的离集合最近的点的距离都是无穷 那就说明不是连通图 不存在最小生成树
            if (i!=0 && dist[t] == INF)
                return INF;
            if (i != 0)
                result += dist[t];
            for (int j = 1; j <= n ; j++)
                dist[j] = Math.min(dist[j],g[t][j]);
            st[t] = true;
        }
        return result;
    }

    public static void main(String[] args) throws IOException {
        BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
        String[] s = bf.readLine().split(" ");
        n = Integer.parseInt(s[0]);
        m = Integer.parseInt(s[1]);
        for (int i = 1; i <= n; i++)
            Arrays.fill(g[i], INF);
        for (int i = 0; i < m; i++) {
            String[] s1 = bf.readLine().split(" ");
            int a = Integer.parseInt(s1[0]);
            int b = Integer.parseInt(s1[1]);
            int c = Integer.parseInt(s1[2]);
            g[a][b] = g[b][a] = Math.min(g[a][b],c);  //无向图 取最小的那条边
        }
        int result = Prim();
        if (result == INF)
            System.out.println("impossible");
        else
            System.out.println(result);
    }
}

t=-1那个点,去看上一个文件的迪杰斯特拉的解释。还有注意一个点,要先更新 result 再去更新集合外点的距离。因为假如一个点自身存在负环,更新到自己时这个 dist[j] 就会不停变小导致 result 一直变小。后续优化思路参考迪杰斯特拉的堆优化。

Kruskal

859. Kruskal算法求最小生成树

给定一个 n 个点 m 条边的无向图,图中可能存在重边和自环,边权可能为负数。求最小生成树的树边权重之和,如果最小生成树不存在则输出 impossible。给定一张边带权的无向图 G=(V,E),其中 V 表示图中点的集合,E 表示图中边的集合,n=|V|,m=|E|。由 V 中的全部 n 个顶点和 E 中 n−1 条边构成的无向连通子图被称为 G 的一棵生成树,其中边的权值之和最小的生成树被称为无向图 G 的最小生成树。

输入格式:第一行包含两个整数 n 和 m。接下来 m 行,每行包含三个整数 u,v,w,表示点 u 和点 v 之间存在一条权值为 w 的边。

输出格式:共一行,若存在最小生成树,则输出一个整数,表示最小生成树的树边权重之和,如果最小生成树不存在则输出 impossible

数据范围:1≤n≤105,1≤m≤2∗105。图中涉及边的边权的绝对值均不超过 1000。

输入样例:

4 5
1 2 1
1 3 2
1 4 3
2 3 2
3 4 4

输出样例:

6
思路

(比较简单,不多说,看图)判断是否连通时有用到之前学的并查集。

代码实现
public class _859 {
    static int N = 200010;
    static int n,m;  //输入的图的点数和边数
    static int[] p = new int[N];
    static Edge[] edges = new Edge[N];  //存储边的数组

    //查找 x 所在集合的操作(返回该节点的祖先)
    public static int find(int x){
        if (x != p[x])   //不断向上找爸爸,并进行路径压缩
            p[x] = find(p[x]);   //给px赋值就是在路径压缩——让路上所有父辈将来都直接变成祖先的儿子(如果直接递归执行find就没有实现路径压缩)
        return p[x];
    }

    public static void main(String[] args) throws IOException {
        BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
        String[] s = bf.readLine().split(" ");
        n = Integer.parseInt(s[0]);
        m = Integer.parseInt(s[1]);

        for (int i = 0; i < m; i++) {
            String[] s1 = bf.readLine().split(" ");
            int a = Integer.parseInt(s1[0]);
            int b = Integer.parseInt(s1[1]);
            int c = Integer.parseInt(s1[2]);
            edges[i] = new Edge(a,b,c);
        }
        Arrays.sort(edges,0,m);

        //初始化并查集
        for (int i = 1; i <= n ; i++)
            p[i] = i;

        int res = 0;  //记录最小生成树边权总和
        int count = 0;  //记录目前一共加入最小生成树的边数
        for (int i = 0; i < m; i++) {
            int a = edges[i].a;
            int b = edges[i].b;
            int w = edges[i].w;
            if (find(a) != find(b)){   //如果a和b不在一个集合(即二者的祖先不一样)
                p[find(a)] = find(b);  //让a的祖先的爸爸=b的祖先(即把a合并到b集合)
                count++;
                res += w;
            }
        }

        if (count < n-1)
            System.out.println("impossible");
        else
            System.out.println(res);
    }
}

class Edge implements Comparable<Edge>{
    int a,b,w;

    public Edge(int a, int b, int w) {
        this.a = a;
        this.b = b;
        this.w = w;
    }

    @Override
    //实现Comparable接口,重写compareTo方法,便于Arrays.sort()方法排序
    public int compareTo(Edge e) {
        return Integer.compare(w, e.w);//this.w > e.w时,返回1
    }
}

二分图

二分图的定义

有两顶点集且图中每条边的的两个顶点分别位于两个顶点集中,每个顶点集中没有边直接相连接!

说人话的定义:图中点通过移动能分成左右两部分,左侧的点只和右侧的点相连,右侧的点只和左侧的点相连。

下图就是个二分图:

染色法

含义

  • 开始对任意一未染色的顶点染色。

  • 判断其相邻的顶点中,若未染色则将其染上和相邻顶点不同的颜色。

  • 若已经染色且颜色和相邻顶点的颜色相同则说明不是二分图,若颜色不同则继续判断。

  • bfs和dfs可以搞定

860. 染色法判定二分图

给定一个 n 个点 m 条边的无向图,图中可能存在重边和自环。请你判断这个图是否是二分图。

输入格式:第一行包含两个整数 n 和 m 。接下来 m 行,每行包含两个整数 u 和 v,表示点 u 和点 v 之间存在一条边。

输出格式:如果给定图是二分图,则输出 Yes,否则输出 No

数据范围:1≤n,m≤10^5

输入样例:

4 4
1 3
1 4
2 3
2 4

输出样例:

Yes
思路

遍历所有点,如果没染色就调用dfs:1.让他染;2.看他的邻接点:(1)没染,则染,并且递归调用dfs一直往深度染;(2)如果染了但染的是同色,就直接返回false。

代码实现
public class _860 {
    static int N = 200010;
    static int[] color = new int[N];  //标记染色值的数组。默认0未染色,1、2两种色
    static int h[] = new int[N];  //存储各节点的第一条边的下标索引
    static int e[] = new int[N];   //第idx条边的终点的编号
    static int ne[] = new int[N];   //与 第 idx 条边 同起点 的 下一条边 的 idx
    static int idx;

    public static void add(int a, int b){
        e[idx] = b;
        ne[idx] = h[a];
        h[a] = idx++;
    }

    public static boolean dfs(int x, int c){
        color[x] = c;
        //遍历x的所有邻点 i是x为起点的第一条边的边号
        for (int i = h[x]; i != -1 ; i=ne[i]) {
            int j = e[i];
            //如果邻点j还没染色
            if (color[j] == 0) {
                //如果 x 的颜色是2,则j染成1;反之x为1,j染成2
                if (!dfs(j, 3 - c))
                    return false;
            }
            //如果邻点染色了且颜色和x一样
            else if (color[j] == c)
                return false;
        }
        return true;
    }

    public static void main(String[] args) throws Exception{
        BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
        String[] s = bf.readLine().split(" ");
        int n = Integer.parseInt(s[0]);
        int m = Integer.parseInt(s[1]);
        Arrays.fill(h,-1);
        for (int i = 0; i < m; i++) {
            s = bf.readLine().split(" ");
            int a = Integer.parseInt(s[0]);
            int b = Integer.parseInt(s[1]);
            add(a,b);
            add(b,a);
        }

        boolean flag = true;
        for (int i = 1; i <= n ; i++) {
            if (color[i] == 0){
                if (!dfs(i,1)){
                    flag = false;
                    break;
                }
            }
        }

        if (flag)
            System.out.println("Yes");
        else
            System.out.println("No");
    }
}

匈牙利算法

861. 二分图的最大匹配

给定一个二分图,其中左半部包含 n1 个点(编号 1∼n1),右半部包含 n2 个点(编号 1∼n2),二分图共包含 m 条边。数据保证任意一条边的两个端点都不可能在同一部分中。请你求出二分图的最大匹配数。

二分图的匹配:给定一个二分图 G,在 G 的一个子图 M 中,M 的边集 {E} 中的任意两条边都不依附于同一个顶点,则称 M 是一个匹配。

二分图的最大匹配:所有匹配中包含边数最多的一组匹配被称为二分图的最大匹配,其边数即为最大匹配数。

输入格式:第一行包含三个整数 n1、 n2 和 m。接下来 m 行,每行包含两个整数 u 和 v,表示左半部点集中的点 u 和右半部点集中的点 v 之间存在一条边。

输出格式:输出一个整数,表示二分图的最大匹配数。

数据范围:1≤n1,n2≤500,1≤u≤n1,1≤v≤n2,1≤m≤10^5

输入样例:

2 2 4
1 1
1 2
2 1
2 2

输出样例:

2
思路
形象化总体思路

根据上述二分图的匹配和最大匹配的概念,这里借助一个很形象的例子去理解匈牙利算法找最大匹配(虽然感觉有点恶臭)。假如你是一个媒人,你手下有 n1 个男生和 n2 个女生,n1个男生对这 n2 个女生中的若干个有好感;n2 个女生也对这 n1 个男生中的若干个有好感。在满足不 ”脚踏多只船“ 的情况下,尽量多的让两两成对。问题就是,怎么牵线能让最多对的男女在一起。

已经把问题简化成通俗的例子,现在再说一下对应到原题的关系:

二分图左半部点集 = n1 个男生 二分图右半部点集 = n2 个女生 二分图中的边 = 男女生之间的好感关系

题给的二分图 = n1 个男生 + n2 个女生 + 他们之间的好感关系(连线表示有好感) 组成的整个图

匹配 = 任意男女生都不存在 “脚踏多只船” 的情况 最大匹配 = 凑成的鸳鸯对数最多

图解匈牙利算法具体过程

下面用图展示具体实现步骤。假设所给二分图如下:

匈牙利算法步骤如下:

  1. 给 1 号男生找对象,发现第一个和他相连的1号女生还名花无主,连上一条蓝线:

  2. 给 2 号男生找对象,发现第一个和他相连的 2 号女生还名花无主,连上一条蓝线:

  3. 给 3 号男生找对象,发现第一个和他相连的 1 号女生已经名花有主,这时,我们试着给这位有主的名花的主(也就是1号男生)另外分配一个女生。(黄色表示这条边被临时拆掉):

  4. 与1号男生相连的第二个女生是2号女生,但是2号女生也有主了,这时,我们再试着给2号女生的原配(即2号男生)重新找个女生(注意这个步骤和上面是一样的,这是一个递归的过程):

  5. 此时发现2号男生还能找到3号女生,那么之前的问题迎刃而解了,回溯回去:

  1. 于是前 3 步(给1、2、3号男生找对象)的结果就是:

  2. 接下来给 4 号男生找对象。很遗憾,按照第三步的方法我们没法给4号男生腾出来一个女生。至此,匈牙利算法结束。

代码实现
public class _861 {
    static int N = 510;
    static int M = 100010;
    static int n1,n2,m;
    static boolean[] st = new boolean[N];  //临时预定数组,st[j]=a表示一轮模拟匹配中,女孩j被男孩a预定了。
    static int[] match = new int[N];  //match[j]=a,表示女孩j的现有配对男友是a
    static int h[] = new int[N];  //存储各节点的第一条边的下标索引
    static int e[] = new int[M];   //第idx条边的终点的编号
    static int ne[] = new int[M];   //与 第 idx 条边 同起点 的 下一条边 的 idx
    static int idx;

    public static void add(int a, int b){
        e[idx] = b;
        ne[idx] = h[a];
        h[a] = idx++;
    }

    //为男生x尝试找对象的方法,能有办法找到不与前面的兄弟冲突的对象(有冲突解决冲突也算)则返回true,反之返回false
    public static boolean find(int x){
        //遍历男生x所有有好感的女生
        for (int i = h[x]; i != -1; i=ne[i]) {
            int j = e[i];   //j为男生x有好感的女生
            if (!st[j]) {  //如果该女生没被临时预定
                st[j] =true;  //那就预定!
                if (match[j] == 0 || find(match[j])) {  //如果她还没有男朋友 or 现任有其他备胎
                    match[j] = x; //j换现男友了
                    return true;  //并且此次匹配成功为男生x找到女朋友
                }
            }
        }
        return false;
    }

    public static void main(String[] args) throws Exception{
        BufferedReader bf = new BufferedReader(new InputStreamReader(System.in));
        String[] s = bf.readLine().split(" ");
        n1 = Integer.parseInt(s[0]);
        n2 = Integer.parseInt(s[1]);
        m = Integer.parseInt(s[2]);
        Arrays.fill(h,-1);
        for (int i = 0; i < m; i++) {
            s = bf.readLine().split(" ");
            int a = Integer.parseInt(s[0]);
            int b = Integer.parseInt(s[1]);
            add(a,b);
        }

        int res = 0;   //最终成对的鸳鸯数(二分图的最大匹配数)
        //为所有的男生试着找对象
        for (int i = 1; i <= n1 ; i++) {
            Arrays.fill(st,false);  //每为一个男生找对象时,临时预定数组都要清空
            //如果找到合理女友,匹配数就+1
            if (find(i))
                res++;
        }
        System.out.println(res);
    }
}

重点在于这个 find 方法。方法的整体逻辑已经在注释中写的很清楚,下面说几个会产生疑惑的点:

  1. st[]的作用:
    对于男生x,遍历他所有喜欢的女生。如果某个女生 j 没有被他预定过的话,就标记 j 被他预定,即 st[j]=true。
    这时如果:

    (1) j 还没有匹配过,即 match[j]==0 ,那这个预定就成真了, x 立即得到 j,有 match[j]=x;

    (2)j 已经有现男友 k 了,那我们就调用 find(k),看看这个兄弟 k 能不能找到别的备胎。既然是递归调用 find,那必然还是会遍历 k 的所有喜欢的女生,就一定会再找到 j。既然是找备胎,肯定不能再找 j 了。因此st[j]=true 的作用就在此了:能让 k 在找备胎的时候第一时间跳过 j。如果 find(k)返回true,即 x 的好兄弟 k 能找到备胎,那么 j 就让给 x了,预定也成真。

    (3)如果 (1)(2)都不满足,即 女生 j 有男朋友了而且男朋友让不了一点,那男生 x 就单着吧,就回到主函数的循环里给下一个男生找对象。

  2. 输入加边的时候,编号是有可能重复的(因为左右部分的集合是分开编号的),为什么没有影响呢?(为什么还可以像之前那样处理呢?为什么不需要 add(b,a) 呢?)

    因为题目的大前提就是这个图是二分图,加的边一定是 左集合的 u 与右集合的 v 相连,不会出现比如左集合中的两点连线这种情况。因此 add 方法的含义就不再是,增加图中两点之间的边了,而是增加两子图之间的边。具体而言,对于 add(a,b) 方法,含义为:加入一条 左集合编号为 a 与右集合编号为 b 的连线。按照这个定义,加入一条左集合编号为 u 到 右集合编号为 v 的边(即题目中输入边的定义),那就执行add(u,v);如果你再执行 add(v,u),意义就变成了 加入一条 左集合编号为 v 的点与右集合编号为 u 的点的连线,显然是错的。

评论 1
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值