Facebook Pixel

1514. Path with Maximum Probability

Problem Description

You have an undirected weighted graph with n nodes (labeled from 0 to n-1). The graph is represented by:

  • An edge list edges where edges[i] = [a, b] indicates an undirected edge between nodes a and b
  • A probability list succProb where succProb[i] represents the probability of successfully traversing the edge edges[i]

Given a start node and an end node, you need to find the path from start to end that has the maximum probability of success.

The probability of successfully traversing a path is calculated by multiplying the probabilities of all edges along that path. For example, if a path goes through edges with probabilities 0.5, 0.8, and 0.6, the total probability would be 0.5 * 0.8 * 0.6 = 0.24.

Return the maximum probability of successfully reaching the end node from the start node. If no path exists between them, return 0.

Your answer will be considered correct if it differs from the actual answer by at most 1e-5.

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

Intuition

This problem asks us to find the path with maximum probability, which is essentially finding the "best" path in a weighted graph. This immediately brings to mind shortest path algorithms like Dijkstra's algorithm.

The key insight is that we can adapt Dijkstra's algorithm for this problem. While Dijkstra traditionally finds minimum distances by adding edge weights, here we need to find maximum probabilities by multiplying edge probabilities.

Think about how probabilities work along a path: if we go from node A to B with probability 0.8, and then from B to C with probability 0.5, our total probability is 0.8 * 0.5 = 0.4. Since we're multiplying values between 0 and 1, longer paths generally have lower probabilities (unlike distances which increase).

We can use a greedy approach similar to Dijkstra's: always explore the node we can reach with the highest probability so far. This works because:

  1. Probabilities are always between 0 and 1
  2. Multiplying by a probability (≤ 1) can never increase the total probability
  3. Once we've found the best way to reach a node, exploring it first ensures we find the best paths through it

The algorithm maintains the maximum probability to reach each node from the start. We use a max-heap (priority queue) to always process the most promising node next. When we pop a node from the heap, we know we've found the best way to reach it, and we can use it to potentially improve paths to its neighbors.

The modification from standard Dijkstra is straightforward: instead of adding distances, we multiply probabilities, and instead of finding minimum values, we find maximum values.

Learn more about Graph, Shortest Path and Heap (Priority Queue) patterns.

Solution Approach

We implement a modified Dijkstra's algorithm using a max-heap to find the path with maximum probability.

Step 1: Build the Graph

First, we construct an adjacency list representation of the graph. For each node, we store its neighbors along with the probability of the connecting edge:

g: List[List[Tuple[int, float]]] = [[] for _ in range(n)]
for (a, b), p in zip(edges, succProb):
    g[a].append((b, p))
    g[b].append((a, p))

Since the graph is undirected, we add edges in both directions.

Step 2: Initialize Data Structures

We need:

  • A max-heap pq to store nodes with their current maximum probability. Python's heapq is a min-heap, so we store negative probabilities to simulate a max-heap
  • A dist array to track the maximum probability to reach each node from the start
pq = [(-1, start_node)]  # Start with probability 1 (stored as -1)
dist = [0] * n
dist[start_node] = 1

Step 3: Process Nodes Using Dijkstra's Algorithm

We repeatedly extract the node with highest probability from the heap:

while pq:
    w, a = heappop(pq)
    w = -w  # Convert back to positive probability

For optimization, we skip processing if we've already found a better path to this node:

if dist[a] > w:
    continue

Step 4: Relax Edges

For the current node a, we check all its neighbors b. If going through a gives a better probability to reach b, we update it:

for b, p in g[a]:
    if (t := w * p) > dist[b]:  # New probability = current prob * edge prob
        dist[b] = t
        heappush(pq, (-t, b))  # Add to heap with negative value

The relaxation condition w * p > dist[b] checks if the path through node a to node b has higher probability than the best known path to b.

Step 5: Return Result

