Facebook Pixel

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 to vi and from vi to ui

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 graph
  • edges: A 2D array representing the edges in the graph
  • source: The starting vertex
  • destination: The target vertex

Output:

  • true if there is a path from source to destination
  • 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.

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

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:

  1. We only care about reachability, not the path itself - We just need a yes/no answer about whether a path exists
  2. We can stop as soon as we find the destination - Unlike finding all paths or the shortest path, we can return immediately upon success
  3. 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 where g[i] contains all neighbors of vertex i
  • Since the graph is bi-directional, we add both v to g[u] and u to g[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 equals destination, we've found a path - return True
  • Base case 2: If vertex i is already visited, return False to avoid cycles
  • Recursive case: Mark vertex i as visited, then recursively explore all neighbors
    • The any() function returns True if at least one neighbor leads to the destination
    • This implements the backtracking naturally - if one path fails, we try the next neighbor

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 Evaluator

Example 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 takes O(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 is 2m. Therefore, the DFS takes O(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: Contains n empty lists initially, and stores a total of 2m edge references (each edge (u, v) is stored twice: once in g[u] and once in g[v]). This requires O(n + m) space.
  • Visited set vis: Can contain at most n nodes, requiring O(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.

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

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

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

Load More