







一、先认识这道题:什么叫"必经点"?
题目给我们一张有向图:
-
有 n 个结点;
-
有 m 条有向边;
-
每条边都有方向,比如:
1 → 3
表示只能从1走到3,不能反过来从3走到1。
题目规定:
-
入度为0的点:可以作为合法起点;
-
出度为0的点:可以作为合法终点。
如果:
从所有可能的合法起点出发,到所有可能的合法终点,无论怎么走,都必须经过某个点 u,
那么这个点 u 就叫做:
必经点
题目要求我们把所有必经点找出来。
二、把它想象成"王国逃生游戏"
假设有一个王国:
城堡A → 森林 → 山洞 → 城门
国王规定:
所有冒险者都必须从"入口"进入,最后从"出口"离开。
如果所有路线都必须经过一座桥:
┌───────┐
↓ ↓
入口 → 森林 → 【桥】 → 城门
↑ ↑
└───────┘
那么这座桥所在的位置就是:
必经点!
因为不管你选择哪条路线:
都绕不过它。
三、先看题目的样例
样例1:
8 9
1 3
2 3
3 4
4 5
5 6
6 7
6 8
2 4
5 7
我们把它画得简单一些:
1 ──→ 3 ──→ 4 ──→ 5 ──→ 6 ──→ 7
↑ ↑ │ └───→ 8
│ │ │
2─────┘─────│ └──→ 7
实际上,题目给出的合法起点是:
1、2
合法终点是:
7、8
题目把所有可能路径列出来以后发现:
4和5一定会经过。
所以答案是:
2
4 5
也就是:
这就是样例1的输出。
四、这道题最关键的问题
小朋友看到题目后,很容易产生一个想法:
"我把所有路径都找出来,然后看看哪些点出现次数最多,不就可以了吗?"
不可以!
因为图里面可能有非常多条路径,甚至有环。
而且题目真正问的是:
这个点是不是所有合法起点 → 合法终点的路径都必须经过?
所以我们需要换一个思路。
五、换个角度:如果这个点"消失"了呢?
这是整道题最重要的思维!
假设我们怀疑:
4
是必经点。
那么我们可以做一个非常聪明的实验:
把4号点暂时关闭,不允许经过它。
然后问:
还有没有办法从某个合法起点走到某个合法终点?
情况1:还能走出去
例如:
起点 → 3 → 5 → 终点
完全没有经过4。
那么说明:
4并不是必经点。
因为我们找到了一个"不经过4"的路线。
情况2:完全走不到终点
如果把4关闭之后:
起点 → 3 → ❌
所有道路都被堵死了。
那么说明:
所有原来的合法起点到合法终点的路线,都必须经过4。
所以:
4是必经点
六、于是问题就变得非常简单了
我们可以:
一个点一个点地进行"拆除实验"。
例如有8个点:
尝试删除1 → 看还能不能从起点到终点
尝试删除2 → 看还能不能从起点到终点
尝试删除3 → 看还能不能从起点到终点
......
尝试删除8 → 看还能不能从起点到终点
如果:
删除这个点以后,再也无法从任何合法起点到达任何合法终点。
那么:
这个点 = 必经点
是不是一下子就清楚了?
七、但是还有一个小问题
我们怎么判断:
"删除一个点之后,还有没有起点能够到达终点?"
这时候就轮到我们之前学过的超级英雄:
DFS!
当然,BFS也可以。
因为我们只是想知道:
能不能走到?
而不是求最短距离。
所以:
-
DFS可以;
-
BFS也可以。
这里为了让初学者更容易理解,我们使用 DFS。
八、DFS在这里干什么?
假设我们暂时关闭4号点:
4号点不能进入!
那么DFS就从所有合法起点开始探索。
例如:
合法起点:
1
2
我们分别从1、2出发。
如果DFS最终能够到达:
7或者8
说明还有路线。
那么:
4不是必经点
如果所有DFS都到不了7和8:
4就是必经点
九、如何找到合法起点和合法终点?
这一步特别重要。
题目告诉我们:
入度为0的结点是合法起点。
什么叫入度?
就是:
有多少条箭头指向这个点。
例如:
1 → 3
2 → 3
那么:
1:入度0
2:入度0
3:入度2
所以:
1、2
可以作为合法起点。
合法终点呢?
题目规定:
出度为0的点是合法终点。
例如:
6 → 7
6 → 8
那么6有两条边出去:
outdegree[6] = 2
而7和8没有边出去:
outdegree[7] = 0
outdegree[8] = 0
所以:
7、8
是合法终点。
十、程序需要准备什么?
我们可以准备几个数组。
1. 邻接表
vector<int> graph[N];
表示:
每个点可以走到哪些点。
例如:
1 → 3
graph[1] = {3}
如果:
2 → 3
2 → 4
那么:
graph[2] = {3, 4}
2. 入度数组
int indegree[N];
记录:
每个点有多少条边指向它。
3. 出度数组
int outdegree[N];
记录:
每个点有多少条边从它出去。
4. 访问数组
bool vis[N];
DFS时防止重复走。
十一、读入每一条边
题目给出的每条边:
u v
表示:
u → v
所以:
graph[u].push_back(v);
indegree[v]++;
outdegree[u]++;
这三句话非常重要。
可以理解成:
在u的通讯录里记下v。
同时:
v的入度+1。
还有:
u的出度+1。
十二、最核心的DFS
我们写:
void dfs(int u, int ban)
这里的 ban 是什么意思?
就是:
这次实验中,我们禁止经过哪个点。
例如:
dfs(1, 4);
意思就是:
从1开始搜索,但是4号点封锁了。
DFS第一件事
如果:
u == ban
直接不能走:
if (u == ban)
return;
第二件事
标记已经访问:
vis[u] = true;
第三件事
看看是不是到达合法终点。
if (outdegree[u] == 0)
found = true;
如果到了合法终点:
说明存在一条没有经过
ban的合法路线。
于是:
found = true;
第四件事
继续向邻居走:
for (int v : graph[u]) {
if (!vis[v] && v != ban) {
dfs(v, ban);
}
}
这样DFS就完成了。
十三、把"删除一个点"的过程完整走一遍
假设我们现在测试:
ban = 4
也就是:
关闭4号点。
首先:
found = false;
然后从所有合法起点开始:
1
2
进行DFS。
如果最终找到:
7
或者:
8
那么:
found = true;
说明:
即使没有4,仍然存在起点→终点的路线。
所以:
4不是必经点
反过来,如果:
found == false
说明:
关闭4以后,所有合法起点都无法到达任何合法终点。
那么:
4就是必经点。
十四、这里有一个非常容易犯的错误
假设我们测试:
ban = 1
而1本身就是合法起点。
这时候:
不能从1开始DFS!
因为题目要求"不经过1"。
所以启动DFS的时候必须:
if (s == ban)
continue;
也就是说:
如果当前合法起点就是被关闭的点,就直接跳过。
十五、如果被关闭的是终点呢?
例如:
ban = 7
7是合法终点。
也不能走到7。
因此DFS过程中:
if (v == ban)
continue;
不能进入被关闭的点。
而且:
if (u == ban)
return;
也可以保证这一点。
十六、完整程序
下面给初学C++的小学生一个C++11版本、尽量清晰的完整程序:
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 1005;
vector<int> graph[N];
int indegree[N];
int outdegree[N];
bool vis[N];
bool found;
// 从 u 出发搜索
// ban 表示禁止经过的点
void dfs(int u, int ban)
{
// 不能进入被关闭的点
if (u == ban)
return;
vis[u] = true;
// 如果到达合法终点
if (outdegree[u] == 0)
{
found = true;
return;
}
// 继续向其他点走
for (int v : graph[u])
{
if (!vis[v] && v != ban)
{
dfs(v, ban);
// 已经找到终点,不需要继续搜索
if (found)
return;
}
}
}
int main()
{
int n, m;
cin >> n >> m;
// 读入所有边
for (int i = 1; i <= m; i++)
{
int u, v;
cin >> u >> v;
graph[u].push_back(v);
indegree[v]++;
outdegree[u]++;
}
vector<int> answer;
// 一个一个测试每个点
for (int ban = 1; ban <= n; ban++)
{
// 清空访问标记
for (int i = 1; i <= n; i++)
{
vis[i] = false;
}
found = false;
// 从所有合法起点出发
for (int s = 1; s <= n; s++)
{
// 入度为0,才是合法起点
if (indegree[s] == 0 && s != ban)
{
dfs(s, ban);
if (found)
break;
}
}
// 如果一个合法起点都无法到达合法终点
// 说明 ban 是必经点
if (!found)
{
answer.push_back(ban);
}
}
// 输出答案数量
cout << answer.size() << endl;
// 输出所有必经点
if (!answer.empty())
{
for (int x : answer)
{
cout << x << " ";
}
cout << endl;
}
return 0;
}
十七、把程序拆成"四个小任务"
对于小学生,我不建议让他们一下子背完整程序。
可以把它拆成四个任务。
任务1:建立地图
vector<int> graph[N];
把所有道路记录下来。
任务2:找入口和出口
indegree[s] == 0
就是合法起点。
outdegree[u] == 0
就是合法终点。
任务3:关闭一个点
ban
把它理解成:
"这个城市今天封路了,谁也不能进去。"
然后DFS。
任务4:判断是不是必经点
如果关闭它以后:
还有起点 → 终点
那么:
不是必经点
如果:
没有任何起点 → 终点
那么:
是必经点
十八、用样例1手动模拟
样例:
8 9
1 3
2 3
3 4
4 5
5 6
6 7
6 8
2 4
5 7
合法起点:
1、2
合法终点:
7、8
我们来测试4。
第一步:关闭4
相当于:
4号城市关闭!
那么原来的:
1 → 3 → 4
走不通。
2也不能通过4。
于是:
1、2
↓
3
↓
❌ 4
到不了5、6、7、8。
所以:
关闭4以后,没有任何起点能够到达终点。
因此:
4是必经点
第二步:关闭5
同理:
1 → 3 → 4 → ❌5
2 → 3 → 4 → ❌5
虽然6、7、8还存在,但是到不了它们。
所以:
5也是必经点
第三步:关闭3
这时候:
1 → ❌3
但是:
2 → 4 → 5 → 6 → 7
仍然可以走到终点。
所以:
3不是必经点
这就是为什么样例最终只有:
4 5
十九、为什么这个方法能通过数据范围?
题目的数据范围是:
n≤1000 m≤2000
我们最多测试:
1000个点
每测试一个点,需要做一次图搜索。
DFS一次大约:
O(n+m)
所以总复杂度大约:
O(n(n+m))
也就是大约:
3,000,000
级别的操作。
对于这个数据范围来说是完全可以接受的。
所以:
这道题不需要一上来就使用非常复杂的图论算法。
用我们已经学习过的:
邻接表 + DFS + 枚举
就可以解决。
二十、这道题真正考查的是什么?
表面上看,这是一道:
图论题。
但实际上它考查了好几个知识点的组合:
① 有向图
知道:
u → v
表示有方向的边。
② 入度
indegree[v]++;
用来寻找合法起点。
③ 出度
outdegree[u]++;
用来寻找合法终点。
④ 邻接表
graph[u].push_back(v);
保存每个点能够走向哪里。
⑤ DFS
从起点不断探索。
⑥ 枚举
一个点一个点地进行"关闭实验"。
⑦ 反向思维
这是最重要的!
题目问:
哪些点所有路线都必须经过?
我们没有直接找"所有路线"。
而是反过来问:
如果不允许经过这个点,还有没有一条路?
如果有:
不是必经点
如果没有:
就是必经点
这就是算法竞赛里非常重要的:
"正着不好做,就反过来想!"
二十一、给小学生的"魔法口诀"
这道题,大家记住这段口诀:
先数入度找入口,
再数出度找出口;
一个点一个点封,
DFS看看能否通;
还能通,不必经;
完全不通,必经点!
再浓缩成一句:
"封一个,搜一次;还能到,不必经;到不了,就是它!"
二十二、这道题和前面学过的知识串起来
这道题特别适合用来帮助学生建立"知识网络":
《必经之路》
│
┌───────────┼───────────┐
↓ ↓ ↓
有向图 邻接表 DFS
│ │ │
┌────┴────┐ │ ┌────┴────┐
↓ ↓ ↓ ↓ ↓
入度 出度 存边 递归 visited
│ │
↓ ↓
合法起点 合法终点
\ /
\ /
↓
必经点
↑
│
枚举"封锁"
│
↓
还能不能走到终点?
这也是非常重要的一种学习方式:
不要把"图、DFS、入度、出度、邻接表"当成一个个孤零零的知识点,而要学会把它们组合起来解决问题。
这道《必经之路》的核心思想其实只有一句话:
"判断一个点是不是必经点,就把它暂时删掉,然后看看是否还存在一条合法起点到合法终点的路。"