256 lines
12 KiB
YAML
256 lines
12 KiB
YAML
title: Network Delay Time
|
||
slug: network-delay-time
|
||
difficulty: medium
|
||
leetcode_id: 743
|
||
leetcode_url: https://leetcode.com/problems/network-delay-time/
|
||
categories:
|
||
- graphs
|
||
- heap
|
||
patterns:
|
||
- slug: heap
|
||
is_optimal: true
|
||
- slug: bfs
|
||
is_optimal: false
|
||
|
||
function_signature: "def network_delay_time(times: list[list[int]], n: int, k: int) -> int:"
|
||
|
||
test_cases:
|
||
visible:
|
||
- input: { times: [[2, 1, 1], [2, 3, 1], [3, 4, 1]], n: 4, k: 2 }
|
||
expected: 2
|
||
- input: { times: [[1, 2, 1]], n: 2, k: 1 }
|
||
expected: 1
|
||
- input: { times: [[1, 2, 1]], n: 2, k: 2 }
|
||
expected: -1
|
||
hidden:
|
||
- input: { times: [[1, 2, 1], [2, 3, 2], [1, 3, 4]], n: 3, k: 1 }
|
||
expected: 3
|
||
- input: { times: [[1, 2, 1], [2, 1, 3]], n: 2, k: 2 }
|
||
expected: 3
|
||
- input: { times: [[1, 2, 1], [2, 3, 7], [1, 3, 4], [2, 1, 2]], n: 3, k: 1 }
|
||
expected: 4
|
||
- input: { times: [[3, 5, 78], [2, 1, 1], [1, 3, 0], [4, 3, 59], [5, 3, 85], [5, 2, 22], [2, 4, 23], [1, 4, 43], [4, 5, 75], [5, 1, 15], [1, 5, 91], [4, 1, 16], [3, 2, 98], [3, 4, 22], [5, 4, 31], [1, 2, 0], [2, 5, 4], [4, 2, 51], [3, 1, 36], [2, 3, 59]], n: 5, k: 5 }
|
||
expected: 31
|
||
|
||
description: |
|
||
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] = (u_i, v_i, w_i)`, where `u_i` is the source node, `v_i` is the target node, and `w_i` 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`.
|
||
|
||
constraints: |
|
||
- `1 <= k <= n <= 100`
|
||
- `1 <= times.length <= 6000`
|
||
- `times[i].length == 3`
|
||
- `1 <= u_i, v_i <= n`
|
||
- `u_i != v_i`
|
||
- `0 <= w_i <= 100`
|
||
- All the pairs `(u_i, v_i)` are **unique** (i.e., no multiple edges)
|
||
|
||
examples:
|
||
- input: "times = [[2,1,1],[2,3,1],[3,4,1]], n = 4, k = 2"
|
||
output: "2"
|
||
explanation: "Starting from node 2, the signal reaches node 1 and node 3 at time 1. The signal then travels from node 3 to node 4, arriving at time 2. The maximum time to reach any node is 2."
|
||
- input: "times = [[1,2,1]], n = 2, k = 1"
|
||
output: "1"
|
||
explanation: "Starting from node 1, the signal reaches node 2 at time 1."
|
||
- input: "times = [[1,2,1]], n = 2, k = 2"
|
||
output: "-1"
|
||
explanation: "Starting from node 2, there is no edge leading to node 1, so node 1 can never receive the signal."
|
||
|
||
explanation:
|
||
intuition: |
|
||
Imagine you're standing at a train station and want to know the fastest time to reach every other station in a railway network. Trains run at different speeds between stations — some routes are faster than others.
|
||
|
||
The key insight is that **this is a single-source shortest path problem**. We need to find the shortest time from the starting node `k` to every other node in the graph. The answer is then the maximum of all these shortest times — because that's when the last node receives the signal.
|
||
|
||
Think of the signal spreading like ripples in a pond, but with weighted edges. The signal doesn't spread at uniform speed; it travels faster along some paths than others. We need to track when each node *first* receives the signal, which is the shortest path from `k` to that node.
|
||
|
||
**Dijkstra's algorithm** is perfect here. It greedily processes nodes in order of their distance from the source, guaranteeing that when we first visit a node, we've found the shortest path to it. Since all edge weights are non-negative (`0 <= w_i <= 100`), Dijkstra's algorithm works correctly.
|
||
|
||
approach: |
|
||
We solve this using **Dijkstra's Algorithm** with a min-heap:
|
||
|
||
**Step 1: Build the adjacency list**
|
||
|
||
- Create a graph representation where `graph[u]` contains all `(v, w)` pairs representing edges from `u` to `v` with weight `w`
|
||
- This allows O(1) lookup of all neighbors for any node
|
||
|
||
|
||
|
||
**Step 2: Initialise data structures**
|
||
|
||
- `dist`: A dictionary or array to store the shortest known distance to each node (initially empty or infinity)
|
||
- `heap`: A min-heap (priority queue) containing `(distance, node)` pairs
|
||
- Push the starting node `k` with distance `0` onto the heap
|
||
|
||
|
||
|
||
**Step 3: Process nodes in order of distance**
|
||
|
||
- Pop the node with the smallest distance from the heap
|
||
- If we've already visited this node (found in `dist`), skip it — we already found a shorter path
|
||
- Otherwise, record this distance as the shortest path to this node
|
||
- For each neighbor, calculate the new distance through this node
|
||
- If we haven't visited the neighbor yet, push `(new_distance, neighbor)` onto the heap
|
||
|
||
|
||
|
||
**Step 4: Check if all nodes are reachable**
|
||
|
||
- After processing, if we've visited all `n` nodes, return the maximum distance
|
||
- If any node is unreachable, return `-1`
|
||
|
||
|
||
|
||
The heap ensures we always process the node with the smallest known distance first, which is the key to Dijkstra's correctness.
|
||
|
||
common_pitfalls:
|
||
- title: Using BFS on a Weighted Graph
|
||
description: |
|
||
A common mistake is treating this like a standard BFS problem. BFS finds shortest paths in **unweighted** graphs (or graphs where all edges have the same weight).
|
||
|
||
With varying edge weights, BFS might visit a node through a path with fewer edges but higher total weight. For example, a direct edge with weight 10 would be processed before a two-hop path with total weight 2.
|
||
|
||
Always use Dijkstra (or Bellman-Ford) for weighted shortest paths.
|
||
wrong_approach: "Standard BFS without considering edge weights"
|
||
correct_approach: "Dijkstra's algorithm with a min-heap priority queue"
|
||
|
||
- title: Forgetting Node Labels Start at 1
|
||
description: |
|
||
The nodes are labeled `1` to `n`, not `0` to `n-1`. If you use a 0-indexed array for distances, you'll have off-by-one errors.
|
||
|
||
Either use a dictionary for distances, or create an array of size `n+1` and ignore index 0.
|
||
wrong_approach: "Using 0-indexed array without adjustment"
|
||
correct_approach: "Use a dictionary or 1-indexed array"
|
||
|
||
- title: Not Handling Unreachable Nodes
|
||
description: |
|
||
The graph may be disconnected — not all nodes may be reachable from `k`. You must check that all `n` nodes received the signal before returning the maximum time.
|
||
|
||
If `len(dist) < n` after Dijkstra completes, some nodes are unreachable, and the answer is `-1`.
|
||
wrong_approach: "Returning max distance without checking reachability"
|
||
correct_approach: "Verify all n nodes are in the distance dictionary"
|
||
|
||
- title: Re-processing Already Visited Nodes
|
||
description: |
|
||
The same node can be pushed onto the heap multiple times with different distances. When you pop a node, check if it's already been finalized (exists in your distance map).
|
||
|
||
Without this check, you might process the same node repeatedly, leading to incorrect results or TLE.
|
||
wrong_approach: "Processing every node popped from the heap"
|
||
correct_approach: "Skip nodes that have already been assigned a shortest distance"
|
||
|
||
key_takeaways:
|
||
- "**Dijkstra's algorithm** is the go-to for single-source shortest paths with non-negative weights"
|
||
- "**Min-heap** ensures we always process the closest unvisited node, maintaining the greedy invariant"
|
||
- "The answer to 'time for all nodes to receive signal' is the **maximum** of all shortest paths"
|
||
- "This pattern appears in many variations: minimum cost to reach destinations, cheapest flights, etc."
|
||
|
||
time_complexity: "O((V + E) log V). Each node is processed once, and each edge triggers at most one heap operation. With `n` nodes and `m` edges, this is O((n + m) log n)."
|
||
space_complexity: "O(V + E). We store the adjacency list (O(E)) and the distance dictionary plus heap (O(V))."
|
||
|
||
pattern_comparison: |
|
||
**Dijkstra (Heap) vs BFS: When Weights Matter**
|
||
|
||
This problem illustrates a critical distinction in graph traversal:
|
||
|
||
| Algorithm | Time | Works With | Use When |
|
||
|-----------|------|------------|----------|
|
||
| **Dijkstra (Heap)** | O((V+E) log V) | Non-negative weighted edges | Edge weights vary |
|
||
| **BFS** | O(V + E) | Unweighted or unit-weight edges | All edges have same cost |
|
||
| **Bellman-Ford** | O(V × E) | Any weights (including negative) | Negative edges possible |
|
||
|
||
**Why BFS fails here:**
|
||
BFS explores nodes in order of *hop count* (number of edges), not *total distance*. Consider:
|
||
```
|
||
A --1--> B --1--> C
|
||
A --------10------> C
|
||
```
|
||
BFS would reach C via the direct edge first (1 hop) with cost 10, missing the shorter 2-hop path with cost 2.
|
||
|
||
**Why Dijkstra works:**
|
||
The min-heap ensures we process nodes in order of *actual distance* from the source. When we first reach a node, we've found the shortest path to it (because all unvisited nodes are at least as far away).
|
||
|
||
**The key insight:** Dijkstra is essentially "weighted BFS" — the heap replaces the queue to account for edge weights.
|
||
|
||
solutions:
|
||
- approach_name: Dijkstra's Algorithm
|
||
is_optimal: true
|
||
code: |
|
||
import heapq
|
||
from collections import defaultdict
|
||
|
||
def network_delay_time(times: list[list[int]], n: int, k: int) -> int:
|
||
# Build adjacency list: graph[u] = [(v, w), ...]
|
||
graph = defaultdict(list)
|
||
for u, v, w in times:
|
||
graph[u].append((v, w))
|
||
|
||
# Min-heap: (distance, node), starting from node k
|
||
heap = [(0, k)]
|
||
# Dictionary to store shortest distance to each node
|
||
dist = {}
|
||
|
||
while heap:
|
||
# Get the node with smallest distance
|
||
d, node = heapq.heappop(heap)
|
||
|
||
# Skip if we've already found a shorter path to this node
|
||
if node in dist:
|
||
continue
|
||
|
||
# Record the shortest distance to this node
|
||
dist[node] = d
|
||
|
||
# Explore all neighbors
|
||
for neighbor, weight in graph[node]:
|
||
if neighbor not in dist:
|
||
# Push new distance to neighbor onto heap
|
||
heapq.heappush(heap, (d + weight, neighbor))
|
||
|
||
# Check if all nodes are reachable
|
||
if len(dist) == n:
|
||
# Return the maximum distance (time for last node to receive signal)
|
||
return max(dist.values())
|
||
else:
|
||
# Some nodes are unreachable
|
||
return -1
|
||
explanation: |
|
||
**Time Complexity:** O((V + E) log V) — Each edge may cause a heap push (O(log V)), and we process each node once.
|
||
|
||
**Space Complexity:** O(V + E) — The adjacency list stores all edges, and the heap/distance dict store at most V entries.
|
||
|
||
Dijkstra's algorithm processes nodes in order of their distance from the source. The min-heap ensures we always pick the closest unvisited node. When we pop a node, we've guaranteed found the shortest path to it (since all edge weights are non-negative).
|
||
|
||
- approach_name: Bellman-Ford Algorithm
|
||
is_optimal: false
|
||
code: |
|
||
def network_delay_time(times: list[list[int]], n: int, k: int) -> int:
|
||
# Initialise distances: infinity for all except source
|
||
dist = [float('inf')] * (n + 1)
|
||
dist[k] = 0
|
||
|
||
# Relax all edges n-1 times
|
||
for _ in range(n - 1):
|
||
# Flag to detect early termination
|
||
updated = False
|
||
for u, v, w in times:
|
||
# If we can reach v faster through u
|
||
if dist[u] != float('inf') and dist[u] + w < dist[v]:
|
||
dist[v] = dist[u] + w
|
||
updated = True
|
||
# Early exit if no updates in this round
|
||
if not updated:
|
||
break
|
||
|
||
# Find maximum distance (excluding index 0 and unreachable nodes)
|
||
max_dist = max(dist[1:])
|
||
|
||
# If any node is unreachable, return -1
|
||
return max_dist if max_dist < float('inf') else -1
|
||
explanation: |
|
||
**Time Complexity:** O(V * E) — We relax all edges up to V-1 times. With n=100 and edges up to 6000, this is about 600,000 operations.
|
||
|
||
**Space Complexity:** O(V) — We only store the distance array.
|
||
|
||
Bellman-Ford works by repeatedly relaxing all edges. After `n-1` iterations, all shortest paths are found (since the longest simple path has at most `n-1` edges). It's slower than Dijkstra but can handle negative edge weights. Here it's shown as an alternative approach.
|