1192. Critical Connections in a Network
Problem Description
You are given n
servers numbered from 0
to n - 1
that are connected by undirected server-to-server connections. The connections are represented as connections[i] = [ai, bi]
, which means there is a direct connection between server ai
and server bi
. Through this network, any server can reach any other server either directly or indirectly through other servers.
A critical connection is defined as a connection that, if removed from the network, would cause some servers to become unreachable from other servers. In other words, removing this connection would break the network into disconnected components.
Your task is to find and return all critical connections in the network. You can return them in any order.
For example, if you have servers connected like a chain: 0 -- 1 -- 2
, the connection between servers 0
and 1
is critical because removing it would leave server 0
isolated from server 2
. Similarly, the connection between 1
and 2
is also critical.
The problem essentially asks you to identify all the "bridges" in the network graph - edges whose removal would increase the number of connected components in the graph.
Flowchart Walkthrough
First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:
Is it a graph?
- Yes: The problem explicitly involves servers (nodes) connected by connections (edges), forming a network graph structure.
Is it a tree?
- No: The graph is not necessarily a tree. It's a general connected undirected graph that may contain cycles.
Is the problem related to directed acyclic graphs?
- No: The graph is undirected, not directed, and may contain cycles.
Is the problem related to shortest paths?
- No: We're not looking for shortest paths between nodes. We're identifying critical connections (bridges) in the graph.
Does the problem involve connectivity?
- Yes: The problem is fundamentally about connectivity - we need to find connections whose removal would break the connectivity of the graph.
Does the problem have small constraints?
- No: With up to
n
servers and potentially many connections, the constraints aren't necessarily small enough for brute force approaches.
Conclusion: While the flowchart leads us toward DSU (Disjoint Set Union) for connectivity problems, the specific nature of finding "bridges" in a graph requires a specialized DFS approach - the Tarjan algorithm. This algorithm uses DFS to traverse the graph while tracking discovery times and low values to identify critical connections that, when removed, would disconnect the graph. The DFS pattern is essential here for efficiently exploring the graph structure and determining which edges are bridges.
Intuition
To find critical connections (bridges), we need to identify edges whose removal would disconnect the graph. The key insight is that a connection is critical if there's no alternative path between its endpoints that doesn't use that connection itself.
Think of it this way: imagine you're exploring a network of caves connected by tunnels. As you explore, you mark each cave with the time you first discovered it. Now, for each tunnel, you want to know: "If this tunnel collapses, can I still reach the other side through a different route?"
This leads us to track two important values for each node:
- Discovery time (
dfn
): When we first visit this node during our exploration - Low value (
low
): The earliest discovered node we can reach from this node and its descendants
The crucial observation is: a connection from node a
to node b
is critical if and only if there's no way to reach a
or any node discovered before a
from b
without using the a-b
connection itself. Mathematically, this means low[b] > dfn[a]
.
Why does this work? If low[b] > dfn[a]
, it means that from b
and all its descendants, we cannot find a "back edge" that leads to a
or any ancestor of a
. This indicates that the a-b
edge is the only connection between the component containing b
and the component containing a
.
The Tarjan algorithm elegantly implements this idea using DFS:
- We traverse the graph, assigning discovery times to nodes
- For each node, we explore its neighbors
- After exploring all descendants of a node, we update its
low
value based on what its descendants could reach - If a child's
low
value is greater than the parent's discovery time, we've found a bridge
This single-pass DFS approach efficiently identifies all bridges in O(V + E)
time, where V
is the number of vertices and E
is the number of edges.
Learn more about Depth-First Search, Graph and Biconnected Component patterns.
Solution Approach
The implementation uses the Tarjan algorithm to find all bridges in the graph. Let's walk through the key components:
1. Graph Representation
First, we build an adjacency list representation of the graph:
g = [[] for _ in range(n)]
for a, b in connections:
g[a].append(b)
g[b].append(a)
This allows efficient traversal of neighbors during DFS.
2. Core Data Structures
dfn[i]
: Discovery time of nodei
- when it was first visited during DFSlow[i]
: The lowest discovery time reachable from nodei
through its subtreenow
: A counter to assign unique discovery times to nodesans
: List to store the critical connections (bridges)
3. The Tarjan Function
The recursive tarjan(a, fa)
function performs DFS where:
a
is the current node being visitedfa
is the parent node (to avoid going back to the parent in undirected graph)
Here's how it works step by step:
def tarjan(a: int, fa: int):
nonlocal now
now += 1
dfn[a] = low[a] = now # Initialize both values to current timestamp
Initially, both dfn[a]
and low[a]
are set to the current timestamp.
4. Exploring Neighbors
For each neighbor b
of node a
:
for b in g[a]: if b == fa: continue # Skip the parent edge
5. Processing Unvisited Nodes
If neighbor b
hasn't been visited (dfn[b] == 0
):
if not dfn[b]:
tarjan(b, a) # Recursively visit
low[a] = min(low[a], low[b]) # Update low value
if low[b] > dfn[a]: # Bridge condition
ans.append([a, b])
After exploring b
's subtree, we update low[a]
to reflect any earlier node reachable through b
. If low[b] > dfn[a]
, it means b
cannot reach a
or any ancestor of a
without using the a-b
edge, making it a bridge.
6. Processing Visited Nodes (Back Edges)
If neighbor b
has been visited:
else:
low[a] = min(low[a], dfn[b])
This handles back edges - edges that connect to ancestors in the DFS tree. We update low[a]
with dfn[b]
to record that we can reach node b
from a
.
7. Starting the Algorithm
tarjan(0, -1) # Start DFS from node 0 with no parent
The algorithm starts from any node (here node 0) since the graph is connected. Using -1
as the parent ensures we don't skip any edge from the starting node.
Time Complexity: O(V + E)
where V
is the number of vertices and E
is the number of edges, as we visit each node once and examine each edge twice (once from each endpoint).
Space Complexity: O(V)
for the dfn
, low
arrays and the recursion stack in the worst case.
Ready to land your dream job?
Unlock your dream job with a 5-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a small example to illustrate how the Tarjan algorithm finds critical connections.
Example Graph:
Servers: 0, 1, 2, 3 Connections: [[0,1], [1,2], [2,3], [3,1]] Visual representation: 0---1---2 | | +---3
Step-by-step execution:
Initial Setup:
- Build adjacency list:
g = [[1], [0,2,3], [1,3], [2,1]]
- Initialize:
dfn = [0,0,0,0]
,low = [0,0,0,0]
,now = 0
,ans = []
Step 1: Start DFS from node 0
- Call
tarjan(0, -1)
now = 1
,dfn[0] = 1
,low[0] = 1
- Explore neighbor 1
Step 2: Visit node 1 from node 0
- Call
tarjan(1, 0)
now = 2
,dfn[1] = 2
,low[1] = 2
- Neighbors of 1: [0, 2, 3]
- Skip 0 (parent)
- Explore neighbor 2
Step 3: Visit node 2 from node 1
- Call
tarjan(2, 1)
now = 3
,dfn[2] = 3
,low[2] = 3
- Neighbors of 2: [1, 3]
- Skip 1 (parent)
- Explore neighbor 3
Step 4: Visit node 3 from node 2
- Call
tarjan(3, 2)
now = 4
,dfn[3] = 4
,low[3] = 4
- Neighbors of 3: [2, 1]
- Skip 2 (parent)
- Node 1 is already visited:
low[3] = min(4, dfn[1]) = min(4, 2) = 2
Step 5: Backtrack to node 2
- Update:
low[2] = min(low[2], low[3]) = min(3, 2) = 2
- Check bridge:
low[3] > dfn[2]
? →2 > 3
? → No, not a bridge
Step 6: Backtrack to node 1
- Update:
low[1] = min(low[1], low[2]) = min(2, 2) = 2
- Check bridge:
low[2] > dfn[1]
? →2 > 2
? → No, not a bridge - Continue with neighbor 3 (already visited):
low[1] = min(2, dfn[3]) = min(2, 4) = 2
Step 7: Backtrack to node 0
- Update:
low[0] = min(low[0], low[1]) = min(1, 2) = 1
- Check bridge:
low[1] > dfn[0]
? →2 > 1
? → Yes, this is a bridge! - Add
[0, 1]
to answer
Final Result:
dfn = [1, 2, 3, 4]
low = [1, 2, 2, 2]
ans = [[0, 1]]
The connection [0, 1]
is critical because removing it would isolate node 0 from the rest of the network. The cycle formed by nodes 1, 2, and 3 means those connections are not critical - there are alternative paths between those nodes.
Key Insight: The bridge condition low[1] > dfn[0]
tells us that from node 1 and its descendants, we cannot reach node 0 or any node discovered before node 0 without using the 0-1 edge. This makes the 0-1 connection critical to the network's connectivity.
Solution Implementation
1class Solution:
2 def criticalConnections(
3 self, n: int, connections: List[List[int]]
4 ) -> List[List[int]]:
5 """
6 Find all critical connections (bridges) in an undirected graph.
7 A critical connection is an edge that, if removed, increases the number of connected components.
8
9 Args:
10 n: Number of nodes in the graph (nodes are labeled from 0 to n-1)
11 connections: List of edges where each edge is [node1, node2]
12
13 Returns:
14 List of critical connections (bridges)
15 """
16
17 def tarjan_dfs(current_node: int, parent_node: int) -> None:
18 """
19 Perform DFS using Tarjan's algorithm to find bridges.
20
21 Args:
22 current_node: Current node being visited
23 parent_node: Parent of the current node in DFS tree (-1 for root)
24 """
25 nonlocal timestamp
26
27 # Assign discovery time and initialize low-link value
28 timestamp += 1
29 discovery_time[current_node] = timestamp
30 low_link[current_node] = timestamp
31
32 # Explore all neighbors
33 for neighbor in adjacency_list[current_node]:
34 # Skip the edge back to parent (avoid going back on the same edge)
35 if neighbor == parent_node:
36 continue
37
38 if discovery_time[neighbor] == 0: # Neighbor not yet visited
39 # Recursively visit the neighbor
40 tarjan_dfs(neighbor, current_node)
41
42 # Update low-link value after returning from DFS
43 low_link[current_node] = min(
44 low_link[current_node],
45 low_link[neighbor]
46 )
47
48 # Check if the edge is a bridge
49 # If low_link[neighbor] > discovery_time[current], then neighbor
50 # cannot reach current or any ancestor without using edge (current, neighbor)
51 if low_link[neighbor] > discovery_time[current_node]:
52 critical_edges.append([current_node, neighbor])
53 else:
54 # Neighbor already visited (back edge)
55 # Update low-link value using discovery time of neighbor
56 low_link[current_node] = min(
57 low_link[current_node],
58 discovery_time[neighbor]
59 )
60
61 # Build adjacency list representation of the graph
62 adjacency_list = [[] for _ in range(n)]
63 for node_a, node_b in connections:
64 adjacency_list[node_a].append(node_b)
65 adjacency_list[node_b].append(node_a)
66
67 # Initialize arrays for Tarjan's algorithm
68 discovery_time = [0] * n # Discovery time for each node (0 means unvisited)
69 low_link = [0] * n # Lowest discovery time reachable from subtree
70 timestamp = 0 # Global timestamp counter
71 critical_edges = [] # Result list to store bridges
72
73 # Start DFS from node 0 (assuming graph is connected)
74 # Use -1 as parent for the root node
75 tarjan_dfs(0, -1)
76
77 return critical_edges
78
1class Solution {
2 // Global timestamp counter for DFS traversal
3 private int timestamp;
4
5 // Adjacency list representation of the graph
6 private List<Integer>[] adjacencyList;
7
8 // Result list to store critical connections (bridges)
9 private List<List<Integer>> criticalEdges = new ArrayList<>();
10
11 // Discovery time for each node (when first visited in DFS)
12 private int[] discoveryTime;
13
14 // Lowest discovery time reachable from subtree rooted at each node
15 private int[] lowestTime;
16
17 public List<List<Integer>> criticalConnections(int n, List<List<Integer>> connections) {
18 // Initialize adjacency list for n nodes
19 adjacencyList = new List[n];
20 Arrays.setAll(adjacencyList, index -> new ArrayList<>());
21
22 // Initialize discovery and low arrays
23 discoveryTime = new int[n];
24 lowestTime = new int[n];
25
26 // Build the undirected graph from connections
27 for (List<Integer> edge : connections) {
28 int nodeA = edge.get(0);
29 int nodeB = edge.get(1);
30 adjacencyList[nodeA].add(nodeB);
31 adjacencyList[nodeB].add(nodeA);
32 }
33
34 // Start Tarjan's algorithm from node 0 with no parent (-1)
35 tarjan(0, -1);
36
37 return criticalEdges;
38 }
39
40 /**
41 * Tarjan's algorithm to find bridges in an undirected graph
42 * @param currentNode - current node being visited
43 * @param parentNode - parent of current node in DFS tree (-1 if root)
44 */
45 private void tarjan(int currentNode, int parentNode) {
46 // Set discovery time and initial low value for current node
47 discoveryTime[currentNode] = lowestTime[currentNode] = ++timestamp;
48
49 // Explore all neighbors of current node
50 for (int neighbor : adjacencyList[currentNode]) {
51 // Skip the edge back to parent (avoid going back on same edge)
52 if (neighbor == parentNode) {
53 continue;
54 }
55
56 // If neighbor hasn't been visited yet
57 if (discoveryTime[neighbor] == 0) {
58 // Recursively visit the neighbor
59 tarjan(neighbor, currentNode);
60
61 // Update low value of current node based on subtree rooted at neighbor
62 lowestTime[currentNode] = Math.min(lowestTime[currentNode], lowestTime[neighbor]);
63
64 // Check if edge (currentNode, neighbor) is a bridge
65 // Bridge condition: no back edge from subtree of neighbor reaches currentNode or above
66 if (lowestTime[neighbor] > discoveryTime[currentNode]) {
67 criticalEdges.add(List.of(currentNode, neighbor));
68 }
69 } else {
70 // Neighbor already visited - this is a back edge
71 // Update low value considering this back edge
72 lowestTime[currentNode] = Math.min(lowestTime[currentNode], discoveryTime[neighbor]);
73 }
74 }
75 }
76}
77
1class Solution {
2public:
3 vector<vector<int>> criticalConnections(int n, vector<vector<int>>& connections) {
4 // Initialize timestamp counter for DFS traversal
5 int timestamp = 0;
6
7 // discovery[i]: timestamp when node i was first visited
8 vector<int> discovery(n, 0);
9
10 // lowest[i]: lowest timestamp reachable from node i through its subtree
11 vector<int> lowest(n, 0);
12
13 // Build adjacency list representation of the graph
14 vector<vector<int>> adjacencyList(n);
15 for (const auto& edge : connections) {
16 int nodeA = edge[0];
17 int nodeB = edge[1];
18 adjacencyList[nodeA].push_back(nodeB);
19 adjacencyList[nodeB].push_back(nodeA);
20 }
21
22 // Store all critical connections (bridges)
23 vector<vector<int>> bridges;
24
25 // Tarjan's algorithm to find bridges using DFS
26 function<void(int, int)> findBridges = [&](int currentNode, int parentNode) -> void {
27 // Mark discovery time and initialize lowest reachable time
28 discovery[currentNode] = lowest[currentNode] = ++timestamp;
29
30 // Explore all neighbors
31 for (int neighbor : adjacencyList[currentNode]) {
32 // Skip the edge back to parent (avoid going back on the same edge)
33 if (neighbor == parentNode) {
34 continue;
35 }
36
37 // If neighbor hasn't been visited yet
38 if (discovery[neighbor] == 0) {
39 // Recursively visit the neighbor
40 findBridges(neighbor, currentNode);
41
42 // Update lowest reachable timestamp after exploring subtree
43 lowest[currentNode] = min(lowest[currentNode], lowest[neighbor]);
44
45 // Check if edge (currentNode, neighbor) is a bridge
46 // Bridge condition: no back edge from subtree crosses this edge
47 if (lowest[neighbor] > discovery[currentNode]) {
48 bridges.push_back({currentNode, neighbor});
49 }
50 }
51 // If neighbor was already visited (back edge found)
52 else {
53 // Update lowest reachable timestamp using back edge
54 lowest[currentNode] = min(lowest[currentNode], discovery[neighbor]);
55 }
56 }
57 };
58
59 // Start DFS from node 0 (assuming connected graph)
60 findBridges(0, -1);
61
62 return bridges;
63 }
64};
65
1/**
2 * Finds all critical connections (bridges) in an undirected graph
3 * @param n - Number of nodes in the graph (0 to n-1)
4 * @param connections - Array of edges where each edge is [node1, node2]
5 * @returns Array of critical connections (bridges) in the graph
6 */
7function criticalConnections(n: number, connections: number[][]): number[][] {
8 // Timer for DFS traversal order
9 let timer: number = 0;
10
11 // Adjacency list representation of the graph
12 const adjacencyList: number[][] = Array(n)
13 .fill(0)
14 .map(() => []);
15
16 // Discovery time for each node (when first visited in DFS)
17 const discoveryTime: number[] = Array(n).fill(0);
18
19 // Lowest discovery time reachable from subtree rooted at each node
20 const lowestTime: number[] = Array(n).fill(0);
21
22 // Build the adjacency list from connections
23 for (const [nodeA, nodeB] of connections) {
24 adjacencyList[nodeA].push(nodeB);
25 adjacencyList[nodeB].push(nodeA);
26 }
27
28 // Result array to store critical connections
29 const criticalEdges: number[][] = [];
30
31 /**
32 * Tarjan's algorithm to find bridges using DFS
33 * @param currentNode - Current node being visited
34 * @param parentNode - Parent of the current node in DFS tree
35 */
36 const findBridges = (currentNode: number, parentNode: number): void => {
37 // Set discovery time and initial lowest time for current node
38 discoveryTime[currentNode] = lowestTime[currentNode] = ++timer;
39
40 // Explore all neighbors of current node
41 for (const neighbor of adjacencyList[currentNode]) {
42 // Skip the edge back to parent
43 if (neighbor === parentNode) {
44 continue;
45 }
46
47 // If neighbor hasn't been visited yet
48 if (!discoveryTime[neighbor]) {
49 // Recursively visit the neighbor
50 findBridges(neighbor, currentNode);
51
52 // Update lowest time reachable from current node
53 lowestTime[currentNode] = Math.min(lowestTime[currentNode], lowestTime[neighbor]);
54
55 // Check if edge (currentNode, neighbor) is a bridge
56 // Bridge condition: lowest time of neighbor is greater than discovery time of current
57 if (lowestTime[neighbor] > discoveryTime[currentNode]) {
58 criticalEdges.push([currentNode, neighbor]);
59 }
60 } else {
61 // If neighbor is already visited, update lowest time
62 // This handles back edges in the graph
63 lowestTime[currentNode] = Math.min(lowestTime[currentNode], discoveryTime[neighbor]);
64 }
65 }
66 };
67
68 // Start DFS from node 0 (assuming graph is connected)
69 findBridges(0, -1);
70
71 return criticalEdges;
72}
73
Time and Space Complexity
Time Complexity: O(V + E)
where V
is the number of vertices (nodes) and E
is the number of edges (connections).
- Building the adjacency list takes
O(E)
time as we iterate through all connections once - The Tarjan algorithm performs a DFS traversal visiting each vertex exactly once, taking
O(V)
time - For each vertex, we explore all its adjacent edges. Since the graph is undirected, each edge is stored twice in the adjacency list, so we process
2E
edge traversals in total, which isO(E)
- Overall:
O(V + E)
Space Complexity: O(V + E)
- The adjacency list
g
stores all edges, requiringO(E)
space (each edge appears twice in an undirected graph) - The
dfn
array usesO(V)
space to store discovery times - The
low
array usesO(V)
space to store low-link values - The recursion stack depth can go up to
O(V)
in the worst case (e.g., a linear chain of nodes) - The
ans
list can contain at mostO(V)
critical connections (bridges) - Overall:
O(V + E)
dominated by the adjacency list storage
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Incorrectly Handling Parent Edges in Undirected Graphs
The Pitfall: One of the most common mistakes is not properly handling the parent edge when traversing an undirected graph. Since each edge appears twice in the adjacency list (once for each direction), we need to avoid immediately traversing back to the parent node. However, a naive implementation might skip ALL edges back to the parent, which is incorrect when there are multiple edges between the same two nodes (parallel edges).
Problematic Code:
for neighbor in adjacency_list[current_node]: if neighbor == parent_node: # This skips ALL edges to parent continue
The Issue:
If there are multiple connections between the same two nodes (e.g., [[0,1], [0,1]]
), the above code would skip both edges when going from node 1 back to node 0, potentially missing that neither edge is critical when parallel edges exist.
The Solution: Track the specific edge used to reach the current node, not just the parent node:
def tarjan_dfs(current_node: int, parent_edge_id: int) -> None:
nonlocal timestamp
timestamp += 1
discovery_time[current_node] = timestamp
low_link[current_node] = timestamp
for edge_id in adjacency_list[current_node]:
neighbor = edges[edge_id][0] if edges[edge_id][1] == current_node else edges[edge_id][1]
# Skip only the specific edge we came from
if edge_id == parent_edge_id:
continue
if discovery_time[neighbor] == 0:
tarjan_dfs(neighbor, edge_id)
low_link[current_node] = min(low_link[current_node], low_link[neighbor])
if low_link[neighbor] > discovery_time[current_node]:
critical_edges.append(edges[edge_id])
else:
low_link[current_node] = min(low_link[current_node], discovery_time[neighbor])
# Build adjacency list with edge IDs
adjacency_list = [[] for _ in range(n)]
edges = connections
for i, (node_a, node_b) in enumerate(edges):
adjacency_list[node_a].append(i)
adjacency_list[node_b].append(i)
2. Updating Low-Link Values with Wrong Values for Back Edges
The Pitfall:
When encountering a back edge (an edge to an already visited node that's not the parent), developers sometimes incorrectly update the low-link value using low[neighbor]
instead of dfn[neighbor]
.
Problematic Code:
else: # Back edge case
low[current] = min(low[current], low[neighbor]) # WRONG!
The Issue:
Using low[neighbor]
can propagate incorrect values through the graph. The low-link value should represent the earliest discovered node reachable from the current subtree. For back edges, we should use the discovery time of the neighbor, not its low-link value.
The Solution:
else: # Back edge case
low[current] = min(low[current], dfn[neighbor]) # CORRECT
3. Handling Disconnected Graphs
The Pitfall: The code assumes the graph is connected and starts DFS from only node 0. If the graph has multiple connected components, bridges in other components won't be found.
The Issue: Starting DFS from only one node means unvisited components will be ignored entirely.
The Solution: Iterate through all nodes and start DFS from unvisited ones:
def criticalConnections(self, n: int, connections: List[List[int]]) -> List[List[int]]:
# ... (setup code remains the same)
# Handle potentially disconnected graphs
for start_node in range(n):
if discovery_time[start_node] == 0:
tarjan_dfs(start_node, -1)
return critical_edges
This ensures all components are explored and all bridges are found, even in disconnected graphs.
What are the two properties the problem needs to have for dynamic programming to be applicable? (Select 2)
Recommended Readings
https assets algo monster cover_photos dfs svg Depth First Search Prereqs Recursion Review problems recursion_intro Trees problems tree_intro With a solid understanding of recursion under our belts we are now ready to tackle one of the most useful techniques in coding interviews Depth First Search DFS As the name suggests
https assets algo monster cover_photos graph svg Graph Fundamentals Tree with 0 cycle At this point you should be pretty familiar with trees A tree is a special kind of graph a connected acyclic cycle less graph A graph may contain cycle s and nodes could be disconnected A tree
Number of Connected Components Prereq DSU Introduction problems dsu_intro For this question we start with n nodes in a graph that are all independent of each other We will then begin connecting nodes in the graph After each connection connecting two different nodes we ask you to compute the number of connected components in
Want a Structured Path to Master System Design Too? Don’t Miss This!