1971. Find if Path Exists in Graph
Problem Description
You are given a bi-directional graph with n
vertices labeled from 0
to n - 1
. The graph is represented by a 2D array edges
, where each edges[i] = [ui, vi]
represents an undirected edge between vertices ui
and vi
.
The graph has the following properties:
- Each pair of vertices is connected by at most one edge (no multiple edges between the same pair)
- No vertex has an edge to itself (no self-loops)
- The edges are bi-directional, meaning you can travel from
ui
tovi
and fromvi
toui
Your task is to determine whether there exists a valid path from a given source
vertex to a destination
vertex. A valid path means you can travel from the source to the destination by following the edges in the graph.
The function should return true
if such a path exists, and false
otherwise.
Input:
n
: The number of vertices in the graphedges
: A 2D array representing the edges in the graphsource
: The starting vertexdestination
: The target vertex
Output:
true
if there is a path fromsource
todestination
false
if no such path exists
For example, if you have 3 vertices and edges [[0,1], [1,2]]
, there would be a path from vertex 0 to vertex 2 (going through vertex 1), but no direct path from vertex 0 to vertex 2 without going through vertex 1.
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 states we have a graph with
n
vertices and edges connecting them. We need to find if a path exists between two vertices.
Is it a tree?
- No: The graph is not necessarily a tree. It could have cycles, disconnected components, or multiple paths between vertices. Trees have exactly n-1 edges for n vertices and no cycles, but this problem doesn't guarantee those properties.
Is the problem related to directed acyclic graphs?
- No: The graph is bi-directional (undirected), not a directed acyclic graph (DAG).
Is the problem related to shortest paths?
- No: We only need to determine if ANY valid path exists from source to destination, not the shortest one.
Does the problem involve connectivity?
- Yes: The core of the problem is determining if two vertices (source and destination) are connected by some path in the graph.
Disjoint Set Union
- While DSU could solve this connectivity problem, let's continue exploring other options based on the constraints.
Does the problem have small constraints?
- The constraints aren't explicitly mentioned as small, but for a connectivity problem, we can use DFS efficiently.
DFS/backtracking
- Yes: DFS is perfect for exploring paths in a graph to determine connectivity between two vertices.
Conclusion: The flowchart suggests using DFS (Depth-First Search) to explore the graph from the source vertex and determine if we can reach the destination vertex. This aligns perfectly with the solution approach, where we recursively explore neighbors until we either find the destination or exhaust all reachable vertices.
Intuition
When we need to check if two vertices are connected in a graph, we essentially need to explore all possible paths starting from the source vertex to see if we can reach the destination. This is a classic graph traversal problem.
Think of it like exploring a maze: you start at the entrance (source) and want to know if you can reach the exit (destination). The natural approach is to explore each path systematically - go as far as you can in one direction, and if you hit a dead end or a place you've already been, backtrack and try another path.
DFS is perfect for this because:
- We only care about reachability, not the path itself - We just need a yes/no answer about whether a path exists
- We can stop as soon as we find the destination - Unlike finding all paths or the shortest path, we can return immediately upon success
- We need to avoid cycles - Since the graph might have cycles, we need to track visited vertices to avoid infinite loops
The key insight is that from any vertex, we recursively explore all its unvisited neighbors. If any neighbor leads to the destination (either directly or through further exploration), we've found a valid path. The recursion naturally handles the backtracking - when we can't proceed further from one neighbor, the recursive call returns and we try the next neighbor.
The solution builds an adjacency list from the edges to make neighbor lookup efficient (O(1)
per vertex). Then, starting from the source, we mark vertices as visited and recursively explore. The base cases are:
- If we reach the destination, return
true
- If we reach a visited vertex, return
false
(to avoid cycles) - Otherwise, explore all neighbors and return
true
if any path succeeds
This approach guarantees we'll find a path if one exists, as DFS exhaustively explores all reachable vertices from the source.
Learn more about Depth-First Search, Breadth-First Search, Union Find and Graph patterns.
Solution Approach
The solution uses DFS to traverse the graph and determine if there's a path from source
to destination
. Let's walk through the implementation step by step:
1. Build the Adjacency List
First, we convert the edge list into an adjacency list representation for efficient neighbor lookup:
g = [[] for _ in range(n)]
for u, v in edges:
g[u].append(v)
g[v].append(u)
- We create a list
g
whereg[i]
contains all neighbors of vertexi
- Since the graph is bi-directional, we add both
v
tog[u]
andu
tog[v]
for each edge
2. Initialize Visited Set
vis = set()
We use a set to track visited vertices, preventing cycles and redundant exploration.
3. Implement DFS Function
The recursive DFS function explores the graph:
def dfs(i: int) -> bool:
if i == destination:
return True
if i in vis:
return False
vis.add(i)
return any(dfs(j) for j in g[i])
The function works as follows:
- Base case 1: If current vertex
i
equalsdestination
, we've found a path - returnTrue
- Base case 2: If vertex
i
is already visited, returnFalse
to avoid cycles - Recursive case: Mark vertex
i
as visited, then recursively explore all neighbors- The
any()
function returnsTrue
if at least one neighbor leads to the destination - This implements the backtracking naturally - if one path fails, we try the next neighbor
- The
4. Start the Search
return dfs(source)
We initiate the DFS from the source
vertex. The function will return True
if any path to destination
exists, False
otherwise.
Time Complexity: O(V + E)
where V
is the number of vertices and E
is the number of edges. In the worst case, we visit every vertex and traverse every edge once.
Space Complexity: O(V)
for the recursion stack and visited set in the worst case, plus O(E)
for storing the adjacency list.
The elegance of this solution lies in its simplicity - the recursive DFS naturally handles path exploration and backtracking, while the visited set ensures we don't get stuck in cycles.
Ready to land your dream job?
Unlock your dream job with a 3-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a concrete example to understand how the DFS solution works.
Example Input:
n = 6
(vertices labeled 0 to 5)edges = [[0,1], [0,2], [3,5], [5,4], [4,3]]
source = 0
destination = 5
Step 1: Build the Adjacency List
From the edges, we create:
g[0] = [1, 2] g[1] = [0] g[2] = [0] g[3] = [5, 4] g[4] = [5, 3] g[5] = [3, 4]
This gives us two disconnected components:
- Component 1: vertices {0, 1, 2}
- Component 2: vertices {3, 4, 5}
Step 2: Initialize and Start DFS
vis = {}
(empty set initially)- Call
dfs(0)
starting from source vertex 0
Step 3: DFS Execution
dfs(0): - Is 0 == 5? No - Is 0 in vis? No - Add 0 to vis: vis = {0} - Explore neighbors of 0: [1, 2] dfs(1): - Is 1 == 5? No - Is 1 in vis? No - Add 1 to vis: vis = {0, 1} - Explore neighbors of 1: [0] dfs(0): - Is 0 == 5? No - Is 0 in vis? Yes β return False - No unvisited neighbors lead to destination - return False dfs(2): - Is 2 == 5? No - Is 2 in vis? No - Add 2 to vis: vis = {0, 1, 2} - Explore neighbors of 2: [0] dfs(0): - Is 0 == 5? No - Is 0 in vis? Yes β return False - No unvisited neighbors lead to destination - return False - Neither neighbor (1 or 2) leads to destination - any([False, False]) = False - return False
Result: The function returns False
because vertices 0 and 5 are in different connected components with no path between them.
Alternative Example with Path:
If we had source = 3
and destination = 4
instead:
dfs(3): - Is 3 == 4? No - Is 3 in vis? No - Add 3 to vis: vis = {3} - Explore neighbors of 3: [5, 4] dfs(5): - Is 5 == 4? No - Is 5 in vis? No - Add 5 to vis: vis = {3, 5} - Continue exploring... dfs(4): - Is 4 == 4? Yes β return True! - any([..., True]) = True - return True
The function would return True
because vertex 4 is a direct neighbor of vertex 3.
Solution Implementation
1class Solution:
2 def validPath(
3 self, n: int, edges: List[List[int]], source: int, destination: int
4 ) -> bool:
5 """
6 Determines if there exists a valid path from source to destination in an undirected graph.
7
8 Args:
9 n: Number of vertices in the graph (0 to n-1)
10 edges: List of edges where each edge is [u, v] representing an undirected edge
11 source: Starting vertex
12 destination: Target vertex
13
14 Returns:
15 True if a path exists from source to destination, False otherwise
16 """
17
18 def dfs(current_node: int) -> bool:
19 """
20 Performs depth-first search to find if destination is reachable from current_node.
21
22 Args:
23 current_node: The current vertex being explored
24
25 Returns:
26 True if destination is reachable from current_node, False otherwise
27 """
28 # Base case: reached the destination
29 if current_node == destination:
30 return True
31
32 # If node has already been visited, avoid cycles
33 if current_node in visited:
34 return False
35
36 # Mark current node as visited
37 visited.add(current_node)
38
39 # Recursively explore all neighbors
40 # Returns True if any neighbor leads to the destination
41 return any(dfs(neighbor) for neighbor in adjacency_list[current_node])
42
43 # Build adjacency list representation of the graph
44 adjacency_list = [[] for _ in range(n)]
45 for u, v in edges:
46 adjacency_list[u].append(v) # Add edge from u to v
47 adjacency_list[v].append(u) # Add edge from v to u (undirected graph)
48
49 # Initialize set to track visited nodes
50 visited = set()
51
52 # Start DFS from the source node
53 return dfs(source)
54
1class Solution {
2 // Class member variables for DFS traversal
3 private int destination; // Target node to reach
4 private boolean[] visited; // Track visited nodes to avoid cycles
5 private List<Integer>[] graph; // Adjacency list representation of graph
6
7 /**
8 * Determines if there exists a valid path between source and destination nodes
9 * @param n Number of nodes in the graph (0 to n-1)
10 * @param edges Array of edges where edges[i] = [u, v] represents an undirected edge
11 * @param source Starting node
12 * @param destination Target node
13 * @return true if a path exists from source to destination, false otherwise
14 */
15 public boolean validPath(int n, int[][] edges, int source, int destination) {
16 // Initialize destination for DFS
17 this.destination = destination;
18
19 // Initialize visited array to track explored nodes
20 visited = new boolean[n];
21
22 // Initialize adjacency list for graph representation
23 graph = new List[n];
24 Arrays.setAll(graph, index -> new ArrayList<>());
25
26 // Build undirected graph by adding edges in both directions
27 for (int[] edge : edges) {
28 int u = edge[0];
29 int v = edge[1];
30 graph[u].add(v); // Add edge from u to v
31 graph[v].add(u); // Add edge from v to u (undirected)
32 }
33
34 // Perform DFS starting from source node
35 return dfs(source);
36 }
37
38 /**
39 * Depth-First Search to find path to destination
40 * @param currentNode Current node being explored
41 * @return true if destination is reachable from current node, false otherwise
42 */
43 private boolean dfs(int currentNode) {
44 // Base case: reached destination
45 if (currentNode == destination) {
46 return true;
47 }
48
49 // Mark current node as visited
50 visited[currentNode] = true;
51
52 // Explore all unvisited neighbors
53 for (int neighbor : graph[currentNode]) {
54 // Only explore unvisited neighbors to avoid cycles
55 if (!visited[neighbor] && dfs(neighbor)) {
56 return true; // Path found through this neighbor
57 }
58 }
59
60 // No path found from current node
61 return false;
62 }
63}
64
1class Solution {
2public:
3 bool validPath(int n, vector<vector<int>>& edges, int source, int destination) {
4 // Build adjacency list representation of the graph
5 vector<vector<int>> adjacencyList(n);
6
7 // Mark visited nodes during DFS traversal
8 vector<bool> visited(n, false);
9
10 // Construct the undirected graph by adding edges in both directions
11 for (const auto& edge : edges) {
12 int u = edge[0];
13 int v = edge[1];
14 adjacencyList[u].push_back(v);
15 adjacencyList[v].push_back(u);
16 }
17
18 // Define DFS lambda function to search for a path
19 function<bool(int)> dfs = [&](int currentNode) -> bool {
20 // Base case: reached the destination
21 if (currentNode == destination) {
22 return true;
23 }
24
25 // Mark current node as visited
26 visited[currentNode] = true;
27
28 // Explore all unvisited neighbors
29 for (int neighbor : adjacencyList[currentNode]) {
30 if (!visited[neighbor] && dfs(neighbor)) {
31 return true; // Path found through this neighbor
32 }
33 }
34
35 // No path found from current node
36 return false;
37 };
38
39 // Start DFS from source node
40 return dfs(source);
41 }
42};
43
1/**
2 * Determines if there exists a valid path between source and destination nodes in an undirected graph
3 * @param n - Number of vertices in the graph (0 to n-1)
4 * @param edges - Array of edges where each edge is [u, v] representing an undirected edge between u and v
5 * @param source - Starting vertex
6 * @param destination - Target vertex
7 * @returns true if a path exists from source to destination, false otherwise
8 */
9function validPath(n: number, edges: number[][], source: number, destination: number): boolean {
10 // Build adjacency list representation of the graph
11 const adjacencyList: number[][] = Array.from({ length: n }, () => []);
12
13 // Add edges to adjacency list (undirected graph, so add both directions)
14 for (const [vertex1, vertex2] of edges) {
15 adjacencyList[vertex1].push(vertex2);
16 adjacencyList[vertex2].push(vertex1);
17 }
18
19 // Set to track visited nodes during DFS traversal
20 const visitedNodes = new Set<number>();
21
22 /**
23 * Performs depth-first search to find if destination is reachable from current node
24 * @param currentNode - Current node being explored
25 * @returns true if destination is reachable from current node, false otherwise
26 */
27 const depthFirstSearch = (currentNode: number): boolean => {
28 // Base case: reached destination
29 if (currentNode === destination) {
30 return true;
31 }
32
33 // If node already visited, avoid cycles
34 if (visitedNodes.has(currentNode)) {
35 return false;
36 }
37
38 // Mark current node as visited
39 visitedNodes.add(currentNode);
40
41 // Recursively explore all neighbors, return true if any path leads to destination
42 return adjacencyList[currentNode].some(depthFirstSearch);
43 };
44
45 // Start DFS from source node
46 return depthFirstSearch(source);
47}
48
Time and Space Complexity
Time Complexity: O(n + m)
The algorithm uses depth-first search (DFS) to find a path from source to destination. Breaking down the time complexity:
- Building the adjacency list: We iterate through all
m
edges once, and for each edge, we perform two constant-time append operations. This takesO(m)
time. - DFS traversal: In the worst case, we visit each of the
n
nodes at most once (thanks to the visited set check). For each visited node, we explore all its adjacent edges. Since each edge connects two nodes, the total number of adjacencies across all nodes is2m
. Therefore, the DFS takesO(n + m)
time. - Overall:
O(m) + O(n + m) = O(n + m)
Space Complexity: O(n + m)
The space usage consists of:
- Adjacency list
g
: Containsn
empty lists initially, and stores a total of2m
edge references (each edge(u, v)
is stored twice: once ing[u]
and once ing[v]
). This requiresO(n + m)
space. - Visited set
vis
: Can contain at mostn
nodes, requiringO(n)
space. - Recursion call stack: In the worst case (e.g., a linear chain of nodes), the DFS recursion depth can be
O(n)
. - Overall:
O(n + m) + O(n) + O(n) = O(n + m)
Where n
is the number of nodes and m
is the number of edges in the graph.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Forgetting to Handle Disconnected Components
A common mistake is assuming the graph is connected. If source
and destination
are in different connected components, no path exists between them. While the DFS solution handles this correctly by returning False
when all reachable nodes are explored, developers might incorrectly assume they need special handling for disconnected graphs.
Example scenario:
n = 6 edges = [[0,1], [0,2], [3,4], [4,5]] # Two disconnected components source = 0 destination = 5 # Returns False correctly, but some might think additional logic is needed
2. Stack Overflow in Deep Recursion
For graphs with very long paths or large numbers of vertices, the recursive DFS can cause stack overflow. Python has a default recursion limit (usually 1000), which can be exceeded in certain graph structures.
Solution: Use iterative DFS with an explicit stack:
def validPath(self, n: int, edges: List[List[int]], source: int, destination: int) -> bool:
# Build adjacency list
adjacency_list = [[] for _ in range(n)]
for u, v in edges:
adjacency_list[u].append(v)
adjacency_list[v].append(u)
# Iterative DFS using stack
stack = [source]
visited = set()
while stack:
current = stack.pop()
if current == destination:
return True
if current in visited:
continue
visited.add(current)
for neighbor in adjacency_list[current]:
if neighbor not in visited:
stack.append(neighbor)
return False
3. Edge Case: source == destination
When the source and destination are the same vertex, the path trivially exists. The current solution handles this correctly (returns True
immediately), but some implementations might overlook this edge case or add unnecessary complexity.
Quick optimization:
# Add this check before starting DFS if source == destination: return True
4. Not Using Early Termination in any()
The current solution correctly uses any()
with a generator expression, which provides early termination. However, a common mistake is to use a list comprehension instead:
Wrong approach:
# This evaluates ALL neighbors even if first one finds the path
return any([dfs(neighbor) for neighbor in adjacency_list[current_node]])
Correct approach (as in the solution):
# Generator expression - stops as soon as True is found
return any(dfs(neighbor) for neighbor in adjacency_list[current_node])
5. Memory Issues with Large Sparse Graphs
For very large values of n
with few edges (sparse graphs), creating an adjacency list with n
empty lists can be memory-inefficient.
Alternative using dictionary:
from collections import defaultdict
adjacency_list = defaultdict(list)
for u, v in edges:
adjacency_list[u].append(v)
adjacency_list[v].append(u)
This only creates entries for vertices that actually have edges, saving memory in sparse graphs.
A person thinks of a number between 1 and 1000. You may ask any number questions to them, provided that the question can be answered with either "yes" or "no".
What is the minimum number of questions you needed to ask so that you are guaranteed to know the number that the person is thinking?
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 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 Breadth First Search BFS
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
Want a Structured Path to Master System Design Too? Donβt Miss This!