|
|
## [$AcWing$ $1174$. 受欢迎的牛](https://www.acwing.com/problem/content/1176/)
|
|
|
|
|
|
### 一、题目描述
|
|
|
每一头牛的愿望就是变成一头最受欢迎的牛。
|
|
|
|
|
|
现在有 $N$ 头牛,编号从 $1$ 到 $N$,给你 $M$ 对整数 $(A,B)$,表示牛 $A$ 认为牛 $B$ 受欢迎。
|
|
|
|
|
|
这种关系是具有传递性的,如果 $A$ 认为 $B$ 受欢迎,$B$ 认为 $C$ 受欢迎,那么牛 $A$ 也认为牛 $C$ 受欢迎。
|
|
|
|
|
|
你的任务是求出有多少头牛被除自己之外的所有牛认为是受欢迎的。
|
|
|
|
|
|
**输入格式**
|
|
|
第一行两个数 $N$,$M$;
|
|
|
|
|
|
接下来 $M$ 行,每行两个数 $A,B$,意思是 $A$ 认为 $B$ 是受欢迎的(给出的信息有可能重复,即有可能出现多个 $A,B$)。
|
|
|
|
|
|
**输出格式**
|
|
|
输出被除自己之外的所有牛认为是受欢迎的牛的数量。
|
|
|
|
|
|
**数据范围**
|
|
|
$1≤N≤10^4,1≤M≤5×10^4$
|
|
|
|
|
|
**输入样例**:
|
|
|
```cpp {.line-numbers}
|
|
|
3 3
|
|
|
1 2
|
|
|
2 1
|
|
|
2 3
|
|
|
```
|
|
|
|
|
|
**输出样例**:
|
|
|
```cpp {.line-numbers}
|
|
|
1
|
|
|
```
|
|
|
|
|
|
**样例解释**
|
|
|
只有第三头牛被除自己之外的所有牛认为是受欢迎的。
|
|
|
|
|
|
### 二、$Tarjan$算法求有向图强连通分量
|
|
|
|
|
|
#### 1. 基础概念
|
|
|
|
|
|
首先了解几个概念:**强连通**,**强连通图**,**强连通分量**
|
|
|
|
|
|
* 强连通:在一个有向图$G$中,两个点$a,b$,$a$可以走到$b$,$b$可以走到$a$,我们就说$(a,b)$强连通
|
|
|
|
|
|
* 强连通图:在一个有向图$G$中,任意两个点都是强连通
|
|
|
|
|
|
* 强连通分量:在一个有向图$G$中,有一个子图,它任意两个点都是强连通,我们就说这个子图为强连通分量,**特别的**,**一个点也是一个强连通分量**
|
|
|
|
|
|
如图:
|
|
|
<center><img src='https://img2018.cnblogs.com/blog/1646527/201908/1646527-20190808104944394-690917072.png'></center>
|
|
|
|
|
|
显然可得:$1,2,3,5$ 构成了一个强连通分量(**一个点也是**)
|
|
|
|
|
|
**代码实现时的设计**
|
|
|
* $dfn[v]$:搜索节点$v$时的时间序
|
|
|
* $low[x]$:以$x$为根的子树中,每个节点中连接的点的时间戳的 **最小值**
|
|
|
初值化:$low[x]=dfn[x]$
|
|
|
|
|
|
#### 2. 有向图的强连通分量用途
|
|
|
主要是通过 **缩点**(将强连通分量缩成一个点),把有向图,转换为 **有向无环图**(拓扑图,$DAG$)。
|
|
|
|
|
|
如下图,左图圈内的是一个强连通分量,通过缩点,转化为右图。这种做法其实有很多应用,比如求最短路等。
|
|
|
|
|
|
<center><img src='https://img-blog.csdnimg.cn/20210331125204686.png?,size_16,color_FFFFFF,t_70'></center>
|
|
|
|
|
|
#### 3. 算法步骤
|
|
|
|
|
|
- ① 初始化:
|
|
|
- 给每个顶点分配一个唯一的标识号
|
|
|
- 初始化一个空栈
|
|
|
- 初始化一个访问标记数组,用于记录每个顶点是否已经被访问
|
|
|
|
|
|
- ② 对于图中的每个未被访问的顶点执行步骤$3$
|
|
|
|
|
|
- ③ 遍历顶点:
|
|
|
- 给当前顶点设置一个访问标记并将其压入栈中
|
|
|
- 将当前顶点的访问次序(标记号)和最小后序号($low$值)都设置为当前最小值
|
|
|
- 遍历当前顶点的所有邻居节点:
|
|
|
- 如果邻居节点未被访问,则对邻居节点执行步骤$3$。
|
|
|
- 在递归步骤中,如果邻居节点没有被访问过,将它的父节点设置为当前节点
|
|
|
- 如果邻居节点已经在栈中,更新当前顶点的最小后序号($low$值)
|
|
|
- 如果当前顶点是一个 **根节点**,则弹出栈中从当前顶点开始的所有顶点,并将它们组成一个强连通分量。
|
|
|
- ④ 返回所有的强连通分量
|
|
|
|
|
|
|
|
|
#### 4. 算法实现
|
|
|
```cpp {.line-numbers}
|
|
|
// Tarjan算法求强连通分量
|
|
|
int stk[N], top, in_stk[N]; // stk[N]:堆栈,top:配合堆栈使用的游标top,in_stk[N]:是否在栈内
|
|
|
int dfn[N], ts, low[N]; // dfn[N]:时间戳记录数组,ts:时间戳游标, low[N]:从u开始走所能遍历到的最小时间戳
|
|
|
int id[N], scc_cnt, sz[N]; // id[N]:强连通分量块的编号,scc_cnt:强连通分量的编号游标,sz[i]:编号为i的强连通分量中原来点的个数
|
|
|
|
|
|
void tarjan(int u) {
|
|
|
dfn[u] = low[u] = ++ts; // 初始时间戳
|
|
|
stk[++top] = u; // 入栈
|
|
|
in_stk[u] = 1; // 在栈内
|
|
|
|
|
|
for (int i = h[u]; ~i; i = ne[i]) {
|
|
|
int v = e[i];
|
|
|
if (!dfn[v]) { // v未访问过
|
|
|
tarjan(v); // dfs深搜
|
|
|
low[u] = min(low[u], low[v]); // 更新low[u]
|
|
|
} else if (in_stk[v]) // v已经栈中,找到强连通分量
|
|
|
low[u] = min(low[u], low[v]); // 更新low[u]
|
|
|
}
|
|
|
// 发现强连通分量
|
|
|
if (dfn[u] == low[u]) {
|
|
|
++scc_cnt;
|
|
|
int x;
|
|
|
do {
|
|
|
x = stk[top--];
|
|
|
in_stk[x] = 0;
|
|
|
id[x] = scc_cnt; // 记录x是缩点后编号为ssc_cnt号强连通分量的一员
|
|
|
sz[scc_cnt]++; // 缩点后编号为ssc_cnt号强连通分量中的成员个数+1
|
|
|
} while (x != u);
|
|
|
}
|
|
|
}
|
|
|
```
|
|
|
|
|
|
##### 答疑解惑
|
|
|
|
|
|
```cpp {.line-numbers}
|
|
|
for (int i = h[u]; ~i; i = ne[i]) {
|
|
|
int v = e[i];
|
|
|
if (!dfn[v]) { // v未访问过
|
|
|
tarjan(v); // dfs深搜
|
|
|
low[u] = min(low[u], low[v]); // 更新low[u]
|
|
|
} else if (in_stk[v]) // v已经栈中,找到强连通分量
|
|
|
low[u] = min(low[u], low[v]);
|
|
|
}
|
|
|
```
|
|
|
这段代码中的节点$u$和节点$v$可能存在三种情况,分别如下:
|
|
|
|
|
|
- ① 节点$v$未被访问过($!dfn[v]$):这种情况说明节点$v$是一个未探索的节点,还没有被访问过。在深度优先搜索中,我们需要继续从节点$u$探索到节点$v$。
|
|
|
|
|
|
- ② 节点$v$已经在栈内($in\_stk[v]$):这种情况表示节点$v$已经访问过,并且它当前在栈中。这表示节点$v$与节点$u$在同一个强连通分量中,即形成了一个环。
|
|
|
|
|
|
- ③ 节点$v$已被访问过但不在栈内:这种情况发生在节点$v$已经访问过,并且已经出栈。在这种情况下,我们不需要做任何操作,因为节点$v$已经被处理过并且不再对强连通分量的构建产生影响。
|
|
|

