概述
图的严格定义是一个表达式 ,其中V表示点集,E表示边集, 表示边与点的映射关系。
- 如果 ,那么 G 为无向图;
- 如果 ,那么 G 为有向图。
平时我们使用的时候就不用那么严谨了。
图的计算机存储方式一般有两种,一种是邻接矩阵的方法,一种是邻接表。邻接矩阵相对来说对于大数据的稀疏图不适用,所以我们一般使用邻接表的存储方法。C++中用 vector<type> G[maxn]
这种方式非常方便,还有一种链式向前星的方法不用使用STL,也挺好的。(vector开了O2优化应该也是很快的)
还有一些常见的名词,比如说连通性、强连通性、分支、完全图、环、度、强连通分量、生成树、有向无环、桥等等。
图的遍历
图有两种遍历方式:深度优先(dfs)和广度优先(bfs),深度优先一般使用递归实现,广度优先一般使用队列实现。这其实跟搜索差不多。
二分图判断
使用深度优先搜索可以判断一个图是否为二分图,这对于其它的处理很有帮助。
判断方法是交替染色,如果遇到矛盾(相邻结点颜色一样)说明存在奇环,不是二分图。
判断二分图代码如下:
const int maxn=1e5+5;
vector<int> G[maxn];
int n,m,vis[maxn];
bool dfs(int u)
{
REP(i,0,G[u].size()-1)
{
int v=G[u][i];
if(vis[u]==vis[v]) return 0;
if(!vis[v])
{
vis[v]=3-vis[u]; // 这里用1和2来交替染色
if(!dfs(v)) return 0;
}
}
return 1;
}
int main()
{
n=read(),m=read();
while(m--)
{
int u=read(),v=read();
G[u].push_back(v);
G[v].push_back(u);
}
int flag=1;
REP(i,1,n) if(!vis[i]) vis[i]=1,flag&=dfs(i);
puts(flag?"Yes":"No");
return 0;
}
拓扑排序
拓扑排序是针对有向无环图(DAG)的,所谓有向无环图,就是没有环的有向图(这里的环在图论中对应于有向回路)。任何有向无环图都至少有一个拓扑序列。
拓扑排序:指一个DAG的所有顶点的线性序列,使得对于任何有向边<u,v>,序列中u都在v的前面。
要注意的是有时候我们笼统地把反拓扑序列也称为拓扑序列,及所有u都在v后面,总之这个序列满足一定的先后性。
拓扑排序的方法很简单:建图的时候记录每个结点的入度,然后用队列去维护所有入度为0的点,每去掉一个点的时候遍历其所有的边,将边的末端结点的入度减一,遇到入度为0的结点就入队即可。
最小生成树
生成树定义为(n个点)无向图的一个具有n-1条边的连通生成子图。而最小生成树是对应于具有边权的连通无向图来说的,最小生成树就是所有生成树中边权和最小的那一个。
计算最小生成树往往采用Kruskal算法,算法流程是:将所有边按照边权从小到大排序,然后从小到大遍历,用并查集维护结点的连通性,每遇到未连通的两个结点,就将该边加入生成树的边集之中。这实际上是一种贪心算法。
Kruskal算法的代码如下:
const int maxn=2e5+5;
struct edge
{
int u,v,w;
bool operator < (const edge &x) const {return w<x.w;}
}e[maxn];
int far[maxn];
int findd(int x) {return x==far[x]?x:far[x]=findd(far[x]);}
bool isSame(int x,int y) {return findd(x)==findd(y);}
void unite(int x,int y) {far[findd(x)]=findd(y);}
int main()
{
int n=read(),m=read(),tot=0,ans=0;
REP(i,1,m)
{
int u=read(),v=read(),w=read();
e[i]=(edge){u,v,w};
}
sort(e+1,e+m+1);
REP(i,1,n) far[i]=i;
REP(i,1,m) if(!isSame(e[i].u,e[i].v))
unite(e[i].u,e[i].v),tot++,ans+=e[i].w;
if(tot<n-1) puts("orz");
else printf("%d",ans);
return 0;
}
注意,这样算出来的最小生成树同时也是最小瓶颈生成树,即生成树中最大边权值在所有生成树中是最小的。
还有一种次小生成树,即最小的大于等于最小生成树边权和的生成树,这里我们可以先求出最小生成树之后,对于每一个不在最小生成树中的边e=<u,v,w>,我们寻找u到v的路径(这条路径一定是唯一的)上的最大边权的那条边,然后用w去替换它的边权;这样构建的所有树当中边权和最小的就是次小生成树。其中u到v最大边权的求解可以使用树链剖分+线段树。
最小树形图
最小树形图是指:在一个带边权的有向图中,给定一个根root,构建一个以root为根节点的有向树,使得其边权和最小。
计算最小边权和采用朱刘算法,时间复杂度为O(VE)。
算法的流程为不断重复以下过程:
- 对除root之外的每个结点找出一个边权最小的入边(如果没有入边说明不存在树形图,直接返回-1),这些入边(总共n-1条)构成一个边集E;
- 将E中所有环缩点,如果E中没有环说明已经找到了最小树形图,跳出;(由于每个点只有一个入边,故E要么是一棵树,要么由一些树加上一些环组成)
- 缩点过后重新建图,建图时对边权做一些处理(反悔机制,减去上一次已选入边的边权);
这其实本质上还是一个贪心算法。
朱刘算法代码如下:
// pre记录前驱结点,in记录最小入边权,vis用于循环枚举pre找环,id用于记录缩点后各个结点的编号
const int maxn=1e4+5,inf=1e8;
struct edge{int u,v,w;}e[maxn];
int pre[maxn],in[maxn],vis[maxn],id[maxn],n,m;
int zhuliu(int root)
{
int ans=0;
while(1)
{
REP(i,1,n) in[i]=inf,vis[i]=id[i]=0;
REP(i,1,m) if(e[i].u!=e[i].v && e[i].w<in[e[i].v]) in[e[i].v]=e[i].w,pre[e[i].v]=e[i].u;
REP(i,1,n) if(i!=root && in[i]==inf) return -1;
int cnt=0; in[root]=0;
REP(i,1,n)
{
ans+=in[i];
int v=i;
while(vis[v]!=i && !id[v] && v!=root) vis[v]=i,v=pre[v];
if(!id[v] && v!=root)
{
id[v]=++cnt;
for(int u=pre[v];u!=v;u=pre[u]) id[u]=cnt;
}
}
if(!cnt) break;
REP(i,1,n) if(!id[i]) id[i]=++cnt;
REP(i,1,m)
{
int u=e[i].u,v=e[i].v;
e[i].u=id[u],e[i].v=id[v];
if(id[u]!=id[v]) e[i].w-=in[v];
}
root=id[root]; n=cnt;
}
return ans;
}