Jc-alt logo
jc
data structures and algorithms

LeetCode: Graphs II Dijkstras BellmanFord Single Source Shortest Path

LeetCode: Graphs II Dijkstras BellmanFord Single Source Shortest Path
23 min read
#data structures and algorithms

Dijkstra's Algorithm Intro

Intro

Dijkstra's Algorithm is a graph traversal algorithm used to find the shortest path from one source node to all other nodes in weight graph with non-negative edge weights.

It is often described as a Greedy BFS using a MinHeap/Priority Queue.

Like BFS, it expands outward from the source node. Unlike BFS, it chooses the next closest node by total distance, not by layer

Graph Requirements

  1. Weighted Graph
  2. Non-negative Edge Weights
  3. Directed or Non Directed
  4. Represented Using:
    • Adjacency List
    • Adjacency Matrix

Output

A shortest path tree rooted at the source. Any node not visited is unreachable.

Video Animation

https://www.youtube.com/watch?v=_lHSawdgXpI

Greedy BFS Analogy

Always expand the node with the smallest currently known distance

Pseudo Code

    def dijkstra(graph, source):
        
        # 1. Initialize all distances to ∞
        # 2. Set source distance = 0
        # 3. Use MinHeap to always expand smallest distance node
        # 4. Relax edges (update neighbors if shorter path found)
        # 5. Continue until all nodes processed

        # Initialize distances
        distances = {node: float('inf') for node in graph}
        distances[source] = 0

        # MinHeap (priority queue)
        min_heap = []

        # Push all distances to minHeap
        heapq.heappush(min_heap, (0, source))  # (distance, node)

        # Tracking closed nodes
        visited = set()

        # Process nodes
        while min_heap:

            # Smallest distance node
            current_dist, node = heapq.heappop(min_heap)

            # Skip if already finalized
            if node in visited:
                continue

            # Close Node
            visited.add(node)

            # Relax edges
            for neighbor, weight in graph[node]:
                new_dist = current_dist + weight

                if new_dist < distances[neighbor]:
                    distances[neighbor] = new_dist
                    heapq.heappush(min_heap, (new_dist, neighbor))

        # Map of shortest path from source node to all nodes
        return distances

Time Complexity

V = number of vertices E = number of edges

Using adjacency list + minHeap: O((V + E) log v)

Each vertex is inserted into the minHeap at most once. Each edge may cause a minHeap update. Heap operations cost log V

Space Complexity

V = number of vertices E = number of edges

Using adjacency list + minHeap: O(V)

Distance Map: O(V) Visited Set: O(V) MinHeap: O(V)

IRL Use Case

  • GPS Navigation Systems Finding the shortest driving route between locations

  • Network Routing Determining the least-cost path for packet transmission

  • Game AI Path Finding NPC movement optimization on weighted maps

Bellman Ford Algorithm Intro

Intro

Bellman Ford is a single source shortest path algorithm that works for graphs with negative edge weights.

It finds the shortest distance from a source node to all other nodes in a weighted graph and can detect negative weight cycles.

Unlike Dijkstra, it does not require non-negative weights, but it is slower

Graph Requirements

  1. Weight Graph, can include negative weights
  2. Directed or Undirected
  3. No requirements for non-negative edges
  4. Represented Using:
    • Adjacency List
    • Edge List

Output

  • Shortest distance from a source to every other node
  • Can indicate if a negative weight cycle exists (impossible to define shortest paths)

Video Animation

https://www.youtube.com/watch?v=obWXjtg0L64

Relaxing Analogy

Think of it as repeatedly 'relaxing' edges: For each

Pseudo Code

    def bellman_ford(edges, V, source):

        # Initialize distances
        dist = [float('inf')] * V
        dist[source] = 0

        # Relax all edges V-1 times
        for _ in range(V - 1):
            for u, v, w in edges:
                if dist[u] + w < dist[v]:
                    dist[v] = dist[u] + w

        # Check for negative weight cycles
        for u, v, w in edges:
            if dist[u] + w < dist[v]:
                raise ValueError("Graph contains a negative weight cycle")

        return dist

Time Complexity

V = number of vertices E = number of edges

Relaxing All Edges V-1 Times: O(V * E)

Space Complexity

V = number of vertices E = number of edges

Distance Array: O(V)

IRL Use Case

  • Network Routing Supports networks where some paths may have penalties or negative costs

  • Timing Scheduling Problems Detect impossible schedules due ot negative constraints

743. Network Delay Time ::1:: - Medium

Topics: Depth First Search, Breadth First Search, Graph, Heap (Priority Queue), Shortest Path

Intro

You are given a network of n nodes, labeled from 1 to n. You are also given times, a list of travel times as directed edges times[i] = (ui, vi, wi), where ui is the source node, vi is the target node, and wi is the time it takes for a signal to travel from source to target. We will send a signal from a given node k. Return the minimum time it takes for all the n nodes to receive the signal. If it is impossible for all the n nodes to receive the signal, return -1.

Example InputOutput
times = [[2,1,1],[2,3,1],[3,4,1]], n = 4, k = 22
times = [[1,2,1]], n = 2, k = 11
times = [[1,2,1]], n = 2, k = 2-1

Constraints:

1 ≤ k ≤ n ≤ 100

1 ≤ times.length ≤ 6000

times[i].length == 3

1 ≤ ui, vi ≤ n

ui != vi

0 ≤ wi ≤ 100

All the pairs (ui, vi) are unique. (i.e., no multiple edges.)

Abstraction

Given a graph, each node with 1 edges, determine how much time is needed to get from start node to target node.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: [Dijkstras] [Greedy] BFS And MinHeap To Keep Shortest Path - Advanced Graphs/Advanced Graphs

    def networkDelayTime(self, times: List[List[int]], n: int, k: int) -> int:
        
        # Dijkstra's Algorithm (Single-Source Shortest Path):
        # Find the shortest time from node k signal to all nodes
        # Find minimum time for node k signal to reach all nodes in 
        # a directed weighted graph

        # Idea Greedy:
        #   - Greedily expand node with smallest current travel time using minHeap
        #   - Once a node is popped from the minHeap, its shortest time is finalized,
        #     because the weights are non negative

        # Graph Construction:
        # Build Adjacency List:
        # graph[u] = [(v, weight), ...]

        # Key Ideas:
        #   1. Model the network as a directed weighted graph.
        #   2. Use a min-heap to always expand the node with the smallest
        #      known signal arrival time.
        #   3. Maintain a dictionary of shortest known times to each node.
        #   4. If all nodes are reached, return the maximum of these times.
        #   5. If some node is unreachable, return -1.

        # Adjacency list storage: graph[src_node] = [(dest_node, weight), ...]
        # sc: O(E)
        graph = defaultdict(list)

        # iterate over and store edges   
        # tc: O(E)   
        # sc: O(E) 
        for u, v, w in times:
            graph[u].append((v, w))
        
        # minHeap:
        # Ensures we always explore the node with the smallest known travel time next
        # sc: O(V)
        minHeap = []

        # Append root for processing
        # tc: O(log V)
        heapq.heappush(minHeap, (0, k))
        
        # Invariant:
        # Tracks finalized shortest time to each node
        # Once a node enters shortest_time,
        # its minimum distance is guaranteed final, because graph is non-negative
        # sc: O(V)
        shortest_time = {}
        
        # Dijkstras Traversal (Greedy BFS)
        #   - each edge may be pushed into heap
        #   - heap operations cost log V
        # tc: O(E log V)
        while minHeap:

            # Grad node with smallest known arrival time
            # tc: O(log V)
            time, node = heapq.heappop(minHeap)
            
            # Skip finalized nodes:
            # if node has already been finalized
            # tc: O(1)
            if node in shortest_time:
                continue
            
            # New node encountered:
            # track final shortest time
            # tc: O(1)
            shortest_time[node] = time
            
            # Edge Relaxation Opportunity:
            # from new node, check if we can relax the neighbors
            # and find a shorter path by going through the new node
            # tc: O(V)
            for neighbor, wt in graph[node]:

                # Skip finalized nodes:
                # Only relax neighbor if time has not be finalized
                if neighbor not in shortest_time:

                    # Relaxing possible:
                    # Get time going through new node to its neighbor
                    
                    # Calculate the total time to reach the neighbor via current node
                    new_time = time + wt

                    # Push relaxed time to minHeap
                    # tc: O(log V)
                    heapq.heappush(minHeap, (time + wt, neighbor))
        

        # Final Validation:
        # if not all nodes were reached, signal cannot propagate everywhere
        # tc: O(1)
        if len(shortest_time) != n:
            return -1
        
        # Network delay:
        # The node with the longest path will be the last node to receive the signal,
        # and determine the total network delay time
        # tc: O(V)
        res = max(shortest_time.values())

        # overall: tc O(E log V)
        # overall: sc O(V + E)     
        return res

1631. Path With Minimum Effort ::1:: - Medium

Topics: Array, Binary Search, Depth First Search, Breadth First Search, Union Find, Heap (Priority Queue), Matrix

Intro

You are a hiker preparing for an upcoming hike. You are given heights, a 2D array of size rows x columns, where heights[row][col] represents the height of cell (row, col). You are situated in the top-left cell, (0, 0), and you hope to travel to the bottom-right cell, (rows-1, columns-1) (i.e., 0-indexed). You can move up, down, left, or right, and you wish to find a route that requires the minimum effort. A route's effort is the maximum absolute difference in heights between two consecutive cells of the route. Return the minimum effort required to travel from the top- left cell to the bottom-right cell.

Example InputOutput
heights = [[1,2,2],[3,8,2],[5,3,5]]2
heights = [[1,2,3],[3,8,4],[5,3,5]]1

Constraints:

rows == heights.length

columns == heights[i].length

1 ≤ rows, columns ≤ 100

1 ≤ heights[i][j] ≤ 106

Abstraction

Given a graph, determine the route with minimal climbing.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: [Dijkstras] Dijkstra + MinHeap - Advanced Graphs/Advanced Graphs

    def minimumEffortPath(self, heights: List[List[int]]) -> int:
        
        # Dijkstra's Algorithm (Single-Source Shortest Path):
        # Dijkstras finds shortest path from a source node to all other nodes
        # in a weighted graph with non-negative edge weights

        # Node = cell (r, c)
        # Edge weight = |height difference| between adjacent cells
        # MinHeap ensures we always expand the cell with smallest effort first

        rows, cols = len(heights), len(heights[0])

        # Tracks minimum effort needed to reach each cell
        efforts = [[float('inf')] * cols for _ in range(rows)]

        # starting cell has 0 effort
        efforts[0][0] = 0

        # MinHeap stores: (current path's max effort, row, col)
        min_heap = [(0, 0, 0)]

        # Directions: up, down, left, right
        directions = [(0,1),(1,0),(-1,0),(0,-1)]

        # While we still have 
        while min_heap:

            # Grab current effort node
            curr_effort, r, c = heappop(min_heap)

            # Exit Case:
            # If we reached bottom right, this is minimum possible effort
            if r == rows - 1 and c == cols - 1:
                return curr_effort

            # Explore neighbors
            for dr, dc in directions:
                nr, nc = r + dr, c + dc

                # Check bounds
                if 0 <= nr < rows and 0 <= nc < cols:

                    # Effort to reach neighbor = max(current path effort, edge weight)
                    height_diff = abs(heights[r][c] - heights[nr][nc])
                    next_effort = max(curr_effort, height_diff)

                    # Only update if we found a smaller effort path to neighbor
                    if next_effort < efforts[nr][nc]:
                        efforts[nr][nc] = next_effort
                        heappush(min_heap, (next_effort, nr, nc))

        # Default return (problem guarantees a path exists)

        # overall: tc O(R*C*log(R*C)) due to heap operations
        # overall: sc O(R*C) for effort tracking and heap
        return 0

778. Swim in Rising Water ::1:: - Hard

Topics: Array, Binary Search, Depth First Search, Breadth First Search, Union Find, Heap (Priority Queue), Matrix

Intro

You are given an n x n integer matrix grid where each value grid[i][j] represents the elevation at that point (i, j). It starts raining, and water gradually rises over time. At time t, the water level is t, meaning any cell with elevation less than equal to t is submerged or reachable. You can swim from a square to another 4-directionally adjacent square if and only if the elevation of both squares individually are at most t. You can swim infinite distances in zero time. Of course, you must stay within the boundaries of the grid during your swim. Return the minimum time until you can reach the
bottom right square (n - 1, n - 1) if you start at the top left square (0, 0).

Example InputOutput
grid = [[0,2],[1,3]]3
grid = [[0,1,2,3,4],[24,23,22,21,5],[12,13,14,15,16],[11,17,18,19,20],[10,9,8,7,6]]16

