2316. Count Unreachable Pairs of Nodes in an Undirected Graph
Problem Description
In this task, we are presented with an undirected graph defined by n
nodes numbered from 0
to n - 1
. The graph's connectivity is provided as an array, edges
, where each element consists of a pair of integers that represent an undirected edge between two nodes in the graph. Our goal is to determine the number of node pairs that are unreachable from each other. Specifically, we must find all the pairs of different nodes where there is no path from one node to the other within the graph.
To visualize this, you could picture a set of islands (nodes) connected by bridges (edges). We are trying to count how many pairs of islands cannot be traveled between directly or indirectly.
Flowchart Walkthrough
Using the flowchart, let's determine the appropriate search strategy for LeetCode Problem 2316, "Count Unreachable Pairs of Nodes in an Undirected Graph." Here's the breakdown following the flowchart steps:
-
Is it a graph?
- Yes: The problem explicitly discusses nodes and their connectivity in an undirected graph.
-
Is it a tree?
- No: There are potentially multiple unconnected components, not necessarily a single connected component which would define a tree.
-
Is the problem related to directed acyclic graphs (DAGs)?
- No: The graph is undirected.
-
Is the problem related to shortest paths?
- No: The task is to count unreachable pairs, not to find a path.
-
Does the problem involve connectivity?
- Yes: We need to identify all pairs of nodes that can't reach each other, which requires knowing about the connectivity of different graph components.
-
Conclusion: Given that the problem involves checking connectivity in an undirected graph without specific data constraints mentioned requiring optimization for large graphs, Depth-First Search (DFS) or Breadth-First Search (BFS) can be used. Depth-First Search (DFS) is typically preferred for its straightforward recursive nature and utility in exploring all nodes in a connected component, necessary for counting pairs in those components.
Hence, the flowchart and the problem specifications point toward utilizing the Depth-First Search pattern to solve the problem effectively by finding and counting all disjoint (unconnected) components.
Intuition
The approach to solving this problem involves understanding how connected components in an undirected graph work. A connected component is a subgraph where any two nodes are connected to each other by paths, and which is connected to no additional nodes in the supergraph. Essentially, all nodes within a connected component can reach each other, but they cannot reach nodes in other connected components.
By traversing the graph and determining the size of each connected component, we can calculate the number of unreachable pairs. The idea is that if a connected component has t
nodes, none of the nodes in this component can reach nodes in the rest of the graph, which we can denote as s
nodes. The number of unreachable pairs involving nodes from this component would then be the product s * t
.
For instance, suppose we have a connected component of 4 nodes, and there are 6 nodes not in this component. There can be no paths between any of the 4 nodes and the 6 outside nodes, giving us 4 * 6 = 24
unreachable pairs.
To implement this concept programmatically, depth-first search (DFS) is a fitting choice. DFS can be used to explore the graph from each node, marking visited nodes to avoid counting a connected component more than once.
The algorithm systematically goes through each node. If the node hasn't been visited yet, it gets passed to a depth-first search, which counts all nodes reachable from that starting node (i.e., the size of the connected component). Once we get the size t
of a connected component, we can calculate the number of unreachable pairs with nodes outside this component (which we have kept track of in s
), and add it to the answer. We then update s
to include the nodes from the newly found connected component before moving on to the next unvisited node.
This method ultimately gives us the sum of unreachable pairs for each connected component in the graph, which is the desired result.
Learn more about Depth-First Search, Breadth-First Search, Union Find and Graph patterns.
Solution Approach
The solution to the problem uses a classical graph traversal method known as Depth-First Search (DFS). DFS is a recursive algorithm that starts at a node and explores as far as possible along each branch before backtracking. This is perfect for exploring and marking all nodes within a connected component.
Here's how the algorithm is implemented:
-
An adjacency list representation of the graph
g
is created, which is a list of lists. For every edge (a, b) in the given listedges
, we add nodeb
to the list of nodea
and vice versa because the graph is undirected.1g = [[] for _ in range(n)] 2for a, b in edges: 3 g[a].append(b) 4 g[b].append(a)
-
An array
vis
of boolean values is used to keep track of visited nodes. Initially, all nodes are unvisited, so they are set toFalse
.1vis = [False] * n
-
The solution defines a recursive function
dfs
that takes an integeri
representing the current node. It checks if this node is already visited. If it is, the function returns0
because it shouldn't be counted again. If not, it sets the current node as visited (True
) and explores all its neighbors by recursively callingdfs(j)
for every neighborj
.1def dfs(i: int) -> int: 2 if vis[i]: 3 return 0 4 vis[i] = True 5 return 1 + sum(dfs(j) for j in g[i])
The
dfs
function returns1
(for the current node) plus the sum of nodes that can be reached from it, giving us the total size of the connected component. -
The main body of the solution maintains two variables,
ans
ands
. Theans
variable holds the cumulative count of unreachable pairs, whiles
keeps track of the total number of nodes processed so far across connected components. -
The solution iterates over all nodes, and for each unvisited node, it calls
dfs
to get the size of its connected component. The product of the current connected component sizet
and the count of nodes processed so fars
gives us the number of unreachable pairs with respect to the component starting at this node.1ans = s = 0 2for i in range(n): 3 t = dfs(i) 4 ans += s * t 5 s += t
-
Finally, after iterating through all the nodes,
ans
will contain the total number of pairs of nodes that are unreachable from each other. This is returned as the final result.
The algorithm effectively partitions the graph into disconnected “islands” (connected components) and calculates unreachable pairs by considering the complement of nodes for each component encountered.
Ready to land your dream job?
Unlock your dream job with a 2-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a small example to illustrate the solution approach.
Suppose we are given a graph with n = 5
nodes and the following edges
: [[0, 1], [1, 2], [3, 4]]
.
This graph consists of two separate connected components:
- Component 1: Nodes
0
,1
, and2
are connected (0↔1↔2). - Component 2: Nodes
3
and4
are connected (3↔4).
Using the approach described above, we will determine the number of pairs of nodes that are unreachable from each other.
- We create an adjacency list for our graph:
1g = [[1], [0, 2], [1], [4], [3]]
- Initialize a visited list with
False
showing none of the nodes is visited:
1vis = [False, False, False, False, False]
-
We define our DFS function
dfs
. During the DFS process, this function will return the number of connected nodes for each connected component in the graph. -
We start traversing the nodes and applying DFS:
-
When we apply DFS to node
0
, it will visit nodes1
and2
since they are connected. After the DFS call,visited
becomes[True, True, True, False, False]
. -
The size
t
of this component is3
. Thes
is initialized to0
, soans
becomes0 * 3 = 0
. -
We update
s
tos + t
which becomes3
.
-
-
Node
1
and2
are already visited, so our loop moves on to node3
. DFS on node3
will visit node4
.visited
becomes[True, True, True, True, True]
.-
The size
t
of this second component is2
. Nows = 3
(from the previous step), and we updateans
toans + (s * t)
which becomes0 + (3 * 2) = 6
. -
Update
s
again tos + t
which is now5
.
-
-
Our
ans
is6
, which represents the total number of pairs of nodes that can't be reached from each other, which corresponds to the pairs (0,3), (0,4), (1,3), (1,4), (2,3), and (2,4).
After iterating through all nodes, the ans
variable contains the correct number of unreachable node pairs, which is 6
in this case.
Solution Implementation
1from typing import List
2
3class Solution:
4 def countPairs(self, n: int, edges: List[List[int]]) -> int:
5 # Depth First Search function to count nodes in a connected component
6 def dfs(node: int) -> int:
7 if visited[node]:
8 return 0
9 visited[node] = True
10 # Count current node + all nodes reachable from current node
11 return 1 + sum(dfs(neighbor) for neighbor in graph[node])
12
13 # Initialize the graph as an adjacency list
14 graph = [[] for _ in range(n)]
15 for node1, node2 in edges: # Build undirected graph
16 graph[node1].append(node2)
17 graph[node2].append(node1)
18
19 visited = [False] * n # Track visited nodes
20
21 # Main logic to count pairs
22 answer = total_nodes_visited = 0
23 for i in range(n):
24 component_size = dfs(i) # Size of connected component for node i
25 answer += total_nodes_visited * component_size # Multiply with size of previously found components
26 total_nodes_visited += component_size # Update total nodes visited after exploring component
27
28 # Return the total number of pairs
29 return answer
30
1class Solution {
2
3 // Graph represented by an adjacency list
4 private List<Integer>[] graph;
5 // Visited array to keep track of visited nodes during DFS
6 private boolean[] visited;
7
8 // Method to count the number of pairs that can be formed
9 public long countPairs(int n, int[][] edges) {
10 graph = new List[n];
11 visited = new boolean[n];
12 // Initialize adjacency lists for each node
13 Arrays.setAll(graph, i -> new ArrayList<>());
14 // Build the graph by adding edges
15 for (int[] edge : edges) {
16 int a = edge[0], b = edge[1];
17 graph[a].add(b);
18 graph[b].add(a);
19 }
20
21 long answer = 0;
22 // Sum of component sizes found so far
23 long sumOfComponentSizes = 0;
24 // Traverse each node
25 for (int i = 0; i < n; ++i) {
26 // Perform a DFS from the node, count the size of the component
27 int componentSize = dfs(i);
28 // Update the answer with the product of component sizes
29 answer += sumOfComponentSizes * componentSize;
30 // Add the component size to the sum of component sizes
31 sumOfComponentSizes += componentSize;
32 }
33 return answer;
34 }
35
36 // Depth-first search to find component size
37 private int dfs(int currentNode) {
38 // If node is visited, return 0
39 if (visited[currentNode]) {
40 return 0;
41 }
42 // Mark the current node as visited
43 visited[currentNode] = true;
44 // Start with a count of 1 for the current node
45 int count = 1;
46 // Recur for all the vertices adjacent to this vertex
47 for (int nextNode : graph[currentNode]) {
48 count += dfs(nextNode);
49 }
50 // Return the size of the component
51 return count;
52 }
53}
54
1class Solution {
2public:
3 long long countPairs(int n, vector<vector<int>>& edges) {
4 // Create an adjacency list for the graph
5 vector<int> graph[n];
6 for (const auto& edge : edges) {
7 int from = edge[0], to = edge[1];
8 graph[from].push_back(to);
9 graph[to].push_back(from);
10 }
11
12 // Create a visited array to keep track of visited nodes
13 vector<bool> visited(n, false);
14
15 // Define a depth-first search (DFS) lambda function to count nodes in a component
16 function<int(int)> dfs = [&](int node) {
17 if (visited[node]) {
18 return 0; // If already visited, terminate this path
19 }
20 visited[node] = true; // Mark this node as visited
21 int count = 1; // Start count with the current node itself
22 for (int neighbor : graph[node]) {
23 count += dfs(neighbor); // Recursively visit neighbors and add to the count
24 }
25 return count;
26 };
27
28 long long answer = 0; // Initialize the answer to 0
29 long long sumOfCounts = 0; // Initialize the running sum of counts to 0
30
31 // Iterate through each node in the graph
32 for (int i = 0; i < n; ++i) {
33 int componentSize = dfs(i); // Get the size of the component via DFS
34 answer += sumOfCounts * componentSize; // Add to the answer the product of current sum of counts and component size
35 sumOfCounts += componentSize; // Update the running sum of counts with the size of this component
36 }
37
38 // Return the final answer, the total count of pairs
39 return answer;
40 }
41};
42
1// Function to count the number of reachable pairs in the undirected graph,
2// where n is the total number of nodes and edges is a list of edges connecting the nodes.
3function countPairs(n: number, edges: number[][]): number {
4 // Create an adjacency list to represent the graph.
5 const graph: number[][] = Array.from({ length: n }, () => []);
6
7 // Populate the adjacency list with bidirectional edges.
8 for (const [node1, node2] of edges) {
9 graph[node1].push(node2);
10 graph[node2].push(node1);
11 }
12
13 // Array to track visited nodes to prevent revisiting.
14 const visited: boolean[] = Array(n).fill(false);
15
16 // Depth-first search function to count connected nodes.
17 const depthFirstSearch = (node: number): number => {
18 // If the node is already visited, return 0 to avoid counting it again.
19 if (visited[node]) {
20 return 0;
21 }
22
23 // Mark the current node as visited.
24 visited[node] = true;
25
26 // Start with a count of 1 for the current node.
27 let count = 1;
28
29 // Recursively visit all connected nodes and increment count.
30 for (const connectedNode of graph[node]) {
31 count += depthFirstSearch(connectedNode);
32 }
33
34 // Return the count of nodes in the connected component.
35 return count;
36 };
37
38 // Initialize the answer to 0 and sum to keep track of the number of nodes visited so far.
39 let answer = 0;
40 let sum = 0;
41
42 // Iterate over each node to calculate the number of reachable pairs.
43 for (let i = 0; i < n; ++i) {
44 // Get the count of nodes in the connected component starting from node i.
45 const connectedNodes = depthFirstSearch(i);
46
47 // Update the answer with the number of pairs formed between the current
48 // connected component and the previously processed nodes.
49 answer += sum * connectedNodes;
50
51 // Update the sum with the number of nodes in the current connected component.
52 sum += connectedNodes;
53 }
54
55 // Return the final count of reachable pairs in the graph.
56 return answer;
57}
58
Time and Space Complexity
Time Complexity
The time complexity of the code is primarily determined by the depth-first search (dfs
) function and the construction of the graph g
.
- Constructing the graph
g
involves iterating over alledges
, which takesO(m)
time wherem
is the total number of edges. - The
dfs
function will visit each node exactly once. Since an edge is considered twice (once for each of its endpoints), thedfs
calls contributeO(n + m)
time, wheren
is the total number of nodes. - The main loop (
for i in range(n)
) iteratesn
times and callsdfs
during its iterations.
Combining these steps, the total time complexity is O(n + m)
strictly speaking, as it accounts for the time to build the graph and the time to perform the DFS across all nodes and edges.
Space Complexity
The space complexity of the algorithm is influenced by the space needed to store the graph and the vis
array.
- The graph
g
is an adjacency list representation of the graph, which can consume up toO(n + m)
space since each edge connects two nodes, and it is stored twice. - The
vis
array contains one boolean per node, contributingO(n)
space.
Adding these up, the total space complexity is O(n + m)
which comes from the adjacency list and the vis
array.
Learn more about how to find time and space complexity quickly using problem constraints.
What's the output of running the following function using the following tree as input?
1def serialize(root):
2 res = []
3 def dfs(root):
4 if not root:
5 res.append('x')
6 return
7 res.append(root.val)
8 dfs(root.left)
9 dfs(root.right)
10 dfs(root)
11 return ' '.join(res)
12
1import java.util.StringJoiner;
2
3public static String serialize(Node root) {
4 StringJoiner res = new StringJoiner(" ");
5 serializeDFS(root, res);
6 return res.toString();
7}
8
9private static void serializeDFS(Node root, StringJoiner result) {
10 if (root == null) {
11 result.add("x");
12 return;
13 }
14 result.add(Integer.toString(root.val));
15 serializeDFS(root.left, result);
16 serializeDFS(root.right, result);
17}
18
1function serialize(root) {
2 let res = [];
3 serialize_dfs(root, res);
4 return res.join(" ");
5}
6
7function serialize_dfs(root, res) {
8 if (!root) {
9 res.push("x");
10 return;
11 }
12 res.push(root.val);
13 serialize_dfs(root.left, res);
14 serialize_dfs(root.right, res);
15}
16
Recommended Readings
https algomonster s3 us east 2 amazonaws com 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
https algomonster s3 us east 2 amazonaws com cover_photos bfs svg Breadth First Search on Trees Hopefully by this time you've drunk enough DFS Kool Aid to understand its immense power and seen enough visualization to create a call stack in your mind Now let me introduce the companion spell
Union Find Disjoint Set Union Data Structure Introduction Prerequisite Depth First Search Review problems dfs_intro Once we have a strong grasp of recursion and Depth First Search we can now introduce Disjoint Set Union DSU This data structure is motivated by the following problem Suppose we have sets of elements