DSA sheet · Graphs · Topological sort (BFS) pattern

Find Eventual Safe States

This is the third topological sort problem after Course Schedule I and II. The question looks new (it talks about "safe" and "terminal" nodes, not courses), but the teacher shows that it is the same indegree + queue idea with one twist: we run it on the reversed graph, starting from the terminal nodes and walking backwards. She also points out that the problem can be solved by cycle detection with DFS. She only codes the BFS method, so that is Part A. Part B gives the DFS method she mentions, so you have both ways in your pocket.

Every part below follows the same order:
① the question in simple words → ② what the constraints tell us → ③ intuition → ④ building the logic from examples → ⑤ approach steps → ⑥ code → ⑦ code line by line → ⑧ dry run → ⑨ complexity & remember

Part 0 · Graph basics

Nodes and edges

A graph is a set of points called nodes (or vertices) joined by lines called edges. Here the nodes are numbered 0 to n − 1.

Out-degree and in-degree

   (0) ──→ (1)
    │
    ↓
   (2) ──→ (1)       out-degree of 0 = 2   (two arrows leave 0)
                     in-degree  of 1 = 2   (two arrows enter 1)
                     out-degree of 1 = 0   (nothing leaves 1)

Three ways the input can come

formlooks likemeaning
edge list[[0,1],[0,2],[2,1]]each pair is one arrow a → b
adjacency matrixm[a][b] = 1an n × n table, 1 means "arrow a → b exists". Uses n² memory.
adjacency list[[1,2],[],[1]]adj[a] = the list of nodes that a points to (its children or neighbours). Uses n + E memory, E = number of edges.

This problem already gives an adjacency list, called graph. If you get an edge list instead, this is how you build one:

edge list → adjacency list (directed)
n = 3
edges = [[0, 1], [0, 2], [2, 1]]
adj = [[] for _ in range(n)]      # one empty list per node
for a, b in edges:
    adj[a].append(b)              # directed: only a -> b
    # for an undirected graph you would also do adj[b].append(a)
print(adj)                        # [[1, 2], [], [1]]

Visited, and connected components

A graph can have loops, so a walk could come back to the same node forever. We keep a visited set (or a list of True/False) and never process a node twice. A connected component is a group of nodes that can reach each other. A graph can have several separate groups, so traversal code usually loops over every node and starts a new search from each unvisited one.

BFS and DFS in one picture