|
|
|
|
|
|
> **注意**:树边,后向边,前向边,都有祖先,后裔的关系,但横叉边没有,$u->v$为横叉边,说明在这棵$DFS$树中,它们不是祖先后裔的关系它们可能是兄弟关系,堂兄弟关系,甚至更远的关系,如果是$dfs$森林的话,$u$和$v$甚至可以在不同的树上
|
|
|
在很多算法中,后向边都是有作用的,但是前向边和横叉边的作用往往被淡化,其实它们没有太大作用,上面的③就是横叉边。
|
|
|
|
|
|
以上三种情况涵盖了节点$u$与节点$v$之间的所有可能情况。根据具体的情况,算法会执行相应的操作,例如继续深度优先搜索、更新$low[u]$值或者识别出一个强连通分量并进行缩点操作。
|
|
|
|
|
|
### 三、本题思路
|
|
|
题意是找到被其他所有牛都欢迎的牛的数量。在有向图的角度,就是 **所有的点都可以走到当前这个点**。
|
|
|
|
|
|
#### 暴力解法
|
|
|
如果暴力做的话,对于每个点都要$dfs$或者$bfs$,看是否所有点都可以到达该点,做一遍的时间复杂度是$O(n + m)$,那么$n$个点,时间复杂度是$O(n \times (n+m))$,这里的$n$是$1w$,$m$是$5w$,时间限制是$1s$,会超时。
|
|
|
|
|
|
#### 优化解法
|
|
|
如果是拓扑图(有向无环图$DAG$)的话,就好解决了。
|
|
|
>**结论**:**只需要统计出度为$0$的点的数量。如果出度为$0$的点的数量大于等于$2$,那么一定不存在最受欢迎的牛**。
|
|
|
|
|
|
**解释**:有向无环图中,如果有$2$个或$2$个以上的点出度为$0$,那么其中$1$个叶子结点必然有到不了的点,也就是不存在最受欢迎的牛。**画图理解**:
|
|
|
|
|
|

