AcWing 1174. 受欢迎的牛


题目传送门

一、有向图强连通分量

在有向图\(G\)中,如果两个顶点间至少存在一条互相可达的路径,称两个顶点强连通(\(strongly\) \(connected\))。如果有向图\(G\)的每两个顶点都强连通,称\(G\)是一个强连通图。非强连通图有向图的极大强连通子图,称为强连通分量(\(strongly\) \(connected\) \(components\))。

下图中,子图\(\{1,2,3,4\}\)为一个强连通分量,因为顶点\(1,2,3,4\)两两可达。\(\{5\},\{6\}\)也分别是两个强连通分量。

直接根据定义,用双向遍历交集的方法求强连通分量,时间复杂度为\(O(N^2+M)\)。更好的方法是\(Kosaraju\)算法或\(Tarjan\)算法,两者的时间复杂度都是\(O(N+M)\)。本文介绍的是\(Tarjan\)算法。

二、Tarjan算法

\(Tarjan\)算法是基于对图深度优先搜索的算法,每个强连通分量为搜索树中的一棵子树。搜索时,把当前搜索树中未处理的节点加入一个堆栈,回溯时可以判断栈顶到栈中的节点是否为一个强连通分量。

定义\(DFN(u)\)为节点\(u\)搜索的次序编号(时间戳),\(Low(u)\)\(u\)\(u\)的子树能够追溯到的最早的栈中节点的次序编号。由定义可以得出:

Low(u)=Min{
    DFN(u),   当前节点ID号
    Low(v),  (u,v)为树枝边,u为v的父节点 (如果我的孩子能走到更小的号,就是我能走到的最小号)
    DFN(v),  (u,v)为指向栈中节点的后向边(非横叉边)
}

\(DFN(u)=Low(u)\)时,以\(u\)为根的搜索子树上所有节点是一个强连通分量。
算法伪代码如下:

tarjan(u){
	DFN[u]=Low[u]=++Index                      // 为节点u设定次序编号和Low初值
	Stack.push(u)                              // 将节点u压入栈中
	for each (u, v) in E                       // 枚举每一条边
		if (v is not visted)               // 如果节点v未被访问过
			tarjan(v)                  // 继续向下找
			Low[u] = min(Low[u], Low[v])
		else if (v in S)                   // 如果节点v还在栈内
			Low[u] = min(Low[u], DFN[v])
	
        if (DFN[u] == Low[u])                      // 如果节点u是强连通分量的根
		repeat
			v = S.pop                  // 将v退栈,为该强连通分量中一个顶点
			print v
		until (u== v)
}

接下来是对算法流程的演示。

从节点\(1\)开始\(DFS\),把遍历到的节点加入栈中。搜索到节点\(u=6\)时,\(DFN[6]=LOW[6]\),找到了一个强连通分量。退栈到\(u=v\)为止,{\(6\)}为一个强连通分量。

返回节点\(5\),发现\(DFN[5]=LOW[5]\),退栈后{\(5\)}为一个强连通分量。

返回节点\(3\),继续搜索到节点\(4\),把\(4\)加入堆栈。发现节点\(4\)向节点\(1\)有后向边,节点\(1\)还在栈中,所以\(LOW[4]=1\)。节点\(6\)已经出栈,\((4,6)\)是横叉边,返回\(3\)\((3,4)\)为树枝边,所以\(LOW[3]=LOW[4]=1\)

继续回到节点\(1\),最后访问节点\(2\)。访问边\((2,4)\)\(4\)还在栈中,所以\(LOW[2]=DFN[4]=5\)。返回\(1\)后,发现\(DFN[1]=LOW[1]\),把栈中节点全部取出,组成一个连通分量{\(1,3,4,2\)}。

至此,算法结束。经过该算法,求出了图中全部的三个强连通分量{\(1,3,4,2\)},{\(5\)},{\(6\)}。

可以发现,运行\(Tarjan\)算法的过程中,每个顶点都被访问了一次,且只进出了一次堆栈,每条边也只被访问了一次,所以该算法的时间复杂度为\(O(N+M)\)

求有向图的强连通分量还有一个强有力的算法,为\(Kosaraju\)算法。\(Kosaraju\)是基于对有向图及其逆图两次\(DFS\)的方法,其时间复杂度也是\(O(N+M)\)。与\(Trajan\)算法相比,\(Kosaraju\)算法可能会稍微更直观一些。但是\(Tarjan\)只用对原图进行一次\(DFS\),不用建立逆图,更简洁。在实际的测试中,\(Tarjan\)算法的运行效率也比\(Kosaraju\)算法高\(30\)%左右。此外,该\(Tarjan\)算法与求无向图的双连通分量(割点、桥)的\(Tarjan\)算法也有着很深的联系。学习该\(Tarjan\)算法,也有助于深入理解求双连通分量的\(Tarjan\)算法,两者可以类比、组合理解。