After processing all reachable nodes, dist[end_node] contains the maximum probability to reach the end node from the start node. If the end node is unreachable, this value remains 0.

The algorithm guarantees finding the maximum probability path because:

  • We always process nodes in order of decreasing probability from the start
  • Once a node is processed with probability p, any future path to it must have probability ≤ p
  • The greedy choice of processing highest probability nodes first ensures optimality

Ready to land your dream job?

Unlock your dream job with a 5-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's walk through a small example to illustrate the solution approach.

Example Graph:

  • Nodes: 0, 1, 2
  • Edges: [[0,1], [1,2], [0,2]]
  • Probabilities: [0.5, 0.5, 0.2]
  • Start: 0, End: 2

This creates a triangle graph where:

  • Path 0→1→2 has probability: 0.5 × 0.5 = 0.25
  • Path 0→2 has probability: 0.2

Step 1: Build Graph

g[0] = [(1, 0.5), (2, 0.2)]
g[1] = [(0, 0.5), (2, 0.5)]
g[2] = [(1, 0.5), (0, 0.2)]

Step 2: Initialize

pq = [(-1, 0)]  # Start at node 0 with probability 1
dist = [1, 0, 0]  # dist[0]=1, others are 0

Step 3-4: Process Nodes

Iteration 1:

  • Pop (-1, 0) from heap → node=0, prob=1
  • Check neighbors of node 0:
    • Node 1: 1 × 0.5 = 0.5 > dist[1]=0 ✓ Update
    • Node 2: 1 × 0.2 = 0.2 > dist[2]=0 ✓ Update
  • Push (-0.5, 1) and (-0.2, 2) to heap
  • State: dist = [1, 0.5, 0.2], pq = [(-0.5, 1), (-0.2, 2)]

Iteration 2:

  • Pop (-0.5, 1) from heap → node=1, prob=0.5
  • Check neighbors of node 1:
    • Node 0: 0.5 × 0.5 = 0.25 < dist[0]=1 ✗ Skip
    • Node 2: 0.5 × 0.5 = 0.25 > dist[2]=0.2 ✓ Update
  • Push (-0.25, 2) to heap
  • State: dist = [1, 0.5, 0.25], pq = [(-0.25, 2), (-0.2, 2)]

Iteration 3:

  • Pop (-0.25, 2) from heap → node=2, prob=0.25
  • Since we reached the end node, we could stop here
  • Check neighbors of node 2:
    • Node 1: 0.25 × 0.5 = 0.125 < dist[1]=0.5 ✗ Skip
    • Node 0: 0.25 × 0.2 = 0.05 < dist[0]=1 ✗ Skip
  • No updates needed

Iteration 4:

  • Pop (-0.2, 2) from heap → node=2, prob=0.2
  • Since dist[2]=0.25 > 0.2, skip processing (already found better path)

Step 5: Return Result

  • dist[2] = 0.25
  • The maximum probability path from 0 to 2 is 0.25 (via path 0→1→2)

The algorithm correctly identified that going through node 1 (0→1→2) with probability 0.25 is better than the direct path (0→2) with probability 0.2.

Solution Implementation

