Find if Path Exists in Graph

Easy DFS

Problem

Given an undirected graph with n nodes labeled from 0 to n-1 and a list of edges, determine if there is a valid path from the source node to the destination node.

  • 1 ≤ n ≤ 2 * 10⁵
  • 0 ≤ edges.length ≤ 2 * 10⁵
  • edges[i].length == 2
  • 0 ≤ ui, vi < n
  • ui != vi
  • 0 ≤ source, destination < n
  • There are no duplicate edges.

Example

Input: n = 3, edges = [[0,1],[1,2],[2,0]], source = 0, destination = 2
Output: true

The edges create a connected cycle among nodes {0,1,2}, so every node is reachable from every other node. Starting from 0, a DFS (or BFS) marks reachable nodes by following edges while avoiding revisits via a visited set. Node 2 is reachable from 0 (either directly via edge (2,0) or through 0 → 1 → 2), so the search will eventually visit 2 and return true.

Approach

Straightforward Solution

A brute-force approach might attempt to enumerate all possible paths between source and destination, which is exponential and infeasible for large graphs.

Core Observation

The problem reduces to checking connectivity between two nodes in an undirected graph. This is a classic graph traversal problem where the existence of a path corresponds to reachability in the graph.

Path to Optimal

Recognizing that the problem is about reachability, a Depth-First Search (DFS) or Breadth-First Search (BFS) can efficiently explore the graph starting from the source node. DFS explores as deep as possible along each branch before backtracking, and can terminate early upon reaching the destination. This approach avoids enumerating all paths and runs in linear time relative to the number of nodes and edges.

Optimal Approach

Build an adjacency list representation of the graph for O(1) neighbor access. Use a recursive DFS starting from the source node, marking visited nodes to avoid cycles. If the destination node is encountered during traversal, return true immediately. If DFS completes without finding the destination, return false. This guarantees O(n + m) time complexity, where n is the number of nodes and m is the number of edges.

Time

O(n + m)

Each node is visited at most once, and each edge is explored at most twice (once from each endpoint) during the DFS traversal, resulting in linear time relative to the graph size.

Space

O(n + m)

The adjacency list requires O(n + m) space to store all edges, and the visited set requires O(n) space to track visited nodes. The recursion stack can go as deep as O(n) in the worst case.

Pattern Spotlight

DFS (Graph Connectivity)

When checking if a path exists between two nodes in an unweighted graph, use DFS to explore reachable nodes from the source, marking visited nodes to avoid cycles, and terminate early upon reaching the destination.

Solution

1from collections import defaultdict
2
3class Solution:
4 def validPath(self, n: int, edges: [[int]], source: int, destination: int) -> bool:
5 graph = defaultdict(list)
6
7 for u, v in edges:
8 graph[u].append(v)
9 graph[v].append(u)
10
11 visited = set()
12
13 def dfs(node):
14 if node == destination:
15 return True
16 visited.add(node)
17
18 for neighbor in graph[node]:
19 if neighbor not in visited and dfs(neighbor):
20 return True
21 return False
22
23 return dfs(source)

Step-by-Step Solution

1

Construct Adjacency List to Represent the Undirected Graph

5graph = defaultdict(list)
7for u, v in edges:
8 graph[u].append(v)
9 graph[v].append(u)

Objective

To build a graph representation that allows efficient neighbor lookups for each node.

Key Insight

An adjacency list is the most efficient way to represent sparse graphs, enabling O(1) average-time access to neighbors. Since the graph is undirected, each edge is added to both nodes' adjacency lists, ensuring bidirectional traversal during DFS.

Interview Quick-Check

Core Logic

Building the adjacency list from edges allows the DFS to quickly access all neighbors of a node without scanning the entire edge list.

Common Pitfalls & Bugs

Forgetting to add both directions for undirected edges leads to incomplete graph representation and incorrect traversal.

2

Track Visited Nodes to Avoid Revisiting and Cycles

11visited = set()

Objective

To maintain a record of nodes already explored to prevent infinite recursion and redundant work.

Key Insight

Without tracking visited nodes, DFS can revisit nodes endlessly in cyclic graphs, causing infinite loops. The visited set ensures each node is processed once, preserving correctness and efficiency.

Interview Quick-Check

Core Logic

The visited set acts as a memory of explored nodes, preventing cycles and repeated work during DFS.

Common Pitfalls & Bugs

Failing to mark nodes as visited before recursive calls can cause infinite recursion or stack overflow.

3

Recursively Explore Nodes Using DFS and Terminate Early on Destination

13def dfs(node):
14 if node == destination:
15 return True
16 visited.add(node)
18 for neighbor in graph[node]:
19 if neighbor not in visited and dfs(neighbor):
20 return True
21 return False

Objective

To traverse the graph depth-first from the source, returning true immediately upon reaching the destination.

Key Insight

DFS explores neighbors recursively, diving deep into each path. The early return upon finding the destination avoids unnecessary exploration, improving efficiency. The recursion unwinds as soon as a valid path is found, ensuring minimal traversal.

Interview Quick-Check

Core Logic

DFS recursively explores neighbors, returning true immediately when the destination node is found, enabling early termination.

State & Boundaries

The base case checks if the current node is the destination, ensuring correctness and termination.

Common Pitfalls & Bugs

Not returning the result of recursive calls or failing to check visited nodes before recursion can cause incorrect results or infinite loops.

4

Initiate DFS from Source and Return Final Reachability Result

23return dfs(source)

Objective

To start the DFS traversal from the source node and return whether a path to the destination exists.

Key Insight

The initial call triggers the entire recursive exploration. The returned boolean directly answers the problem by indicating if the destination is reachable from the source.

Interview Quick-Check

Core Logic

Starting DFS from the source node explores all reachable nodes, and the returned boolean indicates path existence.

Line Analysis

This solution has 5 Critical lines interviewers watch for.

Line 14 Critical
if node == destination:

Check if the current node is the destination.

This line is the critical early-exit condition that allows the DFS to stop searching as soon as the destination is reached, preventing unnecessary exploration and ensuring efficiency.

Line 11 Critical
visited = set()

Create a set to track visited nodes.

The visited set prevents revisiting nodes, which is critical to avoid infinite loops in cyclic graphs and to ensure linear time complexity.

Line 16 Critical
visited.add(node)

Mark the current node as visited.

Adding the node to visited before exploring neighbors prevents revisiting it in future recursive calls, avoiding cycles and redundant work.

Line 19 Critical
if neighbor not in visited and dfs(neighbor):

Recursively call DFS on unvisited neighbors and check if destination is reachable.

This recursive call explores deeper paths, and the conditional ensures that only unvisited nodes are explored, maintaining correctness and efficiency.

Line 20 Critical
return True

Return true immediately if a path to destination is found through a neighbor.

This early return propagates success up the call stack, enabling the algorithm to stop searching once a valid path is found.

Line 8 Core
graph[u].append(v)

Add the edge's second node to the first node's adjacency list.

This step records the connection from u to v, enabling traversal from u to v.

Line 9 Core
graph[v].append(u)

Add the edge's first node to the second node's adjacency list.

Because the graph is undirected, this step ensures traversal from v to u is possible, maintaining bidirectional connectivity.

Line 15 Core
return True

Return true if the destination is reached.

Returning true here propagates the success signal up the recursive call stack, enabling early termination of all ancestor calls.

Line 21 Core
return False

Return false if no neighbors lead to the destination.

Returning false here correctly signals that the current path does not lead to the destination, allowing backtracking to explore alternative paths.

Line 23 Core
return dfs(source)

Start DFS from the source node and return whether a path exists.

This call initiates the entire traversal and returns the final answer to the problem, encapsulating the solution.

Line 5 Core
graph = defaultdict(list)

Initialize the adjacency list as a default dictionary of lists.

Using a defaultdict simplifies graph construction by automatically creating empty neighbor lists for new nodes, enabling efficient edge insertion.

Line 13 Core
def dfs(node):

Define the recursive DFS function to explore nodes.

Encapsulating DFS logic in a helper function allows clean recursion and state management for graph traversal.

Line 18 Core
for neighbor in graph[node]:

Iterate over all neighbors of the current node.

Exploring neighbors is the core of DFS traversal, enabling the search to proceed through the graph.

Line 7 Standard
for u, v in edges:

Iterate over each edge to build the graph.

Processing each edge ensures the graph representation includes all connections, which is essential for accurate traversal.

Test Your Understanding

Why does marking nodes as visited during DFS prevent infinite loops and ensure correctness?

Marking nodes as visited prevents revisiting the same node multiple times, which avoids infinite cycles in graphs with cycles and ensures that each node is processed only once, maintaining linear time complexity.

Related Problems

DFS pattern

Don't just read it. Drill it.

Reconstruct Find if Path Exists in Graph from memory until it sticks. AlgoDrill blanks out key lines and makes you fill them back in, step by step.

Unlock the Find if Path Exists in Graph drill

or drill a free problem