Finding pockets of mutual reachability: Strongly connected components are like friend groups where everyone knows everyone — within an SCC, you can reach any node from any other node. Condensing SCCs into single nodes reveals the DAG structure of the graph.
Why it matters: SCCs are fundamental to understanding graph structure — they identify cyclic dependencies in build systems, strongly connected regions in social networks, and mutually reachable states in automata.
The key insight: Kosaraju algorithm uses two DFS passes — the first determines the order to process nodes, the second finds SCCs on the transposed graph. This elegant two-pass approach runs in O(V+E) time.
A strongly connected component (SCC) of a directed graph is a maximal set of vertices such that There is a path from every vertex to every other vertex within the set.
Kosaraju’s algorithm finds all SCCs in O ( V + E ) O(V + E) O ( V + E ) time using two DFS passes.
Find strongly connected components using Kosaraju's algorithm.
Returns: list of SCCs (each SCC is a list of vertices)
radj = [[] for _ in range (n)]
for v in reversed (order):
Tarjan’s algorithm finds SCCs in a single DFS pass using a stack and low-link values.
Find SCCs using Tarjan's algorithm.
index[v] = index_counter[ 0 ]
low[v] = index_counter[ 0 ]
low[v] = min (low[v], low[w])
low[v] = min (low[v], index[w])
The condensation of a directed graph is the DAG formed by contracting each SCC to a single vertex. The condensation DAG is useful for solving problems on the original graph.
def build_condensation ( n , adj , sccs ):
Build condensation DAG from SCCs.
for i, scc in enumerate (sccs):
cond_adj = [ set () for _ in range (num_sccs)]
if scc_id[v] != scc_id[u]:
cond_adj[scc_id[v]].add(scc_id[u])
return [ list (s) for s in cond_adj], scc_id
graph TD
subgraph "Original Graph"
A1["A"] --> B1["B"]
B1 --> C1["C"]
C1 --> A1
C1 --> D1["D"]
D1 --> E1["E"]
E1 --> F1["F"]
F1 --> D1
F1 --> G1["G"]
end
subgraph "Condensation DAG"
SCC1["SCC1: A,B,C"] --> SCC2["SCC2: D,E,F"]
SCC2 --> SCC3["SCC3: G"]
end A bridge is an edge whose removal increases the number of connected components.
def find_bridges ( n , adj ):
Find all bridges in an undirected graph.
Returns: list of (u, v) bridges
tin[v] = low[v] = timer[ 0 ]
low[v] = min (low[v], tin[u])
low[v] = min (low[v], low[u])
An articulation point is a vertex whose removal increases the number of connected components.
def find_articulation_points ( n , adj ):
Find all articulation points in an undirected graph.
is_articulation = [ False ] * n
tin[v] = low[v] = timer[ 0 ]
low[v] = min (low[v], tin[u])
low[v] = min (low[v], low[u])
if low[u] >= tin[v] and parent != - 1 :
is_articulation[v] = True
if parent == - 1 and children > 1 :
is_articulation[v] = True
return [v for v in range (n) if is_articulation[v]]
It is `low[u] >= tin[v]` (non-strict). The difference matters: a back edge to the parent vertex Satisfies `low[u] == tin[v]` but does not make the edge a bridge.A biconnected component is a maximal set of edges such that any two edges lie on a common simple Cycle. Biconnected components are separated by articulation points.
The 2-SAT problem asks whether a boolean formula in conjunctive normal form with exactly 2 literals Per clause is satisfiable. It reduces to finding SCCs in an implication graph.
Each clause ( x ∨ y ) (x \lor y) ( x ∨ y ) is equivalent to ( ¬ x → y ) ∧ ( ¬ y → x ) (\lnot x \to y) \land (\lnot y \to x) ( ¬ x → y ) ∧ ( ¬ y → x ) . Build an Implication graph where each variable x x x has two vertices (x x x and ¬ x \lnot x ¬ x ), and add directed Edges for each implication.
Build the implication graph Find SCCs using Tarjan’s or Kosaraju’s algorithm For every variable x x x , x x x and ¬ x \lnot x ¬ x must be in different SCCs If any variable has both x x x and ¬ x \lnot x ¬ x in the same SCC, the formula is unsatisfiable Otherwise, assign truth values: if scc_id[x] > scc_id[not_x]Set x x x to true def solve_2sat ( n_vars , clauses ):
Solve 2-SAT using SCC-based algorithm.
Time: O(V + E) where V = 2*n_vars, E = 2*len(clauses)
Returns: (satisfiable, assignment)
adj = [[] for _ in range (n)]
return 2 * v if v >= 0 else 2 * ( - v - 1 ) + 1
adj[neg(var(a))].append(var(b))
adj[neg(var(b))].append(var(a))
sccs = tarjan_scc(n, adj)
for i, scc in enumerate (sccs):
assignment = [ None ] * n_vars
if scc_id[ 2 * v] == scc_id[ 2 * v + 1 ]:
assignment[v] = scc_id[ 2 * v] > scc_id[ 2 * v + 1 ]
The Ford-Fulkerson method computes the maximum flow in a flow network. It repeatedly finds Augmenting paths in the residual graph and pushes flow along them.
def ford_fulkerson ( n , adj , capacity , source , sink ):
Maximum flow using Ford-Fulkerson with DFS.
Time: O(E * max_flow) — can be exponential
if not visited[v] and capacity[u][v] > 0 :
path_flow = min (path_flow, capacity[u][v])
capacity[u][v] -= path_flow
capacity[v][u] += path_flow
Edmonds-Karp is Ford-Fulkerson with BFS for finding augmenting paths, guaranteeing O ( V E 2 ) O(VE^2) O ( V E 2 ) time.
def edmonds_karp ( n , adj , capacity , source , sink ):
Maximum flow using Edmonds-Karp (BFS-based Ford-Fulkerson).
from collections import deque
if not visited[v] and capacity[u][v] > 0 :
path_flow = min (path_flow, capacity[u][v])
capacity[u][v] -= path_flow
capacity[v][u] += path_flow
Dinic’s algorithm achieves O ( V 2 E ) O(V^2 E) O ( V 2 E ) time by using BFS to build a level graph and then finding Blocking flows with DFS.
from collections import deque
Dinic's maximum flow algorithm.
self .adj = [[] for _ in range (n)]
def add_edge ( self , u , v , cap ):
self .adj[u].append([v, cap, len ( self .adj[v])])
self .adj[v].append([u, 0 , len ( self .adj[u]) - 1 ])
def bfs ( self , s , t , level ):
if cap > 0 and level[v] == - 1 :
def dfs ( self , u , t , f , level , iter_arr ):
for i in range (iter_arr[u], len ( self .adj[u])):
v, cap, rev = self .adj[u][i]
if cap > 0 and level[u] + 1 == level[v]:
pushed = self .dfs(v, t, min (f, cap), level, iter_arr)
self .adj[u][i][ 1 ] -= pushed
self .adj[v][rev][ 1 ] += pushed
def max_flow ( self , s , t ):
while self .bfs(s, t, level):
pushed = self .dfs(s, t, float ( ' inf ' ), level, iter_arr)
Algorithm Time Complexity Best For Ford-Fulkerson O ( E ⋅ f ∗ ) O(E \cdot f^*) O ( E ⋅ f ∗ ) Small flow values Edmonds-Karp O ( V E 2 ) O(VE^2) O ( V E 2 ) General purpose, simple to implement Dinic’s O ( V 2 E ) O(V^2 E) O ( V 2 E ) General purpose, fast in practice Capacity scaling O ( E 2 log U ) O(E^2 \log U) O ( E 2 log U ) Large capacities Push-relabel O ( V 3 ) O(V^3) O ( V 3 ) Dense graphs
Where f ∗ f^* f ∗ is the max flow value and U U U is the maximum edge capacity.
def min_cost_max_flow ( n , adj , cost , capacity , source , sink ):
Min-cost max-flow using successive shortest paths.
Time: O(F * (V + E) log V) where F = max flow
dist = [ float ( ' inf ' )] * n
if capacity[u][v] > 0 and dist[v] > dist[u] + cost[u][v]:
dist[v] = dist[u] + cost[u][v]
heapq.heappush(pq, (dist[v], v))
if dist[sink] == float ( ' inf ' ):
path_flow = min (path_flow, capacity[u][v])
capacity[u][v] -= path_flow
capacity[v][u] += path_flow
total_cost += path_flow * cost[u][v]
return total_flow, total_cost
def bipartite_matching ( n_left , n_right , edges ):
Maximum bipartite matching using max-flow (Dinic's).
Time: O(E * sqrt(V)) for bipartite graphs
dinic = Dinic(n_left + n_right + 2 )
source = n_left + n_right
dinic.add_edge(source, u, 1 )
for v in range (n_left, n_left + n_right):
dinic.add_edge(v, sink, 1 )
dinic.add_edge(u, n_left + v, 1 )
return dinic.max_flow(source, sink)
from collections import deque
def hopcroft_karp ( n_left , n_right , edges ):
Maximum bipartite matching using Hopcroft-Karp.
adj = [[] for _ in range (n_left)]
elif dist[pair_v[v]] == float ( ' inf ' ):
dist[pair_v[v]] = dist[u] + 1
if pair_v[v] == - 1 or (dist[pair_v[v]] == dist[u] + 1 and dfs(pair_v[v])):
return matching, pair_u, pair_v
Theorem : In a bipartite graph, the size of the maximum matching equals the size of the minimum Vertex cover. Furthermore, the minimum vertex cover can be constructed from the maximum matching:
Find a maximum matching Find all unmatched vertices on the left side BFS/DFS from unmatched left vertices, following alternating paths The minimum vertex cover is: (left vertices NOT reached) + (right vertices reached) def konig_min_vertex_cover ( n_left , n_right , edges , pair_u , pair_v ):
Minimum vertex cover using Konig's theorem.
adj = [[] for _ in range (n_left)]
reached_left = [ False ] * n_left
reached_right = [ False ] * n_right
side, node = queue.popleft()
An Eulerian circuit visits every edge exactly once and returns to the starting vertex. An Eulerian path visits every edge exactly once but may not return.
Property Eulerian Circuit Eulerian Path Connected Yes Yes (ignoring isolated) Even degree All vertices All except exactly 2 Odd degree None Exactly 2
Find Eulerian circuit using Hierholzer's algorithm.
Requires: all vertices have even degree, graph is connected.
edge_count = [ len (adj[i]) for i in range (n)]
circuit.append(curr_path.pop())
def kahn_topological_sort ( n , adj ):
Topological sort using Kahn's algorithm (BFS).
Returns: topological order, or None if cycle exists
from collections import deque
queue = deque(i for i in range (n) if in_degree[i] == 0 )
return order if len (order) == n else None
def dfs_topological_sort ( n , adj ):
Topological sort using DFS.
def stoer_wagner ( n , adj ):
Global minimum cut using Stoer-Wagner.
Returns: minimum cut value
weights = [row[ : ] for row in adj]
vertices = list ( range (n))
weights_sub = [[weights[i][j] for j in vertices] for i in vertices]
if not in_a[i] and (sel == - 1 or added[i] > added[sel]):
min_cut = min (min_cut, added[sel])
if i != sel and i != prev:
weights[vertices[prev]][vertices[i]] += weights[vertices[sel]][vertices[i]]
weights[vertices[i]][vertices[prev]] += weights[vertices[i]][vertices[sel]]
added[i] += weights_sub[sel][i]
The condition for a bridge is low[u] > tin[v] (strictly greater). If you use >=You will Incorrectly classify back edges as bridges. The key distinction: low[u] == tin[v] means there is a Back edge from the subtree of u to v (or an ancestor of v), which means the edge (v, u) is NOT a bridge.
In 2-SAT, variable x x x is represented as vertex 2 x 2x 2 x and ¬ x \lnot x ¬ x as vertex 2 x + 1 2x+1 2 x + 1 (or Vice versa). Getting the indexing wrong produces incorrect results. Always verify: neg(neg(x)) == xI.e., (x ^ 1) ^ 1 == x.
If augmenting paths are chosen poorly (e.g., using DFS), Ford-Fulkerson can take O ( E ⋅ f ∗ ) O(E \cdot f^*) O ( E ⋅ f ∗ ) Time where f ∗ f^* f ∗ is the max flow value. For irrational capacities, it may not even terminate. Always Use BFS (Edmonds-Karp) or Dinic’s algorithm unless you are certain the capacities are small Integers.
In Dinic’s algorithm, the level graph is built fresh each time BFS fails to find an augmenting path. Do not reuse the old level graph — it no longer represents valid augmenting paths in the residual Graph.
Self-loops contribute 2 to the degree of their vertex (one for each direction of traversal). A Vertex with one self-loop and no other edges has degree 2 (even), not 1. Forgetting this leads to Incorrect Eulerian path/circuit detection.
Konig’s theorem (min vertex cover = max matching) applies ONLY to bipartite graphs. For general Graphs, the minimum vertex cover can be much larger than the maximum matching. Always verify the Graph is bipartite before applying Konig’s theorem.
In Tarjan’s algorithm, SCCs are produced in reverse topological order of the condensation DAG. In Kosaraju’s algorithm, the second DFS pass produces SCCs in topological order. When the problem Requires processing SCCs in topological order, choose the algorithm accordingly or reverse Tarjan’s Output.
When reducing a problem to max-flow, ensure the flow network is correctly constructed: (1) all edges Have non-negative capacity, (2) the source has only outgoing edges, (3) the sink has only incoming Edges, (4) the graph is directed (or convert undirected edges to two directed edges), and (5) Capacities are integers if using Ford-Fulkerson with DFS.
This topic covers the mathematical techniques and concepts related to advanced graph algorithms, including key theorems, methods, and problem-solving approaches.
Key concepts include:
fundamental definitions and theorems algebraic and graphical methods proof and logical reasoning problem-solving strategies applications and modelling Regular practice with a variety of question types is essential to build fluency and confidence in applying these mathematical techniques.
Problem. Find the shortest path from node 0 to all nodes in a weighted graph with 5 nodes and edges: (0,1,4), (0,2,1), (1,3,1), (2,1,2), (2,3,5), (3,4,3).
Solution.
def dijkstra ( n , edges , start ):
adj = [[] for _ in range (n)]
dist = [ float ( ' inf ' )] * n
if dist[u] + w < dist[v]:
heapq.heappush(pq, (dist[v], v))
dist = dijkstra( 5 , [( 0 , 1 , 4 ),( 0 , 2 , 1 ),( 1 , 3 , 1 ),( 2 , 1 , 2 ),( 2 , 3 , 5 ),( 3 , 4 , 3 )], 0 )
## dist = [0, 3, 1, 4, 7]
Path to node 4: 0 → 2 → 1 → 3 → 4 0 \to 2 \to 1 \to 3 \to 4 0 → 2 → 1 → 3 → 4 with cost 1 + 2 + 1 + 3 = 7 1 + 2 + 1 + 3 = 7 1 + 2 + 1 + 3 = 7 .
■ \blacksquare ■
Problem. Given a directed graph, return a topological ordering or detect a cycle.
Solution.
def topological_sort ( n , edges ):
from collections import deque
adj = [[] for _ in range (n)]
q = deque(i for i in range (n) if indegree[i] == 0 )
return None # Cycle detected
Kahn’s algorithm processes nodes with zero in-degree in BFS order. If the output has fewer than n n n nodes, a cycle exists. Time complexity: O ( V + E ) O(V + E) O ( V + E ) .
■ \blacksquare ■
Dijkstra’s algorithm finds shortest paths in O ( ( V + E ) log V ) O((V+E)\log V) O (( V + E ) log V ) with a priority queue; requires non-negative weights. Bellman-Ford handles negative weights in O ( V E ) O(VE) O ( V E ) and detects negative cycles. Floyd-Warshall computes all-pairs shortest paths in O ( V 3 ) O(V^3) O ( V 3 ) . Topological sorting (Kahn’s or DFS-based) orders DAG vertices; O ( V + E ) O(V + E) O ( V + E ) . Strongly connected components: Kosaraju’s or Tarjan’s algorithm in O ( V + E ) O(V + E) O ( V + E ) . Graph Algorithms — Dijkstra’s and MST algorithms provide the foundation for the advanced techniques here.Dynamic Programming — Floyd-Warshall is a classic all-pairs shortest path algorithm using dynamic programming.Binary Search Trees — Priority queues used in Dijkstra’s algorithm are implemented using heap-based trees.Deques and Priority Queues — Priority queues are essential for efficient implementations of shortest path algorithms.