算法竞赛图论知识点总结,持续更新中~~~

目录

1. 无向图的连通性

1.1 割点与割边

1. 求割点

 2. 求割边

3. 应用

1.2 双联通分量

 1.2.1 点双联通分量

1.2.2 边双联通分量

2.有向图的连通性

        2.1 kosaraju

2.2 tarjan

        2.3 DAG(有向无环图)

3. 基环树

4. 差分约束系统


        本篇不涉及图的存储、拓扑排序、欧拉路等。

1. 无向图的连通性

1.1 割点与割边

        联通分量:无向图中所有能互通的点构成联通分量。一个图中可能有多个联通分量。

        割点:在无向图中,如果一个点删除后,联通分量变多,则该点为割点。注意:如果有只有一个点的联通分量,那么该点删除后,联通分量变少。

        割边:在无向图中,如果一条边删除后,联通分量变多,则该点为割点。

1. 求割点

        求图的割点与割边,需要用到图的深度优先生成树,如下图。

        其中生成树上的回退边是指,指向已经遍历过的点的边,如下(b)图中的虚线边。

        

        (1)对于根节点r,根节点是割点当且仅当在生成树上,根节点至少有两个儿子。

        (2)对于非根节点v,v是割点当且仅当在生成树上,v至少有一个儿子节点u,u及其后代节点没有回退边连向v的祖先。

        如(b)图,点上的数字表示递归遍历到的点的顺序,带有下划线的数字表示递归回溯的顺序。

        对于点e,儿子g及其后代没有回退边连向e的祖先,所以e是割点;儿子f及其后代有回退边连向祖先,所以e删除后,f不会被划分为一个联通分量,但是e仍然是割点,因为g。

        所以用num[v]表示v的递归序,low[v]表示v及其后代能够回退到的num最小的祖先,初始low[v]=num[v],在了解代码后,你会发现这种初始化是没有问题的。

        核心代码如下:

        

int num[N],low[N],dfn,r,is[N];
void dfs(int v,int f){
    num[v]=low[v]=++dfn;
    int c=0;//儿子节点数量,用于根节点是否是割点判断
    for(int i=head[v];~i;i=es[i].nex){
        int u=es[i].to;
        if(u==f)continue;//如果是连向父亲的边,不管
        if(!num[u]){
            c++;
            dfs(u,v);
            low[v]=min(low[v],low[u]);
            if(v!=r&&low[u]>=num[v]||v==r&&c>1)is[v]=1;
        }else low[v]=min(low[v],num[u]);
    }
}

其中有两个关键点:

(1)low[v]的更新:如果点u没有考察过,那么low[v]=min(low[v],low[u]);;如果考察过,那么表示是回退边,用num[u]更新low[v]。

(2)非根节点割点的判断:low[u]>=num[v],只要儿子节点u不能回退到v的祖先,那么v割点,其中v的祖先t,必有num[t]<num[v]。

洛谷3388的完整代码如下:

#include<bits/stdc++.h>
using namespace std;

const int N=2e4+50;
const int M=1e5+50;

struct edge{
    int to,nex;
}es[M<<1];
int head[N],cnt;

void init(){
    memset(head,-1,sizeof(head));
    cnt=0;
    for(auto e:es)e.nex=-1;
}

void add(int f,int t){
    es[cnt].to=t;
    es[cnt].nex=head[f];
    head[f]=cnt++;
}

int num[N],low[N],dfn,r,is[N];
void dfs(int v,int f){
    num[v]=low[v]=++dfn;
    int c=0;//儿子节点数量,用于根节点是否是割点判断
    for(int i=head[v];~i;i=es[i].nex){
        int u=es[i].to;
        if(u==f)continue;//如果是连向父亲的边,不管
        if(!num[u]){
            c++;
            dfs(u,v);
            low[v]=min(low[v],low[u]);
            if(v!=r&&low[u]>=num[v]||v==r&&c>1)is[v]=1;
        }else low[v]=min(low[v],num[u]);
    }
}

int main(){
    int n,m;
    cin>>n>>m;
    init();
    for(int i=0;i<m;i++){
        int u,v;
        cin>>u>>v;
        add(u,v);
        add(v,u);
    }
    for(int i=1;i<=n;i++){
        if(!num[i]){
            r=i;
            dfs(i,0);
        }
    }
    int ans=0;
    for(int i=1;i<=n;i++)ans+=is[i];
    printf("%d\n",ans);
    for(int i=1;i<=n;i++)if(is[i])printf("%d ",i);
}

如果有多个联通分量,那么只需对每个联通分量dfs就行了,代码如下:

for(int i=1;i<=n;i++){
        if(!num[i]){
            r=i;
            dfs(i,0);
        }
    }

考虑图的特殊情况:

