Tiệm Công Nghệ

Đang hiện gợi ý.

Đang mở danh mục của tiệm…
↑↓ để di chuyển↵ để mởesc để đóng
Tìm kiếm Tiệm Công Nghệ

Bài giải thích miễn phí · Video · Code · Trắc nghiệm

Thuật toán Dijkstra

Tìm đường đi ngắn nhất từ một đỉnh trên đồ thị có trọng số: luôn chốt đỉnh gần nhất chưa xong tiếp theo, nhờ hàng đợi ưu tiên. Có khoảng cách, cách dựng lại đường đi và lý do cạnh âm làm thuật toán sai.

Độ khó
Trung bình
Thời gian
O((V + E) log V)
Bộ nhớ
O(V + E)

Tự tay chạy thử

Chạy từng bước theo nhịp của bạn — đây là Pathfinding Maze, chạy ngay trên trang.

Lấy code này

Đang chạyPathfinding Maze

Đang tải bản xem trước…

Cách hoạt động

Ý tưởng, một ví dụ chạy thử, và những bẫy nên tránh.

Ý tưởng

Hãy tưởng tượng một mạng ống nước, mỗi đoạn ống cần một số giây để nước chảy qua. Đổ nước vào một góc: nước lan ra mọi hướng với cùng tốc độ, và thời điểm nước chạm tới một ngã rẽ lần đầu chính là thời gian ngắn nhất để đi tới đó. Nếu có đường nào khác nhanh hơn, dòng nước theo đường đó đã tới trước rồi.

Thuật toán Dijkstra mô phỏng đúng quá trình lan đó. Nó giữ một khoảng cách tạm thời cho mọi đỉnh và lặp đi lặp lại một việc: chốt đỉnh gần nhất chưa được chốt. Hàng đợi ưu tiên đưa ra đỉnh đó thật nhanh. Khi chốt một đỉnh, ta xét các cạnh đi ra từ nó, xem đi qua đỉnh này có giúp đỉnh kề có đường ngắn hơn không. Bước kiểm tra đó gọi là nới lỏng cạnh (relax).

Chạy thử từng bước

Đồ thị vô hướng với các cạnh A–B (4), A–C (1), C–B (2), B–D (1), C–D (5), D–E (3). Bắt đầu từ A. Mọi khoảng cách ban đầu là ∞, trừ A bằng 0.

Bước Lấy ra Chuyện gì xảy ra Hàng đợi sau đó
1 (0, A) A→B: B = 4. A→C: C = 1 (1, C), (4, B)
2 (1, C) C→B: 1 + 2 = 3 < 4, nên B = 3 qua C. C→D: D = 6 (3, B), (4, B), (6, D)
3 (3, B) B→D: 3 + 1 = 4 < 6, nên D = 4 qua B (4, B), (4, D), (6, D)
4 (4, B) Cũ rồi: B đã là 3. Bỏ qua (4, D), (6, D)
5 (4, D) D→E: E = 7 (6, D), (7, E)
6 (6, D) Cũ rồi: D đã là 4. Bỏ qua (7, E)
7 (7, E) Không cải thiện được gì rỗng

Khoảng cách cuối cùng: A 0, C 1, B 3, D 4, E 7. Để ý bước 2: cạnh thẳng A→B tốn 4, nhưng đi vòng qua C chỉ tốn 3. Dijkstra tìm ra được vì nó chốt C (khoảng cách 1) trước B.

Dựng lại đường đi. Mỗi khi một khoảng cách được cải thiện, ta ghi lại đỉnh liền trước vào mảng prev. Muốn biết đường tới E, đi ngược lại: E đến từ D, D từ B, B từ C, C từ A. Đảo chiều lại là được A → C → B → D → E. Nếu đi ngược mãi mà không về tới đỉnh nguồn, nghĩa là không có đường tới đích, và shortestPath trả về đường rỗng.

Xóa lười (lazy deletion)

Nhìn lại bước 4 và bước 6. Khi B giảm từ 4 xuống 3, mục cũ (4, B) vẫn nằm trong hàng đợi. Bản trong giáo trình dùng thao tác "giảm khóa" (decrease-key) để sửa mục đó tại chỗ, nhưng hàng đợi ưu tiên trong hầu hết thư viện chuẩn (heapq, PriorityQueue của Java, priority_queue của C++) không làm được. Vì vậy ta cứ đẩy thêm một mục mới, và lúc lấy ra thì bỏ qua mục đã cũ: nếu khoảng cách lấy ra lớn hơn dist[u] thì bỏ qua. Đơn giản, mà trên thực tế cũng nhanh không kém.

Vì sao thuật toán đúng

Khẳng định: khi một đỉnh u được lấy ra khỏi hàng đợi với khoảng cách hiện tại của nó, khoảng cách đó là cuối cùng.

