Tác giả:
Reviewer:
Bài toán tìm đường đi ngắn nhất trên đồ thị là một trong những bài toán đa dạng, có nhiều ứng dụng thực tế (như trong Google Maps, hay các bài toán networking, ...). Các dạng bài về tìm đường đi ngắn nhất cũng thường xuyên có mặt trong các kì thi lập trình.
Bài viết này sẽ giới thiệu ba thuật toán cơ bản của dạng bài tìm đường đi ngắn nhất:
Cần lưu ý rằng, có một thuật toán thông dụng khác cũng có tên thường gọi là thuật toán Floyd, dùng để tìm chu trình trong đồ thị có hướng. Bài viết này sẽ chỉ đề cập đến thuật toán tìm đường đi ngắn nhất.
Thuật toán Bellman-Ford dùng để giải quyết bài toán đường đi ngắn nhất một nguồn (Single-source shortest path), đồ thị có thể có trọng số âm.
Cho đồ thị có hướng đỉnh và cạnh, và một đỉnh nguồn là đỉnh . Mỗi cạnh có trọng số nguyên. Trọng số này có thể âm hoặc dương hoặc bằng 0. Với mỗi đỉnh từ đến . Yêu cầu xuất kết quả tại mỗi đỉnh như sau:
Input: Dòng đầu tiên gồm 3 số nguyên . dòng tiếp theo, mỗi dòng gồm ba số nguyên biểu diễn một cạnh một chiều từ đến với trọng số là .
Output: gồm dòng cho biết đường đi ngắn nhất từ đỉnh đến các đỉnh từ đến
Giới hạn bài toán :
Sample Input
7 6 4
0 1 7
2 0 1
1 2 -9
2 5 1000
3 0 7
4 3 3
Sample Output
-Infinity
-Infinity
-Infinity
3
0
-Infinity
Impossible
Xét trường hợp đơn giản hơn, khi đồ thị không có trọng số âm (tức đường đi ngắn nhất luôn tồn tại).
Thuật toán Bellman-Ford sẽ lặp nhiều lần. Ở mỗi vòng lặp, ta sẽ đi qua tất cả các cạnh trên đồ thị, so sánh đường đi đã tìm được với đường đi
Có thể chứng minh được rằng, vòng lặp trên cần thực hiện lần, mỗi lần đi qua toàn bộ cạnh, là sẽ đủ để tìm đường đi ngắn nhất.
Ở thuật toán này, đồ thị thường được lưu ở dạng danh sách cạnh.
Định nghĩa là trọng số cạnh nối từ đỉnh đến đỉnh (nếu có).
Định nghĩa mảng là đường đi ngắn nhất từ . Ban đầu, chưa có đường đi nào, với mọi , ngoại trừ .
Thực hiện lần: Xét lần lượt các cạnh trong đồ thị. Nếu , cập nhật , đồng thời cập nhật .
Độ phức tạp: Ta có một vòng lặp được thực hiện lần, mỗi lần lặp ta sẽ xử lí tất cả các cạnh trong đồ thị, như vậy độ phức tạp của thuật toán là .
Code:
const long long INF = 2000000000000000000LL;
struct Edge {
int u, v;
long long w; // cạnh từ u đến v, trọng số w
};
void bellmanFord(int n, int S, vector<Edge> &e, vector<long long> &D, vector<int> &trace) {
// e: danh sách cạnh
// n: số đỉnh
// S: đỉnh bắt đầu
// D: độ dài đường đi ngắn nhất
// trace: mảng truy vết đường đi
// INF nếu không có đường đi
// -INF nếu có đường đi âm vô tận
D.resize(n, INF);
trace.resize(n, -1);
D[S] = 0;
for(int T = 1; T < n; T++) {
for (auto E : e) {
int u = E.u;
int v = E.v;
long long w = E.w;
if (D[u] != INF && D[v] > D[u] + w) {
D[v] = D[u] + w;
trace[v] = u;
}
}
}
}
Thao tác tìm đường đi ngắn nhất từ đến khá đơn giản, ta sẽ bắt đầu từ đỉnh , sau đó truy vết theo mảng ngược về .
vector<int> trace_path(vector<int> &trace, int S, int u) {
if (u != S && trace[u] == -1) return vector<int>(0); // không có đường đi
vector<int> path;
while (u != -1) { // truy vết ngược từ u về S
path.push_back(u);
u = trace[u];
}
reverse(path.begin(), path.end()); // cần reverse vì đường đi lúc này là từ u về S
return path;
}
Thuật toán Bellman-Ford có thể xử lí được thêm trường hợp nhận biết chu trình âm, cũng như nhận biết nếu không tồn tại đường đi ngắn nhất đến một đỉnh.
Code:
// sau khi chạy xong N-1 vòng lặp Bellman-Ford
for(int T = 0; T < n; T++){
for (auto E : e) {
int u = E.u;
int v = E.v;
long long w = E.w;
if (D[u] != INF && D[v] > D[u] + w) {
// vẫn còn tối ưu được --> âm vô cực
D[v] = -INF;
trace[v] = u;
}
}
}
Một số bài toán có thể yêu cầu ta tìm một chu trình âm bất kì trong đồ thị. Ta có thể chỉnh sửa thuật toán Bellman-Ford lại như sau:
Trước hết gán đủ lần.
Ở đây, từ đến có độ dài đường đi ngắn nhất bằng , tuy nhiên đỉnh lại không thuộc chu trình âm nào.
Sau đó, sẽ thuộc một chu trình âm. Ta chỉ cần truy vết đỉnh theo mảng cho đến khi gặp lại chính nó, sẽ được một chu trình.
bool findNegativeCycle(int n, vector<long long> &D, vector<int> &trace, vector<int> &negCycle) {
// mảng D và trace đã được chạy qua thuật toán Bellman-Ford
int negStart = -1; // đỉnh bắt đầu
for (auto E : e) {
int u = E.u;
int v = E.v;
long long w = E.w;
if (D[u] != INF && D[v] > D[u] + w) {
D[v] = -INF;
trace[v] = u;
negStart = v; // đã tìm thấy -INF
}
}
if (negStart == -1) return false; // không có chu trình âm
int u = negStart;
for (int i = 0; i < n; i++) {
u = trace[u]; // đưa u về chu trình âm
}
negCycle = vector<int>(1, u);
for (int v = trace[u]; v != u; v = trace[u]) {
negCycle.push_back(v); // truy vết một vòng
}
reverse(negCycle.begin(), negCycle.end());
return true;
}
Thuật toán Dijkstra dùng để giải quyết bài toán đường đi ngắn nhất một nguồn (Single-source shortest path), đồ thị trọng số không âm.
Cho một đồ thị có hướng với đỉnh (được đánh số từ đến ), cạnh có hướng, có trọng số, và một đỉnh nguồn . Trọng số của tất cả các cạnh đều không âm. Yêu cầu tìm ra đường đi ngắn nhất từ đỉnh tới tất cả các đỉnh còn lại (hoặc cho biết nếu không có đường đi).
Sample Input
7 8 0
0 2 7
0 1 1
0 3 4
2 5 8
5 3 3
4 5 6
1 4 3
2 4 3
Sample Output
0
1
7
4
4
10
-1
Hình ảnh của Test ví dụ. Ở đồ thị này, đỉnh nguồn là đỉnh , đường đi ngắn nhất từ đến các đỉnh đến là . Riêng đỉnh không có đường đi đến.
Giống như thuật toán Bellman-Ford, thuật toán Dijkstra cũng tối ưu hóa đường đi bằng cách xét các cạnh , so sánh hai đường đi sẵn có với đường đi .
Thuật toán hoạt động bằng cách duy trì một tập hợp các đỉnh trong đó ta đã biết chắc chắn đường đi ngắn nhất. Mỗi bước, thuật toán sẽ chọn ra một đỉnh mà chắc chắn sẽ không thể tối ưu hơn nữa, sau đó tiến hành tối ưu các đỉnh khác dựa trên các cạnh đi ra từ đỉnh . Sau bước, tất cả các đỉnh đều sẽ được chọn, và mọi đường đi tìm được sẽ là ngắn nhất.
Cụ thể hơn, thuật toán sẽ duy trì đường đi ngắn nhất đến tất cả các đỉnh. Ở mỗi bước, chọn đường đi có tổng trọng số nhỏ nhất trong tất cả các đường đi đang được duy trì. Sau đó tiến hành tối ưu các đường đi bằng cách thử kéo dài thành như đã mô tả trên.
Ta sẽ minh họa thuật toán bằng một đồ thị như hình. Định nghĩa:
Đỉnh được tô đen (đỉnh 0) sẽ là đỉnh nguồn.
Ban đầu, ,
Sau bước này, ,
Sau bước này, ,
Sau bước này, ,
Đến đây, tất cả các đỉnh đều đã được đánh dấu. Thuật toán kết thúc. Đường đi ngắn nhất tìm được từ đỉnh là .
Ở thuật toán này, ta sẽ lưu đồ thị dưới dạng danh sách kề. Ta định nghĩa như sau:
Ta sẽ lặp lần quá trình sau:
Độ phức tạp thuật toán: Ta có lần lặp:
Như vậy độ phức tạp của cách cài đặt cơ bản sẽ là .
Code:
const long long INF = 2000000000000000000LL;
struct Edge{
int v;
long long w;
};
void dijkstra(int n, int S, vector<vector<Edge>> E, vector<long long> &D, vector<int> &trace) {
D.resize(n, INF);
trace.resize(n, -1);
vector<bool> P(n, 0);
D[S] = 0;
for (int i = 0; i < n; i++) {
int uBest; // tìm đỉnh u chưa dùng, có khoảng cách nhỏ nhất
long long Max = INF;
for (int u = 0; u < n; u++) {
if(D[u] < Max && P[u] == false) {
uBest = u;
Max = D[u];
}
}
// cải tiến các đường đi qua u
int u = uBest;
P[u] = true;
for(auto x : E[u]) {
int v = x.v;
long long w = x.w;
if(D[v] > D[u] + w) {
D[v] = D[u] + w;
trace[v] = u;
}
}
}
}
Nhận xét rằng bước đầu tiên: "Tìm đỉnh có nhỏ nhất và ", có thể được cải tiến. Ta có thể sử dụng cấu trúc dữ liệu Heap (cụ thể là Min Heap) hoặc cây nhị phân tìm kiếm để cải tiến bước này.
Mỗi lần tối ưu , ta phải push vào Heap một lần. Với mỗi lần push vào trong Heap, ta đều phải pop ra lại một lần. Do có tối đa lần tối ưu, độ phức tạp của thuật toán là .
Độ phức tạp sau khi cải tiến: . Lưu ý rằng với lớn thì độ phức tạp này không tốt hơn cài đặt cơ bản. Chứng minh như sau:
Code:
const long long INF = 2000000000000000000LL;
struct Edge{// kiểu dữ liệu tự tạo để lưu thông số của một cạnh.
int v;
long long w;
};
struct Node{// kiểu dữ liệu để lưu đỉnh u và độ dài của đường đi ngắn nhất từ s đến u.
int u;
long long Dist_u;
};
struct cmp{
bool operator() (Node a, Node b) {
return a.Dist_u > b.Dist_u;
}
};
void dijkstraSparse(int n, int s, vector<vector<Edge>> &E, vector<long long> &D, vector<int> &trace) {
D.resize(n, INF);
trace.resize(n, -1);
vector<bool> P(n, 0);
D[s] = 0;
priority_queue<Node, vector<Node>, cmp> h; // hàng đợi ưu tiên, sắp xếp theo dist[u] nhỏ nhất trước
h.push({s, D[s]});
while(!h.empty()) {
Node x = h.top();
h.pop();
int u = x.u;
if(P[u] == true) // Đỉnh u đã được chọn trước đó, bỏ qua
continue;
P[u] = true; // Đánh dấu đỉnh u đã được chọn
for(auto e : E[u]) {
int v = e.v;
long long w = e.w;
if(D[v] > D[u] + w) {
D[v] = D[u] + w;
h.push({v, D[v]});
trace[v] = u;
}
}
}
}
Cũng giống như thuật toán Bellman-Ford, để tìm lại đường đi ngắn nhất từ về , ta sẽ bắt đầu từ đỉnh , sau đó truy vết theo mảng ngược về .
vector<int> trace_path(vector<int> &trace, int S, int u) {
if (u != S && trace[u] == -1) return vector<int>(0); // không có đường đi
vector<int> path;
while (u != -1) { // truy vết ngược từ u về S
path.push_back(u);
u = trace[u];
}
reverse(path.begin(), path.end()); // cần reverse vì đường đi lúc này là từ u về S
return path;
}
Thuật toán Floyd-Warshll dùng để giải quyết bài toán đường đi ngắn nhất mọi cặp đỉnh (All-pairs shortest path), đồ thị có thể có trọng số âm.
Cho đồ thị gồm đỉnh và một ma trận trọng số , trong đó ô cho biết rằng có một đường đi trực tiếp từ với trọng số là . Yêu cầu tìm đường đi ngắn nhất giữa mọi cặp đỉnh trên đồ thị.
Giới hạn bài toán:
Input: Dòng đầu tiên gồm số nguyên dương , dòng tiếp theo, mỗi dòng gồm số biểu diễn một ma trận trong đó ô cho biết đường đi trực tiếp từ có trọng số là .
Output: Ma trận kết quả trong đó giá trị ô là đường đi ngắn nhất từ .
Sample Input
5
0 4 2 1 6
7 0 1 2 4
2 3 0 1 2
4 5 5 0 1
6 8 7 3 0
Sample Output
0 4 2 1 2
3 0 1 2 3
2 3 0 1 2
4 5 5 0 1
6 8 7 3 0
Ý tưởng của thuật toán này là: "Liệu chúng ta có thể chèn một đỉnh vào đường đi ngắn nhất giữa 2 đỉnh và ?".
Ta nhận thấy có một cấu trúc đệ quy, chia nhỏ bài toán ở đây. Ý tưởng này cho phép chúng ta thực hiện một thuật toán mang hương vị quy hoạch động như sau:
Đến đây ta có thể sử dụng trực tiếp công thức quy hoạch động để cài đặt thuật toán. Tuy nhiên, để đảm bảo bộ nhớ, ta có thể tính các với lần lượt từ đến , và khi cài đặt chỉ cần lưu lại .
Định nghĩa:
Đồ thị sẽ được lưu dưới dạng ma trận kề. Ban đầu sẽ khởi tạo mọi vì khi chưa tối ưu gì thì đường đi trực tiếp luôn là đường đi ngắn nhất.
Thuật toán chỉ cần một vòng lặp xét mọi đỉnh như một đỉnh trung gian. Tiếp theo đến là 2 vòng lặp , , có ý nghĩa thử chèn đỉnh vào giữa đường đi từ đến .
Độ phức tạp của thuật toán là .
Code:
Thuật toán có thể được cài đặt rất dễ dàng chỉ với 3 vòng lặp:
void init_trace(vector<vector<int>> &trace) {
int n = trace.size();
for (int u = 0; u < n; u++) {
for (int v = 0; v < n; v++) {
trace[u][v] = u;
}
}
}
void floydWarshall(int n, vector<vector<long long>> &w, vector<vector<long long>> &D, vector<vector<int>> &trace) {
D = w;
init_trace(trace); // nếu cần dò đường đi
for (int k = 0; k < n; k++) {
for (int u = 0; u < n; u++) {
for (int v = 0; v < n; v++) {
if (D[u][v] > D[u][k] + D[k][v]) {
D[u][v] = D[u][k] + D[k][v];
trace[u][v] = trace[k][v];
}
}
}
}
}
Giống như hai thuật toán Bellman-Ford và Dijkstra, để tìm đường đi từ đến , ta sẽ bắt đầu từ , truy ngược về theo mảng trace đã tìm được.
vector<int> trace_path(vector<vector<int>> &trace, int u, int v) {
vector<int> path;
while (v != u) { // truy vết ngược từ v về u
path.push_back(v);
v = trace[u][v];
}
path.push_back(u);
reverse(path.begin(), path.end()); // cần reverse vì đường đi từ v ngược về u
return path;
}
Bellman-Ford | Dijkstra (cơ bản) | Dijkstra (trên đồ thị thưa) | Floyd-Warshall | |
---|---|---|---|---|
Bài toán giải quyết | Đường đi ngắn nhất một nguồn | Đường đi ngắn nhất một nguồn | Đường đi ngắn nhất một nguồn | Đường đi ngắn nhất mọi cặp đỉnh |
Độ phức tạp | ||||
Sử dụng được cho trọng số âm | Có | Không | Không | Có |
Tìm được chu trình âm | Có | Không | Không | Không |
Trong trường hợp có chu trình âm, thuật toán Floyd-Warshall có thể phải tính toán đến những giá trị rất nhỏ (về phía số âm), đủ để gây ra hiện tượng tràn số (thậm chí với tương đối nhỏ). Cần phải chú ý đặc biệt đến trường hợp này khi cài đặt.
D[u][v] = max(D[u][v], -INF)
ngay sau mỗi lần tối ưu, chặn không cho xuống dưới hằng số âm vô tận.Thuật toán Floyd-Warshall có thứ tự 3 vòng lặp là thay vì (đỉnh trung gian phải được đặt ở vòng lặp ngoài cùng), đây là một nhầm lẫn tương đối phổ biến khi cài đặt.
Heap không phải là cấu trúc dữ liệu duy nhất có thể sử dụng khi cài đặt Dijkstra dành cho đồ thị thưa. Ta có thể sử dụng bất cứ cấu trúc dữ liệu nào hỗ trợ các thao tác "xóa khỏi tập hợp", "cập nhật phần tử trong tập hợp", "tìm phần tử nhỏ nhất trong tập hợp". Thực tế cây nhị phân tìm kiếm (std::set
trong C++) cũng là một lựa chọn phổ biến khi cài đặt thuật toán này.
Với đồ thị thưa, không có trọng số âm, thay vì sử dụng thuật toán Floyd, ta có thể chạy thuật toán Dijkstra cải tiến lần với đỉnh nguồn để tìm đường đi ngắn nhất giữa mọi cặp đỉnh, với độ phức tạp tốt hơn thuật toán Floyd:
Thuật toán Bellman-Ford:
Thuật toán Dijkstra:
Thuật toán Floyd-Warshall: