Buổi 08 — Weighted Graphs (Dijkstra & Bellman-Ford)

Navigation

07-Union-Find | → 09-Dynamic-Programming Giai đoạn: FAANG Mastery | Độ khó: ⭐⭐⭐⭐


1. Analogy & Context

Dijkstra = GPS điều hướng theo giao thông thực tế

Hình dung bạn là một GPS navigator. Bạn muốn tìm đường rẻ nhất từ nhà đến sân bay. Cách tiếp cận của Dijkstra: luôn khám phá thành phố “rẻ nhất để đến” tiếp theo. Giống như một du khách thận trọng luôn chọn chuyến bay giá rẻ nhất có thể book ngay lúc đó.

Một khi đã “finalize” một thành phố (đặt vé xong), bạn không bao giờ quay lại — vì không thể rẻ hơn được nữa (giả sử giá vé luôn dương).

Bellman-Ford = Du khách hoang mang, kiểm tra lại MỌI tuyến đường N-1 lần

Bellman-Ford hoạt động như người mắc OCD: mỗi “round”, kiểm tra lại TẤT CẢ các chuyến bay, xem có đường nào rẻ hơn không. Sau N-1 vòng, chắc chắn đã tìm ra đường rẻ nhất.

Điểm mạnh: chịu được negative edges (discount coupons làm giảm chi phí) và phát hiện negative cycle (chu trình có thể giảm cost vô hạn — không có shortest path).

Khi nào dùng thuật toán nào?

Tình huốngThuật toán
Tất cả edge weight ≥ 0Dijkstra (nhanh hơn)
Có negative weightsBellman-Ford
Phát hiện negative cycleBellman-Ford (round thứ N)
All-pairs shortest pathFloyd-Warshall
Unweighted graphBFS ()
Dense graph, small VFloyd-Warshall

2. The FAANG Standard

Weighted Graph trong phỏng vấn FAANG L5+

  • Google Maps team: Network delay optimization, route planning
  • Amazon Logistics: Warehouse routing, delivery cost minimization
  • Uber/Lyft: Surge pricing paths, driver dispatch
  • Finance: Currency arbitrage detection (negative cycle = arbitrage opportunity!)

Taxonomy bài toán graph có trọng số:

Shortest path từ single source:
  ├── Non-negative weights     → Dijkstra  O(E log V)
  ├── Negative weights         → Bellman-Ford O(VE)
  └── DAG (directed acyclic)   → Topological Sort + Relax O(V+E)

Shortest path all-pairs:
  └── Floyd-Warshall O(V³)

Minimum Spanning Tree:
  ├── Kruskal (sort edges)     → Union-Find O(E log E)
  └── Prim (grow from node)    → Priority Queue O(E log V)

Bridge/Articulation:
  └── Tarjan's Algorithm O(V+E)

Priority Queue trong TypeScript

JavaScript không có built-in min-heap. Trong interview, thường được chấp nhận implement MinHeap đơn giản hoặc dùng sorted array (kém hiệu quả hơn nhưng OK nếu nói rõ). Một số interviewer cho phép assume có sẵn priority queue.


3. Visual Thinking (Mermaid)

3.1 Dijkstra — Step-by-step trên example graph

graph LR
    A((A,0)) -->|4| B((B,4))
    A -->|2| C((C,2))
    C -->|1| B
    B -->|3| D((D,7))
    C -->|5| D

    style A fill:#4CAF50,color:#fff
    style C fill:#FF9800,color:#fff

Bảng distances sau mỗi bước:

StepProcessABCD
Init0
1Process A (dist=0)042
2Process C (dist=2)0327
3Process B (dist=3)0326
4Process D (dist=6)0326

3.2 Bellman-Ford — Relaxation Rounds

graph LR
    S((S,0)) -->|"-1"| A((A))
    S -->|4| B((B))
    A -->|3| B
    B -->|"-2"| C((C))
    A -->|2| C

    style S fill:#4CAF50,color:#fff
Round 0 (init): S=0, A=∞, B=∞, C=∞
Round 1 (relax all edges):
  S→A: A = min(∞, 0 + (-1)) = -1
  S→B: B = min(∞, 0 + 4) = 4
  A→B: B = min(4, -1 + 3) = 2
  B→C: C = min(∞, 2 + (-2)) = 0
  A→C: C = min(0, -1 + 2) = 0 (no change)
Round 2: No changes → DONE (converged early)
Final: S=0, A=-1, B=2, C=0

3.3 Negative Cycle Detection

graph LR
    X((X)) -->|1| Y((Y))
    Y -->|"-3"| Z((Z))
    Z -->|1| X

    Note["Cycle weight: 1 + (-3) + 1 = -1 < 0<br/>NEGATIVE CYCLE!"]

    style Note fill:#FF5252,color:#fff

Negative cycle = vô hạn vòng lặp

Nếu có negative cycle, “shortest path” không tồn tại — bạn có thể đi vòng mãi mãi để giảm cost. Bellman-Ford phát hiện bằng cách: nếu Round thứ V vẫn còn relaxation → negative cycle tồn tại.


4. TypeScript Template

4.1 Graph Representation — Adjacency List với Weights

// Graph type: adjacency list, mỗi entry = [neighbor, weight]
type WeightedGraph = Map<number, [number, number][]>;
 
function buildGraph(n: number, edges: number[][]): WeightedGraph {
  const graph: WeightedGraph = new Map();
  for (let i = 0; i < n; i++) graph.set(i, []);
 
  for (const [u, v, w] of edges) {
    graph.get(u)!.push([v, w]);
    graph.get(v)!.push([u, w]); // undirected: add both directions
  }
  return graph;
}

4.2 Dijkstra — Min-Heap Priority Queue

// MinHeap implementation (đơn giản cho interview)
class MinHeap {
  private heap: [number, number][] = []; // [distance, node]
 
  push(item: [number, number]): void {
    this.heap.push(item);
    this._bubbleUp(this.heap.length - 1);
  }
 
  pop(): [number, number] | undefined {
    if (this.heap.length === 0) return undefined;
    const top = this.heap[0];
    const last = this.heap.pop()!;
    if (this.heap.length > 0) {
      this.heap[0] = last;
      this._sinkDown(0);
    }
    return top;
  }
 
  get size(): number { return this.heap.length; }
 
  private _bubbleUp(i: number): void {
    while (i > 0) {
      const parent = Math.floor((i - 1) / 2);
      if (this.heap[parent][0] <= this.heap[i][0]) break;
      [this.heap[parent], this.heap[i]] = [this.heap[i], this.heap[parent]];
      i = parent;
    }
  }
 
  private _sinkDown(i: number): void {
    const n = this.heap.length;
    while (true) {
      let smallest = i;
      const l = 2 * i + 1, r = 2 * i + 2;
      if (l < n && this.heap[l][0] < this.heap[smallest][0]) smallest = l;
      if (r < n && this.heap[r][0] < this.heap[smallest][0]) smallest = r;
      if (smallest === i) break;
      [this.heap[smallest], this.heap[i]] = [this.heap[i], this.heap[smallest]];
      i = smallest;
    }
  }
}
 
// Dijkstra — O(E log V)
function dijkstra(graph: WeightedGraph, start: number, n: number): number[] {
  const dist = new Array(n).fill(Infinity);
  dist[start] = 0;
 
  const pq = new MinHeap();
  pq.push([0, start]); // [distance, node]
 
  while (pq.size > 0) {
    const [d, u] = pq.pop()!;
 
    // Stale entry check: if we've found a better path already, skip
    if (d > dist[u]) continue;
 
    for (const [v, w] of (graph.get(u) ?? [])) {
      const newDist = dist[u] + w;
      if (newDist < dist[v]) {
        dist[v] = newDist;
        pq.push([newDist, v]);
      }
    }
  }
 
  return dist;
}

4.3 Bellman-Ford — Handles Negative Weights

// Bellman-Ford — O(VE)
// Returns distances array, or null if negative cycle detected
function bellmanFord(
  n: number,
  edges: [number, number, number][], // [u, v, weight]
  start: number
): number[] | null {
  const dist = new Array(n).fill(Infinity);
  dist[start] = 0;
 
  // Relax ALL edges exactly V-1 times (not V!)
  for (let round = 0; round < n - 1; round++) {
    let updated = false;
    for (const [u, v, w] of edges) {
      if (dist[u] !== Infinity && dist[u] + w < dist[v]) {
        dist[v] = dist[u] + w;
        updated = true;
      }
    }
    if (!updated) break; // early termination optimization
  }
 
  // Round V: if any edge can still be relaxed → negative cycle
  for (const [u, v, w] of edges) {
    if (dist[u] !== Infinity && dist[u] + w < dist[v]) {
      return null; // NEGATIVE CYCLE DETECTED
    }
  }
 
  return dist;
}

