3.3图论(算法提高课)

目录

一,最近公共祖先(lca)

1,祖训询问

 2,距离

3,次小生成树

4,闇の連鎖

二,有向图的强连通分量

1,受欢迎的牛

2,学校网络

3,最大半连通子图

4,银河

 三,无向图的双连通分量

1,冗余路径

2,电力

3,矿场搭建


一,最近公共祖先(lca)

首先介绍一下什么时最近公共祖先。

求lca一般有两种方法,一种是向上标记法(时间复杂度O(N^2),另一种是树上倍增法 (时间复杂度O(NlogN)

  

一般来说,用的比较多的就是树上倍增法,因为树上倍增法的效率比较高,其实树上倍增法的原理与ST表很像,都是利用倍增的思想, 首先预处理 出来从一个点跳1,2,4,……,2^k步的一个父节点是谁,然后查询a和b两个节点的父节点时,首先将a节点跳到跟b节点一样的高度(如果b的深度比a深,就交换一下a和b),如果此时a和b相同,说明a和b的最近公共祖先就是b,如果a和b不同,再同时将a和b往上跳,直到跳到a和b的最近公共祖先节点的子节点时就停止,此时fa【a】【0】就是父节点,之所以跳到最近公共祖先的子节点就停止而不直接跳到最近公共祖先节点上的原因就是这样可以确定下一步跳到的节点时最近的那个公共祖先节点,不然有可能跳到最近公共祖先节点的上面的节点。

有一个小技巧可以简化我们的代码,那就是我们设置一个哨兵,让哨兵的深度为1,根节点的深度为1,这样如果在找祖先节点的过程中,如果跳出了根节点,那么就会走到哨兵节点上,fa【i】【k】就会等于0

下面看几个例题:

1,祖训询问

题目链接:http://ybt.ssoier.cn:8088/problem_show.php?pid=1557

这就是一个倍增lca的模板题,具体操作上面都已经讲了,首先要用bfs预处理出来所有点跳2的k(0≤k≤log2(n))步后的一个父节点,然后查找

代码如下:

#include <iostream>
#include <algorithm>
#include <cstring>
#include <string>

using namespace std;

typedef long long ll;
typedef pair<int, int> pii;

const int N = 4e4+10,M=2*N;

int n,m;
int h[N],e[M],ne[M],idx;
int depth[N],fa[N][20];
//depth[i]表示点i在树种的深度,fa[i][j]表示点i往上跳2^j步后的节点
int q[N];

void add(int a,int b)//邻接表建图
{
    e[idx]=b,ne[idx]=h[a],h[a]=idx++;
}
void bfs(int root)//预处理出来树中所有点往上跳2^k步后的节点是
{
    memset(depth,0x3f,sizeof depth);//初始化所有节点的深度,便于我们bfs更新每个节点的父节点
    depth[0]=0,depth[root]=1;//小技巧,设置一个哨兵0号点,让0号点的深度为0,根节点的深度为1
    int hh=0,tt=-1;
    q[++tt]=root;//数组模拟队列。将根节点加入到队列中
    while(hh<=tt)
    {
        int t=q[hh++];
        for(int i=h[t];i!=-1;i=ne[i])
        {
            int j=e[i];
            if(depth[j]>depth[t]+1)//说明这个点还没预处理
            {
                depth[j]=depth[t]+1;//点j的高度等于点t的高度+1
                q[++tt]=j;
                fa[j][0]=t;//j的父节点就是t
                for(int k=1;k<=15;k++)//因为图中最多只有4e4的点,所以最多跳2^15步到达根节点
                    fa[j][k]=fa[fa[j][k-1]][k-1];//类似于动态规划的更新
            }
        }
    }
}
int lca(int a,int b)
{
    if(depth[a]<depth[b])swap(a,b);//如果b的深度比a深,就交换一下a和b
    for(int k=15;k>=0;k--)//先把a跳到和一样的高度
        if(depth[fa[a][k]]>=depth[b])
            a=fa[a][k];
    if(a==b)return a;//说明a和b在一条链上
    for(int k=15;k>=0;k--)//将a和b同时往上跳,跳到最近公共祖先的子节点上
        if(fa[a][k]!=fa[b][k])
        {
            a=fa[a][k];
            b=fa[b][k];
        }
    return fa[a][0];//此时a再往上走一步就是最近公共祖先
}
int main()
{
    int n;
    scanf("%d",&n);
    int root=0;
    memset(h,-1,sizeof h);//邻接表头初始化
    for(int i=0;i<n;i++)
    {
        int a,b;
        scanf("%d%d",&a,&b);
        if(b==-1)root=a;
        else add(a,b),add(b,a);
    }
    bfs(root);//从根节点开始预处理出来每个点往上跳2^K步后的节点
    
    scanf("%d",&m);
    while(m--)
    {
        int a,b;
        scanf("%d%d",&a,&b);
        int p=lca(a,b);//查询a和b的父节点
        if(p==a)puts("1");
        else if(p==b)puts("2");
        else puts("0");
    }
    return 0;
}

 2,距离

题目链接:https://www.acwing.com/problem/content/description/1173/

还有一种用离线求lca的做法,tarjan算法,时间复杂度为O(N+M) 。离线做法意思就是先将所有询问存下来然后统一查找,最后统一输出,在线做法就是询问一次输出一次

tarjan离线求lca就相当于是每次搜索一条路,将这条路上的每个点的已经搜过的分支节点用并查集合并到当前节点中

代码如下:

#include<iostream>
#include<algorithm>
#include<cstring>

using namespace std;

typedef pair<int,int>pii;

const int N=1e4+10,M=2e4+10;


int n,m;
int h[N],e[M],ne[M],w[M],idx;
int dist[N];//存储每个点到1号点的距离
int p[N];//并查集的父节点集合
int res[M];//res记录答案
int st[N];//st[i]判断当前点的搜索状态,0表示未搜索,1表示正在搜索还未回溯,2表示完全搜索完了
vector<pii>query[N];//query[i],first存距离点i的另一个点j,second存询问编号

int find(int x)//查询x所在的集合
{
    if(x!=p[x])p[x]=find(p[x]);
    return p[x];
}
void add(int a,int b,int c)//邻接表建图
{
    e[idx]=b,w[idx]=c,ne[idx]=h[a],h[a]=idx++;
}
void dfs(int u,int fa)//dfs求出所有点到1号点的dist距离
{
    for(int i=h[u];i!=-1;i=ne[i])
    {
        int j=e[i];
        if(j==fa)continue;
        dist[j]=dist[u]+w[i];
        dfs(j,u);
    }
}
void tarjan(int u)//求出所有点的lca
{
    st[u]=1;//表示当前点正在搜索
    for(int i=h[u];i!=-1;i=ne[i])//遍历与点u相连的没有访问过的点
    {
        int j=e[i];
        if(!st[j])
        {
            tarjan(j);
            p[j]=u;//点j的父节点就为点u,这一步操作一定要在tarjan后面,否则后面查询父节点时会出错
        }
    }
    for(auto item:query[u])//查找点u的另一个点
    {
        int y=item.first,id=item.second;
        if(st[y]==2)//如果点y已经搜索过了
        {
            int lca=find(y);//找到点y的父节点,即为点u和点y的lca
            res[id]=dist[y]+dist[u]-2*dist[lca];//两点的距离还要减去2倍lca到根节点的距离
        }
    }
    st[u]=2;//表示当前点搜索完了
}
int main()
{
    scanf("%d%d",&n,&m);
    memset(h,-1,sizeof h);
    for(int i=0;i<n-1;i++)
    {
        int a,b,c;
        scanf("%d%d%d",&a,&b,&c);
        add(a,b,c),add(b,a,c);//无向边建图
    }
    //把所有的询问记录下来
    for(int i=0;i<m;i++)
    {
        int a,b;
        scanf("%d%d",&a,&b);
        if(a!=b)//如果a和b在一个点的话res为0,就不用存下来这个询问了
        {
            query[a].push_back({b,i});
            query[b].push_back({a,i});
        }
    }
    for(int i=1;i<=n;i++)p[i]=i;
    
    //dfs搜索每个点距离1号节点的距离
    dfs(1,-1);
    tarjan(1);//求出来每个节点的lca
    
    for(int i=0;i<m;i++)printf("%d\n",res[i]);
    return 0;
}

3,次小生成树

题目链接:https://www.acwing.com/problem/content/358/

在上一节3.2图论中,已经讲出了求次小生成树的方法。对于求严格次小生成树,我们要预处理出来最小生成树中任意两点的最大边权和次大边权,对于求非严格次小生成树,我们只需要预处理出来最小生成树中任意两点的最大边权即可。上一节3.2图论中我们是用dfs求出来任意两点的最大边权,只适用于求非严格次小生成树。

这里用倍增lca的方法求严格次小生成树,因为要预处理出来最大边权和次大边权,所以会比较复杂

代码如下:

#include <iostream>
#include <algorithm>
#include <cstring>

using namespace std;

typedef long long ll;

const int N = 1e5 + 10, M = 3e5 + 10, INF = 0x3f3f3f3f;

int n, m;
int h[N], e[M], ne[M], w[M], idx;//对于最小生成树建图
//depth表示最小生成树中每个节点的深度
//fa[i][j]表示点i往上跳2^j步后到达的节点
//d1[i][j]表示点i往上跳2^j步的过程中经过的所有边的最大边权
//d2[i][j]表示点i往上跳2^j步的过程中经过的所有边的次大边权
int depth[N], fa[N][17], d1[N][17], d2[N][17];
int p[N];//并查集中的father数组
int q[N];//数组模拟队列

struct Edge
{
    int a, b, w;
    bool used;//判断该条边是否在最小生成树中
    bool operator<(const Edge &t) const//运算符重载,让所有边权按从小到大的顺序排序
    {
        return w < t.w;
    }
} edge[M];

int find(int x)//找到x所在的连通块,并将连通块中的所有点进行路径压缩
{
    if (x != p[x])p[x] = find(p[x]);
    return p[x];
}
ll kruskal()//kruskal求最小生成树
{
    for (int i = 1; i <= n; i++)p[i] = i;//并查集初始化
    sort(edge, edge + m);//将所有边按从小到大排序

    ll res = 0;//res记录最小生成树的总权值
    for (int i = 0; i < m; i++)//遍历所有的边
    {
        int a = edge[i].a, b = edge[i].b, w = edge[i].w;
        int pa = find(a), pb = find(b);
        if (pa != pb)
        {
            p[pa] = pb;
            res += w;
            edge[i].used = true;//表示该条边在最小生成树中
        }
    }
    return res;//返回最小生成树的总权值
}
void add(int a, int b, int c)//邻接表建图加边函数
{
    e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
void build()//对最小生成树建图
{
    memset(h, -1, sizeof h);//邻接表头初始化
    for (int i = 0; i < m; i++)
    {
        int a = edge[i].a, b = edge[i].b, w = edge[i].w;
        if (edge[i].used)//如果当前边在最小生成树中才建图
            add(a, b, w), add(b, a, w);
    }
}
void bfs()//用bfs预处理出来fa,d1,d2数组
{
    memset(depth, 0x3f, sizeof depth);//首先初始化所有点的深度为正无穷
    depth[0] = 0, depth[1] = 1;//将0号点设置成哨兵,深度为0,1号点设置为根节点,深度为1
    int hh = 0, tt = 0;
    q[0] = 1;//根节点入队
    while (hh <= tt)
    {
        int t = q[hh++];
        for (int i = h[t]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (depth[j] > depth[t] + 1)//表示当前点j还没预处理
            {
                depth[j] = depth[t] + 1;//更新深度
                q[++tt] = j;//点j入队

                fa[j][0] = t;//点j的父节点是t
                d1[j][0] = w[i], d2[j][0] = -INF;//点j向上走一步的最大边权是w[i],没有次大边权,设置成负无穷
                for (int k = 1; k <= 16; k++)
                {
                    int anc = fa[j][k - 1];//表示点j向上跳2^(j-1)步后到达的节点
                    fa[j][k] = fa[anc][k - 1];//用动态规划的思想更新点j的fa数组

                    //点j向上跳2^k步的过程中的最大边权和次大边权一定是下面这四个的其中两个
                    int distance[4] = {d1[j][k - 1], d2[j][k - 1], d1[anc][k - 1], d2[anc][k - 1]};
                    d1[j][k] = d2[j][k] = -INF;//初始化成负无穷
                    for (int u = 0; u < 4; u++)//在这四个中找到最大边权和次大边权
                    {
                        int d = distance[u];
                        if (d > d1[j][k])//说明找到一个d比此时的最大边权大,那么此时的最大边权就为次大边权,最大边权再更新成d
                            d2[j][k] = d1[j][k], d1[j][k] = d;
                        else if (d != d1[j][k] && d > d2[j][k])//此时的d不等于最大边权且比次大边权大,说明可以更新次大边权
                            d2[j][k] = d;//注意一定要有第一个判断条件,保证最大边权和次大边权不相等
                    }
                }
            }
        }
    }
}
int lca(int a,int b,int w)//返回最小生成树中点a到点b的路径中,w-最大边权或w-次大边权
{
    if(depth[a]<depth[b])swap(a,b);//保证点a的深度大于等于点b的深度

    int distance[N*2];//记录点a和点b向上跳的过程中经过的所有边的最大边权和次大边权
    int cnt=0;//cnt为记录的数量

    for(int k=16;k>=0;k--)//先把a跳到跟b一样的深度
    {
        if(depth[fa[a][k]]>=depth[b])
        {
            //记录下来跳的过程中经过的边的最大边权和次大边权
            distance[cnt++]=d1[a][k];
            distance[cnt++]=d2[a][k];
            a=fa[a][k];
        }
    }
    if(a!=b)//说明还没找到lca,a和b要同时往上跳
    {
        for(int k=16;k>=0;k--)
        {
            if(fa[a][k]!=fa[b][k])
            {
                //记录下来a和b往上跳的过程中经过的边的最大边权和次大边权
                distance[cnt++]=d1[a][k];
                distance[cnt++]=d2[a][k];
                distance[cnt++]=d1[b][k];
                distance[cnt++]=d2[b][k];
                a=fa[a][k],b=fa[b][k];
            }
        }
        distance[cnt++]=d1[a][0];//最后别忘了a和b只是跳到离lca最近的子节点,还有一个从a和b到lca的最大边权需要记录下来
        distance[cnt++]=d1[b][0];//因为只有一条边,所以这里没有次大边权
    }
    
    int dist1=-INF,dist2=-INF;//找到a到b的路径中的最大边权dist1和次大边权dist2,方法于预处理的一样
    for(int i=0;i<cnt;i++)
    {
        int d=distance[i];
        if(d>dist1)dist2=dist1,dist1=d;
        else if(d!=dist1&&d>dist2)dist2=d;
    }
    if(w>dist1)return w-dist1;//要保证w严格大于最大边权
    if(w>dist2)return w-dist2;//要保证w严格大于次大边权
    return INF;//这一步可有可无,因为w最坏也会大于次大边权,否则这条边应该在最小生成树中
}
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);
        edge[i] = {a, b, c};
    }

    ll sum = kruskal();//求最小生成树
    build();//对最小生成树建树
    bfs();//预处理出来fa,d1,d2

    ll res=1e18;
    for(int i=0;i<m;i++)//依次枚举非树边,找到最小答案
    {
        if(!edge[i].used)
        {
            int a=edge[i].a,b=edge[i].b,w=edge[i].w;
            res=min(res,sum+lca(a,b,w));
        }
    }
    cout<<res<<endl;
    return 0;
}

再给出用倍增lca求非严格次小生成树的代码,跟上面的差不多,稍微简单一点

代码如下:

#include <iostream>
#include <algorithm>
#include <cstring>

using namespace std;

typedef long long ll;

const int N = 1e5 + 10, M = 3e5 + 10, INF = 0x3f3f3f3f;

int n, m;
int h[N], e[M], ne[M], w[M], idx;
int depth[N], fa[N][17], d1[N][17];
int p[N];
int q[N];

struct Edge
{
    int a, b, w;
    bool used;
    bool operator<(const Edge &t) const
    {
        return w < t.w;
    }
} edge[M];

int find(int x)
{
    if (x != p[x])
        p[x] = find(p[x]);
    return p[x];
}
ll kruskal()
{
    for (int i = 1; i <= n; i++)
        p[i] = i;
    sort(edge, edge + m);

    ll res = 0;
    for (int i = 0; i < m; i++)
    {
        int a = edge[i].a, b = edge[i].b, w = edge[i].w;
        int pa = find(a), pb = find(b);
        if (pa != pb)
        {
            p[pa] = pb;
            res += w;
            edge[i].used = true;
        }
    }
    return res;
}
void add(int a, int b, int c)
{
    e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
void build()
{
    memset(h, -1, sizeof h);
    for (int i = 0; i < m; i++)
    {
        int a = edge[i].a, b = edge[i].b, w = edge[i].w;
        if (edge[i].used)
            add(a, b, w), add(b, a, w);
    }
}
void bfs()
{
    memset(depth, 0x3f, sizeof depth);
    depth[0] = 0, depth[1] = 1;
    int hh = 0, tt = 0;
    q[0] = 1;
    while (hh <= tt)
    {
        int t = q[hh++];
        for (int i = h[t]; i != -1; i = ne[i])
        {
            int j = e[i];
            if (depth[j] > depth[t] + 1)
            {
                depth[j] = depth[t] + 1;
                q[++tt] = j;

                fa[j][0] = t;
                d1[j][0] = w[i];
                for (int k = 1; k <= 16; k++)
                {
                    int anc = fa[j][k - 1];
                    fa[j][k] = fa[anc][k - 1];
					d1[j][k]=max(d1[j][k-1],d1[anc][k-1]);//因为不用求次大边权,因此最大边权,就等于其中的两段取max
                }
            }
        }
    }
}
int lca(int a,int b,int w)
{
    if(depth[a]<depth[b])swap(a,b);

	int dist1=-INF;//从点a到点b的最大边权

    for(int k=16;k>=0;k--)
    {
        if(depth[fa[a][k]]>=depth[b])
        {
			dist1=max(dist1,d1[a][k]);//在a往上跳的过程中,顺带求出dist1
            a=fa[a][k];
        }
    }
    if(a!=b)//说明a和b的lca还没求出来,a和b都要往上跳
    {
        for(int k=16;k>=0;k--)
        {
            if(fa[a][k]!=fa[b][k])
            {
				dist1=max(dist1,d1[a][k]);//顺带求出来dist1
				dist1=max(dist1,d1[b][k]);
                a=fa[a][k],b=fa[b][k];
            }
        }
        dist1=max(dist1,d1[a][0]);//这里同样别往了,此时点a和点b还没跳到lca的位置上
        dist1=max(dist1,d1[b][0]);
    }

    return w-dist1;//w一定是大于等于dist1的,w是非树边,dist1是树边。因为是非严格,就算等于也没事
}
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);
        edge[i] = {a, b, c};
    }

    ll sum = kruskal();
    build();
    bfs();

    ll res=1e18;
    for(int i=0;i<m;i++)
    {
        if(!edge[i].used)
        {
            int a=edge[i].a,b=edge[i].b,w=edge[i].w;
            res=min(res,sum+lca(a,b,w));
        }
    }
    cout<<res<<endl;
    return 0;
}

4,闇の連鎖

题目链接:https://www.acwing.com/problem/content/description/354/

根据题意,“主要边”构成一棵,“附加边”则是“非树边”。把一条附加边(x,y)添加到主要边构成的树中,会与树上x,y之间的路径一起形成一个环。如果第一步选择切断x,y之间的路径上的某条边,那么第二步距必须切断附加边(x,y),才能令Dark被斩为不连通的两部分。

因此,我们称每条附加边 (x,y)都把树上x,y之间的路径上的每条边“覆盖了一次”。我们只需统计出每条“主要边”被覆盖了多少次。若第一步把覆盖0次的主要边切断,则第二步可任意切断一条附加边。若第一步把覆盖1次的主要边切断,则第二步方法唯一。若第一步把覆盖2次及2次以上的主要边切断,则第二步无论如何操作都不能击败Dark。这样我们就得到了击败Dark的方案数

综上所述,下面我们要解决的问题就是,给定一张无向图和一颗生成树,求每条“树边”被“非树边”覆盖了多少次 。解决此类问题的经典做法就是“树上差分“,树上差分有两种,一种是”点差分“,另一种是”边差分“。

”点差分“就是形如给你一棵树有n次修改操作,每次把u..v的所有点权都加x,最后问点权最大的为多少或点权总和为多少?

边差分“就是形如给你一棵树,有n次修改操作,每次把u..v的路径权值加x,最后问从x..y的路径权值和或权值最大的是多少。

初始时树上的每个节点的权值都为0。对于点差分,我们一般就是将点u,v两点的权值分别加上c,再将点lca(u,v)的权值和lca(u,v)的父节点的权值分别减去c。对于边差分,就是将点u,v两点的权值分别加上c,再将lca(u,v)的权值减去2c。再从根节点作一遍dfs就可以得到点u到点v的路径上所有的边的权值总和。统计每个边权会在回溯的时候统计

这题很明显是边差分,代码如下:

#include<iostream>
#include<algorithm>
#include<cstring>

using namespace std;

const int N=100010,M=200010*2;

int n,m;
int h[N],e[M],ne[M],idx;
int fa[N][17],d[N],depth[N];
//fa[i][j]表示点i往上跳2^j步后的节点
//d[i]表示点i的权值,depth[i]表示点i在树中的深度
int q[N];
int ans;

void add(int a,int b)
{
    e[idx]=b,ne[idx]=h[a],h[a]=idx++;
}
void bfs()//预处理出来fa和depth
{
    memset(depth,0x3f,sizeof depth);//初始时将每个点的depth都设置成正无穷
    depth[0]=0,depth[1]=1;//0号点为哨兵,深度为0,1号点设置为根节点,深度为1
    int hh=0,tt=0;
    q[0]=1;//0号点入队
    while(hh<=tt)
    {
        int t=q[hh++];
        for(int i=h[t];i!=-1;i=ne[i])
        {
            int j=e[i];
            if(depth[j]>depth[t]+1)//更新点j的depth
            {
                depth[j]=depth[t]+1;
                q[++tt]=j;
                fa[j][0]=t;//点j的父节点就是点t
                for(int k=1;k<=16;k++)//类似于st表,即动态规划的思想更新fa
                    fa[j][k]=fa[fa[j][k-1]][k-1];
            }
        }
    }
    
}
int lca(int a,int b)//求出点a和点b的lca
{
    if(depth[a]<depth[b])swap(a,b);//保证点a的深度大于点b的深度
    
    for(int k=16;k>=0;k--)//a一直往上跳到跟b一样的深度
        if(depth[fa[a][k]]>=depth[b])
            a=fa[a][k];
    
    if(a==b)return a;//说明找到lca了,直接返回
    for(int k=16;k>=0;k--)//将点a和点b跳到lca的最近的子节点
    {
        if(fa[a][k]!=fa[b][k])
        {
            a=fa[a][k];
            b=fa[b][k];
        }
    }
    return fa[a][0];//此时点a的父节点就是lca
}
int dfs(int u,int father)//求出树上每条边的权值
{
    int res=d[u];//res表示以点u为根节点的子树中权值的总和(包括点u的权值)
    for(int i=h[u];i!=-1;i=ne[i])
    {
        int j=e[i];
        if(j!=father)
        {
            int s=dfs(j,u);//先搜索,s表示以点u为根节点的子树的权值总和(不包括u的权值)
            if(s==0)ans+=m;//统计答案
            else if(s==1)ans+=1;
            res+=s;//搜索完统计答案后再将子树的权值加上根节点的权值
        }
    }
    return res;//返回以当前点为根节点的所有子树的权值总和(包括点u的节点)
}
int main()
{
    scanf("%d%d",&n,&m);
    memset(h,-1,sizeof h);
    for(int i=0;i<n-1;i++)
    {
        int a,b;
        scanf("%d%d",&a,&b);
        add(a,b),add(b,a);
    }
    bfs();//预处理出来depth和fa
    for(int i=0;i<m;i++)
    {
        int a,b;
        scanf("%d%d",&a,&b);
        int p=lca(a,b);
        d[a]++,d[b]++,d[p]-=2;//树上差分
    }
    dfs(1,-1);//从根节点作一遍dfs
    printf("%d",ans);
    return 0;
}

二,有向图的强连通分量

首先介绍几个概念:

强连通图:在一个有向图中,任意两点之间都有路径可相互到达。

强连通分量(SCC):在一个有向图的子图中,该子图的任意两点都有路径可相互到达。

 tarjan算法就是基于有向图的深度优先遍历,能够在线性的时间内求出一张有向图的各个强连通分量。一个“环”一定是强连通图,如果即存在从x到y的路径,也存在从y到x的路径,那么x,y显然在一个环中。因此,tarjan算法的基本思路就是对于每个点,找到与它一起能构成环的所有节点。

tarjan算法中有两个很重要的数组和一个栈:

dfn【i】:第一次遍历到点i的时候的时间戳,简单来说就是第几个遍历到点i的

low【i】:从点i开始走,能遍历到最小的时间戳是什么

栈:存储从当前点i出发的“后向边”和”横叉边“形成环的节点。

因此点u是其所在连通分量的最高点,等价于dfn【u】==low【u】

接下列tarjan算法的实现过程,从每一个没遍历过的点开始,即dfn【】为0的点

1,进行深度优先遍历,第一次遍历到该点u时,令dfn【u】=low【u】=++timestamp(timestamp为时间戳),并将点u加入到栈中,然后遍历点u的子节点v