1from typing import List, Tuple
2from heapq import heappush, heappop
3
4class Solution:
5    def maxProbability(
6        self,
7        n: int,
8        edges: List[List[int]],
9        succProb: List[float],
10        start_node: int,
11        end_node: int,
12    ) -> float:
13        # Build adjacency list representation of the graph
14        # Each node maps to a list of (neighbor, probability) tuples
15        graph: List[List[Tuple[int, float]]] = [[] for _ in range(n)]
16      
17        # Populate the graph with bidirectional edges and their probabilities
18        for edge, probability in zip(edges, succProb):
19            node_a, node_b = edge
20            graph[node_a].append((node_b, probability))
21            graph[node_b].append((node_a, probability))
22      
23        # Priority queue for Dijkstra's algorithm
24        # Using negative probability as Python's heapq is a min-heap
25        # Format: (negative_probability, node)
26        priority_queue = [(-1.0, start_node)]
27      
28        # Track maximum probability to reach each node
29        max_probability = [0.0] * n
30        max_probability[start_node] = 1.0
31      
32        # Dijkstra's algorithm to find maximum probability path
33        while priority_queue:
34            # Extract node with highest probability (least negative value)
35            current_prob_neg, current_node = heappop(priority_queue)
36            current_prob = -current_prob_neg
37          
38            # Skip if we've already found a better path to this node
39            if max_probability[current_node] > current_prob:
40                continue
41          
42            # Explore all neighbors of current node
43            for neighbor, edge_prob in graph[current_node]:
44                # Calculate new probability to reach neighbor through current node
45                new_probability = current_prob * edge_prob
46              
47                # Update if we found a better path to neighbor
48                if new_probability > max_probability[neighbor]:
49                    max_probability[neighbor] = new_probability
50                    heappush(priority_queue, (-new_probability, neighbor))
51      
52        # Return the maximum probability to reach the end node
53        return max_probability[end_node]
54
1class Solution {
2    public double maxProbability(int n, int[][] edges, double[] succProb, int start_node, int end_node) {
3        // Build adjacency list representation of the graph
4        // Each node maps to a list of pairs (neighbor, probability)
5        List<Pair<Integer, Double>>[] graph = new List[n];
6        Arrays.setAll(graph, index -> new ArrayList<>());
7      
8        // Populate the graph with bidirectional edges and their probabilities
9        for (int i = 0; i < edges.length; i++) {
10            int[] edge = edges[i];
11            int nodeA = edge[0];
12            int nodeB = edge[1];
13            double probability = succProb[i];
14          
15            // Add bidirectional edges
16            graph[nodeA].add(new Pair<>(nodeB, probability));
17            graph[nodeB].add(new Pair<>(nodeA, probability));
18        }
19      
20        // Distance array to store maximum probability to reach each node
21        double[] maxProbability = new double[n];
22        maxProbability[start_node] = 1.0;  // Starting node has probability 1
23      
24        // Priority queue for Dijkstra's algorithm (max heap based on probability)
25        // Using negative value for max heap behavior
26        PriorityQueue<Pair<Integer, Double>> priorityQueue = 
27            new PriorityQueue<>(Comparator.comparingDouble(pair -> -pair.getValue()));
28        priorityQueue.offer(new Pair<>(start_node, 1.0));
29      
30        // Dijkstra's algorithm to find maximum probability path
31        while (!priorityQueue.isEmpty()) {
32            Pair<Integer, Double> current = priorityQueue.poll();
33            int currentNode = current.getKey();
34            double currentProbability = current.getValue();
35          
36            // Skip if we've already found a better path to this node
37            if (maxProbability[currentNode] > currentProbability) {
38                continue;
39            }
40          
41            // Explore all neighbors of current node
42            for (Pair<Integer, Double> neighbor : graph[currentNode]) {
43                int nextNode = neighbor.getKey();
44                double edgeProbability = neighbor.getValue();
45                double newProbability = currentProbability * edgeProbability;
46              
47                // Update if we found a path with higher probability
48                if (newProbability > maxProbability[nextNode]) {
49                    maxProbability[nextNode] = newProbability;
50                    priorityQueue.offer(new Pair<>(nextNode, newProbability));
51                }
52            }
53        }
54      
55        // Return the maximum probability to reach the end node
56        return maxProbability[end_node];
57    }
58}
59
1class Solution {
2public:
3    double maxProbability(int n, vector<vector<int>>& edges, vector<double>& succProb, int start_node, int end_node) {
4        // Define pair type for storing (probability, node)
5        using ProbabilityNodePair = pair<double, int>;
6      
7        // Build adjacency list representation of the graph
8        vector<vector<ProbabilityNodePair>> graph(n);
9        for (int i = 0; i < edges.size(); ++i) {
10            int nodeA = edges[i][0];
11            int nodeB = edges[i][1];
12            double probability = succProb[i];
13          
14            // Add bidirectional edges with their probabilities
15            graph[nodeA].emplace_back(probability, nodeB);
16            graph[nodeB].emplace_back(probability, nodeA);
17        }
18      
19        // Initialize maximum probability array for each node
20        vector<double> maxProb(n, 0.0);
21        maxProb[start_node] = 1.0;
22      
23        // Priority queue for Dijkstra's algorithm (max-heap by default)
24        priority_queue<ProbabilityNodePair> pq;
25        pq.emplace(1.0, start_node);
26      
27        // Process nodes using modified Dijkstra's algorithm
28        while (!pq.empty()) {
29            auto [currentProb, currentNode] = pq.top();
30            pq.pop();
31          
32            // Skip if we've already found a better probability for this node
33            if (maxProb[currentNode] > currentProb) {
34                continue;
35            }
36          
37            // Explore all neighbors of the current node
38            for (auto [edgeProb, neighborNode] : graph[currentNode]) {
39                double newProb = currentProb * edgeProb;
40              
41                // Update if we found a path with higher probability
42                if (newProb > maxProb[neighborNode]) {
43                    maxProb[neighborNode] = newProb;
44                    pq.emplace(newProb, neighborNode);
45                }
46            }
47        }
48      
49        // Return the maximum probability to reach the end node
50        return maxProb[end_node];
51    }
52};
53
1/**
2 * Finds the maximum probability of reaching the end node from the start node
3 * using a modified Dijkstra's algorithm
4 * 
5 * @param n - Number of nodes in the graph
6 * @param edges - Array of edges where each edge is [nodeA, nodeB]
7 * @param succProb - Array of success probabilities for each edge
8 * @param start_node - Starting node index
9 * @param end_node - Target node index
10 * @returns Maximum probability of reaching end_node from start_node
11 */
12function maxProbability(
13    n: number,
14    edges: number[][],
15    succProb: number[],
16    start_node: number,
17    end_node: number,
18): number {
19    // Initialize max priority queue with custom priority function
20    // Priority is based on the probability value (higher probability = higher priority)
21    const priorityQueue = new MaxPriorityQueue({ priority: (item: [number, number]) => item[0] });
22  
23    // Build adjacency list representation of the graph
24    // Each node maps to an array of [neighborNode, edgeProbability] pairs
25    const graph: [number, number][][] = Array.from({ length: n }, () => []);
26  
27    // Populate the graph with bidirectional edges and their probabilities
28    for (let i = 0; i < edges.length; ++i) {
29        const [nodeA, nodeB] = edges[i];
30        const probability = succProb[i];
31      
32        // Add edge in both directions since the graph is undirected
33        graph[nodeA].push([nodeB, probability]);
34        graph[nodeB].push([nodeA, probability]);
35    }
36  
37    // Initialize distance array to track maximum probability to reach each node
38    const maxProbabilities = Array.from({ length: n }, () => 0);
39    maxProbabilities[start_node] = 1; // Starting node has probability 1
40  
41    // Add starting node to priority queue with probability 1
42    priorityQueue.enqueue([1, start_node]);
43  
44    // Process nodes using Dijkstra's algorithm
45    while (!priorityQueue.isEmpty()) {
46        const [currentProbability, currentNode] = priorityQueue.dequeue().element;
47      
48        // Skip if we've already found a better path to this node
49        if (maxProbabilities[currentNode] > currentProbability) {
50            continue;
51        }
52      
53        // Explore all neighbors of the current node
54        for (const [neighborNode, edgeProbability] of graph[currentNode]) {
55            // Calculate new probability to reach the neighbor
56            const newProbability = currentProbability * edgeProbability;
57          
58            // Update if we found a better path to the neighbor
59            if (newProbability > maxProbabilities[neighborNode]) {
60                maxProbabilities[neighborNode] = newProbability;
61                priorityQueue.enqueue([newProbability, neighborNode]);
62            }
63        }
64    }
65  
66    // Return the maximum probability to reach the end node
67    return maxProbabilities[end_node];
68}
69