4.4 Network Delay Time — Dijkstra Application

// LeetCode #743 — Network Delay Time
// Tìm thời gian để signal reach tất cả nodes từ source k
function networkDelayTime(times: number[][], n: number, k: number): number {
  // Build graph
  const graph = new Map<number, [number, number][]>();
  for (let i = 1; i <= n; i++) graph.set(i, []);
  for (const [u, v, w] of times) graph.get(u)!.push([v, w]);
 
  // Dijkstra from k
  const dist = new Array(n + 1).fill(Infinity);
  dist[k] = 0;
  const pq = new MinHeap();
  pq.push([0, k]);
 
  while (pq.size > 0) {
    const [d, u] = pq.pop()!;
    if (d > dist[u]) continue;
    for (const [v, w] of graph.get(u)!) {
      if (dist[u] + w < dist[v]) {
        dist[v] = dist[u] + w;
        pq.push([dist[v], v]);
      }
    }
  }
 
  // Max distance among all nodes (excluding index 0 which is unused)
  const maxDist = Math.max(...dist.slice(1));
  return maxDist === Infinity ? -1 : maxDist;
}

4.5 Cheapest Flights Within K Stops — Bellman-Ford Variant

// LeetCode #787 — Full solution in Section 8

4.6 Critical Connections — Tarjan’s Bridge Finding

// LeetCode #1192 — Find Critical Connections in a Network
// Bridge = edge mà nếu xóa đi, graph bị ngắt kết nối
function criticalConnections(n: number, connections: number[][]): number[][] {
  const graph = new Map<number, number[]>();
  for (let i = 0; i < n; i++) graph.set(i, []);
  for (const [u, v] of connections) {
    graph.get(u)!.push(v);
    graph.get(v)!.push(u);
  }
 
  const disc = new Array(n).fill(-1); // discovery time
  const low = new Array(n).fill(-1);  // lowest reachable disc time
  const result: number[][] = [];
  let timer = 0;
 
  function dfs(node: number, parent: number): void {
    disc[node] = low[node] = timer++;
 
    for (const neighbor of graph.get(node)!) {
      if (neighbor === parent) continue; // don't go back to parent
      if (disc[neighbor] === -1) {
        // Tree edge: unvisited neighbor
        dfs(neighbor, node);
        low[node] = Math.min(low[node], low[neighbor]);
        // Bridge condition: low[neighbor] > disc[node]
        // means neighbor cannot reach node or earlier without this edge
        if (low[neighbor] > disc[node]) {
          result.push([node, neighbor]);
        }
      } else {
        // Back edge: update low with discovered time of neighbor
        low[node] = Math.min(low[node], disc[neighbor]);
      }
    }
  }
 
  dfs(0, -1);
  return result;
}

5. Complexity Deep-dive

Khi nào dùng thuật toán nào — Decision framework

Dijkstra:

  • Binary heap:
  • Fibonacci heap (theoretical):
  • Điều kiện: TẤT CẢ edge weight phải ≥ 0
  • Best for: Dense graphs where E >> V

Bellman-Ford:

  • Simple but slower
  • Điều kiện: Chịu được negative weights
  • Detect negative cycle:
  • Relaxation rounds: chính xác (không phải V)

Floyd-Warshall: , Space

function floydWarshall(dist: number[][]): number[][] {
  const n = dist.length;
  // For each intermediate node k
  for (let k = 0; k < n; k++) {
    for (let i = 0; i < n; i++) {
      for (let j = 0; j < n; j++) {
        if (dist[i][k] + dist[k][j] < dist[i][j]) {
          dist[i][j] = dist[i][k] + dist[k][j];
        }
      }
    }
  }
  return dist;
}
  • Good for: V ≤ 500, all-pairs shortest path
  • Cũng detect negative cycle: nếu dist[i][i] < 0 sau khi chạy