①有重边:重边理论上对割点是没有影响的,而在如上的代码中,如果e、g(上图(b)中的e、g)有重边,那么在g考虑第二条指向e的边时,会因e是g的父亲而跳过。

②自环:当点e考虑自环边时,会因为遍历过而执行low[v]=min(low[v],num[u]),其中u==v,没有影响。

③不联通:对每个联通分量dfs即可。

 2. 求割边

对于求割边,非根节点与根节点的判断方式一样,对于点v连向u的边x,如果low[u]>num[v],那么x是割边,也即u不能回退到v及其v的祖先(注意割点是主要u不能回退到v的祖先即可,能回退到v没有关系)。

伪代码如下:

int num[N],low[N],dfn;
void dfs(int v,int f){
    num[v]=low[v]=++dfn;
    for(int i=head[v];~i;i=es[i].nex){
        int u=es[i].to;
        if(u==f)continue;//如果是连向父亲的边,不管
        if(!num[u]){
            dfs(u,v);
            low[v]=min(low[v],low[u]);
            if(low[u]>num[v])i是割边;
        }else low[v]=min(low[v],num[u]);
    }
}

 考虑图的特殊情况:

①不联通:和割点的处理方式一样。

②自环:自环是不会算作割边的,在代码中会因遍历过而执行low[v]=min(low[v],num[u]),其中u==v,没有影响。

③重边:如果v、u有重边,那么理论上这些边都不会是割边,所以只需预处理哪些点之间有重边即可。

3. 应用

        在无向图中:

        ①判断一条边是否在环中,等价于判断其是否是割边。

1.2 双联通分量

针对无向图而言。

 1.2.1 点双联通分量

        点双联通图:图中任意两个点之间至少有两条点不重复(除端点外)的路径。

        点双联通分量:一个图中的点双联通极大子图,一个图中可能有多个,也可能只有一个,即本身。

        一个图中的点双联通分量与该图的割点有什么关系呢?

        ①点双联通分量中一定没有割点,这点可以用反证法证明。

        ②两个不同点双联通分量最多只有一个公共点,这也可以用反证法证明:如果有两个公共点,那么这两个点双联通分量可以合并为一个点双联通分量,与点双联通是极大子图矛盾。

        ③不同的点双联通分量的公共点一定是割点,可以用反证法。

        ③一个割点至少是两个点双联通分量的公共点,由③可得。

        

        仍然是刚才的图,(b)中,e、a是割点,图中有三个点双联通分量{g,e},{a,c,e,f,d},{a,b},e是前两个的公共点,a是后两个的公共点。所以一个图中的所有割点划分出了所有的点双联通分量。

        (1)先考虑第一个问题:如何知道点双联通分量有多少个,这里假设图中只有一个联通分量,也即整个图是联通的。

         很简单,对于每个点v,其儿子u,如果v删除后儿子u会划分为一个联通分量,那么v、u、与u连接的一些点就组成了一个点双联通分量。具体地,在代码中只要满足v!=r&&low[u]>=num[v]||v==r&&c>1那么v、u就能划分出一个点双联通分量。代码如下:

int num[N],low[N],dfn,r,is[N],ans;
void dfs(int v,int f){
    num[v]=low[v]=++dfn;
    int c=0;//儿子节点数量,用于根节点是否是割点判断
    for(int i=head[v];~i;i=es[i].nex){
        int u=es[i].to;
        if(u==f)continue;//如果是连向父亲的边,不管
        if(!num[u]){
            c++;
            dfs(u,v);
            low[v]=min(low[v],low[u]);
            if(v!=r&&low[u]>=num[v]||v==r&&c>1)is[v]=1,ans++;
        }else low[v]=min(low[v],num[u]);
    }
}

当然最终答案为ans+1。因为如果根节点是割点,那么v==r且c==1时的那个点双联通分量没有考虑进去;如果根节点不是割点,那么仍然与根节点相连的那个点双联通分量没有考虑进去,可以节点图(b)思考。

        (2)如何求出所有的点双联通分量的大小与点集。

        思路:在dfs遍历的时候,把生成树上的边保存起来存到栈中,当遇到ans++的情况的时候,也即u不能回退v的祖先的时候,从栈中弹边,直到边的from为v。比如仍然还是图(b)。

        

        按照遍历顺序,从g回溯到e时,栈中的边为{a->c,c->e,e->f,f->d,e->g},回到e时,发现e对于g是割边,从栈中弹边,直到from为e,那么会弹出{e->g},所以这两个点就是一个联通分量了。

        注:如果一个图只有一个点,那么认为其是一个点双联通分量;两个点也可以是点双联通分量。

        洛谷8435 模板代码如下:

#include<bits/stdc++.h>
using namespace std;

const int N=5e5+50;
const int M=2e6+50;