Time and Space Complexity

Time Complexity: O(m × log m) where m is the number of edges.

The algorithm uses Dijkstra's algorithm with a min-heap (priority queue) to find the path with maximum probability. The graph construction takes O(m) time to iterate through all edges. In the main algorithm, each edge can be processed at most once (when relaxed), and each relaxation involves a heap push operation which takes O(log k) where k is the current heap size. In the worst case, the heap can contain up to m elements (since each edge relaxation adds an element to the heap), so each heap operation takes O(log m). Since we process at most m edges and perform up to m heap operations, the total time complexity is O(m × log m).

Space Complexity: O(m) where m is the number of edges.

The adjacency list g stores all edges twice (once for each direction in the undirected graph), requiring O(2m) = O(m) space. The priority queue pq can contain at most O(m) elements in the worst case (one entry per edge relaxation). The dist array requires O(n) space. Since in a connected graph n ≤ m + 1, and we need at least n - 1 edges to connect n nodes, the dominant term is O(m). Therefore, the overall space complexity is O(m).

Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Using Standard Dijkstra with Addition Instead of Multiplication

A common mistake is treating this as a standard shortest path problem and adding probabilities instead of multiplying them. Since probabilities must be multiplied along a path, the standard Dijkstra's relaxation condition needs modification.

Incorrect approach:

