Thuật toán đồ thị có hướng

Edmonds' Branching
Chu-Liu / Edmonds (1965)

Hướng dẫn từng bước để hiểu thuật toán tìm Minimum Spanning Arborescence — cây khung nhỏ nhất có hướng trên đồ thị.

1
🎯

Bài toán

Cho đồ thị có hướng G, gốc r. Tìm cây đến được mọi đỉnh, trọng số nhỏ nhất.

2
🔁

Phương pháp

Tham lam + Co rút chu trình (contraction) + Đệ quy.

3

Độ phức tạp

O(E·V) với cài đặt đơn giản. O(E + V log V) với Fibonacci Heap.

4

Ứng dụng

Mạng phân tán, NLP dependency trees, định tuyến có hướng.

Đồ thị mẫu & Kết quả MSA

Cạnh thường
minIn (rẻ nhất)
Chu trình C
MSA cuối
Giải thích chi tiết

6 Bước thuật toán

B1
Chọn cạnh vào rẻ nhất cho mỗi đỉnh (minIn)
Với mỗi đỉnh v ≠ root, duyệt qua mọi cạnh đi vào v, chọn cạnh có trọng số nhỏ nhất — gọi là minIn[v].

Nếu có đỉnh không có cạnh vào nào → không tồn tại arborescence, trả về null.
💡
Hình dung: Mỗi đỉnh (trừ gốc) chọn 1 "ông chủ" rẻ nhất để phụ thuộc vào. Bước này giống thuật toán tham lam, nhưng chưa chắc đúng vì có thể tạo vòng lặp.
Edmonds_Branching.java — Bước 1
// minIn[v] = cạnh rẻ nhất đi vào v
DirectedEdge[] minIn = new DirectedEdge[n];
double[]       minW  = new double[n];
Arrays.fill(minW, Double.POSITIVE_INFINITY);

for (DirectedEdge e : G.edges()) {
    int vao = e.to();
    int ra  = e.from();
    if (vao == root) continue; // gốc không có cha
    if (ra  == vao)  continue; // bỏ tự vòng
    if (e.weight() < minW[vao]) {
        minW[vao]  = e.weight();
        minIn[vao] = e;
    }
}
B2
Kiểm tra chu trình trong tập minIn
Xây đồ thị con chỉ gồm các cạnh minIn và chạy phát hiện chu trình có hướng (DFS).

Không có chu trình → tập minIn đã là MSA hợp lệ. Trả về ngay, thuật toán kết thúc!
Có chu trình → tiếp tục Bước 3.
⚠️
Tại sao tập minIn có thể là MSA? Vì mỗi đỉnh đã chọn cạnh vào rẻ nhất, nếu không có vòng thì đây đúng là cây khung tối ưu — không thể làm tốt hơn được nữa.
Bước 2
EdgeWeightedDigraph subG = buildSubgraph(n, minIn, root);
EdgeWeightedDirectedCycle cycleFinder = new EdgeWeightedDirectedCycle(subG);

if (!cycleFinder.hasCycle()) {
    // Không có chu trình → minIn chính là MSA
    List<DirectedEdge> result = new ArrayList<>();
    for (int vao = 0; vao < n; vao++)
        if (vao != root && minIn[vao] != null)
            result.add(minIn[vao]);
    return result; // ← MSA hoàn chỉnh!
}
B3
Tìm và đánh dấu chu trình C
Lấy chu trình C từ cycleFinder, đánh dấu tất cả đỉnh nằm trong C bằng mảng inCycle[].
💡
Trong ví dụ: Đỉnh 1 → 2, cạnh 2 → 3, cạnh 3 → 1 tạo thành chu trình C = {1, 2, 3}. Đây là "vòng lặp" phải xử lý.
Bước 3
List<DirectedEdge> cycleEdgeList = new ArrayList<>();
for (DirectedEdge e : cycleFinder.cycle())
    cycleEdgeList.add(e);

// Đánh dấu đỉnh nào trong chu trình
boolean[] inCycle = new boolean[n];
for (DirectedEdge e : cycleEdgeList) {
    inCycle[e.from()] = true;
    inCycle[e.to()]   = true;
}
B4
Co rút chu trình C thành siêu đỉnh → đồ thị G'
Gộp toàn bộ đỉnh trong C thành 1 siêu đỉnh duy nhất (superNodeId). Xây đồ thị mới G' nhỏ hơn G.

Quan trọng: Các cạnh từ ngoài đi vào C phải điều chỉnh trọng số:
w' = w(e) − minW[v]