Comparison table:

DijkstraBellman-FordFloyd-Warshall
Time
Space
Negative weightsNOYESYES
Negative cycleCannot detectYESYES
Single sourceYESYESAll-pairs
Dense graphOKSlowOK if V small

6. Edge Cases & Pitfalls

Dijkstra và negative edges — counterexample

Tại sao Dijkstra FAIL với negative edges?

Graph: A --(1)--> B --((-3))--> C
       A -----(2)-----------> C

Dijkstra từ A:
  dist = [A:0, B:∞, C:∞]
  Process A: dist[B] = 1, dist[C] = 2
  Process B (dist=1): dist[C] = min(2, 1+(-3)) = -2 ???

  VẤN ĐỀ: C đã bị "finalized" với dist=2 trước khi B được process!
  Dijkstra greedy assumption (once finalized, always optimal) bị vi phạm.

Lỗi phổ biến với khoảng cách infinity

// BUG: Dùng Number.MAX_SAFE_INTEGER gây overflow khi cộng weight
const dist = new Array(n).fill(Number.MAX_SAFE_INTEGER);
// dist[u] + w có thể overflow (wrap around thành số âm)!
 
// FIX: Dùng Infinity — không overflow trong JavaScript
const dist = new Array(n).fill(Infinity);
// Infinity + 5 = Infinity (safe)
// Infinity > 100 = true (safe comparison)

Stale entries trong Priority Queue

// Dijkstra PQ có thể chứa stale entries (cũ hơn)
// Khi pop [d, u], có thể d > dist[u] (đã tìm đường tốt hơn từ trước)
// PHẢI check và skip:
const [d, u] = pq.pop()!;
if (d > dist[u]) continue; // CRITICAL: skip stale entry
 
// Không có dòng này → revisit nodes, O(E) thay vì O(E log V)

Bellman-Ford edge cases:

// BUG: Relax V lần thay vì V-1 lần
for (let round = 0; round < n; round++) { ... } // WRONG
 
// FIX: Chính xác n-1 lần để shortest path (V nodes → max V-1 edges)
for (let round = 0; round < n - 1; round++) { ... }
 
// NOTE: Sau n-1 rounds, dùng round thứ n để DETECT negative cycle
// Đừng nhầm: detect bằng round thứ n, không phải V-1

Directed vs Undirected:

// Directed graph: chỉ add one direction
graph.get(u)!.push([v, w]);
 
// Undirected graph: add cả hai chiều
graph.get(u)!.push([v, w]);
graph.get(v)!.push([u, w]); // MUST add this for undirected

Disconnected graph:

const maxDist = Math.max(...dist.slice(1));
// Nếu maxDist === Infinity → có node không reachable
return maxDist === Infinity ? -1 : maxDist;

7. Pattern Recognition

Nhận diện bài Weighted Graph trong 30 giây

Keywords → Dijkstra:

  • “shortest path” + “non-negative weights”
  • “minimum cost/time to reach from source”
  • “network delay”, “signal propagation time”
  • “path with minimum maximum weight” (binary search + Dijkstra)
  • “minimum effort” (BFS/Dijkstra hybrid)

Keywords → Bellman-Ford:

  • “shortest path” + “negative weights possible”
  • “at most K edges/stops” (K-limited Bellman-Ford)
  • “detect negative cycle” / “currency arbitrage”
  • “minimum cost flights with at most K stops”

Keywords → Floyd-Warshall:

  • “all pairs shortest path”
  • “find city with smallest number of neighbors within threshold”
  • Graph nhỏ (V ≤ 500) + need all-pairs distances

Keywords → Tarjan’s Bridge:

  • “critical connections”, “bridges in network”
  • “if we remove this connection, network splits”
  • “single points of failure”

Modified Dijkstra patterns

  • K stops limit: Dùng state (node, stops_remaining) — thêm dimension vào Dijkstra
  • Min-max path: Binary search trên answer + BFS/Dijkstra để check feasibility
  • Multiple sources: Add virtual source node với weight-0 edges to all real sources

8. Top LeetCode Problems