struct edge{
    int f,to,nex;
}es[M<<1];
int head[N],cnt;

void init(){
    memset(head,-1,sizeof(head));
    cnt=0;
    for(auto e:es)e.nex=-1;
}

void add(int f,int t){
    es[cnt].to=t;
    es[cnt].f=f;
    es[cnt].nex=head[f];
    head[f]=cnt++;
}

int num[N],low[N],dfn,s,is[N];
vector<vector<int> >rec;
stack<int>stk;

void process(int v){
    if(stk.empty()){
        //用于判断只有一个点的子图
        rec.push_back(vector<int>(1,v));
        return ;
    }
    vector<int>tmp;
    int i;
    do{
        i=stk.top();
        stk.pop();
        tmp.push_back(es[i].to);
     }while(es[i].f!=v);
    tmp.push_back(v);
    rec.push_back(tmp);
}
void dfs(int v,int f){
    num[v]=low[v]=++dfn;
    int c=0;
    for(int i=head[v];~i;i=es[i].nex){
        int u=es[i].to;
        if(u==f)continue;
        if(!num[u]){
            c++;
            stk.push(i);
            dfs(u,v);
            low[v]=min(low[v],low[u]);
            if(low[u]>=num[v]&&v!=s||v==s&&c>1){
                is[v]=1;
                process(v);
            }
        }else low[v]=min(low[v],num[u]);
    }
}

int main(){
    int n,m;
    cin>>n>>m;
    init();
    for(int i=0;i<m;i++){
        int u,v;
        cin>>u>>v;
        add(u,v);
        add(v,u);
    }
    for(int i=1;i<=n;i++){
        if(!num[i]){
            s=i;
            dfs(i,0);
            process(i);
        }
    }
    printf("%d\n",rec.size());
    for(auto vec:rec){
        printf("%d ",vec.size());
        for(auto v:vec)
            printf("%d ",v);
        printf("\n");
    }
}

其中在dfs后,还需要再处理栈一次,一是为了处理只有一个点的联通分量,二是因为ans+1,前面说过ans为点双联通分量的数量,按照前面的求解方法,最终答案ans需要+1,是因为有一个与根节点相关的点双联通分量没有处理到。这里同理, 有一个点双联通分量没有处理到。

其中特别注意:在process函数中,不能将:

 do{
    i=stk.top();
    stk.pop();
    tmp.push_back(es[i].to);
}while(es[i].f!=v);

写为:

do{
    i=stk.top();
    stk.pop();
    tmp.push_back(es[i].to);
}while(!is[es[i].f]);

也即,不能通过判断是否处理到了from为割点的边来确定这是一个联通分量,你可能会认为这两种写法是一样的,因为在process前,将is[v]=1,也即将v标记为了割点,但问题就是在这个点双联通分量中可能不止v一个割点,任然考虑图(b)。

现在生成树的遍历顺序改变一下,b->a->c->e->f->d->g。那么从g回溯到e时,栈中{a->b,a->c,c->e,e->f,f->d,e->g}。弹出边e->g,栈中: {a->b,a->c,c->e,e->f,f->d},然后回溯到a,因为c>1,所以弹边,如果弹边是按照while(es[i].f!=v),那么会弹出{a->c,c->e,e->f,f->d};但如果按照!is[es[i].f],那么只会弹出{e->f,f->d},因为e是割点。

1.2.2 边双联通分量

        边双联通图:图中任意两个点之间至少有两条边不重复(点可以重复)的路径。

        边双联通分量:一个图中的边双联通极大子图,一个图中可能有多个,也可能只有一个,即本身。

        与点双联通类似,边双联通分量与割边有密切关系,具体地,一条割边连接两个边双联通分量,如图。

        

        明显有:边双联通个数=割边个数+1

        如果把一个边双联通看成一个点(缩点),那么最终形成了一颗树,如图。

        

        考虑这么一个问题:至少添加多少条边能使一个图为边双联通图。问题等价于:求出缩点树后,至少添加多少条边,使其为边双联通图。答案为:(树上度为1的点+1)/2。你可能想问说度为1的点不就是叶节点吗,为什么不说是叶节点。实则不然,因为这是无根树,所以任意一个点都能作为根,然而根可以度为1,但根不是叶节点,所以为了不产生混淆,所度为1是更好的选择。

        而具体求每个边双联通分量的方法与求点双联通类似(注意:判断割边不用特殊考虑根节点),具体代码如下(洛谷8436):

        

#include<bits/stdc++.h>
using namespace std;

const int N=5e5+50,M=2e6+50;

struct edge{
    int to,nex,id;
}es[M<<1];
int head[N],cnt;
void init(){
    memset(head,-1,sizeof(head));
    cnt=0;
    for(auto e:es)e.nex=-1;
}