Vì: nếu ta chọn cạnh ngoài đi vào đỉnh v trong C, ta phải bỏ cạnh minIn[v] vốn đang có trong C. Chi phí thực chỉ là phần chênh lệch.
⚠️
Tại sao giảm trọng số? Khi chọn cạnh ngoài vào đỉnh v, ta ngầm hiểu là đã "tiêu" chi phí minW[v] rồi (vì toàn bộ minIn của C đã được tính vào chu trình). Nên chỉ tính thêm phần dư = w − minW[v].
Bước 4 — Co rút & xây G'
// Gán id mới: đỉnh ngoài C → 0,1,2,...; đỉnh trong C → superNodeId
int[] newId = new int[n];
int   counter = 0;
for (int v = 0; v < n; v++)
    if (!inCycle[v]) newId[v] = counter++;
int superNodeId = counter++;
for (int v = 0; v < n; v++)
    if (inCycle[v]) newId[v] = superNodeId;

// Thêm cạnh vào G', điều chỉnh trọng số cạnh vào C
for (DirectedEdge e : G.edges()) {
    if (inCycle[ra] && inCycle[vao])  continue; // bỏ nội bộ C
    double newWeight = inCycle[vao]
        ? (e.weight() - minW[vao]) // cạnh vào C: giảm trọng số
        : e.weight();               // cạnh thường: giữ nguyên
    contractedG.addEdge(new DirectedEdge(nra, nvao, newWeight));
}
B5
Đệ quy: tìm MSA trên G' (nhỏ hơn)
Gọi đệ quy edmonds(contractedG, newRoot). Mỗi lần đệ quy, G' nhỏ hơn G ít nhất 1 đỉnh (nhiều đỉnh C → 1 siêu đỉnh), đảm bảo thuật toán luôn kết thúc.
💡
Tại sao đệ quy hoạt động? Sau mỗi co rút, số đỉnh giảm đi ít nhất 1. Khi G' còn 1 đỉnh thì hiển nhiên không có chu trình → base case của đệ quy.
Bước 5
// Đệ quy — G' nhỏ hơn G, sẽ đạt base case
List<DirectedEdge> contractedMSA = edmonds(contractedG, newRoot);
if (contractedMSA == null) return null;
B6
Mở rộng siêu đỉnh — phục hồi kết quả gốc
Sau khi có MSA trên G', ta phục hồi về đồ thị gốc:

1. Dịch mỗi cạnh trong contractedMSA về cạnh gốc tương ứng (qua bảng ánh xạ originalList).
2. Tìm đỉnh replacedVertex: đỉnh trong C bị cạnh từ ngoài thay thế.
3. Thêm lại tất cả cạnh của C, trừ cạnh đi vào replacedVertex (vì đỉnh đó đã có cha từ ngoài rồi).
💡
Tại sao bỏ đúng 1 cạnh trong C? Arborescence yêu cầu mỗi đỉnh có đúng 1 cạnh vào (trừ gốc). Khi cạnh ngoài vào đỉnh v, đỉnh v đã có cha → phải xóa cạnh minIn[v] trong C để không vi phạm.
Bước 6 — Phục hồi
int replacedVertex = -1;

for (DirectedEdge ce : contractedMSA) {
    DirectedEdge original = findOriginalEdge(ce, ...);
    if (original != null) {
        result.add(original);
        if (ce.to() == superNodeId)
            replacedVertex = original.to(); // đỉnh bị cạnh ngoài thay thế
    }
}

// Thêm chu trình C, bỏ cạnh vào replacedVertex
for (DirectedEdge e : cycleEdgeList)
    if (e.to() != replacedVertex)
        result.add(e);

Các lớp sử dụng trong bài

DirectedEdge

Cạnh có hướng: from(), to(), weight(). Bất biến sau khi tạo.

EdgeWeightedDigraph

Đồ thị có hướng có trọng số. Danh sách kề adj(v), phương thức edges().

EdgeWeightedDirectedCycle

Phát hiện chu trình có hướng bằng DFS. hasCycle(), cycle() trả về danh sách cạnh.

Edmons_Branching

Lớp chính. Constructor nhận G và root, gọi đệ quy edmonds(), lưu kết quả vào msaEdges.

Bảng tóm tắt thuật toán

Bước Hành động Chi phí Ghi chú
B1 Chọn minIn mỗi đỉnh O(E) Duyệt toàn bộ cạnh 1 lần
B2 Phát hiện chu trình (DFS) O(V) Đồ thị con chỉ có V cạnh
B3 Thu thập cạnh chu trình C O(V) Độ dài C ≤ V
B4 Co rút C → G' O(E) G' có ít hơn ≥1 đỉnh
B5 Đệ quy trên G' T(V−1, E) Mỗi lần giảm ≥1 đỉnh
B6 Phục hồi kết quả O(E + V) Tra bảng ánh xạ
Tổng cộng (đơn giản) O(V · E) Tối đa V lần đệ quy