#Tên bàiĐộ khóAlgorithm
#743Network Delay TimeMediumDijkstra
#787Cheapest Flights K StopsMediumBellman-Ford
#1631Path With Minimum EffortMediumBinary Search + Dijkstra
#1334City Smallest ThresholdMediumFloyd-Warshall
#1192Critical ConnectionsHardTarjan’s Bridge
#332Reconstruct ItineraryHardEulerian Path (Hierholzer)
#1168Water DistributionHardMST (Kruskal)
#778Swim in Rising WaterHardBinary Search + BFS

Complete Solution: #787 Cheapest Flights Within K Stops (Bellman-Ford Variant)

Bài #787 là bài Bellman-Ford quan trọng nhất. Modified Bellman-Ford với giới hạn K stops.

Key insight: Standard Bellman-Ford chạy V-1 rounds (shortest path dùng tối đa V-1 edges). Bài này giới hạn tối đa K stops = K+1 edges → chạy đúng K+1 rounds.

// LeetCode #787 — Cheapest Flights Within K Stops
// Time: O(K * E) where E = number of flights
// Space: O(N) for distance arrays
 
function findCheapestPrice(
  n: number,
  flights: number[][],  // [from, to, price]
  src: number,
  dst: number,
  k: number
): number {
  // dist[i] = minimum cost to reach city i with at most (round) edges used
  let dist = new Array(n).fill(Infinity);
  dist[src] = 0;
 
  // Run K+1 rounds (K stops = K+1 edges)
  for (let round = 0; round <= k; round++) {
    // CRITICAL: Use a COPY of current dist to avoid using updates from same round
    // Without this, one flight might use updates from the same round (more than 1 edge per round)
    const tempDist = [...dist];
 
    for (const [from, to, price] of flights) {
      if (dist[from] !== Infinity && dist[from] + price < tempDist[to]) {
        tempDist[to] = dist[from] + price;
      }
    }
 
    dist = tempDist;
  }
 
  return dist[dst] === Infinity ? -1 : dist[dst];
}
 
// Test case 1
console.log(findCheapestPrice(
  4,
  [[0,1,100],[1,2,100],[2,0,100],[1,3,600],[2,3,200]],
  0, 3, 1
)); // Expected: 700 (0→1→3 = 700)
 
// Test case 2
console.log(findCheapestPrice(
  3,
  [[0,1,100],[1,2,100],[0,2,500]],
  0, 2, 1
)); // Expected: 200 (0→1→2 = 200)
 
// Test case 3 — tại sao cần tempDist
console.log(findCheapestPrice(
  4,
  [[0,1,1],[0,2,5],[1,2,1],[2,3,1]],
  0, 3, 1
)); // Expected: 6 (0→2→3, 1 stop), NOT 3 (0→1→2→3, 2 stops)

Tại sao phải dùng tempDist (copy)?

Nếu update dist trực tiếp trong cùng một round:

  • Round 1, process flight [0→1]: dist[1] = 0 + 1 = 1
  • Cùng round 1, process flight [1→2]: dist[2] = dist[1] + 1 = 2
  • Cùng round 1, process flight [2→3]: dist[3] = dist[2] + 1 = 3

Kết quả sai! Chỉ có 1 round nhưng đã dùng 3 edges (qua 2 stops thêm chứ không phải K=1).

Với tempDist: luôn relax từ dist (trạng thái đầu round), write vào tempDist. Đảm bảo mỗi round chỉ dùng thêm đúng 1 edge.

Alternative: Dijkstra với state (node, stops_remaining)

// Modified Dijkstra — O(E log(V*K)) — tốt hơn với dense graphs
function findCheapestPriceDijkstra(
  n: number,
  flights: number[][],
  src: number,
  dst: number,
  k: number
): number {
  const graph = new Map<number, [number, number][]>();
  for (let i = 0; i < n; i++) graph.set(i, []);
  for (const [from, to, price] of flights) graph.get(from)!.push([to, price]);
 
  // State: [cost, node, stopsRemaining]
  // dist[node][stops] = min cost to reach node with stops remaining
  const dist: number[][] = Array.from({length: n}, () => new Array(k + 2).fill(Infinity));
  dist[src][k + 1] = 0;
 
  // Min heap: [cost, node, stopsRemaining]
  // CHÚ Ý: MinHeap class ở § 4.2 được định nghĩa cho [number, number].
  // Để dùng cho tuple 3 phần tử, generalize thành number[] (key là phần tử đầu).
  // Dùng sort+shift LẶP TRONG VÒNG WHILE là O(N² log N) — sai complexity!
  const pq = new MinHeapGeneric();
  pq.push([0, src, k + 1]);
 
  while (pq.size > 0) {
    const [cost, node, stops] = pq.pop()!;
 
    if (node === dst) return cost;
    if (stops === 0) continue; // no more stops allowed
 
    for (const [neighbor, price] of (graph.get(node) ?? [])) {
      const newCost = cost + price;
      if (newCost < dist[neighbor][stops - 1]) {
        dist[neighbor][stops - 1] = newCost;
        pq.push([newCost, neighbor, stops - 1]);
      }
    }
  }
 
  return -1;
}
 