void add(int f,int t,int id){
    es[cnt].to=t;
    es[cnt].id=id;
    es[cnt].nex=head[f];
    head[f]=cnt++;
}

int num[N],low[N],dfn,stk[N],top=-1,ans;
vector<int>res[N];

void process(int v){
    ans++;
    while(stk[top]!=v){
        res[ans].push_back(stk[top]);
        top--;
    }
    res[ans].push_back(v);
    top--;
}
void dfs(int v,int fid){
    num[v]=low[v]=++dfn;
    stk[++top]=v;
    for(int i=head[v];~i;i=es[i].nex){
        int u=es[i].to;
        if(es[i].id==fid)continue;//这里是根据id判断是否是走过的边,与割点中根据f(父亲)不//同,原因在于考虑重边,如果没有重边,可以写为if(u==f)continue
        if(!num[u]){
            dfs(u,es[i].id);
            low[v]=min(low[v],low[u]);
            if(low[u]>num[v])process(u);
            
        }else low[v]=min(low[v],num[u]);
    }
}
int main(){
    int n,m;
    cin>>n>>m;
    init();
    for(int i=0;i<m;i++){
        int u,v;
        cin>>u>>v;
        add(u,v,i);
        add(v,u,i);
    }
    for(int i=1;i<=n;i++){
        if(!num[i]){
            dfs(i,-1);
            process(i);
        }
    }
    printf("%d\n",ans);
    for(int i=1;i<=ans;i++){
        printf("%d",res[i].size());
        for(auto v:res[i])
            printf(" %d",v);
        printf("\n");
    }
}

 考虑图的特殊情况:

①自环:对于自环边,在代码中会被认为是回退边,更新low[v]=min(num[u],low[v]),其中u==v,不产生影响。

②不联通:对于每个联通分量,dfs一次即可。

③重边:对于重边,需要特别注意,如果两条边是重边,那么这两条边一定不是割边,因为割去任意一条边,另一条边都还在,所以需要把其中一条当成回退边,具体在代码中,需为每条边记录一个id,根据id来判断是否是从父亲走来的边,而不是边的to为父亲来判断。

        可能你会在某些书上看到说一个边双联通分量中所有点的low值相同,所以可以根据一个图中有多少种不同的low值确定有多少个边双联通分量,真的是这样吗,看如下的图。

        

        每个点左边的数字是递归顺序,右边的数字是low值,这是一个边双联通分量,但是每一个点的low值并不一样。

        更简单的一个图:

        

2.有向图的连通性


        强联通图:在有向图中,如果任意两个点互相可达(也即对于任意u、v,从u能找到到v的路径,从v也能找到到u的路径),则该图为强联通图。

        强联通分量(Strongly Connected Component,SCC):一个图中的极大强联通子图。 

        自然有关的问题就是一个图中有多少个SCC,以及每个SCC有哪些点。

        在讨论这个问题前,需要先明确一些问题。

        

        将一个SCC看成一个点,那么最终的图一定是有向无环图,因为如果出现环了,那么这个环应该为一个更大的SCC,就矛盾了。

        求解所有的SCC主要有两个算法。

        2.1 kosaraju

        kosaraju算法主要用到了拓扑排序与反图的方法。

        考虑刚才的图,因为缩点后的图为有向无环图,所以其必有拓扑排序,但问题在于我们不知道缩点后的图的样貌,但如果直接对原图采用递归的方法求解拓扑排序,那么一定有这样的结论:缩点图上的拓扑排序中越排在前面的SCC认为是越高优先级的,那么对原图拓扑排序一定有高优先级的SCC中至少会有一个点排在所有低优先级的SCC中的所有点的前面。比如在缩点图中,SCC的优先级从高到低为F、E、A,那么对原图拓扑排序一定有F中的至少一个点在拓扑的最前面,E中至少有一个点在A中所有点的前面。为什么说至少有一个点,什么时候刚好只有一个点呢,考虑如下这种图:

        

        每个点上的顺序为递归遍历到的顺序,图中有两个SCC,因为优先级高的SCC最终至少会有一个点会跑到优先级低的SCC,在优先级低的SCC中的所有点拓扑排序确定完后才会回到高优先级的SCC。当然还有一种特殊情况,就是先从5进入dfs,那么拓扑排序就为1、2、3、4、5、6、7、8。

        确定原图中每个点的拓扑后,再考虑反图。

        

        高优先级的F在反图中变为了低优先级,又因为原图的拓扑排序中高优先级的SCC中至少一个点在低优先级的SCC所有点的前面,所以从原图的拓扑排序的前面往后考虑,会先考虑到高优先级的F中的一个点,然后再反图中dfs一次即可找到F中的所有点了,因为反图中高优先级的F出度为0,x、y边把F给堵住了。求出F中的所有点后,删除这些点,就变为一样的规模更小的问题了。

        具体代码如下(洛谷3609) :

        