2,如果点u的子节点v的dfn【v】==0,即还没遍历过,就从点v继续进行深度优先遍历,进行1操作,回溯时用low【u】=min(low【u】,low【v】),因为点v是点u的子节点,点v能走到的最小的时间戳点u一样能走到。

3,如果点u的子节点v已经遍历过并且已经在栈中,回溯时就用low【u】=min(low【u】,dfn【v】)

4,如果点v已经被遍历过并且不在栈中,就不用管,继续遍历点u的其他子节点

5,每次深度优先遍历回溯时,都判断一下dfn【u】是否等于low【u】,如果相等,说明此时栈中,点u以及点u以上的节点都在一个强连通分量中,此时就不断弹栈,给每个弹出的节点记录下来强连通分量的编号,直到弹到点u为止

具体代码如下:

void tarjan(int u)
{
    dfn[u]=low[u]=++timestamp;//对于新遍历的每个点都赋值成一个新的时间戳
    stk[++top]=u,in_stk[u]=true;//点u入栈,并标记在栈中

    for(int i=h[u];i!=-1;i=ne[i])//遍历点u的子节点
    {
        int j=e[i];//点u的子节点j
        if(!dfn[j])//如果点j还没没遍历过的话
        {
            tarjan(j);//以点j开始继续深度优先遍历
            low[u]=min(low[u],low[j]);//回溯时更新点u的low[u]
        }
        else if(in_stk[j])//说明点j被遍历过并且此时在栈中
            low[u]=min(low[u],dfn[j]);//回溯时用点j更新点u的low[u]
    }

    if(dfn[u]==low[u])//说明此时点u的是强连通分量的第一个点
    {
        int y;
        ++scc_cnt;//强连通分量的个数+1
        do
        {
            y=stk[top--];//从栈顶开始弹出
            in_stk[y]=false;//出栈标记
            id[y]=scc_cnt;//给出栈的点标记所属强连通分量
        }while(y!=u)//一直弹栈直至弹到点u
    }
}

这就是一个tarjan求强连通分量的模板,现在我们知道了怎么用tarjan求强连通分量,那么求了强连通分量可以用来干嘛呢?一般来说,我们求强连通分量都是为了将强连通分量缩成一个点,这样整个图就变成了拓扑图,将图变成拓扑图后,我们解决问题就会方便很多,缩点的具体操作为:

for(int i=1;i<=n;i++)//遍历所有点
{
    for(int j=h[i];j!=-1;j=ne[j])//遍历所有点的子节点
    {
        int k=e[j];
        if(id[i]!=id[k])//如果不在一个强连通分量中就加一条从i指向k的边
            add(i,k);
    }
}

这样缩点就完成了,一般我们做题都会在缩完点的图中进行一遍拓扑排序,但其实不用,因为我们观察一下就会发现只要按强连通分量编号递减的顺序遍历所有点,就是拓扑序列,这样可以大大减少我们的代码量

下面看几个例题:

1,受欢迎的牛

题目链接:http://ybt.ssoier.cn:8088/problem_show.php?pid=1513

这题如果数据范围小的话可以用floyd求传递闭包做,但是这题的数据范围较大,因此我们用tarjan求强连通分量来做。 

按题意,如果a认为b牛受欢迎,我们就从a连一条有向边指向b,对于给定的所有信息,我们都连边,最后就会得到一个图,这个图中可能会有环,如果图中只有一个环,并且将这个环看成一个点后没有出度的话,这个环上的点的数量就是答案,因此我们可以用tarjan缩点,最后判断一下缩完点后出度为0的scc的个数,如果大于1个,说明没有答案,因为这两个scc中的点必定互相不可达,如果为1,该scc中的元素个数就是答案

代码如下:

#include <iostream>
#include <algorithm>
#include <cstring>
#include <string>

using namespace std;

typedef long long ll;
typedef pair<int, int> pii;

const int N = 1e4 + 10, M = 5e4 + 10;

int n, m;
int h[N], ne[M], e[M], idx;
//dfn[i]表示点i的时间戳,low[i]表示点i最小能走到的时间戳,size1[i]编号为i的scc中的元素个数,dout[i]表示编号为i的scc的出度
int dfn[N], low[N], size1[N],dout[N];
//id[i]表示有向图中的点i的scc编号,stk为数组模拟栈
int id[N], stk[N];
//in_stk[i]表示点i是否在栈中
bool in_stk[N];
//scc_cnt为scc的数量,timestamp为时间戳,top为栈顶
int scc_cnt, timestamp, top;

void add(int a, int b)//邻接表加边函数
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void tarjan(int u)//tarjan求scc
{
    dfn[u] = low[u] = ++timestamp;//第一次遍历时给dfn和low赋值时间戳
    stk[++top] = u, in_stk[u] = true;//点u入栈,并标记在栈中

    for (int i = h[u]; i != -1; i = ne[i])//遍历点u的子节点
    {
        int j=e[i];//点u的子节点j
        if(!dfn[j])//如果点j还没没遍历过的话
        {
            tarjan(j);//以点j开始继续深度优先遍历
            low[u]=min(low[u],low[j]);//回溯时更新点u的low[u]
        }
        else if(in_stk[j])//说明点j被遍历过并且此时在栈中
            low[u]=min(low[u],dfn[j]);//回溯时用点j更新点u的low[u]
    }

    if(dfn[u]==low[u])//说明此时点u的是强连通分量的第一个点
    {
        int y;
        ++scc_cnt;//强连通分量的个数+1
        do
        {
            y=stk[top--];//从栈顶开始弹出
            in_stk[y]=false;//出栈标记
            id[y]=scc_cnt;//给出栈的点标记所属强连通分量
            size1[scc_cnt]++;//统计scc中的元素个数
        }while(y!=u);//一直弹栈直至弹到点u
    }
}
int main()
{
    scanf("%d%d", &n, &m);
    memset(h, -1, sizeof h);//邻接表头初始化

    for (int i = 0; i < m; i++)
    {
        int a, b;
        scanf("%d%d", &a, &b);
        add(a, b);//有向边建图
    }

    for (int i = 1; i <= n; i++)//每个点都要遍历一遍,否则如果该图不是连通图的话,会漏掉一些点
        if (!dfn[i])
            tarjan(i);

    for(int i=1;i<=n;i++)//统计缩点后每个scc的出度
    {
        for(int j=h[i];j!=-1;j=ne[j])
        {
            int k=e[j];
            if(id[i]!=id[k])//说明两个点不在一个scc中
                dout[id[i]]++;//注意这里时点i所在的scc的出度
        }
    }

    //sum统计答案个数,zeros记录出度为0的scc的个数
    int sum=0,zeros=0;
    for(int i=1;i<=scc_cnt;i++)
    {
        if(!dout[i])
        {
            zeros++;
            sum+=size1[i];//编号为i的scc中的元素个数就是答案
            if(zeros>1)//如果出度为0的个数大于1个的话,说明答案是0,因为出度为0的scc中的点必定互相到达不了
            {
                sum=0;
                break;
            }
        }
    }
    printf("%d",sum);
    return 0;
}

2,学校网络

题目链接:https://www.acwing.com/problem/content/description/369/

这题我们同样要用到tarjan的强连通分量,然后缩点。首先对于每个点按题意建图,我们会得到一个可能有环的图,我们首先用tarjan找出所有点强连通分量,然后缩点,这个时候图中肯定就没有环,是一个拓扑图了。接着我们统计该图中所有scc的入度总数和出度总数。

然后对于第一问,答案就是所有scc中入度为0的点的数量。对于第二问,答案就是max(入度总数,出度总数),第二问还要特判一下,如果此时图中只有一个scc,scc中的点都互相可达,那么说明不用再额外建边,答案为0。

这里简单证明一下第二问,如果入度总数<出度总数,并且入度总数为1,那么要想满足题意,答案肯定就是出度总数,因为终点的出度为0,我们要让终点的出度为1如果入度不为1,同样可以知道需要建立的边最少边也是出度总数。因为要让每个终点都能有出度。反之入度总数<出度总数同理。

代码如下:

#include <iostream>
#include <algorithm>
#include <cstring>
#include <string>

using namespace std;

typedef long long ll;
typedef pair<int, int> pii;

const int N = 1e2 + 10,M=N*N;

int n;
int h[N],e[M],ne[M],idx;
int dfn[N],low[N],dout[N],din[N];
int stk[N],id[N];
bool in_stk[N];
int scc_cnt,timestamp,top;