Giả sử có một đường khác ngắn hơn tới u. Đường này bắt đầu bên trong tập các đỉnh đã chốt, nên đến một lúc nào đó nó phải đi qua một cạnh x → y từ đỉnh đã chốt x sang đỉnh chưa chốt y. Khi chốt x, cạnh đó đã được nới lỏng, nên y đã có trong hàng đợi với khoảng cách không lớn hơn độ dài đoạn đường tới y. Cạnh không âm, nên phần đường còn lại chỉ làm dài thêm: mục của y không vượt quá cả con đường, mà con đường thì ngắn hơn khoảng cách của u. Vậy hàng đợi lẽ ra đã đưa y ra trước. Mâu thuẫn.

Mấu chốt là câu "phần đường còn lại chỉ làm dài thêm". Đó là chỗ duy nhất chứng minh cần trọng số không âm.

Vì sao cạnh âm làm thuật toán sai

Lấy A→B (2), A→C (3), C→B (−2). Dijkstra chốt B với khoảng cách 2, nhưng A → C → B chỉ tốn 1. B đã chốt thì không bao giờ được xét lại, nên đáp án sai.

Khi có cạnh âm, hãy dùng Bellman-Ford. Nó chậm hơn, O(V·E), nhưng xử lý được trọng số âm và còn phát hiện được chu trình âm, nơi khái niệm "đường ngắn nhất" không còn ý nghĩa. Cộng thêm một hằng số vào mọi cạnh cho hết âm thì không được: cách đó phạt những đường đi qua nhiều cạnh và làm thay đổi đường nào là ngắn nhất.

Độ phức tạp

Mỗi đỉnh được chốt một lần, mỗi cạnh được nới lỏng nhiều nhất một lần, đúng lúc đỉnh đầu của nó được chốt. Vậy có tối đa E lần đẩy vào và E lần lấy ra, mỗi lần tốn O(log E) = O(log V), vì E ≤ V². Tổng cộng: thời gian O((V + E) log V), bộ nhớ O(V + E) cho đồ thị, hai mảng khoảng cách và prev, và hàng đợi.

Với đồ thị rất dày, bản quét mảng để tìm đỉnh nhỏ nhất thay vì dùng đống chạy trong O(V²), và tốt hơn trong trường hợp đó.

Lỗi hay gặp

  • Đánh dấu một đỉnh đã xong ngay lúc đẩy vào hàng đợi, thay vì lúc lấy ra. Khoảng cách của nó vẫn có thể giảm tiếp, nên kết quả sai.
  • Quên kiểm tra mục cũ. Kết quả vẫn đúng, nhưng bạn làm lại việc thừa cho từng mục đã cũ.
  • Tràn số. Tổng các trọng số kiểu int có thể vượt 2³¹. Dùng long hoặc long long cho khoảng cách, và đừng bao giờ cộng thêm vào "vô cực".
  • Đồ thị vô hướng. Nhớ thêm mỗi cạnh theo cả hai chiều.
  • Dùng BFS cho đồ thị có trọng số. BFS chỉ đúng khi mọi cạnh có cùng trọng số.

Khi nào nên dùng, khi nào không

Dùng để tìm đường ngắn nhất từ một đỉnh nguồn với trọng số không âm: bản đồ đường đi, định tuyến mạng (OSPF chính là Dijkstra), bản đồ trong game, lưới có chi phí di chuyển. Nếu chỉ cần một đích, có thể dừng ngay khi đích được lấy ra khỏi hàng đợi.

Chọn thuật toán khác khi có trọng số âm (Bellman-Ford), đồ thị không trọng số (BFS thường, O(V + E)), cần mọi cặp đỉnh trên đồ thị nhỏ và dày (Floyd-Warshall), hoặc chỉ một đích kèm ước lượng khoảng cách tốt (A*, tức Dijkstra cộng thêm hàm heuristic).

Biến thể khi phỏng vấn

  1. Network delay time. Chạy Dijkstra từ đỉnh nguồn. Đáp án là khoảng cách lớn nhất, hoặc −1 nếu có đỉnh không tới được.
  2. Đường rẻ nhất trên lưới ("path with minimum effort", "swim in rising water"). Mỗi ô là một đỉnh, bốn ô kề là các cạnh. Không cần dựng đồ thị; sinh các ô kề ngay khi cần.
  3. Chuyến bay rẻ nhất với tối đa K điểm dừng. Thuật toán gốc bỏ qua giới hạn điểm dừng. Hãy cho trạng thái là (thành phố, số điểm dừng đã dùng), hoặc chạy Bellman-Ford K + 1 vòng.

Code

Cùng một thuật toán, từng bước như nhau, bằng 4 ngôn ngữ.

JavaScript
// Dijkstra's algorithm: shortest distances from one source in a graph whose edge weights are all >= 0.
// A priority queue always hands us the closest vertex we have not finished yet — and with no
// negative edges, nothing found later can beat that distance, so it is final.

/** @typedef {[to: number, weight: number]} Edge an edge to `to`, with weight >= 0 */