BFS (Breadth First Search)DFS (Depth First Search)
ideavisit level by level: the start, then everything 1 step away, then 2 steps away…go as deep as possible down one path, then come back and try the next path
toola queue (collections.deque): add at the back, take from the frontrecursion (Python's call stack) or your own stack
BFS and DFS templates on an adjacency list
from collections import deque

adj = [[1, 2], [3], [3], []]

def bfs(start):
    seen = [False] * len(adj)
    seen[start] = True
    queue = deque([start])
    order = []
    while queue:
        node = queue.popleft()          # take from the front
        order.append(node)
        for nb in adj[node]:
            if not seen[nb]:
                seen[nb] = True         # mark when you push
                queue.append(nb)
    return order

def dfs(node, seen, order):
    seen[node] = True
    order.append(node)
    for nb in adj[node]:
        if not seen[nb]:
            dfs(nb, seen, order)        # go deep first
    return order

print(bfs(0))                            # [0, 1, 2, 3]
print(dfs(0, [False] * 4, []))           # [0, 1, 3, 2]

Grids are graphs too (not needed here, but good to know)

In grid problems each cell is a node, and its neighbours are the cells up, down, left and right. We loop over four direction pairs (dr, dc), check the new cell is inside the grid (the bounds check), and often mark visited cells by changing the grid itself.

the four directions
grid = ["11", "01"]
rows, cols = len(grid), len(grid[0])
r, c = 0, 1
for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
    nr, nc = r + dr, c + dc
    if 0 <= nr < rows and 0 <= nc < cols:      # bounds check
        print((nr, nc), grid[nr][nc])
# prints (1, 1) 1  and  (0, 0) 1

Cycles in a directed graph

A cycle is a path that comes back to where it started, like (0)→(1)→(3)→(0). Once you are on a cycle you can walk round and round forever. This matters a lot for today's problem.

Kahn's algorithm (topological sort with BFS), recap from Course Schedule

A topological order lists the nodes so that every arrow points forward (a before b for every a → b). Kahn's algorithm builds it like this:

  1. Count the in-degree of every node.
  2. Put every node with in-degree 0 in a queue (nothing has to come before them).
  3. Pop a node, write it in the answer, and for each child reduce its in-degree by 1 (as if we deleted the arrow). If a child's in-degree becomes 0, push it.
  4. Nodes on a cycle never reach in-degree 0, so they never enter the queue.
Kahn's algorithm template
from collections import deque

def topo(n, adj):
    indeg = [0] * n
    for a in range(n):
        for b in adj[a]:
            indeg[b] += 1
    queue = deque(i for i in range(n) if indeg[i] == 0)
    order = []
    while queue:
        node = queue.popleft()
        order.append(node)
        for b in adj[node]:
            indeg[b] -= 1               # "remove" the arrow node -> b
            if indeg[b] == 0:
                queue.append(b)
    return order                        # shorter than n means a cycle exists

print(topo(4, [[1, 2], [3], [3], []]))  # [0, 1, 2, 3]
print(topo(2, [[1], [0]]))              # []

Reversing a graph

Reversing means turning every arrow round: a → b becomes b → a. In the reversed list, rev[b] holds the nodes that point to b in the original graph (its parents). Note: the original out-degree of a node equals its in-degree in the reversed graph. This one fact is the key to Part A.

building the reversed adjacency list
graph = [[1, 2], [2], []]           # 0->1, 0->2, 1->2
rev = [[] for _ in range(len(graph))]
for node in range(len(graph)):
    for nb in graph[node]:
        rev[nb].append(node)          # arrow node->nb becomes nb->node
print(rev)                            # [[], [0], [0, 1]]

Part A · BFS topological sort on the reversed graph

LeetCode 802 · Find Eventual Safe States

1The question in simple words

You get a directed graph with nodes 0 … n−1 as an adjacency list graph, where graph[i] lists the nodes that i points to.

Return all safe nodes, sorted in increasing order.

Example 1: graph = [[1,2],[2,3],[5],[0],[5],[],[]]
        ┌─────────────────┐
        ↓                 │
       (0) ───→ (1) ───→ (3)
         ╲       │
          ╲      ↓
           ───→ (2) ───→ (5) ←─── (4)

                                   (6)

Edges: 0→1, 0→2, 1→2, 1→3, 2→5, 3→0, 4→5. Nodes 5 and 6 have empty lists. Answer: [2, 4, 5, 6].

2What the constraints tell us

3Intuition: a mental picture

Think of the terminal nodes as exits of a building, and the arrows as one-way corridors. A room is safe if, whatever corridor you choose, you always end up at an exit. A room is unsafe if some choice lets you wander in a loop forever.

Instead of standing in each room and trying every corridor, we start at the exits and walk the corridors backwards. A room becomes safe the moment all of its corridors are known to lead to safe rooms. Walking backwards = using the reversed graph. Counting "how many of my corridors are still unknown" = the out-degree counter, used exactly like the in-degree in Kahn's algorithm.

4Building the logic from the teacher's examples

Terminal nodes are safe

5 and 6 have no outgoing edges. Every path that starts at 5 is just "5", and it ends at a terminal node. So every terminal node is also a safe node.

Node 4: one corridor, it goes to an exit → safe

From 4 the only move is to 5, which is terminal. Every path from 4 ends at a terminal node → 4 is safe. Same for 2: its only move is to 5 → 2 is safe.

Node 3: "every path", not "some path"

The teacher walks from 3: 3 → 0 → 2 → 5. That reaches a terminal node, so you might call 3 safe. But wait: from 0 you could also go to 1, and from 1 to 3, and from 3 back to 0 … The walk 3 → 0 → 1 → 3 → 0 → 1 → … never reaches a terminal node. One bad path is enough → 3 is not safe.

Nodes 0 and 1 have the same problem: each of them has a path into the loop 0 → 1 → 3 → 0. So 0, 1, 3 are unsafe.

A cycle means not safe

The teacher's conclusion from node 3: a node that sits on a cycle can never be safe. A node on a cycle has at least one outgoing edge (the one that continues the cycle), so it's not terminal, and following the cycle gives a path that never ends.

Doubt 1: what about a node that is not on the cycle but can walk into it?
→ Also unsafe. The teacher adds an imaginary node 7 with an edge 7 → 0. Starting at 7 you can go 7 → 0 → 1 → 3 → 0 → … forever. So the rule is: a node is unsafe if any path from it can reach a cycle. Otherwise every path from it is finite (no repeats possible), and a finite path can only stop at a node with no way out, which is a terminal node. So "safe" = "cannot reach a cycle".

That's why the teacher says the problem can be solved in two ways: by detecting cycles (Part B), or by topological sort (this part).

Why not start from node 0 (the brute force idea)?

The natural first try: for each node, explore every path and check that all of them end at a terminal node. The teacher rejects this for node 0: from 0 you can't decide anything until you have explored every path from 0 to the very end. And then you do the same work again for 1, again for 3… The number of paths can be huge.

Better question: which node can I call safe right away, without exploring anything? The terminal nodes. If a graph has no terminal node at all, then nothing is safe, so the terminal nodes are the natural place to start.

How do we find terminal nodes? Out-degree 0

"No outgoing edges" is exactly out-degree 0. The teacher counts them for Example 1:

node0123456
out-degree2211100

5 and 6 have out-degree 0 → they are terminal → safe → they go into the queue first.

From a safe node, walk backwards

Pop 5. Who points to 5? Nodes 2 and 4. Their arrows into 5 now lead to a safe place, so each of them has one fewer unknown corridor. Reduce their out-degree by 1: 2 goes 1 → 0, and 4 goes 1 → 0. Out-degree 0 now means "all my corridors lead to safe nodes" → 2 and 4 are safe → push them.

Doubt 2: the adjacency list tells me 4's children. How do I find 5's parents?
→ You can't, from the given list: graph[5] is empty. The teacher noticed exactly this: when she popped 5 she had "no children to go to", yet she needed to reach 2 and 4. The fix is to reverse every arrow once at the start. In the reversed list, rev[5] = [2, 4], so the parents become children and the normal BFS loop works.

Building the reversed list for Example 1 (the teacher does it by hand, reading each list "the other way"): 0 has children 1, 2 → write 0 into rev[1] and rev[2]. 1 has children 2, 3 → write 1 into rev[2] and rev[3]. And so on:

reversed adjacency list
nodegraph[node] (original)rev[node] (who points to me)
01, 23
12, 30
250, 1
301
45—
5—2, 4
6——
the reversed graph
        ┌─────────────────┐
        │                 ↓
       (0) ←─── (1) ←─── (3)
         ↖       ↑
          ╲      │
           ──── (2) ←─── (5) ───→ (4)

                                   (6)

Popping 2: the counter stops the cycle nodes

Pop 2. rev[2] = [0, 1]. Reduce: 0 goes 2 → 1, 1 goes 2 → 1. Both are still positive: each of them has one more corridor whose safety we don't know (0 → 1 and 1 → 3). So we don't push them. Nobody will ever reduce those last counters, because the nodes at the other end (1 and 3) are on the cycle and never become safe. The queue empties, and 0, 1, 3 are left out. ✓

Doubt 3: why must the counter reach 0? Isn't one safe child enough?
→ No. Node 0 has a safe child (2), but it also has child 1, which leads into the loop. "Safe" needs every path to end well, so every child must be safe. The out-degree counter counts the children still not known to be safe. Only at 0 are all of them confirmed.
Doubt 4: the teacher calls this array indegree, but it counts outgoing edges. Which is it?
→ Both, depending on which graph you look at. In the original graph it's the out-degree. In the reversed graph the same arrows point the other way, so the same number is the in-degree. Our BFS runs on the reversed graph, so it is literally Kahn's algorithm on the reversed graph, and the name "in-degree" is fair. On this page the code calls it outdeg to keep the original meaning clear.

The teacher links this back to Course Schedule I and II: pop, reduce the counter of each neighbour, push it when the counter hits 0. If that part feels unclear, revise those pages first.

Returning the answer in sorted order without sorting

The popped nodes come out as 5, 6, 2, 4, not sorted. Two options:

She also says this sorting trick isn't part of the graph logic. It's just a small optimisation you can come up with yourself.

5Approach steps

  1. Build rev, the reversed graph, and outdeg[node] = length of graph[node].
  2. Push every node with outdeg 0 (the terminal nodes) into a queue.
  3. While the queue isn't empty: pop a node and mark it safe.
  4. For each prev in rev[node] (each node that points to it), reduce outdeg[prev] by 1. If it becomes 0, all of prev's edges lead to safe nodes → push prev.
  5. Walk 0 … n−1 and return the indices marked safe (already sorted).

6Code (Python)

Eventual Safe States, BFS on the reversed graph
from collections import deque

class Solution:
    def eventualSafeNodes(self, graph):
        n = len(graph)
        rev = [[] for _ in range(n)]      # reversed graph
        outdeg = [0] * n                  # = in-degree in the reversed graph
        for node in range(n):
            for nb in graph[node]:
                rev[nb].append(node)      # arrow node->nb becomes nb->node
                outdeg[node] += 1

        queue = deque()
        for i in range(n):
            if outdeg[i] == 0:            # terminal nodes are safe
                queue.append(i)

        safe = [False] * n
        while queue:
            node = queue.popleft()
            safe[node] = True
            for prev in rev[node]:        # nodes that point to this safe node
                outdeg[prev] -= 1
                if outdeg[prev] == 0:     # all its edges lead to safe nodes
                    queue.append(prev)

        result = []
        for i in range(n):                # walking 0..n-1 keeps it sorted
            if safe[i]:
                result.append(i)
        return result

7Code line by line

linewhat it means
rev = [[] for _ in range(n)]One empty list per node. (The teacher's Java loop that adds an empty list for every index does the same thing.)
rev[nb].append(node)The original says node → nb. We store it as nb → node, so later, from a safe nb, we can reach node.
outdeg[node] += 1Count node's outgoing edges: "how many corridors of mine are still unknown". (Same as len(graph[node]).)
if outdeg[i] == 0: queue.append(i)Terminal nodes need no checking. They are the starting points of the backward walk.
safe[node] = TrueEverything that ever enters the queue is safe, so mark it when we pop it.
for prev in rev[node]:Loop over the reversed graph, not graph. This was the teacher's bug on screen (see step 8).
outdeg[prev] -= 1One of prev's corridors (prev → node) is now known to be safe.
if outdeg[prev] == 0: queue.append(prev)All of prev's corridors are safe → prev is safe.
for i in range(n): if safe[i]: ...Collect in index order → sorted for free, no sort() needed.

8Dry run on Example 1

Start: outdeg = [2, 2, 1, 1, 1, 0, 0], queue = [5, 6].

stepnode poppedrev[node] checkedqueue AFTERoutdeg after (0..6)safe so far
0——[5, 6]2 2 1 1 1 0 0—
152: 1→0 push · 4: 1→0 push[6, 2, 4]2 2 0 1 0 0 05
26(none)[2, 4]2 2 0 1 0 0 05, 6
320: 2→1 · 1: 2→1 (still > 0, no push)[4]1 1 0 1 0 0 05, 6, 2
44(none)[ ]1 1 0 1 0 0 05, 6, 2, 4

Collect in index order: safe = [F, F, T, F, T, T, T] → answer [2, 4, 5, 6] ✓

after step 1 (✓ = safe)
  (0) → (1) → (3) → back to (0)
   ↘     ↓
     → (2)✓ → (5)✓ ← (4)✓      (6)
at the end
  (0)✗ → (1)✗ → (3)✗ → back to (0)
   ↘      ↓
     → (2)✓ → (5)✓ ← (4)✓      (6)✓
  0, 1, 3 keep outdeg 1, 1, 1: stuck on the loop

The bug the teacher hit on screen

Her first run returned only [5, 6]. In the BFS loop she had written for prev in graph[node], the original list. graph[5] and graph[6] are empty, so nothing was ever reduced and nothing else entered the queue. The whole reason we built rev was to walk backwards. Switching the loop to rev[node] fixed it. (The test file runs this wrong version too and checks it gives [5, 6].)

Example 2 (from LeetCode), quickly

graph = [[1,2,3,4],[1,2],[3,4],[0,4],[]]. Only 4 is terminal. Pop 4: rev[4] = [0, 2, 3] → outdeg 0: 4→3, 2: 2→1, 3: 2→1. None reaches 0, so the queue empties. Answer [4]. Node 1 has a self-loop (1 → 1): its counter can only drop when 1 itself is popped, which never happens.

Doubt 5: does a self-loop break anything?
→ No. 1 → 1 puts 1 into rev[1] and adds 1 to outdeg[1]. That count can only be removed by popping 1, but 1 can't be popped while its count is above 0. So 1 is never safe, which is right: 1 → 1 → 1 … is an endless path.

9Complexity & remember

Remember Safe States (BFS)Safe = cannot reach a cycle. Start from the terminal nodes (out-degree 0). Walk backwards on the reversed graph. Reduce each parent's out-degree; when it hits 0, every edge of the parent leads to safety → push it. It's Kahn's algorithm on the reversed graph.

Part B · DFS with three colours

The teacher names this method ("detecting cycle") and sets it aside to focus on topo sort. It's written out here so the idea is complete.

1The question

Same question as Part A. From Doubt 1: a node is safe exactly when no path from it can reach a cycle. So we just need cycle detection, the same idea as Cycle in a Directed Graph (DFS).

2Constraints

Same as Part A. One extra worry for DFS: a long chain of 10⁴ nodes means recursion 10⁴ calls deep. Python's default limit is about 1000, so we raise it with sys.setrecursionlimit.

3Intuition

Walk forward from a node with DFS. Keep a colour for every node:

statemeaning
0 · whitenot visited yet
1 · greyon the current path, still being explored. If the DFS is finished and the node is still grey, it means "unsafe".
2 · blackfinished and proven safe

If DFS steps onto a grey node, it has walked back into its own current path, which is a cycle. Everything on that path is unsafe.

4Building the logic

Doubt: why is it fine to leave unsafe nodes grey forever?
→ An unsafe node can reach a cycle, and that never changes. Any later DFS that touches it should also answer "unsafe", and seeing grey does exactly that. So grey ends up meaning "on the current path, or known to be unsafe". Both answers are False. That's also why we never re-explore a node: each node is decided once.

5Approach steps

  1. state = [0] * n.
  2. dfs(node): if state is not 0, return state == 2.
  3. Set state 1. For each child: if dfs(child) is False, return False.
  4. Set state 2 and return True.
  5. Answer = every i from 0 to n−1 where dfs(i) is True (sorted for free).

6Code (Python)

Eventual Safe States, DFS with three colours
import sys

class Solution:
    def eventualSafeNodes(self, graph):
        sys.setrecursionlimit(30000)
        n = len(graph)
        state = [0] * n          # 0 = new, 1 = on path / unsafe, 2 = safe

        def dfs(node):
            if state[node] != 0:
                return state[node] == 2
            state[node] = 1                  # grey: on the current path
            for nb in graph[node]:
                if not dfs(nb):
                    return False             # stays grey = unsafe
            state[node] = 2                  # black: every path is safe
            return True

        return [i for i in range(n) if dfs(i)]

7Code line by line

linewhat it means
if state[node] != 0: return state[node] == 2Already decided (or on the path). Black → True, grey → False.
state[node] = 1Mark "I'm on the current path" before going to the children, so a loop back to me is caught.
if not dfs(nb): return FalseOne bad child is enough. We return early and the node stays grey.
state[node] = 2All children are safe → I'm safe.
[i for i in range(n) if dfs(i)]Ask every node in order 0 … n−1. Decided nodes answer in O(1).

8Dry run on Example 1

  1. dfs(0): grey. Child 1 → dfs(1): grey. Child 2 → dfs(2): grey. Child 5 → dfs(5): no children → black, True.
  2. 2: all children safe → black, True. Back in dfs(1), next child 3 → dfs(3): grey. Child 0 → 0 is grey → False (a cycle 0 → 1 → 3 → 0).
  3. 3 returns False (stays grey). 1 returns False (stays grey). 0 returns False (stays grey).
  4. dfs(1), dfs(2), dfs(3) answer from their colour: False, True, False.
  5. dfs(4): grey, child 5 is black → True → 4 black. dfs(5) True. dfs(6): no children → True.
  6. Answer [2, 4, 5, 6] ✓
deepest at step 1
dfs(0)dfs(1)dfs(2)dfs(5) → True
step 2: the cycle is found
dfs(0)dfs(1)dfs(3)dfs(0)? grey → False
afterstate 0..6
step 1 (2 and 5 finished)1 1 2 0 0 2 0
step 31 1 2 1 0 2 0
end1 1 2 1 2 2 2

9Complexity & remember

Remember Safe States (DFS)Grey = on my path or unsafe, black = safe. Stepping onto grey = a cycle → False. Leave unsafe nodes grey; paint a node black only when every child is safe.

Part C · Revision page

BFS on reversed graph (teacher)DFS three colours
main ideastart at the exits, walk backwardswalk forward, catch cycles
starting nodesout-degree 0 (terminal)every node 0 … n−1
extra structurereversed graph + counters + queuestate array + call stack
a node is safe whenits counter reaches 0every child returns True
sorted outputsafe boolean array, scan 0 … n−1ask nodes in order 0 … n−1
time / spaceO(n + E) / O(n + E)O(n + E) / O(n)
Course Schedule (Kahn)Safe States
graph usedoriginalreversed
counterin-degreeout-degree (= in-degree of the reversed graph)
start withnodes with no prerequisitesterminal nodes
left out at the endnodes on or after a cyclenodes on or before a cycle (they can reach it)
If you remember only 5 lines 1. Terminal = no outgoing edges. Safe = every path ends at a terminal node.
2. Safe ⇔ the node cannot reach a cycle.
3. Start from terminal nodes (out-degree 0), because they are surely safe.
4. Reverse the graph; pop a safe node, reduce each parent's out-degree, push at 0.
5. Mark safe in a boolean array and scan 0 … n−1 to get sorted output in O(n).
Mistakes to avoid ✗ looping over graph[node] instead of rev[node] in the BFS (you get only the terminal nodes)
✗ calling a node safe when only one of its paths ends well
✗ counting in-degree of the original graph instead of out-degree
✗ forgetting that a node pointing into a cycle is unsafe too
✗ in DFS, painting a node "new" again after it failed (it must stay unsafe)
test it yourself (paste under either solution above)
s = Solution()
print(s.eventualSafeNodes([[1, 2], [2, 3], [5], [0], [5], [], []]))       # [2, 4, 5, 6]
print(s.eventualSafeNodes([[1, 2], [2, 3], [5], [0], [5], [], [], [0]])) # [2, 4, 5, 6]
print(s.eventualSafeNodes([[1, 2, 3, 4], [1, 2], [3, 4], [0, 4], []]))   # [4]
print(s.eventualSafeNodes([[]]))                                        # [0]
print(s.eventualSafeNodes([[0]]))                                       # []
print(s.eventualSafeNodes([[1], [2], [0]]))                             # []

Based on this video: Find Eventual Safe States | Topological Sort (BFS)