概述
图的严格定义是一个表达式 G = < V , E , Ψ > G=<V,E,\Psi> G=<V,E,Ψ> ,其中V表示点集,E表示边集, Ψ \Psi Ψ表示边与点的映射关系。
- 如果 Ψ : E → { { v 1 , v 2 } ∣ v 1 ∈ V , v 2 ∈ V } \Psi: E \rightarrow \{\{v_1,v_2\} \ | \ \ v_1 \in V,v_2 \in V\} Ψ:E→{{v1,v2} ∣ v1∈V,v2∈V} ,那么 G 为无向图;
- 如果 Ψ : E → V × V \Psi: E \rightarrow V\times V Ψ:E→V×V ,那么 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最大边权的求解可以使用树链剖分+线段树。
Kruskal重构树
运用最小生成树或者最大生成树,可以把一个无向图重构成一棵树(或者一个森林),典型的运用是在带有边权的无向图中,求两个结点之间最小边权的最大值:在不破坏连通性的前提下,我们可以删除那些边权尽量小的,这其实就是最大生成树的思想;将原来的图重构为最大生成树时,每遇到一条需要加入边集的边,新建一个结点,该结点的点权为该边边权(为了把维护边权变为维护点权,点权相对容易一些),然后用新结点连接原来的两端结点分别所在集合的父结点(并查集);重构完之后树链剖分,两点之间最小边权就是这两点的LCA的点权(这是因为Kruskal算法中会从大边权到小边权的顺序枚举,所以在树中越深的地方对应越大的边权,画一幅图就很容易理解)。
一个例题 货车运输(求两个结点之间最小边权的最大值)的代码:
const int maxn=2e4+5;
struct edge {int u,v,w;} e[50005];
vector<int> G[maxn];
bool cmp(edge a,edge b) {return a.w>b.w;}
int n,m,far[maxn],cnt,a[maxn];
int siz[maxn],f[maxn],d[maxn],son[maxn],dfn[maxn],which[maxn],top[maxn];
int findd(int x){return x==far[x]?x:far[x]=findd(far[x]);}
void dfs1(int u,int fa,int depth)
{
f[u]=fa; d[u]=depth; siz[u]=1; son[u]=0;
REP(i,0,G[u].size()-1)
{
int v=G[u][i];
if(v==fa) continue;
dfs1(v,u,depth+1);
siz[u]+=siz[v];
if(siz[v]>siz[son[u]]) son[u]=v;
}
}
void dfs2(int u,int tf)
{
top[u]=tf; dfn[u]=++cnt; which[cnt]=u;
if(!son[u]) return;
dfs2(son[u],tf);
REP(i,0,G[u].size()-1)
{
int v=G[u][i];
if(v!=f[u] && v!=son[u]) dfs2(v,v);
}
}
void add_edge(int u,int v) {G[u].push_back(v); G[v].push_back(u);}
void Kruskal()
{
sort(e+1,e+m+1,cmp);
REP(i,1,m)
{
int u=e[i].u,v=e[i].v,fu=findd(u),fv=findd(v);
if(fu==fv) continue;
a[++n]=e[i].w;
far[n]=n;
far[fu]=n; far[fv]=n;
add_edge(fu,n); add_edge(fv,n);
}
}
int LCA(int x,int y)
{
while(top[x]!=top[y])
d[top[x]]>d[top[y]]?(x=f[top[x]]):(y=f[top[y]]);
return d[x]<d[y]?x:y;
}
int main()
{
n=read(),m=read();
REP(i,1,m)
{
int u=read(),v=read(),w=read();
e[i]=(edge){u,v,w};
}
REP(i,1,n) far[i]=i;
Kruskal();
REP(i,1,n) if(!dfn[i])
{
dfs1(findd(i),0,0);
dfs2(findd(i),findd(i));
}
int q=read();
while(q--)
{
int u=read(),v=read(),fu=findd(u),fv=findd(v);
if(fu!=fv) puts("-1");
else printf("%d\n",a[LCA(u,v)]);
}
return 0;
}
其实也可以树链剖分之后用线段树维护边权,然后也可以在相似的复杂度中求出,但是这样普遍会慢很多。其实这个重构树的思路也可以运用到单纯的边权树链剖分中(只适用于路径边权最值的情况,而边权化点权+线段树还适用于路径边权和的情况)。
最小树形图
最小树形图是指:在一个带边权的有向图中,给定一个根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;
}