Constraints:

n == grid.length

n == grid[i].length

1 ≤ n ≤ 50

0 ≤ grid[i][j] < n2

Each value grid[i][j] is unique.

Abstraction

Given a graph, determine the time needed to traverse from top left to bottom right.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: [Dijkstras] Dijkstra + MinHeap - Advanced Graphs/Advanced Graphs

    def swimInWater(self, grid: List[List[int]]) -> int:
        
        # Dijkstras + MinHeap (Single-Source Shortest Path):
        # Dijkstras finds shortest path from a source node to all other nodes
        # in a weighted graph with non-negative edge weights
        
        # Note:
        # 1. We want min time t to reach (n-1, n-1)
        # 2. At any step, t = max elevation along the path
        # 3. Use min-heap to always expand the path with lowest t so far
        # 4. Track visited cells to avoid revisiting

        n = len(grid)

        # Keep track of visited cells to avoid revisiting
        visited = [[False] * n for _ in range(n)]
        
        # MainHeap store tuple: (current_max_elevation, row, col)
        minHeap = [(grid[0][0], 0, 0)]

        # Directions for 4 way movement
        directions = [(0,1),(1,0),(-1,0),(0,-1)]

        # While we have ___
        while minHeap:

            # Pop the cell with the smallest current max elevation
            current_max, r, c = heappop(minHeap)

            # Skip already visited cells
            if visited[r][c]:
                continue

            # Mark cell as visited
            visited[r][c] = True

            # Exit Condition:
            # If we reached the bottom right, return current_max as minimum time
            if r == n - 1 and c == n - 1:
                return current_max

            # Explore Neighbors:
            for dr, dc in directions:
                nr, nc = r + dr, c + dc

                # Check boundaries and whether neighbor is already visited
                if 0 <= nr < n and 0 <= nc < n and not visited[nr][nc]:

                    # Calculate the max elevation along the path to neighbor
                    neighbor_max = max(current_max, grid[nr][nc])

                    # Push neighbor into heap for further exploration
                    heappush(minHeap, (neighbor_max, nr, nc))

        # Default return (problem guarantees a path exists)

        # overall: tc O(n^2 * log(n^2)) due to heap operations
        # overall: sc O(n^2) for heap and visited
        return 0

787. Cheapest Flights Within K Stops ::1:: - Medium

Topics: Dynamic Programming, Depth First Search, Breadth First Search, Graph, Heap (Priority Queue), Shortest Path

Intro

There are n cities connected by some number of
flights. You are given an array flights where flights[i] = [fromi, toi, pricei] indicates that pricei. You are also given three integers src, dst, and k, return the cheapest price from src to dst with at most k stops. If there is no such route, return -1.

Example InputOutput
n = 4, flights = [[0,1,100],[1,2,100],[2,0,100],[1,3,600],[2,3,200]], src = 0, dst = 3, k = 1700
n = 3, flights = [[0,1,100],[1,2,100],[0,2,500]], src = 0, dst = 2, k = 1200

Constraints:

1 ≤ n ≤ 100

0 ≤ flights.length ≤ (n * (n-1) / 2)

flight[i].length == 3

0 ≤ fromi, toi < n

fromi != toi

1 ≤ pricei ≤ 104

There will not be any multiple flights between two cities.

0 ≤ src, dst, k < n

src != dst

Abstraction

Given flights, determine the cheapest flights under k stops.

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: [Dijkstras] Modified Dijkstra via BFS + MinHeap - Advanced Graphs/Advanced Graphs

    def findCheapestPrice(self, n: int, flights: List[List[int]], src: int, dst: int, k: int) -> int:
        
        # Dijkstras + MinHeap (Single-Source Shortest Path):
        # Dijkstras finds shortest path from a source node to all other nodes
        # in a weighted graph with non-negative edge weights

        # Modified Dijkstras For K stops:
        # Each node in the minHeap stores:
        #   (stops_so_far, current_node, cost_so_far)
        # MinHeap ensures we always expand the path with the lowest cost first
        # Only continue paths that respect the stop limit (<= k)
        
        # Build adjacency list
        adj={i:[] for i in range(n)}
        for u, v, w in flights:
            adj[u].append((v,w))

        # Distance array tracks minimum cost to reach each node
        dist = [float('inf')] * n
        dist[src] = 0

        # MinHeap stores tuples: (stops_so_far, current_node, cost_so_far)
        minHeap = []
        
        # (stops_so_far, current_node, cost_so_far)
        heapq.heappush(minHeap, (0, src, 0))

        # Process heap
        while minHeap:
            stops, node, cost = heapq.heappop(minHeap)

            # Skip if stops exceed limit
            if k < stops:
                continue

            # Explore neighbors 
            for nei, w in adj[node]:
                next_cost = cost + w

                # Only push if we improve distance
                if next_cost < dist[nei] and stops <= k:
                    dist[nei] = next_cost
                    heapq.heappush(minHeap, ((stops+1, nei, next_cost)))

        # Check if destination reachable
        if dist[dst] == float('inf'):
            return -1

        res = dist[dst]

        # overall: time complexity O(E log N) in practice, E = # of edges
        # overall: space complexity O(N + E) for adjacency list and heap    
        return res

399. Evaluate Division ::3:: - Medium

Topics: Array, String, Depth First Search, Breadth First Search, Union Find, Graph Theory, Shortest Path

Intro

You are given an array of variable pairs equations and an array of real numbers values, where equations[i] = [Ai, Bi] and values[i] represent the equation Ai / Bi = values[i]. Each Ai or Bi is a string that represents a single variable. You are also given some queries, where queries[j] = [Cj, Dj] represents the jth query where you must find the answer for Cj / Dj = ?. Return the answers to all queries. If a single answer cannot be determined, return -1.0. Note: The input is always valid. You may assume that evaluating the queries will not result in division by zero and that there is no contradiction. Note: The variables that do not occur in the list of equations are undefined, so the answer cannot be determined for them.

Example InputOutput
equations = [["a","b"],["b","c"]], values = [2.0,3.0], queries = [["a","c"],["b","a"],["a","e"],["a","a"],["x","x"]][6.00000,0.50000,-1.00000,1.00000,-1.00000]
equations = [["a","b"],["b","c"],["bc","cd"]], values = [1.5,2.5,5.0], queries = [["a","c"],["c","b"],["bc","cd"],["cd","bc"]][3.75000,0.40000,5.00000,0.20000]

Constraints:

1 ≤ equations.length ≤ 20

equations[i].length == 2

1 ≤ Ai.length, Bi.length ≤ 5

values.length == equations.length

0.0 < values[i] < 20.0

1 ≤ queries.length ≤ 20

1 ≤ Cj.length, Dj.length ≤ 5

Ai, Bi, Cj, Dj consist of lowercase English letters and digits

Abstraction

command window!

Space & Time Complexity

SolutionTime ComplexitySpace ComplexityTime RemarkSpace Remark
BugError

Brute Force:

AspectTime ComplexitySpace ComplexityTime RemarksSpace Remarks

Find the Bug:

Solution 1: [Dijkstras] Shortest Path - Graph/something

    def calcEquation(self, equations: List[List[str]], values: List[float], queries: List[List[str]]) -> List[float]:
        
        # Dijkstra Approach:
        # Treat each variable as a node.
        # Each equation a / b = k becomes an edge a->b with weight log(k) (to transform multiplication into addition)
        # Edge b->a has weight log(1/k)
        # Then shortest path in log-space sums weights, which corresponds to multiplying ratios in normal space.

        # Build graph
        # Graph representation: adjacency list with log weights
       # sc: O(E)
        graph = defaultdict(list)
        for (u, v), val in zip(equations, values):
            graph[u].append((v, math.log(val)))
            graph[v].append((u, math.log(1 / val)))

        res = []

        # Dijkstra Helper
        # tc: O((V + E) log V) per query
        # sc: O(V) for priority queue and distances
        def dijkstra(start, end):
            if start not in graph or end not in graph:
                return -1.0

            heap = [(0.0, start)]  # cumulative log weight, node
            visited = {}

            while heap:
                cum_log, node = heapq.heappop(heap)
                if node in visited:
                    continue
                visited[node] = cum_log

                # Found target
                if node == end:
                    return math.exp(cum_log)  # convert log back to product

                for nei, weight in graph[node]:
                    if nei not in visited:
                        heapq.heappush(heap, (cum_log + weight, nei))

            return -1.0

        # Process all queries
        # tc: O(Q * (V + E) log V)
        for u, v in queries:
            res.append(dijkstra(u, v))

        # overall tc: O(Q * (V + E) log V)
        # overall sc: O(V + E) for graph + O(V) heap
        return res

