Floyd-Warshall — All-pairs shortest path qua DP-on-graph
Dijkstra/Bellman-Ford tìm shortest path từ 1 source. Nếu cần shortest path giữa mọi cặp vertex — transitive closure, social network, flight all-pairs cost — Floyd-Warshall giải bằng 3 nested loop O(V³), 5 dòng code, hỗ trợ negative edge, và detect negative cycle.
Dijkstra tìm shortest path từ một source đến mọi vertex trong O((V+E) log V). Bellman-Ford làm tương tự với hỗ trợ negative edge trong O(V × E). Nhưng cả hai chỉ giải single-source shortest path — từ một đỉnh đến tất cả các đỉnh còn lại.
Bài toán thực tế hay cần hơn: all-pairs shortest path — shortest path giữa mọi cặp vertex. Ví dụ: datacenter cần bảng latency giữa tất cả các node; forex broker cần best conversion rate giữa mọi cặp tiền tệ; social network cần reachability giữa mọi cặp user. Một cách giải: chạy Dijkstra V lần, tổng O(V × (V+E) log V). Hoặc dùng Floyd-Warshall: 3 nested loop, O(V³), code 5 dòng, hỗ trợ negative edge sẵn.
Bài này dạy Floyd-Warshall: DP với intermediate vertex k, chứng minh tại sao in-place 2D đúng, khi nào chọn FW thay V × Dijkstra, và transitive closure variant.
1. Analogy — Bảng khoảng cách thành phố
Hãy tưởng tượng bạn có một bảng khoảng cách trực tiếp giữa các thành phố: Hà Nội → TP.HCM 1200 km, Hà Nội → Đà Nẵng 800 km, Đà Nẵng → TP.HCM 950 km.
Câu hỏi: đường từ Hà Nội đến TP.HCM có rút ngắn được qua Đà Nẵng không? Kiểm tra: dist[HN][DN] + dist[DN][HCM] = 800 + 950 = 1750 > 1200 — không ngắn hơn.
Lặp lại kiểm tra này với mọi thành phố trung gian c, cho mọi cặp (a, b): nếu dist[a][c] + dist[c][b] < dist[a][b] thì cập nhật. Sau khi thử mọi c — ta có bảng khoảng cách tốt nhất giữa mọi cặp thành phố. Đây chính xác là Floyd-Warshall.
| Bảng khoảng cách thành phố | Floyd-Warshall |
|---|---|
| Thành phố | Vertex |
| Khoảng cách trực tiếp a → b | Edge weight ban đầu |
| Thành phố trung gian c | Intermediate vertex k |
| Kiểm tra qua c có ngắn hơn không | Transition dp[i][k] + dp[k][j] < dp[i][j] |
| Bảng khoảng cách tốt nhất cuối cùng | dist[i][j] sau V vòng lặp |
Floyd-Warshall = "thử mọi vertex k làm trạm trung gian, cập nhật bảng khoảng cách". Sau khi thử hết V vertex, mọi cặp (i, j) đã có shortest path tối ưu.
2. DP formulation — Intermediate vertex k
2.1 State definition
Định nghĩa dp[k][i][j] = độ dài shortest path từ i đến j chỉ sử dụng các vertex {0, 1, ..., k} làm intermediate (đỉnh trung gian — không phải start hay end, mà là các đỉnh đi qua).
Base case (k = -1, không có intermediate):
dp[-1][i][j]= weight của edge trực tiếpi → j(vô cùng nếu không có cạnh).dp[-1][i][i] = 0cho mọii.
Transition (thêm vertex k vào tập intermediate):
dp[k][i][j] = min(
dp[k-1][i][j], // khong di qua k
dp[k-1][i][k] + dp[k-1][k][j] // di qua k
)
Final answer: dp[V-1][i][j] cho mọi cặp (i, j).
2.2 Space optimization — 2D in-place
Mảng 3D dp[V][V][V] tốn O(V³) không gian. Ta có thể reduce về 2D:
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j]) // update in-place
Tại sao in-place vẫn correct? Câu hỏi: khi update dp[i][j] với intermediate k, ta dùng dp[i][k] và dp[k][j] — nhưng các giá trị này có bị update trong vòng lặp k hiện tại không?
dp[k][k]= shortest path từ k đến k qua intermediate trong tập{0..k}. Vì path đơn giản nhất là ở lại k (độ dài 0),dp[k][k] = 0— không thay đổi khi thêm k vào intermediate.dp[i][k]: path từ i đến k qua intermediate trong{0..k}. Nếu ta updatedp[i][k]với formula trên:min(dp[i][k], dp[i][k] + dp[k][k]) = min(dp[i][k], dp[i][k] + 0) = dp[i][k]— không thay đổi.- Tương tự
dp[k][j]không thay đổi.
Vậy dp[i][k] và dp[k][j] giữ nguyên giá trị từ k-1, dù update in-place. Thuật toán 2D correct.
3. Code Java
public class FloydWarshall {
// graph[i][j] = edge weight from i to j.
// Use Integer.MAX_VALUE to represent no direct edge.
// graph[i][i] must be 0.
public static int[][] floydWarshall(int[][] graph) {
int n = graph.length;
int[][] dist = new int[n][n];
for (int i = 0; i < n; i++) {
dist[i] = graph[i].clone();
}
// Try each vertex k as intermediate
for (int k = 0; k < n; k++) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
// Guard: skip if i->k or k->j is unreachable (avoid overflow)
if (dist[i][k] == Integer.MAX_VALUE
|| dist[k][j] == Integer.MAX_VALUE) continue;
if (dist[i][k] + dist[k][j] < dist[i][j]) {
dist[i][j] = dist[i][k] + dist[k][j];
}
}
}
}
// Detect negative cycle: dist[i][i] < 0
for (int i = 0; i < n; i++) {
if (dist[i][i] < 0) {
throw new IllegalStateException("Negative cycle detected");
}
}
return dist;
}
}
3.1 Trace ví dụ — graph 4 vertex
Graph directed weighted:
0 ---(3)--> 1
| |
(8) (-4)
| v
3 <--(1)-- 2 <--(2)-- 0
also: 1->(7)->3, 2->(5)->0, 3->(4)->0
Sử dụng graph kinh điển từ CLRS (đơn giản hóa):
0 1 2 3
0 [ 0, 3, INF, 7]
1 [INF, 0, 4, INF]
2 [ 2, INF, 0, INF]
3 [INF, 1, INF, 0]
Edges: 0→1 w3, 0→3 w7, 1→2 w4, 2→0 w2, 3→1 w1.
Sau k=0 (thêm vertex 0 làm intermediate):
dist[2][1]: hiện INF. Qua 0:dist[2][0] + dist[0][1] = 2 + 3 = 5. Cập nhật 5.dist[2][3]: hiện INF. Qua 0:dist[2][0] + dist[0][3] = 2 + 7 = 9. Cập nhật 9.
Sau k=1 (thêm vertex 1):
dist[0][2]: hiện INF. Qua 1:dist[0][1] + dist[1][2] = 3 + 4 = 7. Cập nhật 7.dist[2][2]: hiện 0. Qua 1:dist[2][1] + dist[1][2] = 5 + 4 = 9. Giữ 0.dist[3][2]: hiện INF. Qua 1:dist[3][1] + dist[1][2] = 1 + 4 = 5. Cập nhật 5.
Sau k=2 (thêm vertex 2):
dist[0][0]: hiện 0. Qua 2:dist[0][2] + dist[2][0] = 7 + 2 = 9. Giữ 0.dist[1][0]: hiện INF. Qua 2:dist[1][2] + dist[2][0] = 4 + 2 = 6. Cập nhật 6.dist[3][0]: hiện INF. Qua 2:dist[3][2] + dist[2][0] = 5 + 2 = 7. Cập nhật 7.
Sau k=3 (thêm vertex 3):
dist[0][1]: hiện 3. Qua 3:dist[0][3] + dist[3][1] = 7 + 1 = 8. Giữ 3.dist[1][1]: hiện 0. Qua 3:dist[1][3]= INF. Skip.dist[2][1]: hiện 5. Qua 3:dist[2][3] + dist[3][1] = 9 + 1 = 10. Giữ 5.
Kết quả cuối:
0 1 2 3
0 [ 0, 3, 7, 7]
1 [ 6, 0, 4, 11]
2 [ 2, 5, 0, 9]
3 [ 7, 1, 5, 0]
Không có dist[i][i] < 0 → không có negative cycle.
4. Complexity
| Time | Space | |
|---|---|---|
| Floyd-Warshall | O(V³) | O(V²) |
| V × Dijkstra (binary heap) | O(V × (V+E) log V) | O(V+E) |
| V × Bellman-Ford | O(V² × E) | O(V) |
O(V³) từ đâu? 3 nested loop, mỗi loop chạy V lần — V × V × V operations, mỗi operation O(1).
O(V²) space: chỉ cần ma trận dist[V][V].
Khi nào Floyd-Warshall thắng V × Dijkstra?
- Dense graph (E ≈ V²): V × Dijkstra = O(V × V² log V) = O(V³ log V) — chậm hơn FW.
- V nhỏ (V ≤ 500): V³ = 125 triệu operations, chạy trong vài trăm milliseconds.
- Cần hỗ trợ negative edge: Dijkstra fail, FW xử lý được.
- Code đơn giản hơn nhiều: 5 dòng loop vs Dijkstra + outer loop + bookkeeping.
Khi nào V × Dijkstra thắng?
- Sparse graph (E nhỏ hơn nhiều so với V²): O(V × (V+E) log V) nhỏ hơn nhiều so với O(V³).
- V lớn (V vượt 500-1000): V³ trở nên quá tốn kém.
- Không có negative edge.
5. Floyd-Warshall vs V × Dijkstra
| Aspect | Floyd-Warshall | V × Dijkstra |
|---|---|---|
| Time | O(V³) | O(V × (V+E) log V) |
| Space | O(V²) | O(V+E) per call |
| Negative edge | Xử lý được | Fail |
| Negative cycle detect | Có (dist[i][i] âm) | Không |
| Code complexity | 5 dòng loop | Phức tạp hơn |
| Best for | Dense / small graph | Sparse / large graph |
6. Variants
6.1 Transitive closure — Warshall's algorithm
Thay tìm shortest path, chỉ cần biết "có path từ i đến j không?". Dùng boolean matrix:
// reach[i][j] = true if j is reachable from i
boolean[][] warshall(boolean[][] reach) {
int n = reach.length;
boolean[][] r = new boolean[n][n];
for (int i = 0; i < n; i++) r[i] = reach[i].clone();
for (int k = 0; k < n; k++)
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
r[i][j] = r[i][j] || (r[i][k] && r[k][j]);
return r;
}
Dùng cho: directed reachability, dependency resolution, social network "bạn của bạn".
6.2 Path reconstruction
Theo dõi thêm next[i][j] = vertex tiếp theo trên path từ i đến j:
int[][] next = new int[n][n];
// Init: next[i][j] = j if direct edge exists, else -1
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
next[i][j] = (graph[i][j] != Integer.MAX_VALUE && i != j) ? j : -1;
// In the main loop, also update next:
// if (dist[i][k] + dist[k][j] < dist[i][j]) {
// dist[i][j] = dist[i][k] + dist[k][j];
// next[i][j] = next[i][k]; // go through k first
// }
// Reconstruct path from src to dst:
List<Integer> getPath(int[][] next, int src, int dst) {
if (next[src][dst] == -1) return List.of(); // no path
List<Integer> path = new ArrayList<>();
path.add(src);
while (src != dst) {
src = next[src][dst];
path.add(src);
}
return path;
}
6.3 Maximum bandwidth path
Thay tìm min-sum path, tìm path có bottleneck lớn nhất — "path mà cạnh nhỏ nhất là lớn nhất nhất có thể":
// bandwidth[i][j]: max min-edge weight from i to j
// Transition: bandwidth[i][j] = max(bandwidth[i][j],
// min(bandwidth[i][k], bandwidth[k][j]))
Dùng cho: network routing theo bandwidth, cable tải tải điện max.
7. Pitfall tổng hợp
Pitfall 1 — Overflow khi cộng hai giá trị Integer.MAX_VALUE:
// BUG: dist[i][k] = Integer.MAX_VALUE, dist[k][j] = 5
// Integer.MAX_VALUE + 5 overflows to negative number
if (dist[i][k] + dist[k][j] < dist[i][j]) { // overflow -> always true!
dist[i][j] = dist[i][k] + dist[k][j]; // corrupts dist matrix
}
// CORRECT: guard unreachable paths before addition
if (dist[i][k] == Integer.MAX_VALUE || dist[k][j] == Integer.MAX_VALUE) continue;
if (dist[i][k] + dist[k][j] < dist[i][j]) {
dist[i][j] = dist[i][k] + dist[k][j];
}
Hoặc dùng long cho toàn bộ dist matrix để tránh overflow hoàn toàn.
Pitfall 2 — Quên kiểm tra negative cycle:
// BUG: return dist without checking
return dist; // if negative cycle exists, dist[i][i] < 0
// consumer uses garbage values without knowing
Nếu graph có negative cycle, dist[i][i] sẽ trở thành âm (algorithm giảm dist[i][i] qua cycle). Consumer nhận kết quả sai hoàn toàn — path cost âm vô nghĩa, routing quyết định sai, NPC đi vòng lặp mãi. Luôn check dist[i][i] < 0 sau khi chạy xong.
Pitfall 3 — Input matrix không init dist[i][i] = 0:
// BUG: caller passes matrix with dist[i][i] = INF instead of 0
// Every path i -> ... -> i will be computed as INF + something = wrong
// dist[i][j] for all j will remain inflated or INF
Floyd-Warshall dựa vào dist[i][i] = 0 làm base case — "cost đứng tại i là 0". Nếu init sai, mọi path đi qua i làm intermediate sẽ bị ảnh hưởng. Kiểm tra input hoặc force dist[i][i] = 0 ở bước khởi tạo.
8. Path reconstruction — code hoàn chỉnh
public class FloydWarshallWithPath {
public static int[][] dist;
public static int[][] next;
public static void compute(int[][] graph) {
int n = graph.length;
dist = new int[n][n];
next = new int[n][n];
// Initialize dist and next
for (int i = 0; i < n; i++) {
dist[i] = graph[i].clone();
for (int j = 0; j < n; j++) {
if (i != j && graph[i][j] != Integer.MAX_VALUE) {
next[i][j] = j;
} else {
next[i][j] = -1;
}
}
}
// Main DP loop
for (int k = 0; k < n; k++) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (dist[i][k] == Integer.MAX_VALUE
|| dist[k][j] == Integer.MAX_VALUE) continue;
if (dist[i][k] + dist[k][j] < dist[i][j]) {
dist[i][j] = dist[i][k] + dist[k][j];
next[i][j] = next[i][k]; // route through k first
}
}
}
}
// Check negative cycle
for (int i = 0; i < n; i++) {
if (dist[i][i] < 0) {
throw new IllegalStateException("Negative cycle detected");
}
}
}
public static List<Integer> getPath(int src, int dst) {
if (next[src][dst] == -1) return List.of(); // no path
List<Integer> path = new ArrayList<>();
path.add(src);
int cur = src;
while (cur != dst) {
cur = next[cur][dst];
if (cur == -1) return List.of(); // should not happen if no negative cycle
path.add(cur);
}
return path;
}
}
9. Ứng dụng thực tế
Network engineer — all-pairs latency table: datacenter cần bảng latency giữa mọi cặp node để routing decision. Với V = 50 node, V³ = 125.000 operations — chạy trong microseconds. Kết quả: bảng dist[50][50] dùng làm lookup table cho mọi routing request mà không cần recompute mỗi lần.
Forex broker — all-pairs conversion rate: cần hiển thị best conversion rate giữa mọi cặp trong 170 đơn vị tiền tệ. Thay vì query mỗi cặp riêng, pre-compute toàn bộ dist[170][170] offline (mỗi lần rate thay đổi) và serve từ in-memory lookup. Với maximum bandwidth variant, tìm path có bottleneck conversion rate cao nhất.
Social network — transitive closure: "bạn của bạn" — kiểm tra user A có reachable đến user B trong k hops không. Dùng Warshall's algorithm trên subgraph (V thường nhỏ sau khi filter bởi community). Ứng dụng: friend suggestion, access control ("user này có trong team của người duyệt không?").
Game map AI — small grid pathfinding: grid nhỏ V ≤ 50, pre-compute all-pairs distances một lần khi load map. NPC có thể lookup dist[current][target] trong O(1) cho mọi quyết định pathfinding — không cần chạy A* mỗi frame.
10. Deep Dive
Bài báo gốc:
- Floyd, R.W. (1962) — "Algorithm 97: Shortest Path", Communications of the ACM: bài báo gốc 1 trang mô tả thuật toán, một trong những paper ngắn nhất có ảnh hưởng lớn trong CS.
- Warshall, S. (1962) — "A theorem on Boolean matrices", Journal of the ACM: bài báo gốc transitive closure variant — xuất hiện cùng năm với Floyd, độc lập.
Sách tham khảo:
- Introduction to Algorithms (CLRS), Chapter 25.2 — Floyd-Warshall Algorithm: proof correctness đầy đủ, analysis complexity, path reconstruction.
Cross-link trong khóa học:
- Module 5, Lesson 05 — Dijkstra: single-source shortest path, PriorityQueue O((V+E) log V).
- Module 5, Lesson 06 — Bellman-Ford: single-source với negative edge, detect negative cycle từ một source.
11. Tóm tắt
- Floyd-Warshall giải all-pairs shortest path bằng DP với dimension "intermediate vertex k":
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j]). - 3 nested loop O(V³): thử mọi k làm trạm trung gian cho mọi cặp (i, j).
- In-place 2D correct vì
dp[i][k]vàdp[k][j]không thay đổi trong vòng lặp k — proof từdp[k][k] = 0. - Negative edge xử lý được — không có giả định greedy như Dijkstra.
- Detect negative cycle bằng kiểm tra
dist[i][i] < 0sau khi chạy xong. - FW thắng V × Dijkstra khi graph dense (E ≈ V²), V nhỏ (≤ 500), hoặc cần negative edge support.
- Warshall variant dùng boolean matrix cho transitive closure — directed reachability, "bạn của bạn".
- Guard
Integer.MAX_VALUEtrước khi cộng để tránh overflow corrupt toàn bộ dist matrix.
12. Tự kiểm tra
Q1DP state dp[k][i][j] — vì sao 'intermediate vertex' là dimension thứ 3, không phải 'số cạnh đã dùng' như Bellman-Ford?▸
Bellman-Ford dùng dimension "số cạnh" vì nó relax theo chiều dài path — sau k vòng, path dùng tối đa k cạnh đã hội tụ. Floyd-Warshall thay vào đó DP theo tập intermediate vertex: dp[k][i][j] = shortest path từ i đến j chỉ dùng vertex 0..k làm trung gian.
Dimension này cho phép transition rõ ràng: khi thêm vertex k vào tập, hoặc path không đi qua k (giữ nguyên từ dp[k-1]), hoặc path đi qua k (chia thành i→k và k→j, cả hai đã biết từ dp[k-1]). Transition này không thể viết được nếu dùng "số cạnh" làm dimension.
Q2Tại sao reduce 3D dp[k][i][j] xuống 2D dp[i][j] in-place vẫn cho kết quả đúng?▸
Khi update in-place trong vòng lặp k, ta dùng dp[i][k] và dp[k][j]. Câu hỏi: các giá trị này có bị thay đổi trong vòng lặp k hiện tại không?
Không: dp[k][k] = 0 luôn (cost đứng tại k = 0), nên dp[i][k] khi update bằng công thức sẽ là min(dp[i][k], dp[i][k] + dp[k][k]) = min(dp[i][k], dp[i][k] + 0) = dp[i][k] — không đổi. Tương tự dp[k][j] không đổi. Vì vậy mọi update dp[i][j] trong vòng k đều dùng đúng giá trị từ vòng k-1, dù update in-place.
Q3V × Dijkstra vs Floyd-Warshall — khi nào nên chọn Floyd-Warshall?▸
Chọn Floyd-Warshall khi: (1) Graph dense (E ≈ V²) — V × Dijkstra = O(V³ log V) chậm hơn FW O(V³). (2) V nhỏ (V ≤ 500) — V³ ≤ 125 triệu operations, chạy nhanh. (3) Có negative edge — Dijkstra fail, FW xử lý đúng. (4) Code đơn giản hơn quan trọng — 5 dòng loop vs toàn bộ Dijkstra infrastructure.
Chọn V × Dijkstra khi: graph sparse (E nhỏ hơn nhiều so với V²), V lớn (vượt 500-1000), không có negative edge. Với V = 1000 sparse graph E = 5000: V × Dijkstra = 1000 × (1000+5000) × 10 = 60 triệu; FW = 1 tỷ — FW chậm hơn 16 lần.
Q4Floyd-Warshall xử lý negative edge nhưng cần detect negative cycle — kiểm tra ở đâu và tại sao dist[i][i] phản ánh negative cycle?▸
Kiểm tra sau khi chạy xong toàn bộ 3 vòng lặp: nếu dist[i][i] < 0 cho bất kỳ i nào, có negative cycle reachable.
Tại sao? Ban đầu dist[i][i] = 0. Trong quá trình DP, nếu có negative cycle chứa vertex i, algorithm sẽ tìm thấy path từ i quay về i qua cycle đó với tổng cost âm — cập nhật dist[i][i] xuống giá trị âm. Nếu không có negative cycle, không path nào từ i quay về i có cost âm được, nên dist[i][i] giữ nguyên 0. Đây là invariant: dist[i][i] < 0 khi và chỉ khi có negative cycle qua i.
Q5Transitive closure variant — thay min/sum bằng gì? Tại sao boolean OR/AND đủ?▸
Thay min(a, b) bằng a || b và a + b bằng a && b. Transition trở thành: reach[i][j] = reach[i][j] || (reach[i][k] && reach[k][j]).
Intuition: có path từ i đến j khi và chỉ khi (đã có path trực tiếp) HOẶC (có path từ i đến k VÀ có path từ k đến j). Đây là tất cả thông tin ta cần cho reachability — không quan tâm cost, chỉ quan tâm tồn tại hay không. Boolean OR/AND đủ để encode logic này một cách chính xác.
Q6Graph 1000 vertex sparse với 2000 edge — chọn Floyd-Warshall hay V × Dijkstra? Ước lượng số operations.▸
Chọn V × Dijkstra cho graph sparse này.
Ước lượng: Floyd-Warshall = V³ = 1000³ = 1 tỷ operations — với 1 tỷ ops/s, mất khoảng 1 giây. V × Dijkstra = V × (V+E) log V = 1000 × (1000+2000) × log(1000) ≈ 1000 × 3000 × 10 = 30 triệu operations — nhanh hơn khoảng 33 lần. Với sparse graph, V × Dijkstra luôn thắng Floyd-Warshall về mặt số học. Chỉ khi E tiến tới V² (ví dụ E = 900.000 cho V = 1000) thì FW mới có lợi thế.
Bài tiếp theo: MST — Kruskal vs Prim
Bài này có giúp bạn hiểu bản chất không?