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
whereedges[i] = [a, b]
indicates an undirected edge between nodesa
andb
- A probability list
succProb
wheresuccProb[i]
represents the probability of successfully traversing the edgeedges[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
.
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:
- Probabilities are always between 0 and 1
- Multiplying by a probability (≤ 1) can never increase the total probability
- 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'sheapq
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 EvaluatorExample 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
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
https assets algo monster cover_photos graph svg Graph Fundamentals Tree with 0 cycle At this point you should be pretty familiar with trees A tree is a special kind of graph a connected acyclic cycle less graph A graph may contain cycle s and nodes could be disconnected A tree
Shortest Path Between A and B Prereq BFS on Graph problems graph_bfs Given an unweighted connected graph return the length of the shortest path between two nodes A and B in terms of the number of edges Assume there always exists a path between nodes A and B Input graph
https assets algo monster cover_photos heap svg Priority Queue and Heap What is the relationship between priority queue and heap Priority Queue is an Abstract Data Type and Heap is the concrete data structure we use to implement a priority queue Priority Queue A priority queue is a data structure
Want a Structured Path to Master System Design Too? Don’t Miss This!