// Generic min-heap — key = item[0]. Dùng cho tuple bất kỳ độ dài.
class MinHeapGeneric {
  private heap: number[][] = [];
  push(item: number[]): void {
    this.heap.push(item);
    this._bubbleUp(this.heap.length - 1);
  }
  pop(): number[] | undefined {
    if (this.heap.length === 0) return undefined;
    const top = this.heap[0];
    const last = this.heap.pop()!;
    if (this.heap.length > 0) { this.heap[0] = last; this._sinkDown(0); }
    return top;
  }
  get size(): number { return this.heap.length; }
  private _bubbleUp(i: number): void {
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (this.heap[p][0] <= this.heap[i][0]) break;
      [this.heap[p], this.heap[i]] = [this.heap[i], this.heap[p]];
      i = p;
    }
  }
  private _sinkDown(i: number): void {
    const n = this.heap.length;
    while (true) {
      const l = 2*i + 1, r = 2*i + 2;
      let smallest = i;
      if (l < n && this.heap[l][0] < this.heap[smallest][0]) smallest = l;
      if (r < n && this.heap[r][0] < this.heap[smallest][0]) smallest = r;
      if (smallest === i) break;
      [this.heap[smallest], this.heap[i]] = [this.heap[i], this.heap[smallest]];
      i = smallest;
    }
  }
}

Bellman-Ford vs Dijkstra cho bài #787

  • Bellman-Ford: — đơn giản hơn, đủ cho interview
  • Modified Dijkstra: — tốt hơn khi K lớn, E nhỏ
  • Trong interview: Bellman-Ford được ưu tiên vì code ngắn gọn, dễ giải thích. Đề xuất Dijkstra variant như optimization nếu có thêm thời gian.

9. Self-Assessment Checklist

Kiểm tra bản thân trước khi interview

  • Giải thích tại sao Dijkstra FAIL với negative edges (có counterexample)
  • Implement Dijkstra với MinHeap và stale-entry check trong < 10 phút
  • Nhớ dùng Infinity thay vì Number.MAX_SAFE_INTEGER (tránh overflow)
  • Implement Bellman-Ford và giải thích tại sao chạy V-1 lần (không phải V)
  • Phát hiện negative cycle: chạy round thứ V, nếu còn relax được → negative cycle
  • Giải bài #787: tại sao cần tempDist copy trong mỗi round
  • Biết Floyd-Warshall: 3 vòng lặp lồng nhau, khởi tạo dist[i][i] = 0, dist[i][j] = edge weight or Infinity
  • Biết khi nào dùng Dijkstra vs Bellman-Ford vs Floyd-Warshall
  • Xử lý disconnected graph: kiểm tra dist[dst] === Infinity → return -1
  • Tarjan’s bridge: giải thích disc vs low array, điều kiện low[v] > disc[u]

Quick revision — 20 phút

Tập trung: Dijkstra cơ bản (#743 networkDelayTime), sau đó giải #787 bằng Bellman-Ford với tempDist copy. Đây là hai bài quan trọng nhất của chủ đề này.

Về Priority Queue trong interview thực tế

Nếu interviewer dùng JavaScript/TypeScript, họ biết không có built-in MinHeap. Bạn có thể: (1) implement nhanh MinHeap đơn giản, (2) dùng array sorted (nói rõ đây là O(N log N) per push, không optimal), hoặc (3) hỏi interviewer có thể assume có sẵn PQ không. Lựa chọn 3 thường được chấp nhận tại Google, Meta.