Facebook Pixel

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.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

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:

  1. Discovery time (dfn): When we first visit this node during our exploration
  2. 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 node i - when it was first visited during DFS
  • low[i]: The lowest discovery time reachable from node i through its subtree
  • now: A counter to assign unique discovery times to nodes
  • ans: 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 visited
  • fa 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 Evaluator

Example 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 is O(E)
  • Overall: O(V + E)

Space Complexity: O(V + E)

  • The adjacency list g stores all edges, requiring O(E) space (each edge appears twice in an undirected graph)
  • The dfn array uses O(V) space to store discovery times
  • The low array uses O(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 most O(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.

Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

What are the two properties the problem needs to have for dynamic programming to be applicable? (Select 2)


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More