/**
 * The priority queue: a binary min-heap of [distance, vertex] pairs, stored in an array where
 * the children of index i live at 2i + 1 and 2i + 2. Ties on distance go to the smaller vertex,
 * so the result never depends on the order entries happened to arrive in.
 */
class MinHeap {
  /** @type {Array<[number, number]>} */
  #items = [];

  get size() {
    return this.#items.length;
  }

  /** @param {[number, number]} item */
  push(item) {
    this.#items.push(item);
    this.#siftUp(this.#items.length - 1);
  }

  /** Removes and returns the smallest pair. Call only when size > 0. */
  pop() {
    const items = this.#items;
    const top = items[0];
    const last = items.pop();
    if (items.length > 0) {
      items[0] = last; // move the last leaf to the root, then let it sink to its place
      this.#siftDown(0);
    }
    return top;
  }

  /** @param {number} child */
  #siftUp(child) {
    while (child > 0) {
      const parent = (child - 1) >> 1;
      if (!isLess(this.#items[child], this.#items[parent])) return;
      this.#swap(child, parent);
      child = parent;
    }
  }

  /** @param {number} parent */
  #siftDown(parent) {
    while (true) {
      const smallest = this.#smallestOf(parent);
      if (smallest === parent) return;
      this.#swap(parent, smallest);
      parent = smallest;
    }
  }

  /**
   * The index of the smallest pair among `index` and its (up to two) children.
   * @param {number} index
   */
  #smallestOf(index) {
    let smallest = index;
    for (const child of [2 * index + 1, 2 * index + 2]) {
      if (child < this.#items.length && isLess(this.#items[child], this.#items[smallest])) smallest = child;
    }
    return smallest;
  }

  /**
   * @param {number} a
   * @param {number} b
   */
  #swap(a, b) {
    [this.#items[a], this.#items[b]] = [this.#items[b], this.#items[a]];
  }
}

/**
 * Orders [distance, vertex] pairs by distance, then by vertex.
 * @param {[number, number]} a
 * @param {[number, number]} b
 */
function isLess(a, b) {
  if (a[0] !== b[0]) return a[0] < b[0];
  return a[1] < b[1];
}

/**
 * Shortest distances from `source` to every vertex.
 *
 * @param {Edge[][]} graph adjacency list: graph[u] lists the edges leaving vertex u
 * @param {number} source the start vertex, 0 <= source < graph.length
 * @returns {{ dist: number[], prev: number[] }}
 *   dist[v]: length of the shortest path source → v (Infinity when v cannot be reached);
 *   prev[v]: the vertex just before v on that path (-1 for the source and unreachable vertices)
 */
export function dijkstra(graph, source) {
  const dist = new Array(graph.length).fill(Infinity);
  const prev = new Array(graph.length).fill(-1);
  const queue = new MinHeap();

  dist[source] = 0;
  queue.push([0, source]);

  while (queue.size > 0) {
    const [distance, u] = queue.pop();
    // Lazy deletion: u may sit in the queue several times. Only its best entry counts.
    if (distance > dist[u]) continue;

    for (const [v, weight] of graph[u]) {
      const candidate = distance + weight;
      if (candidate < dist[v]) {
        // Relax the edge u → v: going through u is shorter than anything seen so far.
        dist[v] = candidate;
        prev[v] = u;
        queue.push([candidate, v]);
      }
    }
  }
  return { dist, prev };
}

/**
 * Rebuilds the shortest path source → target by walking `prev` backwards from the target.
 *
 * @param {number[]} prev the `prev` array returned by `dijkstra`
 * @param {number} source the source `dijkstra` ran from
 * @param {number} target where the path should end
 * @returns {number[]} the vertices from source to target, or [] if target cannot be reached
 */
export function shortestPath(prev, source, target) {
  const path = [];
  for (let v = target; v !== -1; v = prev[v]) path.push(v);
  path.reverse();
  return path[0] === source ? path : [];
}

Hỏi về thuật toán này

Kẹt ở bước nào? Hỏi thêm ví dụ hoặc vì sao nó chạy đúng.

Câu trả lời do AI viết dựa trên trang này và có thể sai. Nội dung trên trang mới là chuẩn.

Tự kiểm tra

10 câu hỏi nhanh xem bạn đã nắm chưa.

Câu 1 · Một đồ thị có hướng gồm 6 đỉnh với các cạnh sau (từ → đến, trọng số): 0→1 (7), 0→2 (2), 2→1 (3), 1→3 (1), 2→3 (8), 3→4 (2), 2→4 (9). Đỉnh 5 không có cạnh nào. Sau dijkstra(graph, 0), dist[4] bằng bao nhiêu và shortestPath trả về đường nào tới đích 4?

Làm trắc nghiệm

Dùng ngay trong dự án

  • Animation

    Pathfinding Maze

    Mê cung neon vẽ được, nơi BFS, Dijkstra, A* và tìm kiếm tham lam đua nhau tới đích từng bước

    Miễn phí