Cho đồ thị có hướng đỉnh, cạnh, trọng số có thể âm. Tìm đường đi ngắn nhất từ đỉnh tới mọi đỉnh, hoặc báo rằng có một chu trình có tổng âm làm đường đi ngắn đi mãi, trong .
Dijkstra (bài 3.5.1) chỉ đúng khi mọi cạnh không âm. Bellman-Ford chậm hơn nhưng không cần điều kiện đó: nó thử nới mọi cạnh của đồ thị, lặp lại lượt; sau chừng ấy lượt, mọi khoảng cách đã đúng, và nếu lượt thứ vẫn còn nới được thì đồ thị có chu trình âm. Khi chỉ vài trăm và cần khoảng cách giữa mọi cặp đỉnh, Floyd-Warshall làm việc đó bằng ba vòng lặp lồng nhau, .
IBellman-Ford
Nhắc lại từ bài 3.5.1: gọi là độ dài đường tốt nhất đã biết từ nguồn tới , ban đầu và các đỉnh khác bằng ; nới cạnh dài là gán khi . Dijkstra chốt đỉnh có nhỏ nhất với lý do mọi đường vòng tới nó đều dài thêm. Khi có cạnh âm, lý do này không còn đúng. Chẳng hạn với ba cạnh dài , dài , dài , Dijkstra lấy đỉnh ra trước với , trong khi đường chỉ dài .
Bellman-Ford không cố chọn thứ tự đúng. Mỗi lượt, nó nới mọi cạnh của đồ thị theo một thứ tự bất kỳ, và lặp lại lượt.
for (ll i = 1; i <= n; i++) d[i] = INF;
d[1] = 0;
for (ll it = 1; it < n; it++)
for (ll i = 1; i <= m; i++)
if (d[ea[i]] != INF && d[ea[i]] + ew[i] < d[eb[i]]) d[eb[i]] = d[ea[i]] + ew[i];
Độ phức tạp: , với , cần khoảng phép tính.
Ở đây cạnh thứ đi từ ea[i] tới eb[i], dài ew[i]. Điều kiện d[ea[i]] != INF bảo đảm chỉ nới từ những đỉnh đã tới được từ nguồn; nếu bỏ đi, một cạnh âm nối hai đỉnh chưa tới được sẽ làm giảm thành một số “gần vô cùng”, vừa sai vừa dễ tràn số.
Chứng minh
Giả sử đồ thị không có chu trình âm tới được từ nguồn. Khi đó với mỗi đỉnh tới được, có một đường ngắn nhất từ tới không đi qua đỉnh nào hai lần, vì nếu có chu trình thì bỏ nó đi, đường không dài thêm. Đường đó có nhiều nhất cạnh.
Ta chứng minh: sau lượt thứ , mọi đỉnh có một đường ngắn nhất gồm không quá cạnh đều đã có đúng. Với chỉ có nguồn, đúng. Giả sử đúng sau lượt ; xét có đường ngắn nhất gồm cạnh. Đoạn tới cũng là đường ngắn nhất tới và có cạnh, nên đã đúng; ở lượt , việc nới cạnh làm đúng. Vậy sau lượt, mọi khoảng cách đều đúng. Mặt khác không bao giờ nhỏ hơn độ dài một đường thật, nên nó không thể giảm quá mức.
Nếu một lượt không nới được cạnh nào thì các lượt sau cũng không, nên có thể dừng sớm. Trên nhiều đồ thị, cách này làm Bellman-Ford chạy nhanh hơn hẳn lượt.
Một biến thể quen thuộc là SPFA: thay vì nới mọi cạnh mỗi lượt, chỉ nới cạnh đi ra từ những đỉnh vừa được giảm , giữ các đỉnh đó trong một hàng đợi. SPFA thường chạy nhanh, nhưng vẫn có những đồ thị làm nó tốn đúng , và người ra đề hay cố ý đưa vào loại test đó. Khi mọi cạnh không âm, hãy dùng Dijkstra.
Ví dụĐiểm Số Trên Hành Trình
Đề. Đồ thị có hướng đỉnh, cạnh, mỗi cạnh mang một số điểm từ tới . Đi từ tới , được đi lại các cạnh bao nhiêu lần cũng được; tìm tổng điểm lớn nhất, hoặc in nếu tổng điểm có thể lớn tùy ý.
Lời giải. Bài hỏi lớn nhất, nên ta nới theo chiều tăng: gán khi , với ban đầu là “âm vô cùng”. Khi đó vai trò của chu trình âm thuộc về chu trình có tổng dương.
Cần cẩn thận ở chỗ: một chu trình dương chỉ làm đáp án vô hạn khi nó vừa tới được từ , vừa đi tiếp tới được . Sau lượt, nếu cạnh còn làm tăng được thì đứng sau một chu trình dương tới được từ . Ta chỉ in khi thêm tới được ; điều này kiểm bằng một lượt BFS (bài 3.1) từ trên đồ thị đảo chiều mọi cạnh, đánh dấu các đỉnh tới được .
Hằng “âm vô cùng” chọn là : các tổng thật nằm trong khoảng , và vì chỉ nới từ đỉnh đã có giá trị, ta không bao giờ cộng thêm vào chính hằng này.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2505, M = 5005, NEG = -4e18;
ll n, m, ea[M], eb[M], ew[M], d[N], ok[N];
vector<ll> rg[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (ll i = 1; i <= m; i++) {
cin >> ea[i] >> eb[i] >> ew[i];
rg[eb[i]].push_back(ea[i]);
}
queue<ll> q;
q.push(n);
ok[n] = 1;
while (!q.empty()) {
ll u = q.front();
q.pop();
for (ll v : rg[u])
if (!ok[v]) {
ok[v] = 1;
q.push(v);
}
}
for (ll i = 1; i <= n; i++) d[i] = NEG;
d[1] = 0;
for (ll it = 1; it < n; it++)
for (ll i = 1; i <= m; i++)
if (d[ea[i]] != NEG) d[eb[i]] = max(d[eb[i]], d[ea[i]] + ew[i]);
for (ll i = 1; i <= m; i++)
if (d[ea[i]] != NEG && d[ea[i]] + ew[i] > d[eb[i]] && ok[eb[i]]) {
cout << -1 << '\n';
return 0;
}
cout << d[n] << '\n';
}
4 5
1 2 4
2 4 -1
1 3 -2
3 4 8
2 3 1
13
Độ phức tạp: , với , cần khoảng phép tính.
IIPhát hiện và in chu trình âm
Khi đồ thị có chu trình âm tới được từ nguồn, đi vòng quanh nó mãi thì đường càng ngắn, nên “đường đi ngắn nhất” không tồn tại. Theo ô chứng minh ở mục trên, nếu không có chu trình như vậy thì sau lượt không còn cạnh nào nới được. Vì vậy chỉ cần chạy thêm lượt thứ : còn nới được cạnh nào thì có chu trình âm tới được từ nguồn.
Muốn biết đồ thị có chu trình âm ở bất kỳ đâu, kể cả nơi không tới được từ đỉnh , ta cho mọi ngay từ đầu. Việc này giống thêm một đỉnh ảo nối tới mọi đỉnh bằng cạnh dài rồi lấy nó làm nguồn.
Ví dụChu Trình Giảm Thời Gian
Đề. Có bộ dữ liệu, mỗi bộ là một đồ thị có hướng đỉnh đánh số từ , cạnh, trọng số từ tới . Cho biết từ đỉnh có đi tới được một chu trình có tổng âm hay không.
Lời giải. Chạy Bellman-Ford từ đỉnh , lượt, rồi kiểm lượt thứ . Chỉ nới từ các đỉnh đã tới được, nên chu trình âm nằm ở phần không tới được từ không làm sai kết quả, đúng như đề yêu cầu.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1005, M = 2005, INF = 1e18;
ll t, n, m, ea[M], eb[M], ew[M], d[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> t;
while (t--) {
cin >> n >> m;
for (ll i = 1; i <= m; i++) cin >> ea[i] >> eb[i] >> ew[i];
for (ll i = 0; i < n; i++) d[i] = INF;
d[0] = 0;
for (ll it = 1; it < n; it++)
for (ll i = 1; i <= m; i++)
if (d[ea[i]] != INF && d[ea[i]] + ew[i] < d[eb[i]]) d[eb[i]] = d[ea[i]] + ew[i];
bool neg = false;
for (ll i = 1; i <= m; i++)
if (d[ea[i]] != INF && d[ea[i]] + ew[i] < d[eb[i]]) neg = true;
cout << (neg ? "possible" : "not possible") << '\n';
}
}
1
4 4
0 1 5
1 2 2
2 1 -4
2 3 7
possible
Độ phức tạp: cho mỗi bộ dữ liệu, với , cần khoảng phép tính.
Nhiều bài còn yêu cầu in ra một chu trình âm. Ta lưu thêm mảng cha: mỗi lần nới cạnh thành công thì ghi . Gọi là đỉnh được nới ở lượt thứ . Đỉnh chưa chắc nằm trên chu trình âm, nhưng nó nằm sau chu trình đó trên đường cha. Đi ngược theo cha đúng bước từ thì chắc chắn đã đi vào trong chu trình, vì đường cha không thể dài hơn đỉnh mà không lặp. Từ đỉnh vừa tới, đi theo cha cho tới khi gặp lại nó, ta được cả chu trình theo chiều ngược.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2505, M = 5005;
ll n, m, ea[M], eb[M], ew[M], d[N], par[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (ll i = 1; i <= m; i++) cin >> ea[i] >> eb[i] >> ew[i];
ll x = 0;
for (ll it = 1; it <= n; it++) {
x = 0;
for (ll i = 1; i <= m; i++)
if (d[ea[i]] + ew[i] < d[eb[i]]) {
d[eb[i]] = d[ea[i]] + ew[i];
par[eb[i]] = ea[i];
x = eb[i];
}
}
if (!x) {
cout << "NO\n";
return 0;
}
for (ll i = 1; i <= n; i++) x = par[x];
vector<ll> cyc;
cyc.push_back(x);
for (ll v = par[x]; v != x; v = par[v]) cyc.push_back(v);
cyc.push_back(x);
reverse(cyc.begin(), cyc.end());
cout << "YES\n";
for (ll i = 0; i < (ll)cyc.size(); i++) cout << cyc[i] << (i + 1 == (ll)cyc.size() ? '\n' : ' ');
}
4 5
1 2 1
2 3 1
3 4 1
4 2 -3
3 1 2
YES
3 4 2 3
Độ phức tạp: cho lượt nới, phần lần theo cha chỉ ; với , cần khoảng phép tính.
Ở chương trình này mọi bắt đầu bằng , như đã nói, nên chu trình âm ở đâu cũng được tìm thấy.
Hỏi nhanh
Ở lượt thứ , đỉnh vừa được nới. Vì sao phải đi ngược theo cha bước trước khi in chu trình, thay vì in luôn chu trình bắt đầu từ ?
Đỉnh được nới ở lượt thứ chỉ cho biết đường cha của nó đi qua một chu trình âm, có thể nằm ở cuối một nhánh đi ra từ chu trình đó. Đường cha có nhiều nhất đỉnh trước khi lặp lại, nên sau bước ta chắc chắn đang đứng trên chu trình.
Mảng cha không cần đổi chiều trước khi đi, chu trình in ra chỉ cần đảo ngược thứ tự ở cuối. Lượt thứ không nới nhầm: còn nới được tức là có chu trình âm ở phía trước.
IIIFloyd-Warshall
Khi cỡ vài trăm và cần khoảng cách giữa mọi cặp đỉnh, Floyd-Warshall ngắn gọn nhất. Ban đầu là độ dài cạnh trực tiếp ngắn nhất từ tới (không có thì ), và . Lần lượt với mỗi từ tới , ta thử đi vòng qua :
Sau khi xét xong , là độ dài đường ngắn nhất từ tới mà các đỉnh nằm giữa đường chỉ thuộc . Vì vậy vòng lặp theo phải nằm ngoài cùng: khi xét , các giá trị và cần đã tính xong với mọi đỉnh trung gian nhỏ hơn . Thuật toán vẫn đúng khi có cạnh âm; nếu có chu trình âm thì sẽ có một nhỏ hơn .
Hỏi nhanh
Một bạn viết Floyd-Warshall với vòng nằm trong cùng: for i, for j, for k: d[i][j] = min(d[i][j], d[i][k] + d[k][j]). Điều gì có thể xảy ra?
Với ở trong cùng, được chốt ngay sau một lượt duyệt , khi và có thể còn chưa được cải thiện qua các đỉnh khác. Chẳng hạn đường ngắn nhất cần đã đúng trước khi tính , mà cặp lại có thể được xét sau. Ba vòng lặp vẫn chỉ bước nên không lặp vô hạn, và đồ thị vô hướng cũng bị sai như vậy.
Ví dụBảng Khoảng Cách
Đề. Đồ thị vô hướng đỉnh, có thể có cạnh lặp và khuyên, cùng câu hỏi khoảng cách giữa hai đỉnh; không có đường thì in .
Lời giải. Số đỉnh nhỏ còn câu hỏi rất nhiều, nên tính khoảng cách mọi cặp một lần bằng Floyd-Warshall rồi mỗi câu hỏi chỉ tra bảng. Có cạnh lặp nên chỉ giữ cạnh ngắn nhất giữa mỗi cặp; đồ thị vô hướng nên ghi cả hai chiều. Trong vòng lặp, khi thì bỏ qua cả hàng : vừa tránh phép cộng làm tràn số, vừa nhanh hơn.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 505, INF = 1e18;
ll n, m, q, d[N][N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m >> q;
for (ll i = 1; i <= n; i++)
for (ll j = 1; j <= n; j++) d[i][j] = (i == j ? 0 : INF);
for (ll i = 1; i <= m; i++) {
ll a, b, c;
cin >> a >> b >> c;
d[a][b] = min(d[a][b], c);
d[b][a] = min(d[b][a], c);
}
for (ll k = 1; k <= n; k++)
for (ll i = 1; i <= n; i++) {
if (d[i][k] == INF) continue;
for (ll j = 1; j <= n; j++) d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}
while (q--) {
ll a, b;
cin >> a >> b;
cout << (d[a][b] == INF ? -1 : d[a][b]) << '\n';
}
}
5 5 6
1 2 8
1 2 3
2 3 4
3 4 2
1 4 20
1 4
4 1
1 5
5 5
2 4
1 2
9
9
-1
0
6
3
Độ phức tạp: , với cần khoảng phép tính, mỗi câu hỏi .
Thứ tự hai vòng trong cũng ảnh hưởng tới tốc độ, vì các ô d[i][j] với cùng nằm liền nhau trong bộ nhớ:
Để ở vòng trong cùng nhanh hơn khoảng 1,7 lần.
Cùng ba vòng lặp còn trả lời được câu hỏi “từ có đi tới được không” cho mọi cặp, gọi là bao đóng bắc cầu (transitive closure): thay phép cộng và bằng phép “và”, “hoặc” trên giá trị đúng sai, r[i][j] = r[i][j] || (r[i][k] && r[k][j]). Khi mỗi hàng là một bitset (bài 2.7), cả hàng được xử lý cùng lúc và thuật toán nhanh lên khoảng 64 lần.
IVChu trình nhỏ nhất
Bài toán: cho đồ thị vô hướng có trọng số dương, tìm chu trình có tổng trọng số nhỏ nhất (chu trình có ít nhất ba đỉnh, không đi lại cạnh). Ta chỉ cần sửa Floyd-Warshall một chút.
Xét một chu trình nhỏ nhất và gọi là đỉnh có số hiệu lớn nhất trên đó; hai đỉnh kề trên chu trình là và . Phần còn lại của chu trình là một đường từ tới chỉ đi qua các đỉnh nhỏ hơn . Đúng lúc chuẩn bị xét trong Floyd-Warshall, chính là độ dài đường ngắn nhất như vậy. Vì thế, trước khi dùng làm đỉnh trung gian, ta thử mọi cặp có cạnh tới :
với là trọng số cạnh trực tiếp (không có cạnh thì ).
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 105, INF = 1e18;
ll n, m, w[N][N], d[N][N];
signed main() {
cin >> n >> m;
for (ll i = 1; i <= n; i++)
for (ll j = 1; j <= n; j++) w[i][j] = (i == j ? 0 : INF);
for (ll i = 1; i <= m; i++) {
ll a, b, c;
cin >> a >> b >> c;
w[a][b] = w[b][a] = min(w[a][b], c);
}
for (ll i = 1; i <= n; i++)
for (ll j = 1; j <= n; j++) d[i][j] = w[i][j];
ll ans = INF;
for (ll k = 1; k <= n; k++) {
for (ll i = 1; i < k; i++)
for (ll j = i + 1; j < k; j++)
if (d[i][j] != INF && w[j][k] != INF && w[k][i] != INF) ans = min(ans, d[i][j] + w[j][k] + w[k][i]);
for (ll i = 1; i <= n; i++)
for (ll j = 1; j <= n; j++)
if (d[i][k] != INF && d[k][j] != INF) d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}
if (ans == INF) cout << "No solution\n";
else cout << ans << '\n';
}
4 5
1 2 1
2 3 1
3 1 5
3 4 1
4 1 1
4
Độ phức tạp: , với cần khoảng phép tính.
Với dãy cạnh trên, chu trình dài , ngắn hơn chu trình dài . Khi đồ thị không có trọng số, chạy BFS (bài 3.1) từ mỗi đỉnh cũng tìm được chu trình ngắn nhất trong .
VHệ điều kiện hiệu
Một lớp bài nhìn bề ngoài không giống đồ thị: tìm các số nguyên thỏa điều kiện, mỗi điều kiện có dạng . Hệ như vậy gọi là hệ ràng buộc hiệu (difference constraints).
Hãy để ý rằng điều kiện giống hệt tính chất của khoảng cách ngắn nhất sau khi không còn nới được: với mọi cạnh dài . Vì vậy ta dựng mỗi điều kiện thành một cạnh dài , thêm một đỉnh ảo nối tới mọi đỉnh bằng cạnh dài , và chạy Bellman-Ford từ đỉnh ảo. Nếu có chu trình âm thì hệ vô nghiệm: cộng các điều kiện dọc chu trình, vế trái triệt tiêu còn một số âm. Nếu không, là một nghiệm.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1005, M = 5005;
ll n, m, ea[M], eb[M], ew[M], d[N];
signed main() {
cin >> n >> m;
for (ll i = 1; i <= m; i++) cin >> ea[i] >> eb[i] >> ew[i];
for (ll it = 1; it <= n; it++)
for (ll i = 1; i <= m; i++)
if (d[ea[i]] + ew[i] < d[eb[i]]) d[eb[i]] = d[ea[i]] + ew[i];
for (ll i = 1; i <= m; i++)
if (d[ea[i]] + ew[i] < d[eb[i]]) {
cout << "NO\n";
return 0;
}
for (ll i = 1; i <= n; i++) cout << d[i] << (i == n ? '\n' : ' ');
}
3 3
1 2 3
2 3 -2
3 1 -1
-3 0 -2
Độ phức tạp: , với , cần khoảng phép tính.
Mỗi dòng u v w của input là điều kiện . Cho mọi từ đầu chính là đi trước một lượt từ đỉnh ảo. Nghiệm in ra thỏa cả ba điều kiện: , , . Điều kiện dạng thì đổi thành trước khi dựng cạnh.
Bài nâng cao
Xem 1 bài nâng cao
Ví dụThành Phố Trở Lại
Đề. Có thành phố, giữa mọi cặp có hướng có một đường trực tiếp với thời gian từ tới . Các thành phố lần lượt bị đóng cửa theo thứ tự . Ngay trước mỗi lần đóng, tính tổng khoảng cách ngắn nhất giữa mọi cặp có thứ tự của các thành phố còn mở, chỉ đi qua các thành phố còn mở.
Lời giải. Đóng dần thì khó, nhưng nhìn ngược thời gian thì các thành phố được mở dần theo thứ tự . Floyd-Warshall vốn thêm dần từng đỉnh làm đỉnh trung gian, và không cần thứ tự : thứ tự nào cũng được, miễn đỉnh đã thêm thì dùng được. Vậy ta chạy vòng theo thứ tự mở ; sau khi thêm , ma trận cho đúng khoảng cách chỉ đi qua các thành phố đã mở, và ta cộng trên mọi cặp đỉnh đã mở để được đáp án của bước .
Đường trực tiếp chỉ có hai đầu là và , nên dùng được ngay khi cả hai đã mở, không phụ thuộc thứ tự. Tổng có thể tới , nên dùng long long.
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 505;
ll n, d[N][N], x[N], ans[N], on[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n;
for (ll i = 1; i <= n; i++)
for (ll j = 1; j <= n; j++) cin >> d[i][j];
for (ll i = 1; i <= n; i++) cin >> x[i];
for (ll t = n; t >= 1; t--) {
ll k = x[t];
on[k] = 1;
for (ll i = 1; i <= n; i++)
for (ll j = 1; j <= n; j++) d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
ll s = 0;
for (ll i = 1; i <= n; i++)
if (on[i])
for (ll j = 1; j <= n; j++)
if (on[j]) s += d[i][j];
ans[t] = s;
}
for (ll t = 1; t <= n; t++) cout << ans[t] << (t == n ? '\n' : ' ');
}
3
0 4 10
3 0 2
7 1 0
2 1 3
20 17 0
Độ phức tạp: , với cần khoảng phép tính đơn giản.
Khi đi thi
- Chọn theo trọng số và kích thước: không âm thì Dijkstra; có cạnh âm thì Bellman-Ford; cỡ vài trăm mà cần mọi cặp thì Floyd-Warshall. Đề cố ý có test làm SPFA chậm thì đừng dùng SPFA.
- Với chu trình âm (hay chu trình dương khi tìm lớn nhất), đọc kỹ đề: chu trình phải tới được từ nguồn, và nhiều đề còn đòi nó đi tiếp tới được đích.
- Khởi tạo , vô cùng không quá , và không bao giờ cộng thêm vào vô cùng: luôn kiểm
!= INFtrước khi cộng.
Tóm tắt
- Bellman-Ford nới mọi cạnh lượt, , đúng cả khi có cạnh âm; lượt thứ còn nới được nghĩa là có chu trình âm tới được từ nguồn.
- Muốn in chu trình âm thì lưu cha, đi ngược bước từ đỉnh vừa nới ở lượt để vào chu trình rồi đi tiếp cho tới khi gặp lại.
- Floyd-Warshall với vòng ở ngoài cùng; thứ tự thêm đỉnh trung gian có thể tùy ý, như khi mở dần các đỉnh.
- Chu trình nhỏ nhất: trước khi dùng làm trung gian, thử với .
- Hệ là bài đường đi ngắn nhất với cạnh dài từ một đỉnh ảo; có chu trình âm thì vô nghiệm.
Bài tập
Đọc thêm
- Các thuật toán tìm đường đi ngắn nhất (phần 2)VNOI Wiki
- SPFAVNOI Wiki
- Bellman-Ford Algorithmcp-algorithms
- Finding a negative cycle in the graphcp-algorithms
- Floyd-Warshall Algorithmcp-algorithms
- 差分约束OI Wiki