搜索与图论之最小生成树与二分图
目录
大话数据结构Prime算法模板,可以保存最小生成树中的边集和路径
1.Prime算法
思路:类似于djikstra算法
- 初始化从顶点1为第一个加入集合的点
- 找到不在集合中的一个点,他距离集合的距离最小,如果这个距离是我们初始化的0x3f3f3f3f,说明不存在最小生成树,因为根本就不连通
- 用这个点更新所有点到集合的最短距离
- 其中,dist数组不再表示从起点1到该点的距离,他表示从某集合中的某一个点到该点的距离
AC代码
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 510, M = 100100, INF = 0x3f3f3f3f;
int g[N][N]; //稠密图邻接矩阵
int dist[N]; //最小生成树中每个点到集合(最小生成树顶点集合)当中的距离
int n, m, res; //res为最小生成树的权值
bool st[N]; //判断一个点是否已经加入到最小生成树的集合当中
int prime()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0; //初始化从1节点开始拓展最小生成树
for(int v = 1; v <= n; v ++ )
{
int t = -1; //用来标记加入集合的顶点
for(int i = 1; i <= n; i ++ )
{
if(st[i] == false && (t == -1 || dist[i] < dist[t])) //找到当前状态下不在集合中的最小权值边的顶点
t = i;
}
st[t] = true; //加入集合,标记一下
if(dist[t] == INF) //如果加入集合的顶点对应的最小变为INF,说明不存在最小生成树
return INF;
res += dist[t];
for(int i = 1; i <= n; i ++ ) //用新加入集合的顶点更新最小生成树
dist[i] = min(dist[i], g[t][i]); //g[t][i]表示该顶点到集合的距离
}
return res;
}
int main()
{
cin >> n >> m;
for(int i = 1; i <= n; i ++ ) //邻接矩阵初始化
for(int j = 1; j <= n; j ++ )
if(i == j) g[i][j] = 0;
else g[i][j] = INF;
for(int i = 0; i < m; i ++ )
{
int a, b, c;
cin >> a >> b >> c;
g[a][b] = g[b][a] = min(g[a][b], c); //无向图考虑成特殊的有向图
}
int t = prime();
if(t == INF) cout << "impossible" << endl;
else cout << t << endl;
// for(int i = 1; i <= n; i ++ ) cout << dist[i] << " ";
// cout << endl;
return 0;
}
堆优化Prime算法
优化思路和堆优化djikstra算法类似,但是在稠密图中,kruskal算法比Prime算法要更好一点,代码更短,思路更简单,所以很少(几乎不)使用堆优化Prime算法。
大话数据结构Prime算法模板,可以保存最小生成树中的边集和路径
2.Kruskal算法
思路:
- 用结构体存储边集数组,按照边的权值从小到大对边排序
- 从头到尾遍历边,只要该边加入集合后不会构成回路,就把这条边插入集合当中
- 如果最后插入的边数小于N-1条,说明构不成一个最小生成树
- 通过并查集思想判断加入一条边会不会构成回路
AC代码
#include <cstring>
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 100010, M = 200010, INF = 0x3f3f3f3f;
int far[N]; //前驱数组(并查集的父亲数组)
int n, m;
struct Edge
{
int a, b, w;
bool operator< (const Edge &W)const
{
return w < W.w;
}
}edges[M];
int find(int x) //并查集
{
if(far[x] != x) far[x] = find(far[x]);
return far[x]; //这里要返回far[x]不是返回x
}
int kruskal()
{
int res = 0, cut = 0; //res表示最小生成树的权值,cut表示选取的边数
sort(edges, edges + m);
for(int i = 0; i < m; i ++ )
{
int a = edges[i].a, b = edges[i].b, w = edges[i].w;
a = find(a), b = find(b); //注意找到a和b的根前驱,而不是直接前驱
if(a != b) //如果没有形成换
{
far[b] = a; //b指向他的前驱a
res += w;
cut ++ ;
}
}
if(cut < n - 1) //如果选取的边数小于n-1,那么就不足以构成一个联通无回路,也就不存在最小生成树
return INF;
return res;
}
int main()
{
scanf("%d%d", &n, &m);
for(int i = 0; i < m; i ++ )
{
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
edges[i] = {a, b, c};
}
for(int i = 0 ; i <= n; i ++ ) far[i] = i; //初始化前驱数组
int t = kruskal();
if(t == INF) puts("impossible");
else printf("%d\n", t);
return 0;
}
二分图:二分图_百度百科 (baidu.com)
3.染色法判断二分图
染色法思路
- 因为二分图的点集可以划分成两个区域,我们可以用两种颜色分别给两个区域的点染色
- 二分图一条边的两个端点肯定分别属于这两个点集,不可能属于同一个点集,否则这就不是一个二分图
- 因为整个的联通分量可能大于1,即该图可能不连通。我们可以深度优先遍历每一个联通分量,给起始点染色为1,那么与起始点相邻的点就得染色为2,下一个点染色为1,以此类推,如果出现相邻两个点颜色相同,那么改图不是二分图。
AC代码
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 100010, M = 200010; //因为该题是无向图,所以边数要乘2
int h[N], e[M], ne[M], idx;
int color[N];
int n, m;
void add(int a, int b)
{
e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
}
bool dfs(int u, int c)
{
color[u] = c; //给起点染色
for(int i = h[u]; i != -1; i = ne[i] ) //给该点所在的联通量染色
{
int j = e[i]; //千万不要忘了邻接表存储的是点的下标
if(!color[j])
{
if(!dfs(j, 3 - c)) //因为我们染色除了1就是2,所以下一个点的染色可以用3-c(当前颜色)表示
return false; //如果下一个顶点染色失败,直接返回fasle
}
else if(color[j] == c) return false; //一条边的两端是同一种颜色
}
return true;
}
int main()
{
cin >> n >> m;
memset(h, -1, sizeof h);
while(m -- )
{
int a, b;
cin >> a >> b;
add(a, b); add(b, a); //无向图处理成特殊的有向图
}
int flag = true;
for(int i = 1; i <= n; i ++ )
{
if(!color[i]) //没有染色过
{
if(!dfs(i, 1)) //如果染色出现矛盾
{
flag = false;
break;
}
}
}
if(flag) cout << "Yes" << endl;
else cout << "No" << endl;
return 0;
}
说明
一个减少代码行数的操作:因为我们每一个点的染色只能是1或者2,我们用c表示上一个点的染色方案,那么下一个点的染色方案就是 3-c 。
深入理解邻接表
- 理解邻接表中h[N], e[M], ne[M]为什么大小是N或者M。
- h[i] 表示点 i 构成的邻接表的头指向的点,存储的是点的信息
- e[i] 表示这条边指向的点,存储的是点的信息,但有多少边就有多少 e[i]
- ne[i] 表示下一条边指向的节点,存储的是点的信息,但有多少边就有多少 e[i]
- idx 表示待加入边的编号
- e[i] 和 ne[i] 的 i 都是边号(也可以称为顶点序号)不是点号
4.匈牙利算法:二分图的最大匹配
求二分图最大匹配可以用最大流或者匈牙利算法。
最大匹配概念:
给定一个二分图G,在G的一个子图M中,M的边集中的任意两条边都不依附于同一个顶点,则称M是一个匹配.(有点类似于单射的概念)
选择这样的边数最大的子集称为图的最大匹配问题(maximal matching problem)
如果一个匹配中,图中的每个顶点都和图中某条边相关联,则称此匹配为完全匹配,也称作完备匹配。
算法思路
- 我们可以把二分图的两个点集A和B假象成男生和女生,那么问题就转化成了找男生和女生最多可以1对1结合的对数(我们默认只要男生钟情女生,女生就一定钟情男生)。
- 从第一个男生开始遍历,判断该男生是否可以找到心仪的女生,假设我们这里的男生都是专一(见好就收)的,如果该男生找到了一个心仪的女生,那么往后的女生他就不再考虑了。
- 如果一个男生X第一个钟情的女生K已经有归属Y(属于这个男生前面的某一个男生),这个男生X不会就此放弃,他会不撞南墙不回头,在X的强烈攻势下,Y只好选择另外一个女生,这个女生就归X所有了(成功被绿),但如果Y在接触完所有女生之后,还是只钟情于女生K,X就会被Y的深情感动,他放弃追求,变成了一个单身汉。
- 如果男生X第一个钟情的女生K没有归属,那么他们就可以喜结良缘了。
综上所述,这是一个很现实的算法
- 遍历点集N1,如果n1匹配到的N2点集中的n2没有被选用,匹配成功
- 否则,让n2匹配到的点匹配另外一个,如果n2匹配成功,n1就匹配成功
- 否则,n1匹配失败
AC代码
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 510, M = 100010;
int h[N], e[M], ne[M], idx;
int match[N]; //match[i]表示女生i的匹配对象
bool st[N]; //设置女生的状态,防止一个女生匹配多个男生
int n1, n2, m;
void add(int a, int b)
{
e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
}
bool find(int x)
{
for(int i = h[x]; i != -1; i = ne[i] )
{
int j = e[i];
if(!st[j]) //如果这个女生没有被考虑过
{
st[j] = true; //考虑过了,标记一下
//要放在if的前面,否则j仍会选择match[j]
if(match[j] == 0 || find(match[j])) //如果该女生没有归属或者归属的人可以选择另一个
{
match[j] = x;
return true;
}
}
}
return false;
}
int main()
{
cin >> n1 >> n2 >> m;
memset(h, -1, sizeof h);
while(m -- )
{
int a, b;
cin >> a >> b;
add(a, b); //无论二分图是有向图还是无向图,因为我们只考虑单射,即只考虑点集A->B的情况而不考虑B->A,所以只需要加入一条A->B到的边即可
}
int res = 0; //成功匹配的个数
for(int i = 1; i <= n1; i ++ )
{
memset(st, false, sizeof st); //初始化所有女生都没有考虑过
if(find(i)) res ++ ; //匹配成功
}
cout << res << endl;
return 0;
}
关于ST数组:
st数组的主要作用在于: if(match[j] == 0 || find(match[j])) 这一步
在 find(match[i])的过程中,又调用了一次find函数,这时候如果没有把 st 标记一下,j 和 match[j]就会选择同一个女生
关于时间复杂度:
代码整体思路就是对于点集N中的x遍历整个点集M,时间复杂度最坏就是O(mn)
但是因为x不一定与点集M中的点都有连线,所以实际运行中的时间效率是比较高的,甚至是线性关系。
为什么数据范围是稠密图,选择用邻接表存储图:
因为本题需要用到找一个点x的相邻节点,所以使用邻接表更方便,邻接矩阵不具有查找相邻的元素这个性质。