void add(int a,int b)
{
    e[idx]=b,ne[idx]=h[a],h[a]=idx++;
}
void tarjan(int u)
{
    dfn[u]=low[u]=++timestamp;//对于新遍历的每个点都赋值成一个新的时间戳
    stk[++top]=u,in_stk[u]=true;//点u入栈,并标记在栈中

    for(int i=h[u];i!=-1;i=ne[i])//遍历点u的子节点
    {
        int j=e[i];//点u的子节点j
        if(!dfn[j])//如果点j还没没遍历过的话
        {
            tarjan(j);//以点j开始继续深度优先遍历
            low[u]=min(low[u],low[j]);//回溯时更新点u的low[u]
        }
        else if(in_stk[j])//说明点j被遍历过并且此时在栈中
            low[u]=min(low[u],dfn[j]);//回溯时用点j更新点u的low[u]
    }

    if(dfn[u]==low[u])//说明此时点u的是强连通分量的第一个点
    {
        int y;
        ++scc_cnt;//强连通分量的个数+1
        do
        {
            y=stk[top--];//从栈顶开始弹出
            in_stk[y]=false;//出栈标记
            id[y]=scc_cnt;//给出栈的点标记所属强连通分量
        }while(y!=u);//一直弹栈直至弹到点u
    }
}
int main()
{
    memset(h,-1,sizeof h);

    cin>>n;
    for(int i=1;i<=n;i++)
    {
        int x;
        while(cin>>x,x)
            add(i,x);//有向图建边
    }

    for(int i=1;i<=n;i++)每个点都要遍历一遍,否则如果该图不是连通图的话,会漏掉一些点
        if(!dfn[i])
            tarjan(i);

    for(int i=1;i<=n;i++)//统计每个点缩点后的入度和出度
    {
        for(int j=h[i];j!=-1;j=ne[j])
        {
            int k=e[j];
            if(id[i]!=id[k])//说明两点不在一个scc中
            {
                dout[id[i]]++;//找到点i所在的scc,出度数加1
                din[id[k]]++;//找到点k所在的scc,入度加1
            }
        }
    }

    int ina=0,outa=0;//ina表示所有scc的入度数量,outa表示所有scc的出度数量
    for(int i=1;i<=scc_cnt;i++)//遍历所有scc,统计ina和outa
    {
        if(!din[i])ina++;
        if(!dout[i])outa++;
    }

    printf("%d\n",ina);
    if(scc_cnt==1)puts("0");
    else printf("%d",max(ina,outa));
    return 0;
}

3,最大半连通子图

题目链接:http://ybt.ssoier.cn:8088/problem_show.php?pid=1514

用tarjan求scc后,对于每个scc一定是半连通子图,当我们缩完点后建图,相当于 在一个拓扑图上,求一条最长路,边权为scc中包含的元素个数,并求出最长路数。在3.1图论中讲了如何求最短路数,因此用tarjan求完scc并建图后这题就好做很多了。但是这题有一个坑点,根据题目对半连通子图的定义,我们要对任意两个scc的之间的边去重,保证任意两个scc只由一条边连接。

去重时有一个小技巧,我们可以用unordered_set,但是unordered_set不能存pair容易,因此对于任意两个sccA和B,我们可以用A乘上一个很大的数加上B,这样对于任意两个点都可以用一个数来表示,此时unordered_set中就只用存储一个long long即可

代码如下:

#include <iostream>
#include <algorithm>
#include <cstring>
#include <string>
#include <unordered_set>

using namespace std;

typedef long long ll;
typedef pair<int, int> pii;

const int N = 1e5 + 10, M = 2e6 + 10;

int n, m, mod;
// hr表示按题意建图的邻接表头,hs表示缩点后的图的邻接表头
int hr[N], hs[N], e[M], ne[M], idx;
int dfn[N], low[N], timestamp;
int id[N], stk[N], size1[N];
bool in_stk[N];
// f[i]表示当前点i距离源点的路径上的包含的元素个数,cnt[i]表示包含最多元素个数的路径的个数
int f[N], cnt[N];
int scc_cnt, top;
unordered_set<ll> s; //哈希表,将缩点后的图去掉重复边

void add(int h[], int a, int b)
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void tarjan(int h[], int u) // tarjan求scc
{
    dfn[u] = low[u] = ++timestamp;    //第一次遍历时给dfn和low赋值时间戳
    stk[++top] = u, in_stk[u] = true; //点u入栈,并标记在栈中

    for (int i = h[u]; i != -1; i = ne[i]) //遍历点u的子节点
    {
        int j = e[i]; //点u的子节点j
        if (!dfn[j])  //如果点j还没没遍历过的话
        {
            tarjan(h, j);                 //以点j开始继续深度优先遍历
            low[u] = min(low[u], low[j]); //回溯时更新点u的low[u]
        }
        else if (in_stk[j])               //说明点j被遍历过并且此时在栈中
            low[u] = min(low[u], dfn[j]); //回溯时用点j更新点u的low[u]
    }

    if (dfn[u] == low[u]) //说明此时点u的是强连通分量的第一个点
    {
        int y;
        ++scc_cnt; //强连通分量的个数+1
        do
        {
            y = stk[top--];    //从栈顶开始弹出
            in_stk[y] = false; //出栈标记
            id[y] = scc_cnt;   //给出栈的点标记所属强连通分量
            size1[scc_cnt]++;  //统计scc中的元素个数
        } while (y != u);      //一直弹栈直至弹到点u
    }
}
int main()
{
    memset(hr, -1, sizeof hr); //邻接表头初始化
    memset(hs, -1, sizeof hs);

    scanf("%d%d%d", &n, &m, &mod);
    for (int i = 0; i < m; i++)
    {
        int a, b;
        scanf("%d%d", &a, &b);
        add(hr, a, b);
    }

    for (int i = 1; i <= n; i++) //每个点都要遍历一遍,否则如果该图不是连通图的话,会漏掉一些点,而且我们不确定哪个点是源点
        if (!dfn[i])
            tarjan(hr, i);

    for (int i = 1; i <= n; i++) //缩点建图
    {
        for (int j = hr[i]; j != -1; j = ne[j])
        {
            int k = e[j];
            int a = id[i], b = id[k];
            //这里有个小技巧,因为unordered_set不能存储pair,所以这次用a乘上一个很大的数+b作为哈希值,也就是整数哈希存储再set中
            //这样set中只用存储一个long long即可
            ll has = a * 100000000ll + b; //去掉缩点后图中重复的边,也就是对于每个scc,只有一条边连接
            if (a != b && !s.count(has))
            {
                add(hs, a, b);
                s.insert(has);
            }
        }
    }

    //按拓扑序求包含元素个数最多的路径,以及路径个数
    //相当于求最长路以及最长路数,边权即为scc中包含的元素个数
    for (int i = scc_cnt; i; i--)
    {
        if (!f[i]) //当前点还没遍历过,说明是拓扑图中的起点
        {
            f[i] = size1[i]; //当前点包含的元素个数就为size1[i]
            cnt[i] = 1;      //当前点的路径个数为1
        }
        for (int j = hs[i]; j != -1; j = ne[j]) //从点i开始遍历其所有子节点
        {
            int k = e[j];
            if (f[k] < f[i] + size1[k]) //说明此时点i和点k在一条路中
            {
                f[k] = f[i] + size1[k];
                cnt[k] = cnt[i]; //路径个数不变
            }
            else if (f[k] == f[i] + size1[k])
                cnt[k] = (cnt[k] + cnt[i]) % mod; //说明有另一条不同的最长路
        }
    }

    int maxv = 0, sum = 0;             //在所有scc中找到最长的路径,以及路径个数
    for (int i = 1; i <= scc_cnt; i++) //遍历所有scc
    {
        if (f[i] > maxv)
        {
            maxv = f[i];
            sum = cnt[i];
        }
        else if (f[i] == maxv)
            sum = (sum + cnt[i]) % mod; //说明有一条不同的最长路,加上这条路径上的路径个数
    }
    printf("%d\n%d", maxv, sum);
    return 0;
}

4,银河

题目链接:https://www.acwing.com/problem/content/description/370/

这题跟3.2图论中差分约束那一节的《糖果》一摸一样,这里可以用tarjan来做,效率要比差分约束的解法高很多。我们按同样的方式建边后,用tarjan找到所有的scc,然后进行缩点,缩点后建图,建图的过程如果只要存在两个点在一个scc中并且边权大于0,那么说明肯定就正环,那么就答案就没有最小值,输出-1。并不是每个题都这样判断正环,因为有的题目可能有负权边,之所以可以这么判断是因为题目保证了所有边权都大于等于0,因此如果一个scc中有正权边,那么一定有正环 。因此如果有解的话,环中的点的边权都为0。

