哈喽各位,我是前端小L。
欢迎来到我们的图论专题第十八篇!我们已经习惯了"从入度为0"的节点开始拓扑排序,这代表着"事件的开始"。
今天,我们要解决的问题是:"哪些节点是安全的?"
- 安全节点 :从这个节点出发,无论怎么走,最终一定 能到达一个"终端节点"(即没有出边的节点),而绝对不会陷入"死循环"(环)。
这听起来像是要预测未来。但如果我们反过来想:
-
终端节点(出度为0)本身肯定是安全的。
-
如果一个节点,它所有 的连线都指向安全节点,那它自己也一定是安全的!
这不就是拓扑排序的逻辑吗?只不过方向反了!
-
标准拓扑排序:从"入度0"开始,顺着边找。
-
安全节点查找 :从"出度0"开始,逆着边找!
力扣 802. 找到最终的安全状态
https://leetcode.cn/problems/find-eventual-safe-states/

题目分析:
-
输入 :一个有向图
graph(邻接表形式,graph[i]是i指向的节点列表)。 -
目标:返回所有"最终安全节点"的列表,按升序排列。
-
定义:如果一个节点的所有路径最后都能停下(不进环),它就是安全的。
思路一:DFS 三色标记法 (检测环)
我们在 LC 207 中学过用 DFS 检测环。这里逻辑是一样的:
-
0 (White):未访问。
-
1 (Gray) :正在访问(当前递归栈中)。如果遇到 Gray 节点,说明遇到了环 ,当前路径上的所有节点都不安全。
-
2 (Black) :已访问且安全(所有子孙都安全)。
算法流程 : 对每个节点调用 DFS。如果 DFS 返回 true(安全),加入结果列表。
-
在
dfs(u)中:-
标记
u为 1。 -
遍历邻居
v:-
如果
v是 1,说明有环,u不安全,返回false。 -
如果
v是 0 且dfs(v)返回false,u也不安全,返回false。 -
(如果
v是 2,说明v安全,继续检查其他邻居)。
-
-
所有邻居都检查完了,说明
u安全。标记u为 2,返回true。
-
思路二:"逆向" Kahn 算法 (BFS) ------ 更加优雅的拓扑视角
DFS 虽然有效,但"三色标记"稍微有点绕。让我们试试"逆向思维"。
我们知道 Kahn 算法 是不断移除 入度为 0 的节点。 现在,我们要找"安全节点",其实就是要不断移除 出度为 0 的节点!
核心逻辑:
-
终端节点(出度为 0)是安全的。
-
当我们把所有"终端节点"从图中移除(这是逻辑上的移除,实际上是把指向它们的边的源头 节点的出度减 1),那些原本指向终端节点 的节点,如果出度也变成了 0,说明它们现在的路也"断"了(只能通向已知的安全区),那么它们也变成了新的安全节点!
-
这个过程不断重复,直到没有新的出度为 0 的节点产生。
-
所有被"选中"过的节点,就是安全节点。
工程实现上的关键点: 我们需要"逆着边"找。原图给出的是 u -> v。为了知道"谁指向了 v",我们需要构建一个反图 (Reverse Graph) :v -> u。
算法流程:
-
建反图 & 统计出度:
-
创建
rev_adj。遍历原图,对于边u -> v,在反图中添加v -> u。 -
同时,统计原图中每个节点
u的出度outdegree[u]。
-
-
初始化队列:
- 将所有出度为 0 的节点(终端节点)入队。
-
BFS (逆向拓扑):
-
while (!q.empty()):-
curr = q.front(); q.pop(); -
safe[curr] = true; -
遍历反图
rev_adj[curr]中的邻居prev(即原图中指向curr的节点):-
outdegree[prev]--(prev的一条出路通向了安全区,可以"划掉"了)。 -
核心判断 :如果
outdegree[prev] == 0,说明prev的所有出路都通向了安全区,那么prev也是安全的!入队。
-
-
-
-
整理结果:
- 遍历
safe数组,收集所有true的索引,排序返回。
- 遍历
代码实现 (逆向 Kahn 算法)
C++
#include <vector>
#include <queue>
#include <algorithm>
using namespace std;
class Solution {
public:
vector<int> eventualSafeNodes(vector<vector<int>>& graph) {
int n = graph.size();
vector<vector<int>> rev_adj(n);
vector<int> outdegree(n, 0);
// 1. 建反图 + 统计原图出度
for (int u = 0; u < n; ++u) {
outdegree[u] = graph[u].size();
for (int v : graph[u]) {
// 原图 u -> v
// 反图 v -> u
rev_adj[v].push_back(u);
}
}
// 2. 将所有出度为 0 的节点(终端节点)入队
queue<int> q;
for (int i = 0; i < n; ++i) {
if (outdegree[i] == 0) {
q.push(i);
}
}
// 3. 逆向 BFS
vector<bool> isSafe(n, false);
while (!q.empty()) {
int curr = q.front();
q.pop();
isSafe[curr] = true;
// 在反图中,curr 指向 prev (即原图中 prev -> curr)
for (int prev : rev_adj[curr]) {
outdegree[prev]--;
if (outdegree[prev] == 0) {
q.push(prev);
}
}
}
// 4. 收集结果
vector<int> result;
for (int i = 0; i < n; ++i) {
if (isSafe[i]) {
result.push_back(i);
}
}
return result;
}
};
深度复杂度分析
-
V (Vertices) :节点数
n。 -
E (Edges):边数。
-
时间复杂度 O(V + E):
-
建反图遍历一次所有边:O(E)。
-
初始化队列遍历所有节点:O(V)。
-
BFS 过程中,每个节点最多入队一次,每条边(反图中的)最多被遍历一次:O(V + E)。
-
结果排序(收集过程本身是有序的,所以只需要 O(V))。
-
-
空间复杂度 O(V + E):
-
rev_adj反图存储所有边。 -
outdegree数组 O(V)。 -
队列 O(V)。
-
总结
今天这道题,让我们见识了拓扑排序的灵活应用。
-
标准拓扑排序:从"入度0"开始,解决"依赖/顺序"问题。
-
逆向拓扑排序:从"出度0"开始,解决"安全/终点"问题。
通过构建反图,我们成功地复用了 Kahn 算法的模板,将一个看似复杂的"路径终点判定"问题,转化为一个清晰的"层层剥离"过程。
在下一篇中,我们将面对一个更具挑战性的 DAG 问题------平行课程 。我们需要计算的不再是顺序,而是修完所有课程所需的最少学期数(实际上是求 DAG 上的最长路径)。
下期见!