#include<bits/stdc++.h>
using namespace std;

const int N=1e4+50;

vector<int>g[N],rg[N];
int topo[N],k,vis[N];

void dfs(int v){
    vis[v]=1;
    for(int u:g[v]){
        if(!vis[u])dfs(u);
    }
    topo[k--]=v;
}
vector<int>ans;
void dfs2(int v){
    ans.push_back(v);
    vis[v]=1;
    for(int u:rg[v])
        if(!vis[u])dfs2(u);
}
int main(){
    int n,m;
    cin>>n>>m;
    for(int i=0;i<m;i++){
        int u,v;
        cin>>u>>v;
        g[u].push_back(v);
        rg[v].push_back(u);
    }
    k=n;
    memset(vis,0,sizeof(vis));
    for(int i=1;i<=n;i++)
        if(!vis[i])dfs(i);

    memset(vis,0,sizeof(vis));
    vector<vector<int> >res;
    for(int i=1;i<=n;i++){
        if(!vis[topo[i]]){
            ans.clear();
            dfs2(topo[i]);
            sort(ans.begin(),ans.end());
            res.push_back(ans);
        }
    }
    sort(res.begin(),res.end());
    printf("%d\n",res.size());
    for(auto vec:res){
        for(auto v:vec)
            printf("%d ",v);
        printf("\n");
    }
}

        其中dfs确定每个店的拓扑排序,dfs2求出所有的SCC。

        注:这里所说的拓扑排序与传统的不一样,因为正常来说有环的图是没有拓扑排序的,但是这里的拓扑排序不是为了确定每个点的顺序,只是为了用拓扑排序的方法让高优先级的SCC中的至少一个点在低优先级的所有点的前面,然后从前往后考虑,总是先考虑到高优先级的SCC中的一个点,再利用反图求出高优先级中所有点。

2.2 tarjan

        tarjan算法用到了无向图中用到的low(每个点能回退到的祖先)和num(每个点的递归序)。如图:

        

        在dfs的过程中,每个SCC会有一个入口点,也即dfs到的SCC中的第一个点。

        有这样的结论:如果不考虑缩点图中的边为回退边,那么对于任意一个SCC,有该SCC中的入口点s的num[s]=low[s],其他点本身的low[v]<本身的num[v]。

        其他点的low[v]<num[v]很好理解,因为SCC中任意两个点互相可达,SCC中的任意点都能到入口点,那么对于任意点v(非入口点),一定有v或者v的后代能回退到v的祖先,因为如果从入口点s到v后,v及其后代都不能回到v的祖先,那么也一定回不到s,就与是SCC矛盾了。

        那么入口点s的num[s]为什么一定等于low[s]呢,也有一个前提条件:不考虑缩点图中的边为回退边。这个条件逻辑上相当于让每个SCC内部的点的low的更新分割开来,只由自己内部的点的num更新,也即一个SCC中的点的low不由另一个SCC中的num更新。那么对于任意入口点s,就一定有num[s]=low[s],因为他没有回退边。

        具体地考虑上面的图b,从f->e的边就是缩点图中的边,这个边不能考虑为回退边,因为考虑为了回退边,那么就会用e的num更新f的low,因为这里f比e到递归到,所以不会有影响,但如果e先递归到,那么就会有问题了。

        这里你可以手画一下更大一点的图,比如让上面的e所在的SCC中有更多的点。

        如何求出有多少个SCC,以及每个SCC中有哪些点呢。明显SCC的个数为num[v]=low[v]的点的个数。

        因为递归与栈有密切关系(先递归到的后回溯,栈先进后出),所以可以用栈记录dfs过程中的点,回溯到low[v]=num[v]的点时,就弹栈。

        代码如下 (洛谷3609):

        

#include<bits/stdc++.h>
using namespace std;

const int N=1e4+50;

vector<int>g[N];