# Wrong: Adding probabilities
new_probability = current_prob + edge_prob

Correct approach:

# Correct: Multiplying probabilities
new_probability = current_prob * edge_prob

2. Precision Loss with Very Small Probabilities

When dealing with many edges, multiplying probabilities can lead to extremely small numbers that may underflow to zero or lose precision. For graphs with many edges, the product of probabilities can become smaller than the machine epsilon.

Solution: Use logarithms to convert multiplication to addition:

# Instead of storing probabilities, store log probabilities
import math

# During initialization
max_log_prob = [float('-inf')] * n
max_log_prob[start_node] = 0  # log(1) = 0

# During relaxation
new_log_prob = current_log_prob + math.log(edge_prob)
if new_log_prob > max_log_prob[neighbor]:
    max_log_prob[neighbor] = new_log_prob
  
# Final result
return math.exp(max_log_prob[end_node]) if max_log_prob[end_node] > float('-inf') else 0

3. Not Handling Disconnected Components

If the start and end nodes are in different connected components, there's no path between them. The code handles this correctly by initializing all distances to 0, but developers might forget to handle this case when modifying the algorithm.

Key insight: The algorithm naturally handles this since unreachable nodes will maintain their initial probability of 0.

4. Forgetting the Optimization Check

Omitting the optimization check if max_probability[current_node] > current_prob: continue won't affect correctness but can significantly impact performance. Without it, the same node might be processed multiple times unnecessarily.

Why it matters: In dense graphs or graphs with many paths, the same node can be added to the priority queue multiple times with different probabilities. Processing outdated entries wastes computation time.

5. Using Min-Heap Incorrectly

Python's heapq is a min-heap, so we need to negate probabilities to simulate a max-heap. A common error is forgetting to negate when pushing or popping from the heap.

Common mistakes:

# Wrong: Forgetting to negate when pushing
heappush(priority_queue, (new_probability, neighbor))

# Wrong: Forgetting to convert back when popping
current_prob = current_prob_neg  # Should be -current_prob_neg

Correct approach:

# Push with negative value
heappush(priority_queue, (-new_probability, neighbor))

# Pop and convert back to positive
current_prob_neg, current_node = heappop(priority_queue)
current_prob = -current_prob_neg
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

What is the best way of checking if an element exists in an unsorted array once in terms of time complexity? Select the best that applies.


Recommended Readings

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

Load More