求有向图的强连通分量的\(Tarjan\)算法是以其发明者\(Robert\) \(Tarjan\)命名的。\(Robert\) \(Tarjan\)还发明了求双连通分量的\(Tarjan\)算法,以及求最近公共祖先的离线\(Tarjan\)算法,在此对\(Tarjan\)表示崇高的敬意。

网络大神的完美图解,附上以备复习之用:

三、本题题解

通过求强连通分量+缩点,可以得到一个\(DAG\),在\(DAG\)上讨论此问题就容易了:

  • 只要是出度为\(0\)的只有一个,那么此强连通分量中节点的个数就是答案。
  • 如果出度为\(0\)的不只一个,那么就没有答案。
    论述过程:https://lishizheng.blog.csdn.net/article/details/115121867

四、完整代码

#include 
using namespace std;
const int N = 10010, M = 50010;
int n, m;   // n个点,m条边
int out[N]; //记录一下每个强连通分量的出度

int stk[N], top; // tarjan算法需要用到的堆栈
bool in_stk[N];  //是否在栈内
int dfn[N];     // dfs遍历到u的时间
int low[N];     // 从u开始走所能遍历到的最小时间戳
int timestamp;  // 时间戳,dfs序的标识,记录谁先谁后
int id[N], cnt; //强连通分量块的最新索引号
int sz[N];      // sz[i]表示编号为i的强连通分量中原来点的个数
/*
(1)求强连通分量

(2)缩点
for i=1;i<=n;i++
 for i的所有邻点j
   if i和j不在同一scc中:
    加一条新边id[i]→id[j]

缩点操作后变成有向无环图
就能做topsort序了(此时连通分量编号id[]递减的顺序就是topsort序了)


*/
//邻接表
int e[M], h[N], idx, ne[M];
void add(int a, int b) {
    e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}

// Tarjan算法求强连通分量
void tarjan(int u) {
    dfn[u] = low[u] = ++timestamp;      //初始化u节点的遍历序号(连通块号),从u开始走所能遍历到的最小时间戳
    stk[++top] = u;                     // u入栈,栈的意义一会TODO
    in_stk[u] = true;                   //标识u在栈中
    for (int i = h[u]; ~i; i = ne[i]) { //枚举u的每一条出边
        int j = e[i];
        if (!dfn[j]) {                    //如果j没有被访问过
            tarjan(j);                    // dfs继续探索
            low[u] = min(low[u], low[j]); //我孩子能到达的最小遍历序号,我也可以用
        } else if (in_stk[j])             //如果访问过,而且这个点出现在栈中,表示是后向边,回头路
            low[u] = min(low[u], low[j]); //看看是不是可以被回头路上的low[j]改的更小
    }
    if (dfn[u] == low[u]) { // 找到该连通分量的最高点
        ++cnt;              //强连通分量的序号
        int x;              //临时变量x,用于枚举栈中当前强连通分量中每个节点
        do {
            x = stk[top--];    //弹出节点
            in_stk[x] = false; //标识不在栈中了
            id[x] = cnt;       //记录id号
            sz[cnt]++;         //这个强连通分量中节点的个数+1
        } while (x != u);      //将此强连通分量中的所有节点处理完,都出栈
    }
}
int main() {
    cin >> n >> m;
    memset(h, -1, sizeof h); //初始化邻接表
    for (int i = 0; i < m; i++) {
        int a, b;
        cin >> a >> b;
        add(a, b);
    }
    //(1)求强连通分量
    for (int i = 1; i <= n; i++) //需示枚举每个点出发尝试,否则会出现有的点没有遍历到的情况
        if (!dfn[i]) tarjan(i);

    //(2)缩点,求DAG中每个节点的出度
    for (int i = 1; i <= n; i++)
        for (int j = h[i]; ~j; j = ne[j]) {
            int k = e[j];             // i->k
            int a = id[i], b = id[k]; // i,k都在哪个强连通分量块中
            if (a != b) out[a]++;     // a的出度++
        }

    /*
    当一个强连通的出度为0,则该强连通分量中的所有点都被其他强连通分量的牛欢迎
    但假如存在两个及以上个出度=0的牛(强连通分量) 则必然有一头牛(强连通分量)
    不被所有牛欢迎,见下图最右边两个强连通分量
    o→o→o
      ↑
      o→o
    */
    int zeros = 0, sum = 0;
    for (int i = 1; i <= cnt; i++) //枚举强连通分量块
        if (!out[i]) {             //如果出度是0
            zeros++;               //出度是0的强连通分量个数+1
            sum += sz[i];          //累加此强连通分量中点的个数

            if (zeros > 1) { //如果强连通分量块的数量大于1个,则无解
                sum = 0;
                break;
            }
        }
    //求有多少头牛被除自己之外的所有牛认为是受欢迎的
    printf("%d\n", sum);
    return 0;
}

相关