int num[N],low[N],dfn,stk[N],top=-1,vis[N];
vector<vector<int> >res;
void dfs(int v){
    num[v]=low[v]=++dfn;
    stk[++top]=v;
    for(int i=0;i<g[v].size();i++){
        int u=g[v][i];
        if(!num[u]){
            dfs(u);
            low[v]=min(low[v],low[u]);
        }else if(!vis[u])low[v]=min(low[v],num[u]);
    }
    if(low[v]==num[v]){
        vector<int>rec;
        while(stk[top]!=v)vis[stk[top]]=1,rec.push_back(stk[top--]);
        
        vis[stk[top]]=1,rec.push_back(stk[top--]);
        sort(rec.begin(),rec.end());
        res.push_back(rec);
    }
}
int main(){
    int n,m;
    cin>>n>>m;
    for(int i=0;i<m;i++){
        int u,v;
        cin>>u>>v;
        g[u].push_back(v);
    }
    for(int i=1;i<=n;i++)
        if(!num[i])dfs(i);
    sort(res.begin(),res.end());
    printf("%lu\n",res.size());
    for(auto vec:res){
        for(auto v:vec)
            printf("%d ",v);
        printf("\n");
    }
}

        这里有个关键点为如何不考虑缩点图中的边为回退边,因为最初并不知道哪些边同时是缩点图中的边,但是是有方法可以判断的,方法就是:连向已经从栈中弹出的点的边。注意:这里的不考虑是指不考虑其为回退边,也即用其更新low,因为考虑了会导致其他SCC的num更新这个SCC的low,如果入口点有这样一条边,那么会导致入口点的num!=low了。但有时缩点图中的边是作为dfs走向另一个SCC的边。如下图(缩点图):

        

        如果先dfs A中的点,然后走到了F,那么F中的点递归完后,会回到F中的入口点,就能将F中的所有点确定为一个SCC,然后再走向E,如果y这条边是E中的入口点连向F的,F又先递归到,那么就出问题了。

        具体在代码中,用vis记录是否已确定为SCC中的点。           

        2.3 DAG(有向无环图)

        在有向图中,将SCC变为一个点后,整个图就变成了一个DAG,所以很多与SCC有关的问题也与DAG挂钩,所以我给出一些有关DAG的结论(这些结论也是我做题总结的):

        ①在有向图中,最少从多少个点开始遍历,可以遍历完整个图:有向图缩点后对应的DAG上入度为0的点的数量。这个应该是很好证明的。

        ②在有向图中,最少添加多少条边,使整个图是一个SCC:答案=SCC==1?0:max(DAG上入度为0的点的数量,DAG上出度为0 的数量)。一个点的入度为0且出度为0也要考虑入内。首先肯定是考虑缩点图,因为不可能在一个SCC内部添加边,而在任意一个有向图中,有这样的结论:如果每个点都有出度和入度,并且整个图是连通的(注意:有向图中两个点连通指其中至少一个点可达另一个点),那么整个图一定为SCC(证明略)。所以考察入度为0(x个)点与出度为0(y个)的点的数量,这里假设y>x,而总有一种方法可以使x个出度为0的点连到x个入度为0的点上,y-x个出度为0的点连到其他的SCC上,并且整个图连通(证明略)。

3. 基环树

        这里只考虑无向图,在无向图中,基环树是只有一个环的连通图,n个点、n条边。其实就是在一颗无向树(n个点,n-1条边)上添加一条边形成的。如图:

                                ​​​​​​​        ​​​​​​​        

         将环缩为一个点后,仍然是一颗树。可以看出基环树处处与树关联,所以尽管基环树严格意义上来说并不是一颗树,但仍叫其为树。

        关于基环树的问题,可以从以下几个方面考虑:

        ①一棵树+一条边:从树的角度考虑,特殊处理环中的一条边;

        ②环+多颗树:在基环树中,环上的每一个点在考虑除环中的点后都是一颗树。

        ③环+一棵树:即找出环后,缩点->一棵树。

        上面涉及到的步骤中难点主要在于找环、将基环树分解为一棵树+一条边。

        对于找环,可以采用"剪树"的方法,先剪去度为1的点,删边后,再剪去度为1的点(类似于拓扑排序的使用队列的方法),那么最终只剩一个环,因为环中点的度至少为2。代码如下:

        

int vis[N],du[N],inq[N];
vector<int>vs;
void dfs(int v){
    vis[v]=1;
    vs.push_back(v);
    for(int i=head[v];~i;i=es[i].nex){
        int u=es[i].to;
        du[u]++;
        if(!vis[u])dfs(u);
    }
}

void slove(int v){
    vs.clear();
    dfs(v);
    
    queue<int>que;
    for(int v:vs)if(du[v]==1)que.push(v),inq[v]=1;

    while(!que.empty()){
        int v=que.front();
        que.pop();
        for(int i=head[v];~i;i=es[i].nex){
            int u=es[i].to;
            if(!inq[u]){
                du[u]--;
                if(du[u]==1)que.push(u),inq[u]=1;
            }
        }
    }
}

        其中,du数组为每个点的度(注意无向图中的每条边拆为了两条有向边),vis[i]表示i是否遍历过,inq[i]表示i是否已经入队列。最终不在队列中(即inq[i]==0)的点即为环中的点。

        将基环树分解为一棵树+一条边,可以使用并查集。

        如果n个点与n条边的图不连通,那么也即形成了基环树森林,只需对每个连通块处理一下就行。

        列题见洛谷2607,代码如下:

        环+多颗树的处理方法:

        

#include<bits/stdc++.h>
using namespace std;

