Tổng quan
Các bài toán về tìm đường đi ngắn nhất và biến tướng của nó luôn xuất hiện rất nhiều trong các cuộc thi lập trình thi đấu bởi sự đa dạng trong cách đưa ra đề bài và sử dụng. Một trong những thuật toán tìm đường đi ngắn nhất được sử dụng phổ biến đó là thuật toán Dijkstra.
Theo Wikipedia, thuật toán Dijkstra, mang tên của nhà khoa học máy tính người Hà Lan Edsger Dijkstra vào năm và ấn bản năm , là một thuật toán giải quyết bài toán đường đi ngắn nhất nguồn đơn trong một đồ thị có hướng không có cạnh mang trọng số âm. Thuật toán thường được sử dụng trong định tuyến với một chương trình con trong các thuật toán đồ thị hay trong công nghệ Hệ thống định vị toàn cầu (GPS).
Ví dụ: Chúng ta dùng các đỉnh của đồ thị để biểu diễn các thành phố và các cạnh để biểu diễn các đường nối giữa chúng. Khi đó trọng số các cạnh có thể xem như độ dài của các con đường (do đó không âm). Chúng ta cần di chuyển từ thành phố đến thành phố . Thuật toán Dijkstra sẽ giúp chỉ ra đường đi ngắn nhất có thể đi.
Trọng số không âm của các cạnh của đồ thị mang tính tổng quát hơn khoảng cách hình học giữa hai đỉnh đầu mút của chúng. Ví dụ, với 3 đỉnh đường đi có thể ngắn hơn so với đường đi trực tiếp .
Kĩ thuật
Mô tả thuật toán
Để dễ hình dung cách hoạt động của thuật toán, ta xét ví dụ sau.
Cho đồ thị như hình dưới:
Nguồn ảnh: https://www.codingame.com
Hãy tìm đường đi ngắn nhất giữa đỉnh và các đỉnh còn lại trong đồ thị.
Ta sẽ áp dụng thuật toán Dijkstra cho đồ thị trên, hãy nghiên cứu thật kĩ từng công đoạn một vì nếu chỉ bỏ qua một chi tiết nhỏ ta sẽ không thể hiểu thuật toán được:
- Trong toàn bộ quá trình thực thi thuật toán, ta sẽ đánh dấu tất cả các đỉnh bằng khoảng cách nhỏ nhất từ đỉnh tới các đỉnh đó. Với đỉnh , khoảng cách sẽ là (tất nhiên rồi ). Với các đỉnh còn lại, ban đầu chưa biết khoảng cách nhỏ nhất nên ta sẽ đánh dấu mỗi đỉnh bằng giá trị vô cùng (∞).
Chúng ta cũng đánh dấu đỉnh hiện tại đang xét (ban đầu là đỉnh nguồn ). Ở đồ thị trên, ta đánh dấu bằng một chấm đỏ ở đỉnh (đỉnh hiện tại đang xét).
- Okay! Giờ chúng ta kiểm tra các đỉnh kề với đỉnh hiên tại đang xét (đỉnh - current node) đó là đỉnh A, B, D (không cần theo thứ tự cụ thể). Hãy bắt đầu với đỉnh . Tớ sẽ lấy giá trị dùng đánh dấu ở đỉnh (giá trị ) cộng với trọng số của cạnh nối đỉnh đang xét (đỉnh ) với đỉnh (trong trường hợp này là 7), ta được . Oke roài, sau đó ta sẽ so sánh giá trị vừa tính được với giá trị dùng đánh dấu ở đỉnh (vô cùng). Ta sẽ đánh dấu đỉnh nhận giá trị nhỏ hơn là ( nhỏ hơn vô cùng).
Đỉnh đã xong, giờ ta kiểm tra đỉnh kề . Ta cộng (giá trị dùng đánh dấu ở đỉnh ) với (trọng số của cạnh nối đỉnh với đỉnh ) và được . Dễ thấy giá trị nhỏ hơn vô cùng (giá trị dùng đánh dấu ở đỉnh ). Do đó ta sẽ đánh dấu đỉnh nhận giá trị .
Tương tự với đỉnh :
Yay! Ta đã xét xong các đỉnh kề với đỉnh đang xét (đỉnh ). Tớ sẽ đánh một dấu tick ở đỉnh để thể hiện rằng các đỉnh kề nó đã được xét xong.
- Giờ ta sẽ xét sang một đỉnh mới (gọi là đỉnh hiện tại đang xét - current node). Đỉnh này phải thỏa mãn điều kiện là chưa được xét và được đánh dấu với giá trị nhỏ nhất. Đó là đỉnh (bạn đọc kiểm tra lại hai điều kiện trên với đỉnh nhé ). Tương tự như đỉnh , ta sẽ đánh dấu đỉnh bằng một dấu chấm đỏ.
Okay, bây giờ ta lặp lại thuật toán như đã làm với đỉnh . Chúng ta kiểm tra các đỉnh kề với đỉnh (Current node), nhớ rằng không kiểm tra những đỉnh đã xét rồi (đỉnh ). Vậy là ta chỉ cần kiểm tra đỉnh .
Với , Ta cộng (giá trị được đánh dấu ở đỉnh ) với (trọng số của cạnh nối đỉnh với đỉnh ) và được . Vì nên ta cập nhật giá trị dùng đánh dấu ở đỉnh là .
Sau đó, ta đánh dấu đỉnh đã xét xong bằng dấu tick và chọn một đỉnh để xét mới (current node mới) thỏa mãn điều kiện chưa được xét và giá trị đang đánh dấu cho đỉnh đó nhỏ nhất, đó là đỉnh .
- Ta tiếp tục lặp lại thuật toán. Lần này, ta kiểm tra đỉnh kề với là và . Với , ta có . , vẫn giữ là giá trị đánh dấu cho đỉnh . Với , ta có ( vô cùng) nên ta cập nhật đỉnh nhận giá trị đánh dấu mới là . Ta đánh dấu đã xét xong và chuyển sang đỉnh .
-
Tiếp tục, tương tự thôi ) ta cập nhật được giá trị đánh dấu cho đỉnh mới là . Đỉnh xét xong, chuyển sang đỉnh cuối cùng là .
-
Đỉnh lại không có đỉnh kề mà chưa xét nào, do đó ta không phải kiểm tra gì cả. Đánh dấu đỉnh đã xét xong.
Done!!! Vậy là tất cả các đỉnh đều đã được xét. Giờ khoảng cách ngắn nhất từ đỉnh tới các đỉnh còn lại chính là giá trị đang đánh dấu của đỉnh đó. Ví dụ, từ đến khoảng cách ngắn nhất là , từ đến khoảng cách ngắn nhất là ,...
Tóm lại thuật toán thực hiện các bước như sau:
1. Đánh dấu đỉnh ban đầu (đỉnh nguồn) là $0$ và các đỉnh còn lại là vô cùng.
2. Gọi đỉnh chưa xét với giá trị đánh dấu nhỏ nhất là $C$ (current node).
3. Với mỗi đỉnh kề $N$ (neighbour) với đỉnh $C$: Cộng giá trị đang đánh dấu của đỉnh $C$ với trọng số của cạnh nối đỉnh $C$ với đỉnh $N$. Nếu được kết quả nhỏ hơn giá trị đang đánh dấu ở đỉnh $N$ ta cập nhật giá trị đánh dấu cho đỉnh $N$ mới (Xem ví dụ trên để hiểu hơn).
4. Đánh dấu đỉnh $C$ đã xét xong.
5. Nếu vẫn còn đỉnh chưa xét, lặp lại bước $2$.
Khi đó ta có mã giả cho thuật toán như sau:
void Dijkstra(Graph, source):
dist[source] := 0 // Distance from source to source is set to 0
for each vertex v in Graph: // Initializations
if v ≠ source
dist[v] := infinity // Unknown distance function from source to each node set to infinity
add v to Q // All nodes initially in Q
while Q is not empty: // The main loop
v = vertex in Q with min dist[v] // In the first run-through, this vertex is the source node
remove v from Q
for each neighbor u of v: // where neighbor u has not yet been removed from Q.
dist[u] = min(dist[u], dist[v] + length(v, u)
return dist[]
end function
Chứng minh thuật toán
Hiểu cơ bản ý tưởng của thuật toán rồi, nhưng hãy đặt câu hỏi, tại sao thuật toán này hoạt động? Ta sẽ chứng minh tính đúng đắn của thuật toán này. Hãy tham khảo cách chứng minh dưới đây:
Định lý: Khi thuật toán Dijkstra kết thúc, lưu độ dài chính xác đường đi ngắn nhất từ đỉnh tới đỉnh .
Chứng minh:
Gọi Ký hiệu là độ dài của đường đi ngắn nhất từ đến trong đồ thị . Ta sẽ thực hiện chứng minh bằng phản chứng:
Định nghĩa hàm như sau: .
Giả sử rằng tồn tại ít nhất một đỉnh sao cho khi được xóa khỏi hàng đợi ưu tiên . Cụ thể, gọi là đỉnh đầu tiên được xóa khỏi có tính chất trên. Khi đó, là chuỗi các đỉnh trên đường đi ngắn nhất từ đến , trong đó là số đỉnh trên đường đi ngắn nhất này.
Xét vòng lặp while mà ta dequeue từ . Coi một đỉnh được đánh dấu nếu tại thời điểm này đã bị xóa khỏi . Gọi là đỉnh đầu tiên trong không được đánh dấu, tức là mọi đỉnh trong đường dẫn con được đánh dấu và do đó đã bị xóa khỏi .
Trong phần còn lại của chứng minh, ta sử dụng hai dữ kiện quan trọng sau:
Dữ kiện 1: Vì là đỉnh đầu tiên bị xóa khỏi nên tại thời điểm loại bỏ nó (ở cuối thuật toán), ta có , theo đó với mọi . Nói cách khác, vì tất cả các đỉnh trong đường dẫn con đã bị xóa khỏi trước , mỗi đỉnh này phải được tính đúng các giá trị .
Dữ kiện 2: Với bất kỳ , đường dẫn con của phải là đường đi ngắn nhất từ $u_i$ đến (nếu không, ta có thể thay thế đường dẫn con này trong bằng một đường dẫn ngắn hơn và kết quả thu được một đường đi ngắn hơn từ đến ).
Sử dụng hai dữ kiện này, ta có hai trường hợp dựa trên giá trị của :
-
Trường hợp 1: , và do đó là đỉnh không được đánh dấu đầu tiên trong đường đi. Do đó, được đánh dấu và bị xóa khỏi . Theo dữ kiện 1, ta biết rằng , tức là đỉnh ngay trước trong chứa giá trị d [·] đúng. Do đó, sau khi ta gọi trên lần lặp tại vị trí ta xóa khỏi , được đặt không lớn hơn . Tuy nhiên, vì theo định nghĩa là đường đi ngắn nhất từ tới , hay . Do đó, khi ta gọi hàm , trên thực tế, ta đã lưu trữ tại . Đây là một mâu thuẫn vì ban đầu ta giả định rằng thuật toán sẽ kết thúc với lưu trữ một giá trị lớn hơn độ dài của đường đi ngắn nhất từ đến .
-
Trường hợp 2: , do đó là một đỉnh không được đánh dấu trên chưa bị xóa khỏi hàng đợi ưu tiên trước lần lặp này. Tuy nhiên, vì là đỉnh không bị đánh dấu đầu tiên dọc theo đường đi, ta biết rằng đã bị xóa khỏi , và vì vậy theo dữ kiện 1, ta được . Bây giờ, hãy quan sát rằng ở lần lặp mà ta đã xóa khỏi , gọi hàm , hay , ta có:
(vì ta gọi hàm )
(sử dụng dữ kiện 1: được đánh dấu và )
(dữ kiện 2: là đường đi con của )
(vì cạnh có trọng số dương)
(giả thiết ban đầu)
Ta đã chỉ ra rằng từ bất đẳng thức thứ hai đến bất đẳng thức cuối là sử dụng giả thuyết cạnh có trọng số dương — bởi vì phải có ít nhất một cạnh nữa trong đường đi sau , đường đi ngắn nhất từ đến phải nhỏ hơn đường đi ngắn nhất từ đến . Trên thực tế, thuật toán Dijkstra không chính xác khi các cạnh có trọng số âm tồn tại trong đồ thị.
Vậy là một đỉnh vẫn nằm trong , và ta đang dequeue đỉnh mặc dù bất đẳng thức trên chỉ ra rằng . Đây là mâu thuẫn. Vậy điều giả sử sai. Ta có điều phải chứng minh.
Cài đặt
Code:
#include <stdio.h>
#include <vector>
#include <queue>
#include <algorithm>
using namespace std;
const int oo = 9999999; // Đánh dấu là vô cực
typedef pair<int, int> ii;
int pre[1812];
int graph[14][14];
vector<int>path[1812]; //Lưu đường đi tới đỉnh i
vector<ii> a[1812];
int n, m;
int d[1812];
void dijkstra(int source) {
priority_queue<ii, vector<ii>, greater<ii>> pq; //Hàng đợi ưu tiên, giá trị second bé nhất cho lên đầu
for (int i = 1; i <= n; i++)
d[i] = oo; //gan gia tri d[i] bang vo cuc
d[source] = 0;
pq.push(ii(0, source));
while (pq.size()) {
int u = pq.top().second; //Lấy giá trị second của phần tử đỉnh của pq
int du = pq.top().first;
pq.pop();
if (du != d[u]){ //Điều kiện để bỏ qua pair mà giá trị d[u] được cập nhật từ lần lặp trước.
continue;
}
for (int i = 0; i < a[u].size(); i++) { //Xét đỉnh kề đỉnh u
int v = a[u][i].second;
int uv = a[u][i].first;
if (d[v] > du + uv) { //Nếu tổng của đỉnh đang xét + trọng số của cạnh nối 2 đỉnh u và v nhỏ hơn thì cập nhật d[v] mới
pre[v] = u; // Đỉnh trước v là đỉnh u
d[v] = du + uv; // Lấy giá trị nhỏ hơn
pq.push(ii(d[v], v)); // Thêm pair vào pq
}
}
}
}
int main() {
int p, q, w, source;
//printf("Nhap dinh nguon: ");
scanf("%d", &source);
//printf("Nhap so dinh: ");
scanf("%d", &n);
for(int i = 1; i <= n; i++){
for(int j = 1; j <= n; j++){
scanf("%d", &graph[i][j]);
if(graph[i][j] != 0 && graph[i][j] != INT_MAX){
a[i].push_back(ii(graph[i][j], j));
}
}
}
dijkstra(source);
for (int i = 1; i <= n; i++){
if(i == source) continue;
printf("=== Dinh %d\n", i);
if(d[i] == oo ){
printf("khong co duong di tu nguon toi dinh nay \n");
continue;
}
printf("d( %d -> %d ) = %d\n", source, i, d[i]);
for(int tmp = i; tmp != source; tmp = pre[tmp]){
path[i].push_back(tmp);
}
path[i].push_back(source);
reverse(path[i].begin(),path[i].end());
printf("Duong di: ");
for(int j = 0; j < path[i].size(); j++){
if(j == path[i].size()-1){
printf("%d", path[i][j]);
break;
}
else printf("%d -> ", path[i][j]);
}
printf("\n");
}
return 0;
}
Độ phức tạp thời gian với là số đỉnh và là số cạnh
Ứng dụng thực tế của thuật toán Dijkstra
Một số ứng dụng của thuật toán Dijkstra trong thực tế:
- Tìm đường đi ngắn nhất trên bản đồ.
- Ứng dụng trong mạng xã hội.
- Ứng dụng trong hệ thống thông tin di động
- Ứng dụng trong hàng không
Tổng kết
Vậy trong bài viết vừa rồi, ta đã tìm hiểu các nội dung trong thuật toán Dijkstra. Thuật toán Dijkstra được áp dụng rất nhiều trong các cuộc thi lập trình. Bên cạnh đó, có thêm một số các thuật toán tìm đường đi ngắn nhất khác, chúng ta sẽ bàn luận trong bài viết sau.
Tài liệu tham khảo
- Giải thuật và lập trình - Thầy Lê Minh Hoàng
- cp-algorithms.com
- Handbook Competitive Programming - Antti Laaksonen
- Competitve programming 3 - Steven Halim, Felix Halim
- courses.cs.duke.edu