Solution 2: [DFS] DFS - Graph/something

    def calcEquation(self, equations: List[List[str]], values: List[float], queries: List[List[str]]) -> List[float]:
        
        # DFS Approach:
        # Treat each variable as a node in a directed graph.
        # An edge u -> v with weight w represents u / v = w.
        # To answer a query, traverse the graph from numerator to denominator multiplying weights along the path.

        # Graph Representation: adjacency list with weights
        # sc: O(E)
        graph = defaultdict(list)

        # Build graph
        # tc: O(E)
        for (u, v), val in zip(equations, values):
            graph[u].append((v, val))
            graph[v].append((u, 1 / val))

        # DFS Helper
        # tc: O(V + E) per query worst-case
        # sc: O(V) recursion stack
        def dfs(curr, target, visited, acc):
            if curr == target:
                return acc
            visited.add(curr)
            for nei, val in graph[curr]:
                if nei not in visited:
                    res = dfs(nei, target, visited, acc * val)
                    if res != -1.0:
                        return res
            return -1.0

        res = []
        for u, v in queries:
            if u not in graph or v not in graph:
                res.append(-1.0)
            else:
                visited = set()
                res.append(dfs(u, v, visited, 1.0))

        # overall tc: O(Q * (V + E))
        # overall sc: O(V + E) for graph + O(V) recursion stack
        return res

Solution 3: [BFS] BFS - Graph/something

    def calcEquation(self, equations: List[List[str]], values: List[float], queries: List[List[str]]) -> List[float]:
        
        # BFS Approach:
        # Similar to DFS, but traverse level by level.
        # Each node represents a variable, edges store division ratios.
        # BFS guarantees we find a path without recursion stack issues.

        # Graph representation
        # sc: O(E)
        graph = defaultdict(list)
        for (u, v), val in zip(equations, values):
            graph[u].append((v, val))
            graph[v].append((u, 1 / val))

        res = []

        # BFS Helper
        # tc: O(V + E) per query
        # sc: O(V) for queue + visited
        def bfs(start, end):
            if start not in graph or end not in graph:
                return -1.0
            queue = deque([(start, 1.0)])
            visited = set([start])
            while queue:
                node, acc = queue.popleft()
                if node == end:
                    return acc
                for nei, val in graph[node]:
                    if nei not in visited:
                        visited.add(nei)
                        queue.append((nei, acc * val))
            return -1.0

        for u, v in queries:
            res.append(bfs(u, v))

        # overall tc: O(Q * (V + E))
        # overall sc: O(V + E) for graph + O(V) queue
        return res

Solution 4: [Union Find] Union Find Disjoint Set Union - Graph/something

    def calcEquation(self, equations: List[List[str]], values: List[float], queries: List[List[str]]) -> List[float]:
        
        # Union-Find Approach:
        # Each variable is a node with a parent.
        # Store the ratio of each node to its parent.
        # When performing union, maintain ratios along the path.
        # To answer a query, check if two variables share the same root and compute the ratio.

        parent = {}
        ratio = {}  # node -> value relative to parent

        # Find with path compression
        # tc: O(α(N))
        def find(x):
            if parent[x] != x:
                orig_parent = parent[x]
                parent[x] = find(parent[x])
                ratio[x] *= ratio[orig_parent]
            return parent[x]

        # Union two nodes with given value
        # tc: O(α(N))
        def union(x, y, val):
            if x not in parent:
                parent[x] = x
                ratio[x] = 1.0
            if y not in parent:
                parent[y] = y
                ratio[y] = 1.0
            root_x, root_y = find(x), find(y)
            if root_x != root_y:
                parent[root_x] = root_y
                ratio[root_x] = val * ratio[y] / ratio[x]

        # Build union-find structure
        for (x, y), val in zip(equations, values):
            union(x, y, val)

        res = []
        for x, y in queries:
            if x not in parent or y not in parent:
                res.append(-1.0)
            elif find(x) != find(y):
                res.append(-1.0)
            else:
                res.append(ratio[x] / ratio[y])

        # overall tc: O(E * α(N) + Q * α(N))
        # overall sc: O(N) for parent and ratio
        return res