typedef long long ll;
const int N=1e6+50;
const ll INF=0x3f3f3f3f3f3f3f3f;
struct edge{
    int to,nex;
}es[N<<1];
int head[N],cnt;
void init(){
    memset(head,-1,sizeof(head));
    cnt=0;
    for(auto e:es)e.nex=-1;
}

void add(int f,int t){
    es[cnt].to=t;
    es[cnt].nex=head[f];
    head[f]=cnt++;
}
ll val[N];
int vis[N],du[N],inq[N];
vector<int>vs;
void dfs(int v){
    vis[v]=1;
    vs.push_back(v);
    for(int i=head[v];~i;i=es[i].nex){
        int u=es[i].to;
        du[u]++;
        if(!vis[u])dfs(u);
    }
}

ll dp1[N],dp2[N];

void dfs2(int v,int f){
    dp1[v]=val[v],dp2[v]=0;
    for(int i=head[v];~i;i=es[i].nex){
        int u=es[i].to;
        if(u==f||!inq[u])continue;
        dfs2(u,v);
        dp1[v]+=dp2[u];
        dp2[v]+=max(dp1[u],dp2[u]);
    }
}

ll slove(int v){
    vs.clear();
    dfs(v);
    
    queue<int>que;
    for(int v:vs)if(du[v]==1)que.push(v),inq[v]=1;

    while(!que.empty()){
        int v=que.front();
        que.pop();
        for(int i=head[v];~i;i=es[i].nex){
            int u=es[i].to;
            if(!inq[u]){
                du[u]--;
                if(du[u]==1)que.push(u),inq[u]=1;
            }
        }
    }
    vector<int>inf;
    for(int v:vs)
        if(!inq[v]){
            inf.push_back(v);
            dfs2(v,0);
        }
    int n=inf.size();
    vector<vector<ll> >dp(n);
    for(int i=0;i<n;i++)dp[i]=vector<ll>(2,0);
    dp[0][0]=dp1[inf[0]];
    dp[0][1]=-INF;
    for(int i=1;i<n;i++){
        dp[i][0]=dp[i-1][1]+dp1[inf[i]];
        dp[i][1]=max(dp[i-1][0],dp[i-1][1])+dp2[inf[i]];
    }
    ll ans=dp[n-1][1];
    for(int i=0;i<n;i++)dp[i]=vector<ll>(2,0);
    dp[0][0]=-INF;
    dp[0][1]=dp2[inf[0]];
    for(int i=1;i<n;i++){
        dp[i][0]=dp[i-1][1]+dp1[inf[i]];
        dp[i][1]=max(dp[i-1][0],dp[i-1][1])+dp2[inf[i]];
    }
    ans=max(ans,max(dp[n-1][0],dp[n-1][1]));
    return ans;
}
int main(){
    int n;
    cin>>n;
    init();
    for(int i=1;i<=n;i++){
        int x;
        cin>>val[i]>>x;
        add(x,i);
        add(i,x);
    }
    ll ans=0;
    for(int i=1;i<=n;i++)
        if(!vis[i])ans+=slove(i);
    cout<<ans;
}

        处理思路为:对于每个基环树,找出环后,对于每个点,连接一棵树,求出这个点选和不选的最优值,这里需要先知道如果图只是一棵树怎么求得最优值,其实就是简单的树形dp(如果这棵树只有他这一个点,那么选的最优值就是这个点的值,不选的最优值为0),然后就变为了这样一个问题:一个环,每个点选有一个值,不选有一个值,相邻的点不能同时选,怎么选最优。非常明显的一个dp问题,只是这里是环,如果不是环,就是简单的线性dp,是环只需枚举一下第一个点选和不选的两种情况,两种情况都dp一下,选的话,最后一个点就时不选的最优值,不选的话,最后一个就是选和不选两者中的最优值。 

一棵树+一条边的处理方法:

       

#include<bits/stdc++.h>

using namespace std;

typedef long long ll;
const int N=1e6+50;

struct edge{
    int to,nex;
}es[N<<1];
int head[N],cnt;

void init(){
    memset(head,-1,sizeof(head));
    cnt=0;
    for(auto e:es)e.nex=-1;
}

void add(int f,int t){
    es[cnt].to=t;
    es[cnt].nex=head[f];
    head[f]=cnt++;
}

int fa[N];

void init_set(int n){
    for(int i=1;i<=n;i++)fa[i]=i;
}

int find(int x){
    return fa[x]=(fa[x]==x?x:find(fa[x]));
}

void merge(int x,int y){
    x=find(x);
    y=find(y);

    fa[x]=y;
}

bool same(int x,int y){
    return find(x)==find(y);
}

vector<int>roots,rv;
ll val[N];

ll dp1[N],dp2[N],flag;