|
|
|
|
|
|
如果只存在一个出度为$0$的点呢?
|
|
|
|
|
|

|
|
|
|
|
|
综上,如果是拓扑图的话,这道题就好做了。实际上,我们可以 **用强连通分量算法将图转换为拓扑图** !
|
|
|
|
|
|
>**方法**:
|
|
|
>① 先求出该图的强连通分量,然后缩点,变成 有向无环图$DAG$,再统计一下每个点的出度即可。
|
|
|
② 找到出度为$0$的点的数量为$1$的情况,然后统计该点所表示的强连通分量,其中包含多少个点,这里的所有点都可以被其他所有点走到。
|
|
|
<center><img src='https://img-blog.csdnimg.cn/20210331152849629.png?,size_16,color_FFFFFF,t_70'></center>
|
|
|
|
|
|
### $Code$
|
|
|
```cpp {.line-numbers}
|
|
|
#include <bits/stdc++.h>
|
|
|
using namespace std;
|
|
|
const int N = 10010, M = 50010;
|
|
|
int n, m; // n个点,m条边
|
|
|
int d[N]; // 记录一下每个强连通分量的出度
|
|
|
|
|
|
// 链式前向星
|
|
|
int e[M], h[N], idx, w[M], ne[M];
|
|
|
void add(int a, int b, int c = 0) {
|
|
|
e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++;
|
|
|
}
|
|
|
|
|
|
// Tarjan算法求强连通分量
|
|
|
int stk[N], top, in_stk[N]; // stk[N]:堆栈,top:配合堆栈使用的游标top,in_stk[N]:是否在栈内
|
|
|
int dfn[N], ts, low[N]; // dfn[N]:时间戳记录数组,ts:时间戳游标, low[N]:从u开始走所能遍历到的最小时间戳
|
|
|
int id[N], scc_cnt, sz[N]; // id[N]:强连通分量块的编号,scc_cnt:强连通分量的编号游标,sz[i]:编号为i的强连通分量中原来点的个数
|
|
|
|
|
|
void tarjan(int u) {
|
|
|
dfn[u] = low[u] = ++ts; // 初始时间戳
|
|
|
stk[++top] = u; // 入栈
|
|
|
in_stk[u] = 1; // 在栈内
|
|
|
|
|
|
for (int i = h[u]; ~i; i = ne[i]) {
|
|
|
int v = e[i];
|
|
|
if (!dfn[v]) { // v未访问过
|
|
|
tarjan(v); // dfs深搜
|
|
|
low[u] = min(low[u], low[v]); // 更新low[u]
|
|
|
} else if (in_stk[v]) // v已经栈中,找到强连通分量
|
|
|
low[u] = min(low[u], dfn[v]); // 更新low[u]
|
|
|
}
|
|
|
// 发现强连通分量
|
|
|
if (dfn[u] == low[u]) {
|
|
|
++scc_cnt;
|
|
|
int x;
|
|
|
do {
|
|
|
x = stk[top--];
|
|
|
in_stk[x] = 0;
|
|
|
id[x] = scc_cnt; // 记录x是缩点后编号为ssc_cnt号强连通分量的一员
|
|
|
sz[scc_cnt]++; // 缩点后编号为ssc_cnt号强连通分量中的成员个数+1
|
|
|
} while (x != u);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
int main() {
|
|
|
scanf("%d %d", &n, &m);
|
|
|
memset(h, -1, sizeof h); // 初始化邻接表
|
|
|
for (int i = 1; i <= m; i++) {
|
|
|
int a, b;
|
|
|
scanf("%d %d", &a, &b);
|
|
|
add(a, b);
|
|
|
}
|
|
|
//(1)求强连通分量,缩点
|
|
|
for (int i = 1; i <= n; i++) // 需示枚举每个点出发尝试,否则会出现有的点没有遍历到的情况
|
|
|
if (!dfn[i]) tarjan(i);
|
|
|
|
|
|
//(2)缩点后其实就是一个DAG,计算出DAG中每个新节点的出度
|
|
|
for (int u = 1; u <= n; u++) // 枚举原图中每个出边,用法好怪异~
|
|
|
for (int i = h[u]; ~i; i = ne[i]) {
|
|
|
int v = e[i]; // u->v
|
|
|
int a = id[u], b = id[v]; // u,v分别在哪个强连通分量中
|
|
|
if (a != b) d[a]++; // a号强连通分量,也可以理解为是缩点后的点号,出度++
|
|
|
}
|
|
|
|
|
|
//(3) 出度为0的只能有1个,如果大于1个,就无解,如果正好是一个,返回此强连通分量中结点的个数
|
|
|
int zeros = 0, res = 0;
|
|
|
for (int i = 1; i <= scc_cnt; i++) // 枚举强连通分量块
|
|
|
if (!d[i]) { // 如果出度是0
|
|
|
zeros++; // 出度是0的强连通分量个数+1
|
|
|
res = sz[i]; // 累加此强连通分量中点的个数
|
|
|
if (zeros > 1) { // 如果强连通分量块的数量大于1个,则无解
|
|
|
res = 0;
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
//(4)求有多少头牛被除自己之外的所有牛认为是受欢迎的
|
|
|
printf("%d\n", res);
|
|
|
return 0;
|
|
|
}
|
|
|
```
|