ACM算法总结 图论(一)





概述

的严格定义是一个表达式 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}   v1V,v2V} ,那么 G 为无向图;
  • 如果 Ψ : E → V × V \Psi: E \rightarrow V\times V Ψ:EV×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)。

算法的流程为不断重复以下过程:

  1. 对除root之外的每个结点找出一个边权最小的入边(如果没有入边说明不存在树形图,直接返回-1),这些入边(总共n-1条)构成一个边集E;
  2. 将E中所有环缩点,如果E中没有环说明已经找到了最小树形图,跳出;(由于每个点只有一个入边,故E要么是一棵树,要么由一些树加上一些环组成)
  3. 缩点过后重新建图,建图时对边权做一些处理(反悔机制,减去上一次已选入边的边权);

这其实本质上还是一个贪心算法。

朱刘算法代码如下:

// 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;
}
  • 1
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值