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.
Đ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
longhoặclong longcho 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
- 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.
- Đườ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.
- 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ữ.
// 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 : [];
}
# 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.
import heapq
import math
from typing import List, Tuple
# Adjacency list: graph[u] lists the edges leaving u as (to, weight) pairs, weight >= 0.
Graph = List[List[Tuple[int, int]]]
def dijkstra(graph: Graph, source: int) -> Tuple[List[float], List[int]]:
"""Return (dist, prev) for every vertex.
dist[v]: length of the shortest path source -> v (math.inf when v cannot be reached).
prev[v]: the vertex just before v on that path (-1 for the source and unreachable vertices).
"""
dist: List[float] = [math.inf] * len(graph)
prev = [-1] * len(graph)
dist[source] = 0
# heapq keeps the smallest (distance, vertex) tuple first; ties go to the smaller vertex.
queue: List[Tuple[float, int]] = [(0, source)]
while queue:
distance, u = heapq.heappop(queue)
# Lazy deletion: u may sit in the queue several times. Only its best entry counts.
if distance > dist[u]:
continue
for v, weight in graph[u]:
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
heapq.heappush(queue, (candidate, v))
return dist, prev
def shortest_path(prev: List[int], source: int, target: int) -> List[int]:
"""Rebuild the path source -> target by walking prev backwards from the target.
Return the vertices from source to target, or [] if target cannot be reached.
"""
path = []
v = target
while v != -1:
path.append(v)
v = prev[v]
path.reverse()
return path if path[0] == source else []
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.PriorityQueue;
/**
* 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.
*/
public final class Dijkstra {
/** dist[v] for a vertex the source cannot reach. */
public static final long UNREACHABLE = Long.MAX_VALUE;
/** An edge to vertex {@code to}, with weight >= 0. The graph is a list of these per vertex. */
public record Edge(int to, int weight) {
}
/**
* dist[v]: length of the shortest path source → v ({@link #UNREACHABLE} when there is none).
* prev[v]: the vertex just before v on that path (-1 for the source and unreachable vertices).
*/
public record Result(long[] dist, int[] prev) {
}
/** A queue entry. Ties on distance go to the smaller vertex, so the result is deterministic. */
private record Entry(long dist, int vertex) {
}
private static final Comparator<Entry> CLOSEST_FIRST =
Comparator.comparingLong(Entry::dist).thenComparingInt(Entry::vertex);
private Dijkstra() {
}
/** Shortest distances from source to every vertex of graph (an adjacency list). */
public static Result dijkstra(List<List<Edge>> graph, int source) {
long[] dist = new long[graph.size()];
int[] prev = new int[graph.size()];
Arrays.fill(dist, UNREACHABLE);
Arrays.fill(prev, -1);
PriorityQueue<Entry> queue = new PriorityQueue<>(CLOSEST_FIRST);
dist[source] = 0;
queue.add(new Entry(0, source));
while (!queue.isEmpty()) {
Entry entry = queue.poll();
int u = entry.vertex();
// Lazy deletion: u may sit in the queue several times. Only its best entry counts.
if (entry.dist() > dist[u]) {
continue;
}
for (Edge edge : graph.get(u)) {
long candidate = entry.dist() + edge.weight(); // long: sums of ints can overflow int
if (candidate < dist[edge.to()]) {
// Relax the edge u → v: going through u is shorter than anything seen so far.
dist[edge.to()] = candidate;
prev[edge.to()] = u;
queue.add(new Entry(candidate, edge.to()));
}
}
}
return new Result(dist, prev);
}
/**
* Rebuilds the path source → target by walking prev backwards from the target.
* Returns the vertices from source to target, or an empty list if target cannot be reached.
*/
public static List<Integer> shortestPath(int[] prev, int source, int target) {
List<Integer> path = new ArrayList<>();
for (int v = target; v != -1; v = prev[v]) {
path.add(v);
}
Collections.reverse(path);
return path.get(0) == source ? path : List.of();
}
}
// 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.
#include <algorithm>
#include <functional>
#include <limits>
#include <queue>
#include <utility>
#include <vector>
// An edge to vertex `to`, with weight >= 0.
struct Edge {
int to;
int weight;
};
// Adjacency list: graph[u] lists the edges leaving vertex u.
using Graph = std::vector<std::vector<Edge>>;
// dist[v] for a vertex the source cannot reach.
constexpr long long UNREACHABLE = std::numeric_limits<long long>::max();
// dist[v]: length of the shortest path source -> v (UNREACHABLE when there is none).
// prev[v]: the vertex just before v on that path (-1 for the source and unreachable vertices).
struct ShortestPaths {
std::vector<long long> dist;
std::vector<int> prev;
};
ShortestPaths dijkstra(const Graph& graph, int source) {
ShortestPaths result{std::vector<long long>(graph.size(), UNREACHABLE), std::vector<int>(graph.size(), -1)};
std::vector<long long>& dist = result.dist;
std::vector<int>& prev = result.prev;
// (distance, vertex) pairs; std::greater turns the max-heap into a min-heap.
// Ties on distance go to the smaller vertex, so the result is deterministic.
using Entry = std::pair<long long, int>;
std::priority_queue<Entry, std::vector<Entry>, std::greater<Entry>> queue;
dist[source] = 0;
queue.push({0, source});
while (!queue.empty()) {
auto [distance, u] = queue.top();
queue.pop();
// Lazy deletion: u may sit in the queue several times. Only its best entry counts.
if (distance > dist[u]) {
continue;
}
for (const Edge& edge : graph[u]) {
long long candidate = distance + edge.weight; // long long: sums of ints can overflow int
if (candidate < dist[edge.to]) {
// Relax the edge u -> v: going through u is shorter than anything seen so far.
dist[edge.to] = candidate;
prev[edge.to] = u;
queue.push({candidate, edge.to});
}
}
}
return result;
}
// Rebuilds the path source -> target by walking prev backwards from the target.
// Returns the vertices from source to target, or an empty vector if target cannot be reached.
std::vector<int> shortestPath(const std::vector<int>& prev, int source, int target) {
std::vector<int> path;
for (int v = target; v != -1; v = prev[v]) {
path.push_back(v);
}
std::reverse(path.begin(), path.end());
if (path.front() != source) {
return {};
}
return 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.
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?
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í
