Hi👋SpeedAlgo • Deliberate Practice & Cognitive Ergonomics for Software Engineers

An interactive algorithm mastery and technical interview preparation platform published by Hi👋WebEnterprise. Built for senior and staff software engineers preparing for rigorous coding screens at top tech companies (FAANG/MAMAA).

13 Core Algorithmic Patterns & 225 Practice Problems

  • 1. Two Pointers (16 Paradigms, 43 Problems): Opposite ends, sorted pair sums, palindrome verification, trapping rain water, 3Sum, container with most water — includes the Sliding Window and Fast & Slow Pointers paradigms (contiguous subarray invariants, longest substrings, Floyd's cycle detection, linked list middle).
  • 2. Binary Search (9 Paradigms, 14 Problems): Monotonic predicate partitioning (feasible(x)), boundary search, rotated sorted arrays, square roots, minimum ship capacity, median of two sorted arrays, matrix median on value range.
  • 3. Bit Manipulation (5 Paradigms, 11 Problems): Counting set bits, XOR cancellation, bit reversal, bitmask pairing, maximum XOR with a bitwise trie.
  • 4. Math & Geometry (8 Paradigms, 14 Problems): Sieve of Eratosthenes, integer/roman conversions, modular arithmetic, geometric simulation.
  • 5. Tree/Graph Depth-First Search (15 Paradigms, 24 Problems): Path sums, lowest common ancestor, tree diameter, validating BSTs, tree DP (House Robber III, Binary Tree Cameras).
  • 6. Tree/Graph Breadth-First Search (5 Paradigms, 12 Problems): Level-order traversals, shortest path in unweighted graphs, zig-zag traversals, rotting oranges, word ladders.
  • 7. Graphs (12 Paradigms, 22 Problems): Topological sort, cycle detection, Dijkstra's algorithm, bipartite graph validation, network delay.
  • 8. Backtracking (6 Paradigms, 14 Problems): Subsets, permutations, combinations, constraint satisfaction, pruning, N-Queens, Sudoku solver.
  • 9. Dynamic Programming (13 Paradigms, 28 Problems): Memoization vs tabulation, 0/1 knapsack, unbounded knapsack, coin change, edit distance, longest common subsequence.
  • 10. Heap / Priority Queue (9 Paradigms, 11 Problems): Running medians, top-k elements, interval scheduling, IPO, k-way merges.
  • 11. Advanced Data Structures (7 Paradigms, 17 Problems): Trie (prefix tree), Union-Find (Disjoint Set Union), LRU Cache, LFU Cache, range-sum queries with updates.
  • 12. Stack (5 Paradigms, 9 Problems): Next greater element, largest rectangle in histogram, remove k digits, decode string, min stack.
  • 13. Intervals & Greedy (2 Paradigms, 6 Problems): Merge intervals, interval scheduling, meeting rooms by sweep line, jump game.

4-Stage Deliberate Practice Framework

  1. Stage 1 (Compare & Learn): Read the pattern's mental model, loop invariant and Big-O proof, compare the pattern's template with the problem's solution side by side, and use the step-by-step debugger. The solution can also be shown in C#, Java, TypeScript, C++, Go or Rust for almost every problem.
  2. Stage 2 (Active Recall): Rebuild the solution from memory in a code editor, with an invariant checklist and optional progressive hints, and check it against a judged test suite.
  3. Stage 3 (AI Interview Coach, coming soon): A mock-interview stage still in development; for now, students go straight from Stage 2 to Stage 4.
  4. Stage 4 (Solve on Your Own): Solve a new problem on your own against a 15-minute timer; your code is graded against a test suite. Python and TypeScript run in your browser and C# runs on our server (design problems grade in Python and TypeScript).

Equipped with a spaced-repetition review hub on a fixed 7-step schedule (1 day up to 90 days), a two-pane Studio workspace, and study notes.

Pricing, Access & Commercial Terms

  • Core Curriculum: 100% Free. No credit card required.
  • Coins: New accounts get 40 free coins once, the daily claim adds +20 coins once a day, and inviting a friend earns you +25 coins when they complete their first practice step. These are the only ways to earn coins.
  • AI Coach: Each AI coach question costs 1-5 coins, priced by the server according to its size.
  • BYOK (Bring Your Own Key): AI coaching is free with your own Google Gemini, OpenAI or OpenRouter API key (BYOK).
  • Extras: Coins also unlock extras for 24 hours: a Recall hint level (1 coin), a Solve solution reveal (2), a LeetCode problem card's solutions (1) and a pattern's extra canonical problems (1).
  • Payments & Refunds: Nothing on the site is sold for money: coins can only be earned, never bought. There are no subscriptions or charges, so there is nothing to refund.
  • Platform Operator: Hi👋WebEnterprise. Support & policies at hispeedalgo.com.
Skip to main content
Hi👋SpeedAlgo

Invariant-First Algorithmic Mastery

Arena
238Items
Theory Context•Graph Algorithms

Builds on Tarjan's Bridges, which you haven’t finished yet.

P3HardBuilt on Tarjan's Bridges~6 min speedrun

Tarjan's Strongly Connected Components (One Pass with Low-Link)

Built on
Tarjan's BridgesThe same discovery time and low-link run on a directed graph with a stack, and a node whose low-link equals its own discovery time is the root of one group.
The problem

Flight Routes Check (CSES 1682)(opens the source problem)

A country has n cities, numbered 1 to n, and m one-way flights. A flight [a, b] takes you from city a to city b, and it does not take you back. A route is any sequence of flights, one after another.

Decide whether every city can reach every other city by some route. If it can, return ["YES"]. If it cannot, return ["NO", a, b], where a and b are two cities and no route leads from a to b. If several such pairs exist, any one of them is accepted.

Example 1
Input:n = 3, flights = [[1,2],[2,3],[3,1]]
Output:["YES"]
The three cities form one cycle, so every city reaches every other city.
1230

Tarjan SCC

directed graph ➔ one DFS with disc, low and a stack of waiting nodes ➔ a group popped when low[node] == disc[node]
Spot it when: You must group the nodes of a directed graph so that two nodes share a group exactly when each can reach the other (one-way flights, modules that import each other), and you want the groups from one search over the edges, with no reversed copy of the graph.
Tarjan SCC
Python
1from typing import List
2 
3def tarjan_scc(n: int, edges: List[List[int]]) -> List[int]:
4 """Tarjan's algorithm (the core of Flight Routes Check): each node's strongly
5 connected component id, 0-indexed, in the order the groups are completed.
6 Time: O(V + E) | Space: O(V + E)
7 """
8 adj = [[] for _ in range(n + 1)]
9 for u, v in edges:
10 adj[u].append(v)
11 
12 # discovery time, counted from 1; 0 = not reached yet
13 disc = [0] * (n + 1)
14 # smallest disc this node's subtree reaches while the target still waits on
15 # group_stack
16 low = [0] * (n + 1)
17 # True while a node waits on group_stack for its group to be completed
18 on_stack = [False] * (n + 1)
19 # nodes reached but not yet assigned to a group
20 group_stack = []
21 # group id of each node; -1 = none yet
22 comp = [-1] * (n + 1)
23 time = 0
24 comp_id = 0
25 
26 # 1. Start a Search From Every Unreached Node:
27 # One search reaches only the nodes its edges lead to, so each node that is still
28 # unreached starts its own search.
29 for start in range(1, n + 1):
30 if disc[start] != 0:
31 continue
32 time += 1
33 disc[start] = low[start] = time
34 group_stack.append(start)
35 on_stack[start] = True
36 # iterative DFS: no recursion limit on a long chain
37 path = [(start, iter(adj[start]))]
38 while path:
39 node, it = path[-1]
40 advanced = False
41 for nxt in it:
42 # 2. Go Deeper Into an Unreached Neighbour:
43 # The new node gets the next disc, and it waits on group_stack until its
44 # group is complete.
45 if disc[nxt] == 0:
46 time += 1
47 disc[nxt] = low[nxt] = time
48 group_stack.append(nxt)
49 on_stack[nxt] = True
50 path.append((nxt, iter(adj[nxt])))
51 advanced = True
52 break
53 # 3. Count a Reached Neighbour Only While It Still Waits on group_stack:
54 # A waiting node belongs to a group that node can climb back into. A
55 # node that is not waiting
56 # already finished its own group, so its disc must not lower low[node].
57 if on_stack[nxt]:
58 low[node] = min(low[node], disc[nxt])
59 if advanced:
60 continue
61 path.pop()
62 # 4. Complete a Group When low Equals disc:
63 # Nothing below node climbs above it, so node is the first node of its group
64 # that the search reached.
65 # Every node above it on group_stack, down to node itself, belongs to that
66 # group.
67 if low[node] == disc[node]:
68 while True:
69 member = group_stack.pop()
70 on_stack[member] = False
71 comp[member] = comp_id
72 if member == node:
73 break
74 comp_id += 1
75 # 5. Hand low Up to the Parent:
76 # The parent can climb as far as its child's subtree can, so it keeps the
77 # smaller value.
78 if path:
79 parent = path[-1][0]
80 low[parent] = min(low[parent], low[node])
81 
82 return comp[1:]
Invariant: group_stack holds exactly the reached nodes whose group is not complete; low[node] is the smallest disc of a waiting node that node's subtree reaches, and node is the root of a group exactly when low[node] == disc[node].

Give each node a number disc in the order the search reaches it, and push it on group_stack. Track low[node], the smallest disc of a waiting node that node's subtree reaches. When node has tried all of its neighbours and low[node] == disc[node], nothing below node can climb above it. So node is the root of one group: pop group_stack down to node. This is correct because node reaches every node above it on group_stack: the search reached them through node. None of them can reach a waiting node that is older than node. That would have lowered low[node]. So each of them climbs back to node, and the group reaches itself in both directions. The trap is a neighbour that was already reached. Lower low[node] with disc[nxt] only when on_stack[nxt] is True. A reached neighbour that is not on group_stack belongs to a group that is already complete. It has no path back to node. If you use it anyway, node is popped together with a group it does not belong to.

See it run

Tarjan's one search, step by step

group_stack [1]

group_stack [1, 2]

group_stack [1, 2, 3]

group_stack [1, 2, 3]

group_stack [1, 2, 3, 4]

group_stack [1, 2, 3, 4, 5]

group_stack [1, 2, 3, 4, 5, 6]

group_stack [1, 2, 3, 4, 5, 6]

group_stack [1, 2, 3, 4, 5, 6]

group_stack [1, 2, 3, 4, 5, 6]

group_stack [1, 2, 3]

group_stack [1, 2, 3]

group_stack [1, 2, 3]

group_stack []

group_stack [7]

group_stack [7]

group_stack []

group_stack [] · answer ["NO", 6, 7]

Each circle is a city and each arrow is a flight. The rows under the graph show disc, low, group_stack and comp for every city, and the small number under a box is the city.

  • now the city the search is working on, or the flight just followed
  • wait a city on group_stack: it was reached, but its group is not complete
  • grouped a city whose group is complete and already has a comp_id
  • back a flight to a city that still waits, which lowers low
  • ignored a flight to a city whose group is complete, which is not used

Pass 1 starts a search at city 1. disc[1] and low[1] are 1, and city 1 goes on group_stack. A city on group_stack is waiting for its group to be complete.

The flight 1 to 2 leads to a new city. disc[2] and low[2] are 2, and city 2 goes on group_stack.

The flight 2 to 3 leads to a new city. disc[3] and low[3] are 3, and city 3 goes on group_stack.

The flight 3 to 1 leads to city 1, which still waits on group_stack. So low[3] becomes disc[1] = 1.

The flight 3 to 4 leads to a new city. disc[4] and low[4] are 4, and city 4 goes on group_stack.

The flight 4 to 5 leads to a new city. disc[5] and low[5] are 5, and city 5 goes on group_stack.

The flight 5 to 6 leads to a new city. disc[6] and low[6] are 6, and city 6 goes on group_stack.

The flight 6 to 4 leads to city 4, which still waits on group_stack. So low[6] becomes disc[4] = 4.

City 6 has no flight left, but low[6] = 4 is not disc[6] = 6, so city 6 stays on group_stack. It hands low 4 to city 5: low[5] = min(5, 4) = 4.

City 5 has no flight left, but low[5] = 4 is not disc[5] = 5, so city 5 stays on group_stack. It hands low 4 to city 4: low[4] = min(4, 4) = 4.

City 4 has no flight left, and low[4] equals disc[4] = 4. So city 4 is the first city of a group, and group_stack is popped down to it. 4, 5 and 6 get comp_id 0.

City 3 has no flight left, but low[3] = 1 is not disc[3] = 3, so city 3 stays on group_stack. It hands low 1 to city 2: low[2] = min(2, 1) = 1.

City 2 has no flight left, but low[2] = 1 is not disc[2] = 2, so city 2 stays on group_stack. It hands low 1 to city 1: low[1] = min(1, 1) = 1.

City 1 has no flight left, and low[1] equals disc[1] = 1. So city 1 is the first city of a group, and group_stack is popped down to it. 1, 2 and 3 get comp_id 1.

City 7 was not reached by an earlier search, so a new search starts there. disc[7] and low[7] are 7, and city 7 goes on group_stack.

The flight 7 to 4 leads to city 4, which is already in group 0. on_stack[4] is False, so disc[4] = 4 is not used and low[7] stays 7.

City 7 has no flight left, and low[7] equals disc[7] = 7. So city 7 is the first city of a group, and group_stack is popped down to it. 7 get comp_id 2.

Every search is done, and there are 3 groups. Group 0 was completed first and no flight leaves it, so no route leads from city 6 to city 7. The answer is ["NO", 6, 7].

1now234567
disc
11
-2
-3
-4
-5
-6
-7
low
11
-2
-3
-4
-5
-6
-7
group_stack
1
comp
empty
1 / 18
Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Lowering low[node] with every reached neighbour, not only with a neighbour on group_stack

    Mistake
    low[node] = min(low[node], disc[nxt])
    Fix
    if on_stack[nxt]:
        low[node] = min(low[node], disc[nxt])

    What happens: A reached neighbour that is no longer on group_stack belongs to a group that is already complete. It cannot reach node. If its disc still lowers low[node], then node is no longer the root of its own group. In this example, node 2 has no edge out, so it is popped as a group of its own first. Node 3 then sees node 2 with disc[2] = 2, so low[3] becomes 2 and node 3 is not a root. Node 1 then pops nodes 3 and 1 together, although node 3 has no path to node 1.

    Example: n = 3, edges = [[1, 2], [1, 3], [3, 2]]
    Mistake: returns [1, 0, 1]Fix: returns [2, 0, 1]
  2. 2

    Gotcha 2: Setting on_stack[node] = False when the search leaves node, not when its group is popped

    Mistake
    on_stack[node] = False
    if low[node] == disc[node]:
    Fix
    if low[node] == disc[node]:

    What happens: on_stack[node] must stay True for as long as node waits on group_stack. That is longer than the search stays on node. If it is cleared early, a later node that has an edge to the waiting node ignores that edge. In this example, the search leaves node 2 while node 2 still waits for node 1. Later, node 3 has an edge to node 2, but on_stack[2] is False, so low[3] stays 3 and node 3 is popped alone. All three nodes can reach each other, so they should be one group.

    Example: n = 3, edges = [[1, 2], [2, 1], [1, 3], [3, 2]]
    Mistake: returns [1, 1, 0]Fix: returns [0, 0, 0]
  3. 3

    Gotcha 3: Not handing low[node] up to parent when node is finished

    Mistake
    if path:
        parent = path[-1][0]
    Fix
    if path:
        parent = path[-1][0]
        low[parent] = min(low[parent], low[node])

    What happens: parent can climb as far as its child's subtree can. Without the update, an edge back to an older node, found deep in the subtree, never reaches the nodes between. In this example, node 3 has an edge back to node 1, so low[3] is 1. Node 2 never receives that value, so low[2] == disc[2], and node 2 is wrongly treated as the root of a group. Nodes 3 and 2 are popped together, and node 1 is left alone.

    Example: n = 3, edges = [[1, 2], [2, 3], [3, 1]]
    Mistake: returns [1, 0, 0]Fix: returns [0, 0, 0]
  4. 4

    Gotcha 4: Writing the search as a recursive dfs

    Mistake
    def dfs(node):
    Fix
    path = [(start, iter(adj[start]))]

    What happens: A recursive dfs makes one nested call for each node on the current path. Python allows about 1000 nested calls by default. A chain of 2000 nodes, 1 -> 2 -> ... -> 2000, needs 2000 nested calls and raises RecursionError. Flight Routes Check allows 10^5 cities, so the blueprint keeps its path in the list path, which has no such limit.

    Example: n = 2000 nodes in one chain: 1 -> 2 -> ... -> 2000
    Mistake: raises RecursionErrorFix: returns 2000
Not this one if…
  • Both cards find the groups of a directed graph, for example the cycles of Longest Cycle in a Graph (LC 2360). Switch to Strongly Connected Components when the reversed graph is already stored, or when you want the groups from a source group to a sink group. Its second pass lists them in that order. This card lists them from a sink group to a source group. See Strongly Connected Components
  • The graph is undirected and the question is which single link, when removed, splits it, as in Critical Connections in a Network (LC 1192). A link has no direction and there are no groups to pop, so no stack is needed: use the bridge test on the tree edge. See Tarjan's Bridges
  • You only need to know whether the directed graph has a cycle, as in Course Schedule (LC 207), not which nodes sit on which cycle. The three colors answer that with no low values and no group stack. See Cycle Detection

Deep Dive

Why low[node] == disc[node] marks the first city of a group

A strongly connected group is a set of nodes in a directed graph where each node can reach every other node of the set. Two nodes are in one group exactly when each can reach the other.

Run one DFS (depth-first search) and give each node a number, disc, in the order the search reaches it. Push the node on group_stack. A node on group_stack is waiting: it was reached, but its group is not complete.

low[node] is the smallest disc of a waiting node that the subtree of node can reach. The search lowers it with an edge to a node that still waits, and with the low of a finished child.

When node has no edge left, compare low[node] with disc[node]. If they are equal, nothing in the subtree of node can climb to a waiting node older than node. So node is the first node of its group that the search reached.

Every node above node on group_stack was reached through node, so node reaches it. None of those nodes is a first node of its own group, so each one climbs to an older waiting node, and the climbs end at node. They reach node back. That makes them one group, and the search pops group_stack down to node.

Example: in 1 -> 2, 2 -> 1, 2 -> 3, the search goes 1, 2, 3. Node 3 has no edge left and low[3] = disc[3] = 3, so it forms the group {3}. Node 2 has the edge back to node 1, so low[2] = 1. Node 1 ends with low[1] = disc[1] = 1, and it pops {2, 1}.

One trace: seven cities, three groups

The board uses 7 cities. The groups are {1, 2, 3}, {4, 5, 6} and {7}. The flights are 1 -> 2, 2 -> 3, 3 -> 1, then 3 -> 4, then 4 -> 5, 5 -> 6, 6 -> 4, then 7 -> 4.

The search goes 1, 2, 3. The flight 3 -> 1 leads to city 1, which waits on group_stack, so low[3] becomes 1. Then the search goes 4, 5, 6, and the flight 6 -> 4 makes low[6] equal to 4.

City 6 finishes with low[6] = 4, which is not disc[6] = 6, so it stays on the stack and hands 4 to city 5. City 5 does the same for city 4. City 4 finishes with low[4] = disc[4] = 4, so group_stack is popped down to 4. The group {4, 5, 6} gets comp_id 0.

Cities 3 and 2 finish with low 1, and hand it up. City 1 finishes with low[1] = disc[1] = 1, so the stack is popped down to 1. The group {1, 2, 3} gets comp_id 1.

Then a new search starts at city 7. Its flight 7 -> 4 leads to city 4, which is already in group 0. Here on_stack[4] is False, so disc[4] = 4 is not used, and low[7] stays 7. City 7 forms the group {7} with comp_id 2.

There are 3 groups, so the answer is NO. Group 0 was completed first. No flight leaves it, so no route leads from city 6 to city 7. The answer is ["NO", 6, 7], and the final comp is [1, 1, 1, 0, 0, 0, 2].

The traps: the on_stack test, the flag, and passing low up

The first trap is the test if on_stack[nxt]:. A neighbour that was reached but is not on group_stack is in a group that is already complete. It cannot reach node, so its disc must not lower low[node].

On the board graph, leave the test out. City 7 sees city 4, and low[7] becomes 4. Now low[7] is not disc[7], so city 7 is never popped. The result is comp = [1, 1, 1, 0, 0, 0, -1], and city 7 has no group.

The smallest example is n = 2 and edges = [[2, 1]]. City 1 forms its own group first. City 2 then sees city 1 and takes low[2] = 1, so city 2 is never popped. Only one group is counted, and Flight Routes Check would answer ["YES"] where the right answer is ["NO", 1, 2].

The second trap is the flag. on_stack[node] must become False only when node is popped with its group, not when the search leaves node. A node can wait on group_stack long after the search has left it. For n = 3 and edges = [[1, 2], [2, 1], [1, 3], [3, 2]], an early clear gives [1, 1, 0], two groups. The right answer is [0, 0, 0].

The third trap is the last step of a finished node: low[parent] = min(low[parent], low[node]). On the board graph, without it the result is [3, 2, 2, 1, 0, 0, 4], five groups instead of three.

The search is iterative, with path holding (node, iterator). Flight Routes Check allows 10^5 cities in one chain. A recursive dfs would nest 10^5 calls. With Python's default limit, a chain of 2000 cities already raises RecursionError.

The cost, and how it differs from Kosaraju's algorithm

Each city is pushed on group_stack once and popped once. Each flight is read once by the iterator of its city. The time is O(V + E). adj, disc, low, on_stack, comp, group_stack and path hold one entry per city or per flight, so the space is O(V + E).

On the board graph, 7 cities and 8 flights give 7 pushes, 7 pops and 8 flight reads.

Strongly Connected Components (Kosaraju) finds the same groups with two searches and a reversed copy of the graph. The first search records the finish order. The second search reads that order from the end and walks the reversed graph.

Tarjan's algorithm needs one search over the original graph, and no reversed copy. It needs two extra arrays, low and on_stack, and a stack of waiting nodes. Both algorithms cost O(V + E).

The group ids come out in opposite orders. Kosaraju numbers a group that no edge enters first. Tarjan numbers a group that no edge leaves first. Every edge between two groups goes from a higher comp_id to a lower one in Tarjan's output.

The undirected cousin of this card is Tarjan's Bridges. It uses the same disc and low, but it needs no stack, because an undirected search has no edge between two different subtrees. If the graph is undirected and you only count pieces, a plain search or union-find is enough.

60-Second Pitch

"Every city must reach every other city, so I check whether the graph is one strongly connected group. I use Tarjan's algorithm, which is one depth-first search. I number each city with disc when I reach it, and I keep a stack of cities whose group is not complete. low is the smallest disc of a waiting city that a city's subtree reaches. A flight to a reached city lowers low only if that city is still on the stack. When a city finishes with low equal to disc, it is the first city of a group, so I pop the stack down to it. One group means YES. Otherwise the first group completed has no flight out, so I name a city from it and a city outside it. The search is iterative, and the time and space are both O(V + E)."

Common Questions

Why must the test be on_stack[nxt] and not only disc[nxt] != 0?

A reached node that is not on group_stack is in a group that is already complete. It cannot reach the current node, so it must not lower low. If it does, the current node is never popped. For n = 2 and edges = [[2, 1]], the group of city 2 is lost and Flight Routes Check answers YES instead of naming a pair.

Should low take disc[nxt] or low[nxt] from a node that is still on the stack?

Both give the same groups, and disc[nxt] is the standard form. A test of both forms on 20000 random graphs of up to 8 nodes gave the same comp every time.

How is Tarjan's algorithm different from Kosaraju's, and which would you pick?

Both find the same groups in O(V + E). Kosaraju uses two searches and a reversed graph, and its reason is short to explain. Tarjan uses one search over the original graph, with disc, low and a stack. Pick Tarjan when the reversed graph is not stored, or when you want the groups from a sink group to a source group.

Why does the first group completed give a pair with no route?

A group is completed only when nothing in it climbs to an older waiting node, so every flight that leaves its nodes leads to a group that is already complete. The first group has none to lead to. So no flight leaves it, and no route leads from a city of that group to a city of any other group.

What are the time and space complexity?

O(V + E) time and O(V + E) space. Each node is pushed and popped once, and each flight is read once. The search uses an explicit path, so a long chain does not hit the recursion limit.

SWE Reasoning

Spot it

  • The graph is directed, and the question asks which nodes can reach each other.
  • The words are every city reaches every other city, mutual reachability, groups, kingdoms, or cycles of dependencies.
  • The answer for a whole graph is YES when only one group exists, so the number of groups matters.
  • The input is large, up to 10^5 nodes, so the search must be iterative and O(V + E).
  • You want the groups from one search, without building a reversed copy of the graph.

The invariant, step by step

  1. disc[u] is 0 until the search reaches u. The first time, it gets the next number from time, low[u] starts equal to it, and u goes on group_stack with on_stack[u] True.
  2. The search reads one flight at a time from the top of path. A flight to a node with disc[nxt] == 0 goes deeper.
  3. A flight to a node that is already reached lowers low[node] only if on_stack[nxt] is True. Otherwise the flight is ignored.
  4. When a node has no flight left, it is popped from path. If low[node] == disc[node], group_stack is popped down to node, and each popped node gets comp_id and on_stack False.
  5. If low[node] is smaller than disc[node], the node stays on group_stack. Its low goes to the parent with low[parent] = min(low[parent], low[node]).
  6. When every search is done, each node has one comp_id. Two nodes share it exactly when each can reach the other, and every edge between two groups goes from a higher id to a lower one.

Traps

  • Using disc[nxt] for every reached node. A node outside group_stack is in a complete group. Test on_stack[nxt] first.
  • Clearing on_stack[node] when the search leaves node. Clear it when the node is popped with its group.
  • Skipping low[parent] = min(low[parent], low[node]). A back edge found deep in the search then never reaches the nodes between.
  • Recursive dfs. A chain of 10^5 cities nests 10^5 calls. Python stops at 1000 by default. Keep the path in path.
  • Naming the pair in the wrong direction. The first group completed has no flight leaving it, so it is the city of that group that cannot reach another group, not the other way round.

Practice

Canonical LeetCode:Flight Routes Check (CSES 1682)Planets and Kingdoms (CSES 1683)
Theory Context•Graph Algorithms

Builds on Tarjan's Bridges, which you haven’t finished yet.

P3HardBuilt on Tarjan's Bridges~6 min speedrun

Tarjan's Strongly Connected Components (One Pass with Low-Link)

Built on
Tarjan's BridgesThe same discovery time and low-link run on a directed graph with a stack, and a node whose low-link equals its own discovery time is the root of one group.
The problem

Flight Routes Check (CSES 1682)(opens the source problem)

A country has n cities, numbered 1 to n, and m one-way flights. A flight [a, b] takes you from city a to city b, and it does not take you back. A route is any sequence of flights, one after another.

Decide whether every city can reach every other city by some route. If it can, return ["YES"]. If it cannot, return ["NO", a, b], where a and b are two cities and no route leads from a to b. If several such pairs exist, any one of them is accepted.

Example 1
Input:n = 3, flights = [[1,2],[2,3],[3,1]]
Output:["YES"]
The three cities form one cycle, so every city reaches every other city.
1230

Tarjan SCC

directed graph ➔ one DFS with disc, low and a stack of waiting nodes ➔ a group popped when low[node] == disc[node]
Spot it when: You must group the nodes of a directed graph so that two nodes share a group exactly when each can reach the other (one-way flights, modules that import each other), and you want the groups from one search over the edges, with no reversed copy of the graph.
Tarjan SCC
Python
1from typing import List
2 
3def tarjan_scc(n: int, edges: List[List[int]]) -> List[int]:
4 """Tarjan's algorithm (the core of Flight Routes Check): each node's strongly
5 connected component id, 0-indexed, in the order the groups are completed.
6 Time: O(V + E) | Space: O(V + E)
7 """
8 adj = [[] for _ in range(n + 1)]
9 for u, v in edges:
10 adj[u].append(v)
11 
12 # discovery time, counted from 1; 0 = not reached yet
13 disc = [0] * (n + 1)
14 # smallest disc this node's subtree reaches while the target still waits on
15 # group_stack
16 low = [0] * (n + 1)
17 # True while a node waits on group_stack for its group to be completed
18 on_stack = [False] * (n + 1)
19 # nodes reached but not yet assigned to a group
20 group_stack = []
21 # group id of each node; -1 = none yet
22 comp = [-1] * (n + 1)
23 time = 0
24 comp_id = 0
25 
26 # 1. Start a Search From Every Unreached Node:
27 # One search reaches only the nodes its edges lead to, so each node that is still
28 # unreached starts its own search.
29 for start in range(1, n + 1):
30 if disc[start] != 0:
31 continue
32 time += 1
33 disc[start] = low[start] = time
34 group_stack.append(start)
35 on_stack[start] = True
36 # iterative DFS: no recursion limit on a long chain
37 path = [(start, iter(adj[start]))]
38 while path:
39 node, it = path[-1]
40 advanced = False
41 for nxt in it:
42 # 2. Go Deeper Into an Unreached Neighbour:
43 # The new node gets the next disc, and it waits on group_stack until its
44 # group is complete.
45 if disc[nxt] == 0:
46 time += 1
47 disc[nxt] = low[nxt] = time
48 group_stack.append(nxt)
49 on_stack[nxt] = True
50 path.append((nxt, iter(adj[nxt])))
51 advanced = True
52 break
53 # 3. Count a Reached Neighbour Only While It Still Waits on group_stack:
54 # A waiting node belongs to a group that node can climb back into. A
55 # node that is not waiting
56 # already finished its own group, so its disc must not lower low[node].
57 if on_stack[nxt]:
58 low[node] = min(low[node], disc[nxt])
59 if advanced:
60 continue
61 path.pop()
62 # 4. Complete a Group When low Equals disc:
63 # Nothing below node climbs above it, so node is the first node of its group
64 # that the search reached.
65 # Every node above it on group_stack, down to node itself, belongs to that
66 # group.
67 if low[node] == disc[node]:
68 while True:
69 member = group_stack.pop()
70 on_stack[member] = False
71 comp[member] = comp_id
72 if member == node:
73 break
74 comp_id += 1
75 # 5. Hand low Up to the Parent:
76 # The parent can climb as far as its child's subtree can, so it keeps the
77 # smaller value.
78 if path:
79 parent = path[-1][0]
80 low[parent] = min(low[parent], low[node])
81 
82 return comp[1:]
Invariant: group_stack holds exactly the reached nodes whose group is not complete; low[node] is the smallest disc of a waiting node that node's subtree reaches, and node is the root of a group exactly when low[node] == disc[node].

Give each node a number disc in the order the search reaches it, and push it on group_stack. Track low[node], the smallest disc of a waiting node that node's subtree reaches. When node has tried all of its neighbours and low[node] == disc[node], nothing below node can climb above it. So node is the root of one group: pop group_stack down to node. This is correct because node reaches every node above it on group_stack: the search reached them through node. None of them can reach a waiting node that is older than node. That would have lowered low[node]. So each of them climbs back to node, and the group reaches itself in both directions. The trap is a neighbour that was already reached. Lower low[node] with disc[nxt] only when on_stack[nxt] is True. A reached neighbour that is not on group_stack belongs to a group that is already complete. It has no path back to node. If you use it anyway, node is popped together with a group it does not belong to.

See it run

Tarjan's one search, step by step

group_stack [1]

group_stack [1, 2]

group_stack [1, 2, 3]

group_stack [1, 2, 3]

group_stack [1, 2, 3, 4]

group_stack [1, 2, 3, 4, 5]

group_stack [1, 2, 3, 4, 5, 6]

group_stack [1, 2, 3, 4, 5, 6]

group_stack [1, 2, 3, 4, 5, 6]

group_stack [1, 2, 3, 4, 5, 6]

group_stack [1, 2, 3]

group_stack [1, 2, 3]

group_stack [1, 2, 3]

group_stack []

group_stack [7]

group_stack [7]

group_stack []

group_stack [] · answer ["NO", 6, 7]

Each circle is a city and each arrow is a flight. The rows under the graph show disc, low, group_stack and comp for every city, and the small number under a box is the city.

  • now the city the search is working on, or the flight just followed
  • wait a city on group_stack: it was reached, but its group is not complete
  • grouped a city whose group is complete and already has a comp_id
  • back a flight to a city that still waits, which lowers low
  • ignored a flight to a city whose group is complete, which is not used

Pass 1 starts a search at city 1. disc[1] and low[1] are 1, and city 1 goes on group_stack. A city on group_stack is waiting for its group to be complete.

The flight 1 to 2 leads to a new city. disc[2] and low[2] are 2, and city 2 goes on group_stack.

The flight 2 to 3 leads to a new city. disc[3] and low[3] are 3, and city 3 goes on group_stack.

The flight 3 to 1 leads to city 1, which still waits on group_stack. So low[3] becomes disc[1] = 1.

The flight 3 to 4 leads to a new city. disc[4] and low[4] are 4, and city 4 goes on group_stack.

The flight 4 to 5 leads to a new city. disc[5] and low[5] are 5, and city 5 goes on group_stack.

The flight 5 to 6 leads to a new city. disc[6] and low[6] are 6, and city 6 goes on group_stack.

The flight 6 to 4 leads to city 4, which still waits on group_stack. So low[6] becomes disc[4] = 4.

City 6 has no flight left, but low[6] = 4 is not disc[6] = 6, so city 6 stays on group_stack. It hands low 4 to city 5: low[5] = min(5, 4) = 4.

City 5 has no flight left, but low[5] = 4 is not disc[5] = 5, so city 5 stays on group_stack. It hands low 4 to city 4: low[4] = min(4, 4) = 4.

City 4 has no flight left, and low[4] equals disc[4] = 4. So city 4 is the first city of a group, and group_stack is popped down to it. 4, 5 and 6 get comp_id 0.

City 3 has no flight left, but low[3] = 1 is not disc[3] = 3, so city 3 stays on group_stack. It hands low 1 to city 2: low[2] = min(2, 1) = 1.

City 2 has no flight left, but low[2] = 1 is not disc[2] = 2, so city 2 stays on group_stack. It hands low 1 to city 1: low[1] = min(1, 1) = 1.

City 1 has no flight left, and low[1] equals disc[1] = 1. So city 1 is the first city of a group, and group_stack is popped down to it. 1, 2 and 3 get comp_id 1.

City 7 was not reached by an earlier search, so a new search starts there. disc[7] and low[7] are 7, and city 7 goes on group_stack.

The flight 7 to 4 leads to city 4, which is already in group 0. on_stack[4] is False, so disc[4] = 4 is not used and low[7] stays 7.

City 7 has no flight left, and low[7] equals disc[7] = 7. So city 7 is the first city of a group, and group_stack is popped down to it. 7 get comp_id 2.

Every search is done, and there are 3 groups. Group 0 was completed first and no flight leaves it, so no route leads from city 6 to city 7. The answer is ["NO", 6, 7].

1now234567
disc
11
-2
-3
-4
-5
-6
-7
low
11
-2
-3
-4
-5
-6
-7
group_stack
1
comp
empty
1 / 18
Gotchas1 of 4

Gotcha 1 of 4

  1. 1
    The trap

    Gotcha 1: Lowering low[node] with every reached neighbour, not only with a neighbour on group_stack

    Mistake
    low[node] = min(low[node], disc[nxt])
    Fix
    if on_stack[nxt]:
        low[node] = min(low[node], disc[nxt])

    What happens: A reached neighbour that is no longer on group_stack belongs to a group that is already complete. It cannot reach node. If its disc still lowers low[node], then node is no longer the root of its own group. In this example, node 2 has no edge out, so it is popped as a group of its own first. Node 3 then sees node 2 with disc[2] = 2, so low[3] becomes 2 and node 3 is not a root. Node 1 then pops nodes 3 and 1 together, although node 3 has no path to node 1.

    Example: n = 3, edges = [[1, 2], [1, 3], [3, 2]]
    Mistake: returns [1, 0, 1]Fix: returns [2, 0, 1]
  2. 2

    Gotcha 2: Setting on_stack[node] = False when the search leaves node, not when its group is popped

    Mistake
    on_stack[node] = False
    if low[node] == disc[node]:
    Fix
    if low[node] == disc[node]:

    What happens: on_stack[node] must stay True for as long as node waits on group_stack. That is longer than the search stays on node. If it is cleared early, a later node that has an edge to the waiting node ignores that edge. In this example, the search leaves node 2 while node 2 still waits for node 1. Later, node 3 has an edge to node 2, but on_stack[2] is False, so low[3] stays 3 and node 3 is popped alone. All three nodes can reach each other, so they should be one group.

    Example: n = 3, edges = [[1, 2], [2, 1], [1, 3], [3, 2]]
    Mistake: returns [1, 1, 0]Fix: returns [0, 0, 0]
  3. 3

    Gotcha 3: Not handing low[node] up to parent when node is finished

    Mistake
    if path:
        parent = path[-1][0]
    Fix
    if path:
        parent = path[-1][0]
        low[parent] = min(low[parent], low[node])

    What happens: parent can climb as far as its child's subtree can. Without the update, an edge back to an older node, found deep in the subtree, never reaches the nodes between. In this example, node 3 has an edge back to node 1, so low[3] is 1. Node 2 never receives that value, so low[2] == disc[2], and node 2 is wrongly treated as the root of a group. Nodes 3 and 2 are popped together, and node 1 is left alone.

    Example: n = 3, edges = [[1, 2], [2, 3], [3, 1]]
    Mistake: returns [1, 0, 0]Fix: returns [0, 0, 0]
  4. 4

    Gotcha 4: Writing the search as a recursive dfs

    Mistake
    def dfs(node):
    Fix
    path = [(start, iter(adj[start]))]

    What happens: A recursive dfs makes one nested call for each node on the current path. Python allows about 1000 nested calls by default. A chain of 2000 nodes, 1 -> 2 -> ... -> 2000, needs 2000 nested calls and raises RecursionError. Flight Routes Check allows 10^5 cities, so the blueprint keeps its path in the list path, which has no such limit.

    Example: n = 2000 nodes in one chain: 1 -> 2 -> ... -> 2000
    Mistake: raises RecursionErrorFix: returns 2000
Not this one if…
  • Both cards find the groups of a directed graph, for example the cycles of Longest Cycle in a Graph (LC 2360). Switch to Strongly Connected Components when the reversed graph is already stored, or when you want the groups from a source group to a sink group. Its second pass lists them in that order. This card lists them from a sink group to a source group. See Strongly Connected Components
  • The graph is undirected and the question is which single link, when removed, splits it, as in Critical Connections in a Network (LC 1192). A link has no direction and there are no groups to pop, so no stack is needed: use the bridge test on the tree edge. See Tarjan's Bridges
  • You only need to know whether the directed graph has a cycle, as in Course Schedule (LC 207), not which nodes sit on which cycle. The three colors answer that with no low values and no group stack. See Cycle Detection

Deep Dive

Why low[node] == disc[node] marks the first city of a group

A strongly connected group is a set of nodes in a directed graph where each node can reach every other node of the set. Two nodes are in one group exactly when each can reach the other.

Run one DFS (depth-first search) and give each node a number, disc, in the order the search reaches it. Push the node on group_stack. A node on group_stack is waiting: it was reached, but its group is not complete.

low[node] is the smallest disc of a waiting node that the subtree of node can reach. The search lowers it with an edge to a node that still waits, and with the low of a finished child.

When node has no edge left, compare low[node] with disc[node]. If they are equal, nothing in the subtree of node can climb to a waiting node older than node. So node is the first node of its group that the search reached.

Every node above node on group_stack was reached through node, so node reaches it. None of those nodes is a first node of its own group, so each one climbs to an older waiting node, and the climbs end at node. They reach node back. That makes them one group, and the search pops group_stack down to node.

Example: in 1 -> 2, 2 -> 1, 2 -> 3, the search goes 1, 2, 3. Node 3 has no edge left and low[3] = disc[3] = 3, so it forms the group {3}. Node 2 has the edge back to node 1, so low[2] = 1. Node 1 ends with low[1] = disc[1] = 1, and it pops {2, 1}.

One trace: seven cities, three groups

The board uses 7 cities. The groups are {1, 2, 3}, {4, 5, 6} and {7}. The flights are 1 -> 2, 2 -> 3, 3 -> 1, then 3 -> 4, then 4 -> 5, 5 -> 6, 6 -> 4, then 7 -> 4.

The search goes 1, 2, 3. The flight 3 -> 1 leads to city 1, which waits on group_stack, so low[3] becomes 1. Then the search goes 4, 5, 6, and the flight 6 -> 4 makes low[6] equal to 4.

City 6 finishes with low[6] = 4, which is not disc[6] = 6, so it stays on the stack and hands 4 to city 5. City 5 does the same for city 4. City 4 finishes with low[4] = disc[4] = 4, so group_stack is popped down to 4. The group {4, 5, 6} gets comp_id 0.

Cities 3 and 2 finish with low 1, and hand it up. City 1 finishes with low[1] = disc[1] = 1, so the stack is popped down to 1. The group {1, 2, 3} gets comp_id 1.

Then a new search starts at city 7. Its flight 7 -> 4 leads to city 4, which is already in group 0. Here on_stack[4] is False, so disc[4] = 4 is not used, and low[7] stays 7. City 7 forms the group {7} with comp_id 2.

There are 3 groups, so the answer is NO. Group 0 was completed first. No flight leaves it, so no route leads from city 6 to city 7. The answer is ["NO", 6, 7], and the final comp is [1, 1, 1, 0, 0, 0, 2].

The traps: the on_stack test, the flag, and passing low up

The first trap is the test if on_stack[nxt]:. A neighbour that was reached but is not on group_stack is in a group that is already complete. It cannot reach node, so its disc must not lower low[node].

On the board graph, leave the test out. City 7 sees city 4, and low[7] becomes 4. Now low[7] is not disc[7], so city 7 is never popped. The result is comp = [1, 1, 1, 0, 0, 0, -1], and city 7 has no group.

The smallest example is n = 2 and edges = [[2, 1]]. City 1 forms its own group first. City 2 then sees city 1 and takes low[2] = 1, so city 2 is never popped. Only one group is counted, and Flight Routes Check would answer ["YES"] where the right answer is ["NO", 1, 2].

The second trap is the flag. on_stack[node] must become False only when node is popped with its group, not when the search leaves node. A node can wait on group_stack long after the search has left it. For n = 3 and edges = [[1, 2], [2, 1], [1, 3], [3, 2]], an early clear gives [1, 1, 0], two groups. The right answer is [0, 0, 0].

The third trap is the last step of a finished node: low[parent] = min(low[parent], low[node]). On the board graph, without it the result is [3, 2, 2, 1, 0, 0, 4], five groups instead of three.

The search is iterative, with path holding (node, iterator). Flight Routes Check allows 10^5 cities in one chain. A recursive dfs would nest 10^5 calls. With Python's default limit, a chain of 2000 cities already raises RecursionError.

The cost, and how it differs from Kosaraju's algorithm

Each city is pushed on group_stack once and popped once. Each flight is read once by the iterator of its city. The time is O(V + E). adj, disc, low, on_stack, comp, group_stack and path hold one entry per city or per flight, so the space is O(V + E).

On the board graph, 7 cities and 8 flights give 7 pushes, 7 pops and 8 flight reads.

Strongly Connected Components (Kosaraju) finds the same groups with two searches and a reversed copy of the graph. The first search records the finish order. The second search reads that order from the end and walks the reversed graph.

Tarjan's algorithm needs one search over the original graph, and no reversed copy. It needs two extra arrays, low and on_stack, and a stack of waiting nodes. Both algorithms cost O(V + E).

The group ids come out in opposite orders. Kosaraju numbers a group that no edge enters first. Tarjan numbers a group that no edge leaves first. Every edge between two groups goes from a higher comp_id to a lower one in Tarjan's output.

The undirected cousin of this card is Tarjan's Bridges. It uses the same disc and low, but it needs no stack, because an undirected search has no edge between two different subtrees. If the graph is undirected and you only count pieces, a plain search or union-find is enough.

60-Second Pitch

"Every city must reach every other city, so I check whether the graph is one strongly connected group. I use Tarjan's algorithm, which is one depth-first search. I number each city with disc when I reach it, and I keep a stack of cities whose group is not complete. low is the smallest disc of a waiting city that a city's subtree reaches. A flight to a reached city lowers low only if that city is still on the stack. When a city finishes with low equal to disc, it is the first city of a group, so I pop the stack down to it. One group means YES. Otherwise the first group completed has no flight out, so I name a city from it and a city outside it. The search is iterative, and the time and space are both O(V + E)."

Common Questions

Why must the test be on_stack[nxt] and not only disc[nxt] != 0?

A reached node that is not on group_stack is in a group that is already complete. It cannot reach the current node, so it must not lower low. If it does, the current node is never popped. For n = 2 and edges = [[2, 1]], the group of city 2 is lost and Flight Routes Check answers YES instead of naming a pair.

Should low take disc[nxt] or low[nxt] from a node that is still on the stack?

Both give the same groups, and disc[nxt] is the standard form. A test of both forms on 20000 random graphs of up to 8 nodes gave the same comp every time.

How is Tarjan's algorithm different from Kosaraju's, and which would you pick?

Both find the same groups in O(V + E). Kosaraju uses two searches and a reversed graph, and its reason is short to explain. Tarjan uses one search over the original graph, with disc, low and a stack. Pick Tarjan when the reversed graph is not stored, or when you want the groups from a sink group to a source group.

Why does the first group completed give a pair with no route?

A group is completed only when nothing in it climbs to an older waiting node, so every flight that leaves its nodes leads to a group that is already complete. The first group has none to lead to. So no flight leaves it, and no route leads from a city of that group to a city of any other group.

What are the time and space complexity?

O(V + E) time and O(V + E) space. Each node is pushed and popped once, and each flight is read once. The search uses an explicit path, so a long chain does not hit the recursion limit.

SWE Reasoning

Spot it

  • The graph is directed, and the question asks which nodes can reach each other.
  • The words are every city reaches every other city, mutual reachability, groups, kingdoms, or cycles of dependencies.
  • The answer for a whole graph is YES when only one group exists, so the number of groups matters.
  • The input is large, up to 10^5 nodes, so the search must be iterative and O(V + E).
  • You want the groups from one search, without building a reversed copy of the graph.

The invariant, step by step

  1. disc[u] is 0 until the search reaches u. The first time, it gets the next number from time, low[u] starts equal to it, and u goes on group_stack with on_stack[u] True.
  2. The search reads one flight at a time from the top of path. A flight to a node with disc[nxt] == 0 goes deeper.
  3. A flight to a node that is already reached lowers low[node] only if on_stack[nxt] is True. Otherwise the flight is ignored.
  4. When a node has no flight left, it is popped from path. If low[node] == disc[node], group_stack is popped down to node, and each popped node gets comp_id and on_stack False.
  5. If low[node] is smaller than disc[node], the node stays on group_stack. Its low goes to the parent with low[parent] = min(low[parent], low[node]).
  6. When every search is done, each node has one comp_id. Two nodes share it exactly when each can reach the other, and every edge between two groups goes from a higher id to a lower one.

Traps

  • Using disc[nxt] for every reached node. A node outside group_stack is in a complete group. Test on_stack[nxt] first.
  • Clearing on_stack[node] when the search leaves node. Clear it when the node is popped with its group.
  • Skipping low[parent] = min(low[parent], low[node]). A back edge found deep in the search then never reaches the nodes between.
  • Recursive dfs. A chain of 10^5 cities nests 10^5 calls. Python stops at 1000 by default. Keep the path in path.
  • Naming the pair in the wrong direction. The first group completed has no flight leaving it, so it is the city of that group that cannot reach another group, not the other way round.

Practice

Canonical LeetCode:Flight Routes Check (CSES 1682)Planets and Kingdoms (CSES 1683)
© 2026 Hi👋WebEnterprise. All rights reserved.
Sitemap•llms.txt•