Topics › Graphs › Topo Sort and Problems
Find eventual safe states
LeetCode ↗ Java source ↗ Edit this article ↗
Jump to code:
Problem
You are given a directed graph of n nodes as an adjacency array: graph[i] lists the nodes that i has edges to. A node is terminal if it has no outgoing edges. A node is safe if every possible path starting from it ends at a terminal node. Return all safe nodes in increasing order.
Constraints: 1 ≤ n ≤ 10^4, 0 ≤ graph[i].length ≤ n, total edges up to 4 × 10^4. The graph may contain self-loops.
Example 1
graph = [[1,2], [2,3], [5], [0], [5], [], []]
answer = [2, 4, 5, 6]
// 5 and 6 are terminal; 2 and 4 lead only to 5.
// 0, 1 and 3 can go round 0 → 1 → 3 → 0 forever.
Example 2
graph = [[1,2,3,4], [1,2], [3,4], [0,4], []]
answer = [4] // 1 has a self-loop, and 0 → 3 → 0 is a cycle
Reformulation. In a finite graph, a path that never ends must eventually repeat a node, so it runs around a cycle. Therefore a node is unsafe exactly when it can reach a cycle (including a cycle it sits on), and safe otherwise. Note that “some path reaches a terminal” is not enough: in the test [[1, 3], [2], [1], []], node 0 can go straight to terminal 3, but it can also enter the loop 1 ↔ 2, so it is unsafe.
Approach 1: Brute force
Intuition. Test each node separately: run a DFS from it with fresh state and ask whether that DFS ever meets a node that is still on its own path, which is a back edge and therefore a reachable cycle. The DFS is the standard three-state cycle check: 0 new, 1 on the current path, 2 finished with no cycle found below it.
Algorithm.
- For each node
s, allocate a freshstatearray and callreachesCycle(s). - In
reachesCycle(u): set state1; for each neighbourv, returntrueifvis in state1, or ifvis new and its own call returnstrue. - Set state
2and returnfalse. - Nodes for which the call returns
falseare safe; scanningsin increasing order keeps the output sorted.
static List<Integer> bruteForce(int[][] graph) {
int n = graph.length;
List<Integer> safe = new ArrayList<>();
for (int s = 0; s < n; s++) {
if (!reachesCycle(s, graph, new int[n])) safe.add(s);
}
return safe;
}
/** DFS with states 0 = new, 1 = on current path, 2 = finished. Returns true if a cycle is reachable from u. */
static boolean reachesCycle(int u, int[][] graph, int[] state) {
state[u] = 1;
for (int v : graph[u]) {
if (state[v] == 1) return true;
if (state[v] == 0 && reachesCycle(v, graph, state)) return true;
}
state[u] = 2;
return false;
}
Complexity. Time O(V × (V + E)): a full DFS from each of the V nodes. Space O(V) per DFS for the state array and recursion. With 10^4 nodes and 4 × 10^4 edges this is around 5 × 10^8 steps, too slow.
Approach 2: DFS with shared state
Intuition. The brute force throws away everything it learns. Keep one state array for the whole graph and reuse the same reachesCycle helper. Two facts make the reuse correct:
- A node that reaches state
2finished without finding any cycle below it. Every one of its neighbours was already in state2or became2during the call, so by induction everything reachable from it is cycle-free. It is safe, permanently. - When a call finds a cycle it returns
trueimmediately, without resetting the nodes on the current path to2. They stay at1. Each of them leads to the cycle that was just found, so each is unsafe.
After an aborted search, state 1 no longer only means “on the current path”; it can also mean “known to reach a cycle”. Both readings say the same thing to a later search: if you can walk into this node, you can reach a cycle. So the check state[v] == 1 → return true remains correct, and no node is ever expanded twice.
Algorithm.
- Allocate one
statearray. - For each node
sin increasing order: if it is new, callreachesCycle(s). Afterwards,sis safe exactly whenstate[s] == 2.
static List<Integer> dfsShared(int[][] graph) {
int n = graph.length;
int[] state = new int[n];
List<Integer> safe = new ArrayList<>();
for (int s = 0; s < n; s++) {
if (state[s] == 0) reachesCycle(s, graph, state);
if (state[s] == 2) safe.add(s);
}
return safe;
}
On Example 1: the call from 0 walks 0 → 1 → 2 → 5, finishes 5 and 2 with state 2, continues 1 → 3 → 0 and finds 0 in state 1. It returns true with 0, 1, 3 left at 1. Later, 2 and 5 are already 2, 4 finishes via 5, and 6 finishes alone, giving [2, 4, 5, 6].
Complexity. Time O(V + E): each node is expanded at most once over the whole run. Space O(V) for the state array plus recursion depth up to V.
Approach 3: Optimal, Kahn’s algorithm on the reversed graph
Intuition. Think about safety from the terminal end. Terminal nodes are safe. A node is safe when all of its outgoing edges lead to safe nodes. That is the same shape as Kahn’s topological sort, with “number of incoming edges not yet removed” replaced by “number of outgoing edges not yet known to be safe”:
- Start with every node whose out-degree is
0(the terminals). - When a node is confirmed safe, tell each of its predecessors that one more of their edges is safe by decrementing that predecessor’s out-degree. To find predecessors quickly, build the reversed graph.
- A predecessor whose count drops to
0has only safe edges left, so it is safe and joins the queue.
A node on a cycle keeps the edge to its successor on that cycle, which is never confirmed, so its count never reaches 0. The same holds for any node with an edge into such a node. So the nodes that are never confirmed are exactly the unsafe ones.
Algorithm.
- Build
reversedand recordoutdegree[u] = graph[u].length. - Queue every node with out-degree
0. - Pop
v, mark it safe, and for eachuinreversed[v]decrementoutdegree[u]; queueuwhen it reaches0. - Collect safe nodes by scanning indices in order, which gives sorted output without a sort.
static List<Integer> optimal(int[][] graph) {
int n = graph.length;
List<List<Integer>> reversed = new ArrayList<>();
for (int i = 0; i < n; i++) reversed.add(new ArrayList<>());
int[] outdegree = new int[n];
for (int u = 0; u < n; u++) {
outdegree[u] = graph[u].length;
for (int v : graph[u]) reversed.get(v).add(u);
}
Deque<Integer> queue = new ArrayDeque<>();
for (int i = 0; i < n; i++) if (outdegree[i] == 0) queue.add(i); // terminal nodes are safe
boolean[] safe = new boolean[n];
while (!queue.isEmpty()) {
int v = queue.poll();
safe[v] = true;
for (int u : reversed.get(v)) {
if (--outdegree[u] == 0) queue.add(u); // every edge of u now leads to a safe node
}
}
List<Integer> result = new ArrayList<>();
for (int i = 0; i < n; i++) if (safe[i]) result.add(i);
return result;
}
Dry run on Example 1. Out-degrees for nodes 0..6 are 2, 2, 1, 1, 1, 0, 0. Reversed edges: 5 ← 2, 4; 2 ← 0, 1; 0 ← 3; 1 ← 0; 3 ← 1.
| pop | marked safe | out-degree changes | queue after |
|---|---|---|---|
| start | [5, 6] | ||
| 5 | 5 | node 2: 1 → 0, node 4: 1 → 0 | [6, 2, 4] |
| 6 | 6 | none | [2, 4] |
| 2 | 2 | node 0: 2 → 1, node 1: 2 → 1 | [4] |
| 4 | 4 | none | [] |
Nodes 0, 1 and 3 each keep one unconfirmed edge (along the cycle), so the answer is [2, 4, 5, 6].
Complexity. Time O(V + E): building the reversed graph touches each edge once, and each edge is decremented once. Space O(V + E) for the reversed graph, the degree array and the queue.
Approaches 2 and 3 are both linear. The DFS reuses the cycle-detection pattern directly; the BFS is iterative, so it is safe for long chains, and it makes the “a node is safe when all its edges are safe” rule explicit.
Edge cases and pitfalls
- Self-loop.
[[0]]gives[]: the node is on a cycle of length one. Its out-degree never reaches0in the BFS, and the DFS sees it in state1. - Isolated nodes. A node with no edges is terminal and therefore safe.
- A safe edge does not make a node safe. In
[[1], [], [0, 3], [3]], node2points at the safe node0and at the self-loop3, so it is unsafe. One bad edge is enough. - Do not reset state after a cycle is found. In the shared DFS, returning early leaves the path at
1on purpose; resetting those nodes to0would make later searches redo work and, with2, would mark unsafe nodes safe. - Sorted output. Collect by scanning indices
0..n-1, not in queue order; the queue order in Approach 3 is not sorted. - Using in-degree instead of out-degree. Running ordinary Kahn’s on the original graph removes nodes that nothing points to, which answers a different question.
Related problems
- Detect a cycle in a directed graph is the cycle check this problem builds on.
- Cycle Detection in Directed Graph (DFS) explains the on-path marking used in the DFS approaches.
- Topological sort or Kahn’s algorithm is the queue algorithm that Approach 3 runs on the reversed graph.
- Course Schedule I asks whether the whole graph is acyclic.