void dfs(int v,int f,int r,int p){
    dp1[v]=0;
    dp2[v]=val[v];
    for(int i=head[v];~i;i=es[i].nex){
        int u=es[i].to;
        if(u==f)continue;
        dfs(u,v,r,p);
        dp1[v]+=max(dp1[u],dp2[u]);
        dp2[v]+=dp1[u];
    }
    if(v==p&&flag)dp2[v]=0;
}
ll solve(int r,int p){
    ll ma=0;
    flag=0;
    dfs(r,0,r,p);
    ma=dp1[r];
    flag=1;
    dfs(r,0,r,p);
    ma=max(ma,dp2[r]);
    return ma;
}
int main(){
    int n;
    cin>>n;
    init();
    init_set(n);
    for(int v=1;v<=n;v++){
        cin>>val[v];
        int u;
        cin>>u;

        if(same(v,u)){
            roots.push_back(v);
            rv.push_back(u);
        }else{
            add(v,u);
            add(u,v);
            merge(v,u);
        }
    }
    ll sum=0;
    for(int i=0;i<roots.size();i++)
        sum+=solve(roots[i],rv[i]);
    cout<<sum;
}

 这种思路只需特殊考虑那条边,考虑那条边连接根r(把其中一个点认为树的根,代码中也是这么做的)与一个点v,那么思路也是根不选时dp一下,根选时dp一下。

4. 差分约束系统

        差分约束系统的前置知识为最短路以及在图中判断负环。 

        差分约束系统是指m个n元一次不等式组,且每个不等式满足如下形式:x_i<=x_j+c_k。需要你求出一组解。

        这里有一个性质:如果有解,那么一定有无穷组解。因为同时在每个变量x上同时加上一个常数C,不等式仍然成立。所以你总可以让x1=0。

        那为什么这道题与最短路与负环有关呢?在一个图中,点v的最短路为其所有相邻的点u的最短路加上从u到v的边的权值中的最小值,也即满足对于任意u,如果有从u到v的边,那么有d[v]<=d[u]+e<u,v>,这与x_i<=x_j+c_k形式相同。如果把x_i看成i的最短路,那么就可以从j向i连一条权值为c_k的边,然后求最短路。明显地,如果每个点都有最短路,自然满足所有不等式。什么时候无解呢,也即有点没有最短路,也即出现了负环。

        为什么出现负环无解呢?首先解释这么个点,如果u到v有边权值为k1,v到t有边权值为k2,也即有d[v]<=d[u]+k1,d[t]<=d[v]+k2,即d[t]<=d[u]+k1+k2。如果有负环,不妨设从q到p之间有一条负边权值为-k1(k1>0),p到q有一条正路径权值为k2(k2>=0,k1>k2),所以有d[q]<=d[p]+k2,d[p]<=dp[q]-k1,所以有d[p]+k1<=dp[q]<=d[p]+k2,矛盾。同理如果无解也必有负环。

        这里判断负环时,因为图不一定是强联通的,所以你不能从某一个点所以建立一个虚拟点,到每个点的连一条权值为0的边,明显地,这不影响负环的判断,此时每个点的最短路也确实是一组解。

        代码如下(洛谷P5960):

        

#include<bits/stdc++.h>
using namespace std;

const int N=5005,M=1e4+50;
const double INF=1e15,eps=1e-3;
struct edge{
    int to,nex,w;

}es[M];
int head[N],cnt;

void init(){
    memset(head,-1,sizeof(head));
    cnt=0;
    for(auto e:es)e.nex=-1;
}

void add(int f,int t,int w){
    es[cnt].to=t;
    es[cnt].w=w;
    es[cnt].nex=head[f];
    head[f]=cnt++;
}


int n,m,inq[N],num[N];
int dist[N];
bool spfa(int s){
    for(int i=1;i<=n;i++)dist[i]=INF;
    memset(inq,0,sizeof(inq));
    memset(num,0,sizeof(num));
    dist[s]=0;
    queue<int>que;
    que.push(s);
    inq[s]=1;
    while(!que.empty()){
        int v=que.front();
        que.pop();
        inq[v]=0;
        for(int i=head[v];~i;i=es[i].nex){
            int u=es[i].to;
            double w=es[i].w;
            if(w+dist[v]<dist[u]){
                dist[u]=w+dist[v];
                if(!inq[u])inq[u]=1,que.push(u);
                if(++num[u]>n)return true;
            }
        }
    }
    return false;
}

int main(){
    cin>>n>>m;
    init();
    for(int i=1;i<=n;i++)add(0,i,0);
    for(int i=0;i<m;i++){
        int u,v,w;
        cin>>u>>v>>w;
        add(v,u,w);
    }
    if(spfa(0)){
        cout<<"NO";
    }else{
        for(int i=1;i<=n;i++)printf("%d ",dist[i]);
    }
}

        

   

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值