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:
  - bfs
  - heap

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

    &nbsp;

    **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

    &nbsp;

    **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

    &nbsp;

    **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`

    &nbsp;

    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))."

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.