前面差分约束那一节已经说了如果要求最小值,那么就要求每个点到源点的最长路。缩完点后得到的图是一个拓扑图,我们可以在拓扑图上按拓扑序找每个点到源点的最长路,前面已经说了,按scc编号从大到小遍历就是拓扑序,因此我们不用再求一遍拓扑序列了,这里省去了一个求拓扑序列的操作。

ps:通过这个题,我们可以发现当图中都是大于等于0的边权时,我们可以用tarjan来判断是否有正环

代码如下:

#include <iostream>
#include <algorithm>
#include <cstring>
#include <string>

using namespace std;

typedef long long ll;
typedef pair<int, int> pii;

//M要开6e5,因为要建图最多会建2N条边,从源点到每个点要连一条边,那么总共加起来就是3N,因为要建两边图,所以是6N
const int N = 1e5 + 10,M=6*N+10;

//这一块的数组定义与上一题相同
int n,m;
//hs表示按题意建图的邻接表头,hr表示缩点后的邻接表头
int hs[N],hr[N],e[M],ne[M],w[M],idx;
int dfn[N],low[N],id[N],timestamp;
int stk[N],top,scc_cnt;
bool in_stk[N];
int dist[N],size1[N];

void add(int h[],int a,int b,int c)//邻接表建图
{
    e[idx]=b,w[idx]=c,ne[idx]=h[a],h[a]=idx++;
}
void tarjan(int h[],int u)//tarjan求scc
{
    dfn[u] = low[u] = ++timestamp;//第一次遍历时给dfn和low赋值时间戳
    stk[++top] = u, in_stk[u] = true;//点u入栈,并标记在栈中

    for (int i = h[u]; i != -1; i = ne[i])//遍历点u的子节点
    {
        int j=e[i];//点u的子节点j
        if(!dfn[j])//如果点j还没没遍历过的话
        {
            tarjan(h,j);//以点j开始继续深度优先遍历
            low[u]=min(low[u],low[j]);//回溯时更新点u的low[u]
        }
        else if(in_stk[j])//说明点j被遍历过并且此时在栈中
            low[u]=min(low[u],dfn[j]);//回溯时用点j更新点u的low[u]
    }

    if(dfn[u]==low[u])//说明此时点u的是强连通分量的第一个点
    {
        int y;
        ++scc_cnt;//强连通分量的个数+1
        do
        {
            y=stk[top--];//从栈顶开始弹出
            in_stk[y]=false;//出栈标记
            id[y]=scc_cnt;//给出栈的点标记所属强连通分量
            size1[scc_cnt]++;//统计scc中的元素个数
        }while(y!=u);//一直弹栈直至弹到点u
    }
}
int main()
{
    memset(hr,-1,sizeof hr);//题意邻接表头
    memset(hs,-1,sizeof hs);//缩点后的邻接表头

    scanf("%d%d",&n,&m);
    for(int i=1;i<=m;i++)//按题意建图
    {
        int x,a,b;
        scanf("%d%d%d",&x,&a,&b);
        if(x==1)add(hr,a,b,0),add(hr,b,a,0);
        else if(x==2)add(hr,a,b,1);
        else if(x==3)add(hr,b,a,0);
        else if(x==4)add(hr,b,a,1);
        else if(x==5)add(hr,a,b,0);
    }
    for(int i=1;i<=n;i++)add(hr,0,i,1);//从超级源点到每个点连一条长度为0的有向边

    for(int i=0;i<=n;i++)//每个点都要遍历一遍,否则如果该图不是连通图的话,会漏掉一些点
        if(!dfn[i])
            tarjan(hr,i);

    bool succes=true;//判断是否有正环
    for(int i=0;i<=n;i++)
    {
        for(int j=hr[i];j!=-1;j=ne[j])
        {
            int k=e[j];
            int a=id[i],b=id[k];
            if(a==b&&w[j]>0)//如果有两个点在一个scc中,并且两点的边权大于0,说明肯定就有正环
            {
                succes=false;
                break;
            }
            else 
                add(hs,a,b,w[j]);//否则缩点后建图,注意这里w[j]不要写成w[i]了
        }
    }
    if(!succes)puts("-1");
    else
    {
        for(int i=scc_cnt;i;i--)//按scc编号从大小遍历就是拓扑序
        {
            for(int j=hs[i];j!=-1;j=ne[j])
            {
                int k=e[j];
                dist[k]=max(dist[k],dist[i]+w[j]);//找每个点的最大值
            }
        }
        ll res=0;//将每个点的权值加起来,为scc的权值乘上scc中的元素个数,注意可能会爆int
        for(int i=1;i<=scc_cnt;i++)res+=(ll)dist[i]*size1[i];
        printf("%lld",res);
    }
    return 0;
}

 三,无向图的双连通分量

首先介绍几个概念,给定无向图G=(V,E)

割点:对于x∈V,从图中删去节点x以及所有与点x关联的边之后,G分裂成两个或两个以上不相连的子图,则称x为G的割点。

桥或割边:若对于e∈E,从图中删去边e之后,G分裂成两个不相连的子图,则称e为G的桥或割边。

一般无向图(不一定连通)的“割点”和“桥”就是它的各个连通块的“割点”和“桥”。

无向图的双连通分量

若一张无向连通图不存在割点,则称它为”点双连通图“。若一张无向连通图不存在桥,则称它为”边双连通图“。

无向图的极大点双连通子图被称为”点双连通分量“,简记为”v-DCC“。无向连通图的极大边双连通子图被称为”边双连通分量“,简记为”e-DCC“。二者统称为”双连通分量“,简记为”DCC“。

定理

一张无向连通图是”点双连通图“,当且仅当满足下列两个条件之一:

1,图的顶点数不超过2.

2,图中任意两点同时包含在至少一个简单环中。其中”简单环“指的是不自交的环,也就是我们通常画出的环。

一张无向连通图是”边双连通图“,当且仅当任意一条边都包含在至少一个简单环中。

割边判定法则

上一节我们已经讲了tarjan算法中的dfn和low的定义,对于无向图同样要用到tarjan算法。若无向边(x,y)是桥,当且仅当搜索树上存在x的一个子节点y,满足:

                                                       dfn【x】<low【y】

因为可以发现,桥一定是搜索树中的边,并且一个简单环中的边一定都不是桥。

代码实现中,特别要注意,因为我们遍历的是无向图,所以从每个点x出发,总能访问到它的父节点fa。根据low的计算方法,(x,fa)属于搜索树上的边,且fa不是x的子节点,故不能用fa的时间戳来更新low【x】。但是,如果仅记录每个节点的父节点,会出现无法处理重边的情况——当x与fa之间有多条边时,(x,fa)一定不是桥。在这些重复的边中,只有一条算是“搜索树上的边”,其他的几条都不算、故有重边时,dfn【fa】能用来更新low【x】。

一个好的解决方案是:改为记录”递归进入每个节点的边的编号“。编号可认为是边在邻接表中存储的下标位置。因为我们建图时会正向建一条边,反向建一条边,因此无向边的每一条边可以看成是双向边,并且这两条边是成对存储在下标”2“和”3“,”4“和”5“,”6“和”7“,……处。若沿着编号为i的边递归进入了节点x,则忽略从x出发的编号为i^1的边,即i的反向边,通过其他边计算low【x】即可。

求桥以及边双连通分量的代码如下:

void tarjan(int u,int from)//u表示当前遍历到的点,from表示指向点u的边的编号
{
    dfn[u]=low[u]=++timestamp;//给点u的dfn和low赋值时间戳
    stk[++top]=u;//点u入栈
    
    for(int i=h[u];i!=-1;i=ne[i])//遍历与点u相连的点
    {
        int j=e[i];//点u的子节点j
        if(!dfn[j])//如果点j还没被遍历过
        {
            tarjan(j,i);//递归遍历点j,j为点u的子节点,i为从点u指向点j的边的编号
            low[u]=min(low[u],low[j]);//回溯时更新点u的low
            if(dfn[u]<low[j])//说明找到了一个桥
                is_bridge[i]=is_bridge[i^1]=true;//i^1为i的反向边
        }
        else if(i!=(from^1))low[u]=min(low[u],dfn[j]);
        //如果点j已经被遍历过,且点i不是from的反向边,就可以更新点u的low
    }
    
    if(dfn[u]==low[u])//找到边双连通分量
    {
        int y;
        ++dcc_cnt;//边双连通分量的个数
        do
        {
            y=stk[top--];//一直弹栈
            id[y]=dcc_cnt;//给弹出的点找到其所在的边双连通分量
        }while(y!=u);//直至弹到u后停止
    }
}

  边连通分量的缩点

把每个e-DCC看成一个节点,把桥边(x,y)看作连接编号为id【x】和id【y】的e-DCC对应节点的无向边,会产生一颗树(若原来的无向图不连通,则产生森林),这其实与有向图的scc缩点是一样的

//缩点后会得到一棵树
for(int i=1;i<idx;i++)//从1号边枚举所有边,idx为边的数量
{
    int x=e[i^1],y=e[i];//i^1为i的反向边,i为正向边,x为反向边指向的节点,y为正向边指向的节点,即x指向y
    if(id[x]!=id[y])//如果x所在e-DCC和y所在e-DCC不在一个e—dCC中,就加一条从x所在e-DCC指向y所在e—DCC的边
        add(id[x],id[y]);
}

割点判定法则

若x不是搜索树的根节点(深度优先遍历的起点),则x是割点当且仅当搜索树上存在x的一个子节点y,满足

                                                dfn【x】≤low【y】

特别地,若x是搜索树的根节点,则x是割点当且仅当搜索树上存在至少两个子节点y1,y2满足上述条件。

对于代码实现,因为割点的判定法则是小于等于号,所以在求割点时,不必考虑父节点和重边的问题,从x出发能访问到的所有点的时间戳都可以用来更新low【x】。注意,若某个节点为孤立点,则它单独构成一个点双连通分量。除了孤立点外,点双连通分量的大小至少为2。根据点双连通分量的定义。虽然桥不属于任何边连通分量,但是割点可能属于多个点连通分量。

这里与有向图求scc和无向图求e—DCC有很大的不同因此我们弹栈时,对于点u的子节点j,我们弹到j为止,不弹到点u,但是点u跟点j同样属于一个点双连通分量,同时点u可能属于多个点双连通分量。而且在求v—DCC时,我们是搜索的过程中,对于每个点都判断是否满足割点判定法则,满足就不断弹栈

求割点以及点双连通分量的代码如下:

void tarjan(int u)//遍历到的当前点u
{
    dfn[u]=low[u]=++timestamp;//给点u的dfn和low赋值时间戳
    stk[++top]=u;//点u入栈

    if(u==root&&h[u]==-1)//判断点u是否是孤立点,如果是孤立点,点u也是一个点双连通分量
    {
        dcc[++cnt].push_bcak(u);//cnt表示点双连通分量的个数,用一个vector存储编号为cnt的点双连通分量中的点
        return;
    }

    int flag=0;//flag记录点u满足割点判定法则的子树个数
    for(int i=h[u];i!=-1;i=ne[i])//遍历点u的子节点
    {
        int j=e[i];//点u的子节点j
        if(!dfn[j])//如果点j还没被遍历过
        {
            tarjan(j);//递归遍历点j
            low[u]=min(low[u],low[j]);//回溯时更新点u的low
            if(dfn[u]<=low[j])//说明找到一个割点
            {
                flag++;//用于判断当前点是否是根节点,如果是根节点,需要有两个j满足上述if判断
                //如果点u不是根节点,flag为1也能说明点u是割点,如果点u是根节点,flag必须大于2才能说明点u是割点
                if(u!=root||flag>1)cut[u]=true;//表示点u是割点
                cnt++;//点双连通分量的数量++
                int y;
                do//统计编号为cnt的e-DCC中的点
                {
                    y=stk[top--];
                    dcc[cnt++].push_back(y);//统计编号为cnt的e-DCC中的点
                } while (y!=j);//不断弹栈,直到弹到点j为止,注意这里弹栈是弹到j,不是u,与有向图求scc和无向图求e-DCC不同
                dcc[cnt].push_back(u);//点u也属于该e-DCC
            }
        }
        else low[u]=min(low[u],dfn[j]);
        //如果点j已经被遍历过,也能用点j更新点u的low
    }
}

 点连通分量的缩点

 v-DCC缩点要比e-DCC缩点复杂一些——因为一个割点可能属于多个v-DCC。设图中共有p个割点和t个v-DCC。我们建立一个p+t个节点的新图,把每个v-DCC和每个割点作为新图中的节点,并在每个割点与包含它的v-DCC之间连边,容易发现,这张图其实就是一颗树

int num=cnt;
for(int i=1;i<=n;i++)//找到所有割点
    if(cut[i])//如果点i是割点
        new_id[i]=++num;//给割点新编一个号
for(int i=1;i<=cnt;i++)//枚举所有v-DCC
{
    for(int j=0;j<dcc[i].size();j++)//枚举v-DCC中的所有点
    {
        int x=dcc[i][j];//找到v-DCC中的点
        if(cut[x])//如果点x是割点
        {
            add(i,new_id[x]);//就将割点和其所在v-DCC连双向边
            add(new_id[x],i);
        }
    }
}       

下面看几个例题:

1,冗余路径

题目链接:395. 冗余路径 - AcWing题库

这题抽象出来就是,对于一个图中的任意两个点,要至少有两条完全不同的路径,那么就是说,每个点都至少要在一个环中,因此这题就是让我们求最小的边双连通分量 ,我们用tarjan算法求出来所有的边连通分量(与有向图求强连通分量的方法一样)后,进行缩点,缩点过后,整个图就变成了一棵树,我们只需要对树的叶子节点进行两两连一条边即可,如果叶子数是奇数,剩下的那个就随便连一条边,因此如果叶子的个数为cnt,我们需要连的边的数量就是(cnt+1)/2。

但其实这题找到边双连通分量后不用真的再建一遍图来找叶子树,因为叶子树的度数肯定是1,所以我们只需要统计每个e-DCC的度数,然后记录度数为1的e-DCC有多少个,个数即为cnt

代码如下:

#include<iostream>
#include<algorithm>
#include<cstring>

using namespace std;

const int N=5010,M=20010;

int n,m;
int h[N],e[M],ne[M],idx;
int dfn[N],low[N],timestamp;
int stk[N],top;
int id[N],d[N],dcc_cnt;
bool is_bridge[M];

void add(int a,int b)
{
    e[idx]=b,ne[idx]=h[a],h[a]=idx++;
}
void tarjan(int u,int from)//u表示当前遍历到的点,from表示指向点u的边的编号
{
    dfn[u]=low[u]=++timestamp;//给点u的dfn和low赋值时间戳
    stk[++top]=u;//点u入栈
    
    for(int i=h[u];i!=-1;i=ne[i])//遍历与点u相连的点
    {
        int j=e[i];//点u的子节点j
        if(!dfn[j])//如果点j还没被遍历过
        {
            tarjan(j,i);//递归遍历点j,j为点u的子节点,i为从点u指向点j的边的编号
            low[u]=min(low[u],low[j]);//回溯时更新点u的low
            if(dfn[u]<low[j])//说明找到了一个桥
                is_bridge[i]=is_bridge[i^1]=true;//i^1为i的反向边
        }
        else if(i!=(from^1))low[u]=min(low[u],dfn[j]);
        //如果点j已经被遍历过,且点i不是from的反向边,就可以更新点u的low
    }
    
    if(dfn[u]==low[u])//找到边双连通分量
    {
        int y;
        ++dcc_cnt;//边双连通分量的个数
        do
        {
            y=stk[top--];//一直弹栈
            id[y]=dcc_cnt;//给弹出的点找到其所在的边双连通分量
        }while(y!=u);//直至弹到u后停止
    }
}
int main()
{
    scanf("%d%d",&n,&m);
    memset(h,-1,sizeof h);
    
    for(int i=0;i<m;i++)
    {
        int a,b;
        scanf("%d%d",&a,&b);
        add(a,b),add(b,a);//无向边建图
    }
    
    tarjan(1,-1);//因为题目保证了是连通图,所以直接从1号点开始做tarjan即可,-1表示边的编号
    
    for(int i=0;i<idx;i++)
        if(is_bridge[i])//对于缩点后的图,找到每个点的入度总和
            d[id[e[i]]]++;//对于桥的出边所在的dcc的入度++
            
    int cnt=0;
    for(int i=1;i<=dcc_cnt;i++)//遍历每个dcc,统计入度为1的个数
        if(d[i]==1)
            cnt++;
    printf("%d",(cnt+1)/2);//答案就是(cnt+1)/2,即对任意两个入度为1的dcc连一条边
}

2,电力

题目链接:信息学奥赛一本通(C++版)在线评测系统

这题就是让我们枚举删除每个割点后得到的连通块数量,但是这里的割点并不是真正意义上的割点,结合前面提到的对于割点的定义,我们知道,如果一个点是割点,当把它删除后图会变成两个或两个以上的子图,因此,如果一个点是根节点,就必须要有两个子节点满足上述的等式要求,即如果只有两个点,那么根节点一定不是割点,但是在这题中,如果只有两个点,我们删除其中一个后,连通块的数量会+1,所以对于每个点不用根据其是否是根节点来特判其是否是割点。

那么我们如何统计删除每个割点后得到的连通块数量,如果一个点是上述讲的类似割点的点话,我们统计其满足要求的子节点的个数cnt,如果是根节点,那么删去这个点后得到的连通块的数量就是cnt,如果不是根节点的话,我们还要统计上其父节点所在的连通块,因此数量就是cnt+1.同时还要注意一点,题意中给的图不一定是连通图,所以我们可以在做tarjan算法的时候顺便将初始时连通块的数量求出来

代码如下:

#include <iostream>
#include <algorithm>
#include <cstring>
#include <string>

using namespace std;

typedef long long ll;
typedef pair<int, int> pii;

const int N = 1e4 + 10,M=3*N;

int n,m;
int h[N],e[M],ne[M],idx;
int dfn[N],low[N],timestamp;
int root,ans;
//root为根节点,ans为枚举删除每个类似于割点的点得到的连通块的个数

void add(int a,int b)
{
    e[idx]=b,ne[idx]=h[a],h[a]=idx++;
}
void tarjan(int u)
{
    dfn[u]=low[u]=++timestamp;//第一次遍历到点u时给dfn和low赋值时间戳

    int cnt=0;//如果点u是割点的话,删除该点后得到的连通块的数量
    for(int i=h[u];i!=-1;i=ne[i])//枚举点u的子节点
    {
        int j=e[i];//点j为点u的子节点
        if(!dfn[j])
        {
            tarjan(j);//递归搜索点j
            low[u]=min(low[u],low[j]);//回溯时用点j更新点u的low
            if(low[j]>=dfn[u])cnt++;//说明点u是割点
        }
        else low[u]=min(low[u],dfn[j]);//如果点j已经被搜索过,也可以用来更新点u的low
    }
    if(u!=root&&cnt>=1)cnt++;//如果点u不是根节点且点u是割点,删除后与其父节点也断开了,还要算上其父节点的连通块,所以个数+1
    ans=max(ans,cnt);//统计枚举删除每个割点得到的连通块数量
}
int main()
{
    while(scanf("%d%d",&n,&m),n||m)
    {
        memset(h,-1,sizeof h);
        memset(dfn,0,sizeof dfn);//多组测试数据,记得初始化
        memset(low,0,sizeof low);
        idx=timestamp=0;

        for(int i=0;i<m;i++)
        {
            int a,b;
            scanf("%d%d",&a,&b);
            add(a,b),add(b,a);//无向边建图
        }

        ans=0;
        int cnt=0;//记录初始时连通块的个数
        for(root=0;root<n;root++)
            if(!dfn[root])//从每一个连通块的根节点开始搜索
            {
                cnt++;
                tarjan(root);
            }
        //答案为初始连通块的个数加上删去一个割点得到的最大连通块个数-1,因为割点必定在其中一个连通块中
        printf("%d\n",ans+cnt-1);
    }
    return 0;
}

3,矿场搭建

题目链接:396. 矿场搭建 - AcWing题库

首先搞清楚题目的要求,题目要求我们求至少设置多少个出口,使得无论从哪一个挖煤点坍塌之后 ,其他挖煤点的工人都有一条道路通向救援出口。并求出设置最少出口的方案数。题目并没有保证是连通图,我们将题意给的图看成是多个连通块

1,首先对于整个图来说我们至少要建立两个出口,因为如果我们只建立一个出口,那么这个出口坍塌以后,其他挖煤点的工人就到达不了出口了。

然后我们再看每个连通块的情况:

2,如果一个连通块没有割点,也就是说,在这连通块删去任何一个点后,这个连通块还是连通的,那我们只需要在这个连通块的任意两个点建立出口即可,如果连通块中点的数量为cnt,那么建立的方案数就是C_{cnt}^{2}=(cnt*(cnt-1))/2​.

3,如果该连通块有割点,那我们就要进行缩点,缩点后建图,一个连通块缩点后建图会是一棵树,如果该v-DCC的度数为1,说明该v-DCC是树中的叶子节点,那么我们就要在该v-DCC中的任意一个非割点的点建立出口,方案数就是该v-DCC中点的个数减去割点,即减1。如果该v-DCC的度数大于等于2,如果该v-DCC不是叶子节点,那么我们就不用在该v-DCC中建立出口,因为该v-DCC总能走到别的连通块中的出口。

4,如果该连通块是孤立点,只有一个点,那么我们必须建立一个出口,方案数为1

至此,所有情况都分析完了,因此题目就是让我们找到所有的割点,然后对于每个连通块,求其割点的数量来得到答案数与方案数,对于第3点,要想判断一个v-DCC是不是缩点后的叶子节点,根据我们缩点的建图方式,可以知道只需要看该v-DCC中割点的个数,如果个数为1个,那么就是叶子节点,否则不是

最后,这题有细节问题要注意,并没有给定挖煤点的编号,所以要根据得到的边求出最大的挖煤点的编号,还有数据范围保证在2^64,所以我们统计方案数时要开unsigned long long,如果是2^63的话就只用开long long就够了

代码如下:

#include<iostream>
#include<algorithm>
#include<cstring>
#include<vector>

using namespace std;

typedef unsigned long long ull;

const int N=1010;

int n,m;
int h[N],e[N],ne[N],idx;
int dfn[N],low[N],timestamp;
int stk[N],top;
int dcc_cnt,root;//dcc_cnt为点双连通分量的个数,root为连通块的根节点
bool cut[N];//判断该点是否是割点
vector<int>dcc[N];//存储编号为dcc_cnt的dcc中的点

void add(int a,int b)//邻接表加边函数
{
    e[idx]=b,ne[idx]=h[a],h[a]=idx++;
}
void tarjan(int u)//u为当前遍历到的点
{
    dfn[u]=low[u]=++timestamp;
    stk[++top]=u;
    
    if(u==root&&h[u]==-1)//如果点u是根节点并且没有子节点,说明点u是孤立点
    {
        dcc[++dcc_cnt].push_back(u);
        return;
    }
    
    int flag=0;//记录点u满足割点判定法则的子树个数
    for(int i=h[u];i!=-1;i=ne[i])//遍历点u的子节点
    {
        int j=e[i];//点u的子节点j
        if(!dfn[j])//如果点j还没被遍历过
        {
            tarjan(j);//递归搜索点j
            low[u]=min(low[u],low[j]);//回溯时更新点u的low
            if(low[j]>=dfn[u])//说明点u可能是割点
            {
                flag++;//记录点u满足割点判定法则的子树个数
                if(u!=root||flag>1)cut[u]=true;//如果不是根节点或flag大于1,即可说明点u是割点
                ++dcc_cnt;//dcc的数量
                int y;
                do//找到包含点u的点双连通分量
                {
                    y=stk[top--];
                    dcc[dcc_cnt].push_back(y);//记录编号为dcc_cnt的dcc中的点
                }while(y!=j);//注意!!!遍历到点j结束
                dcc[dcc_cnt].push_back(u);//点u也是该dcc中的点,但是点u没出栈,因为点u可能是多个dcc中的点
            }
        }
        else low[u]=min(low[u],dfn[j]);//如果点u已经被遍历过,也可以更新点u的low
    }
}
int main()
{
    int T=1;
    while(scanf("%d",&m),m)
    {
        for(int i=1;i<=dcc_cnt;i++)dcc[i].clear();//多组测试数据,记得初始化
        memset(h,-1,sizeof h);
        memset(dfn,0,sizeof dfn);
        memset(cut,0,sizeof cut);
        timestamp=top=dcc_cnt=n=idx=0;
        
        while(m--)//m条边
        {
            int a,b;
            scanf("%d%d",&a,&b);
            add(a,b),add(b,a);
            n=max(n,a),n=max(n,b);//找到最大的n
        }
        
        for(root=1;root<=n;root++)//从根节点开始遍历每个连通块,找到孤立点,和每个连通块的割点
            if(!dfn[root])
                tarjan(root);
        
        int res=0;//记录最小出口数
        ull num=1;//记录方案数,记得要开unsigned long long 
        for(int i=1;i<=dcc_cnt;i++)//遍历每个dcc
        {   
            int cnt=0;//记录该dcc中割点的数量
            if(dcc[i].size()==1)res++;//说明是孤立点
            else
            {
                for(int j=0;j<dcc[i].size();j++)//遍历该dcc中的所有点
                    if(cut[dcc[i][j]])//统计该dcc中割点的数量
                        cnt++;
                if(cnt==0)res+=2,num*=dcc[i].size()*(dcc[i].size()-1)/2;//分情况统计答案
                else if(cnt==1)res++,num*=(dcc[i].size()-1);
            }
        }
        printf("Case %d: %d %llu\n",T++,res,num);
    }
    return 0;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值