← Cấu trúc dữ liệu & Giải thuật← Data Structures & Algorithms
Cấu trúc dữ liệu & Giải thuậtData Structures & Algorithms7 Th8, 2026Aug 7, 202627 phút đọc23 min read

Cây khung nhỏ nhấtMinimum Spanning Trees

Mục lục
  1. Tổng quan
  2. Kiến thức nền tảng
  3. Spanning tree
  4. Cut property
  5. Cycle property
  6. Tính duy nhất của MST
  7. Các tính chất khác đáng biết
  8. Khái niệm chính
  9. Thuật toán Kruskal — sort edge, O(E log E)
  10. Thuật toán Prim — nuôi một cây, O(E log V)
  11. Kruskal hay Prim?
  12. Ứng dụng
  13. Best Practices
  14. Tài liệu tham khảo
Table of contents
  1. Overview
  2. Fundamentals
  3. Spanning trees
  4. The cut property
  5. The cycle property
  6. MST uniqueness
  7. Other properties worth knowing
  8. Key Concepts
  9. Kruskal’s algorithm — sort the edges, O(E log E)
  10. Prim’s algorithm — grow one tree, O(E log V)
  11. Kruskal’s or Prim’s?
  12. Applications
  13. Best Practices
  14. References

Thuộc bộ kiến thức Data Structures & Algorithms Roadmap.

Tổng quan

Bạn có một tập địa điểm và một tập kết nối khả dĩ giữa chúng, mỗi kết nối có một chi phí. Bạn muốn mọi địa điểm đều tới được nhau, và muốn trả càng ít tiền càng tốt. Đó là bài toán minimum spanning tree (MST), và nó là một trong số ít bài toán thực sự quan trọng mà chiến lược greedy hiển nhiên không chỉ là một heuristic tạm được — nó tối ưu có chứng minh.

Các ứng dụng đúng như cách phát biểu gợi ý. Kéo cáp quang nối một nhóm toà nhà. Đi dây một bảng mạch với tổng chiều dài đường mạch nhỏ nhất. Thiết kế topology backbone của một mạng. Nhưng MST còn xuất hiện ở những nơi mà “mạng lưới” chỉ là ẩn dụ: single-linkage clustering theo đúng nghĩa đen là “dựng MST rồi xoá k−1 edge nặng nhất”; các thuật toán phân đoạn ảnh dựng MST trên graph tương đồng pixel; thuật toán 2-approximation chuẩn cho bài toán người bán hàng rong metric bắt đầu bằng việc dựng MST; sinh mê cung là MST trên lưới với trọng số ngẫu nhiên.

Điều khiến MST đáng học kỹ không phải là bản thân các thuật toán — Kruskal và Prim mỗi cái chỉ khoảng mười lăm dòng — mà là vì sao chúng đúng. Cả hai đều greedy, và thuật toán greedy thường sai (xem ./20-brute-force-greedy-and-randomised-algorithms.md). Ở đây chúng đúng, và lý do là hai sự thật cấu trúc về spanning tree: cut propertycycle property. Hiểu chúng thì cả hai thuật toán trở nên hiển nhiên; bỏ qua chúng thì bạn chỉ đang học vẹt code.

MST được định nghĩa trên graph vô hướng, liên thông, có trọng số. Việc thêm hướng biến bài toán thành tìm minimum spanning arborescence, một bài toán khác và khó hơn, giải bằng thuật toán Chu-Liu/Edmonds. Note này giả định bạn đã nắm các cách biểu diễn và phép duyệt trong ./13-graph-data-structures.md.

Kiến thức nền tảng

Spanning tree

Spanning tree của một graph vô hướng liên thông G = (V, E) là một subgraph:

  1. chứa mọi vertex (“spanning”),
  2. liên thông, và
  3. không có cycle (“tree”).

Hai bất kỳ trong ba điều kiện đó, cộng với số edge đúng bằng V − 1, sẽ kéo theo điều còn lại. Từ đó có bốn cách đặc trưng tương đương, đều nên có sẵn trong đầu:

Graph G: một hình vuông với một đường chéo.

   0 --- 1        trọng số edge:  0-1 : 1
   | \   |                        0-2 : 4
   |  \  |                        0-3 : 2
   2 --- 3                        1-3 : 3
                                  2-3 : 5

Spanning tree T1 = {0-1, 0-3, 0-2}      Spanning tree T2 = {0-1, 1-3, 2-3}
trọng số 1 + 2 + 4 = 7 (đây là MST)     trọng số 1 + 3 + 5 = 9

   0 --- 1                                 0 --- 1
   | \                                           |
   |  \                                          |
   2    3                                 2 --- 3

Thêm bất kỳ edge nào không thuộc tree vào một spanning tree sẽ tạo ra đúng một cycle (vì trước đó đã có đúng một path giữa hai đầu mút của nó). Bỏ bất kỳ edge nào của tree sẽ tách tree thành đúng hai component. Hai sự thật này là bộ máy đứng sau mọi chứng minh bên dưới.

Một graph liên thông thường có cực kỳ nhiều spanning tree — complete graph K_nn^(n−2) cái theo công thức Cayley, tức 10^8 chỉ với mười vertex. Liệt kê hết chúng để tìm cái rẻ nhất là bất khả thi, và đó là lý do các tính chất greedy trở nên quan trọng.

Minimum spanning tree là spanning tree có tổng trọng số nhỏ nhất trong tất cả các spanning tree. Nếu graph không liên thông thì không có spanning tree nào cả; thứ bạn nhận được thay vào đó là minimum spanning forest — mỗi connected component một MST.

Cut property

Một cut (S, V∖S) là một cách phân hoạch tập vertex thành hai tập khác rỗng. Một edge vượt qua cut nếu nó có một đầu mút ở mỗi phía.

Cut property. Với một cut bất kỳ, nếu e là edge trọng số nhỏ nhất duy nhất vượt qua cut đó, thì e thuộc mọi MST của graph.

(Khi có hoà, phát biểu yếu hơn vẫn đúng: một edge trọng số nhỏ nhất vượt cut thuộc một MST nào đó.)

Chứng minh (lập luận đổi chỗ). Giả sử T là một MST không chứa e = (u, v), với u ∈ Sv ∈ V∖S. T là spanning nên nó chứa một path từ u tới v. Path đó bắt đầu trong S và kết thúc bên ngoài, nên tại một điểm nào đó nó dùng một edge f cũng vượt qua cut. Vì e là edge vượt cut nhỏ nhất duy nhất, ta có w(e) < w(f). Giờ lập T' = T − f + e. Bỏ f tách T thành hai mảnh, và e nối chúng lại (nó nối đúng hai phía của cut), nên T' lại là một spanning tree — với trọng số w(T) − w(f) + w(e) < w(T). Điều đó mâu thuẫn với việc T là nhỏ nhất. ∎

Cut (S, V\S):        S  |  V\S
                        |
              u --------|-------- v      e, trọng số 2  <- edge vượt cut nhỏ nhất duy nhất
                        |
              a --------|-------- b      f, trọng số 7
                        |
              c --------|-------- d      g, trọng số 5

Mọi MST đều chứa e. Không cần chứng minh bằng quan sát - lập luận đổi chỗ ở
trên đúng với bất kỳ cut nào.

Chỉ riêng tính chất này đã biện minh cho cả hai thuật toán. Prim nuôi lớn một tập S và liên tục thêm edge rẻ nhất rời khỏi S — theo cách xây dựng, đó là một cut. Kruskal thêm edge rẻ nhất nối hai component khác nhau — chính là edge rẻ nhất vượt qua cut tách một trong hai component đó khỏi mọi thứ còn lại. Cả hai chỉ là cut property được áp đi áp lại.

Cycle property

Cycle property. Với một cycle C bất kỳ trong graph, nếu f là edge trọng số lớn nhất duy nhất trên C, thì f không thuộc bất kỳ MST nào.

Chứng minh. Giả sử một MST T chứa f = (u, v). Bỏ f: T tách thành hai component, với u ở một bên và v ở bên kia. Phần còn lại của cycle C tạo thành một path từ u tới v, và path đó phải quay lại giữa hai component tại một edge g ≠ f nào đó. Khi đó T − f + g là một spanning tree với trọng số w(T) − w(f) + w(g) < w(T)w(g) < w(f). Mâu thuẫn. ∎

Cycle property là quy tắc loại trừ soi gương với quy tắc thu nhận của cut property. Kruskal dùng nó một cách ngầm định: khi nó bỏ qua một edge vì hai đầu mút đã liên thông, edge đó chính là edge nặng nhất trên cycle mà nó sẽ khép lại (mọi edge nhẹ hơn của cycle đó đã được xét trước rồi). Đây cũng là cơ sở của thuật toán “reverse-delete” — sort edge giảm dần rồi xoá từng edge nào mà việc xoá nó vẫn giữ graph liên thông — đúng nhưng chậm và hiếm khi được dùng.

Kết hợp lại, hai tính chất này là thứ mà sách giáo khoa gọi là blue rule (tô xanh edge nhỏ nhất vượt cut: nó nằm trong MST) và red rule (tô đỏ edge lớn nhất trên cycle: nó nằm ngoài). Mọi thuật toán MST đều là một chính sách lập lịch trên hai quy tắc này.

Tính duy nhất của MST

Nếu mọi trọng số edge đôi một khác nhau, MST là duy nhất.

Chứng minh. Giả sử T₁ ≠ T₂ đều là MST. Gọi e là edge trọng số nhỏ nhất trong hiệu đối xứng của chúng — chẳng hạn e ∈ T₁ ∖ T₂ (lập luận đối xứng cho trường hợp còn lại). Thêm e vào T₂ tạo ra đúng một cycle C. C không thể nằm trọn trong T₁ (tree không có cycle), nên C chứa một edge f ∉ T₁, và f ≠ e. Khi đó f ∈ T₂ ∖ T₁, nên f cũng nằm trong hiệu đối xứng, và do tính nhỏ nhất của e với trọng số đôi một khác nhau, ta có w(e) < w(f). Vậy T₂ − f + e là một spanning tree có trọng số nhỏ hơn hẳn T₂. Mâu thuẫn. ∎

Chiều ngược lại không đúng: MST vẫn có thể duy nhất dù có trọng số trùng nhau. Thứ mà hoà trọng số thực sự gây ra là sự mơ hồ trong output của thuật toán, chứ không nhất thiết là nhiều MST — hai lần chạy Kruskal với cách phá hoà khác nhau có thể xuất ra hai tập edge khác nhau, dù cả hai có cùng tổng trọng số.

Hệ quả thực dụng: nếu bạn cần một MST tất định, tái lập được, hãy phá hoà một cách tất định. Sort edge theo (weight, u, v) thay vì chỉ theo weight khiến output của Kruskal ổn định qua các lần chạy và qua các phiên bản Python.

Các tính chất khác đáng biết

Khái niệm chính

Thuật toán Kruskal — sort edge, O(E log E)

Kruskal là cut property áp dụng ở phạm vi toàn cục: xét các edge từ rẻ nhất tới đắt nhất, và lấy mỗi edge trừ khi nó tạo ra cycle. “Tạo ra cycle” nghĩa là “hai đầu mút đã thuộc cùng một component”, và đó chính xác là truy vấn mà cấu trúc disjoint-set union (union-find) trả lời trong thời gian gần như hằng số — xem ./16-disjoint-set-union-find.md để có phần trình bày đầy đủ về cấu trúc dùng ở đây.

class DSU:
    """Disjoint-set union với union by size và path halving.
    find/union là O(alpha(n)) amortized - trên thực tế là hằng số.
    Trình bày đầy đủ trong ./16-disjoint-set-union-find.md."""

    def __init__(self, n):
        self.parent = list(range(n))     # mỗi phần tử khởi đầu trong tập của riêng nó
        self.size = [1] * n

    def find(self, x):
        while self.parent[x] != x:
            self.parent[x] = self.parent[self.parent[x]]   # path halving
            x = self.parent[x]
        return x

    def union(self, a, b):
        """Gộp tập của a và b. Trả về False nếu chúng vốn đã cùng tập."""
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return False                 # cùng component: edge này sẽ khép một cycle
        if self.size[ra] < self.size[rb]:
            ra, rb = rb, ra              # union by size: treo cây nhỏ vào cây lớn
        self.parent[rb] = ra
        self.size[ra] += self.size[rb]
        return True


def kruskal(n, edges):
    """Minimum spanning tree của một graph vô hướng.

    `edges` là danh sách bộ ba (w, u, v) - biểu diễn edge list.
    Trả về (total_weight, mst_edges). Ném lỗi nếu graph không liên thông.
    Thời gian O(E log E), chi phối bởi phép sort; bộ nhớ O(V + E).
    """
    edges = sorted(edges)                # (w, u, v) sort theo w, rồi u, rồi v
    dsu = DSU(n)
    total = 0
    mst = []

    for w, u, v in edges:
        if dsu.union(u, v):              # khác component -> lấy được an toàn
            total += w
            mst.append((w, u, v))
            if len(mst) == n - 1:
                break                    # spanning tree có đúng n-1 edge

    if len(mst) != n - 1:
        raise ValueError("graph is not connected - no spanning tree exists")
    return total, mst

Vì sao nó đúng. Khi Kruskal nhận edge (u, v), hãy xét cut tách component hiện tại của u khỏi mọi thứ còn lại. Mọi edge vượt cut đó với trọng số nhỏ hơn w đều đã được xét và bị loại — nhưng việc bị loại chỉ xảy ra với edge nằm trong một component, nên không có edge vượt cut nào như vậy. Do đó (u, v) là edge trọng số nhỏ nhất vượt cut đó, và cut property nói rằng nó thuộc một MST. Mọi edge mà nó loại bỏ đều là edge trọng số lớn nhất trên cycle mà edge đó sẽ khép lại, bị loại bởi cycle property.

Độ phức tạp: thời gian O(E log E). Phép sort chi phối với O(E log E); E thao tác union-find tốn O(E · α(V)) với α là hàm Ackermann ngược — nhỏ hơn 5 với mọi input vừa trong vũ trụ này. Vì E ≤ V² nên log E ≤ 2 log V, do đó O(E log E) = O(E log V); cả hai dạng đều xuất hiện trong tài liệu và có nghĩa như nhau. Bộ nhớ là O(V) cho DSU cộng O(E) cho danh sách edge đã sort.

Khi phép sort là miễn phí, Kruskal trở thành O(E α(V)) — về cơ bản là tuyến tính. Điều đó xảy ra khi trọng số là số nguyên nhỏ (counting sort hoặc bucket sort, xem ./08-sorting-algorithms.md) hoặc khi edge đã tới sẵn theo thứ tự. Đây là lợi thế thực dụng lớn nhất của Kruskal.

Hai tính chất đẹp nảy ra từ cấu trúc của nó:

Thuật toán Prim — nuôi một cây, O(E log V)

Prim là cut property áp dụng ở phạm vi cục bộ: bắt đầu từ một vertex tuỳ ý, rồi liên tục thêm edge rẻ nhất nối cây đang có với một vertex bên ngoài. Cut ở đây là (cây, mọi thứ còn lại) và nó thay đổi ở mỗi bước.

Graph G ở trên. Bắt đầu từ 0; cây lớn dần từng vertex một và
cut (cây | phần còn lại) di chuyển theo.

bước 0:  cây = {0}          edge rời cây rẻ nhất: 0-1 (w=1)  -> lấy
bước 1:  cây = {0,1}        ứng viên 0-2 (4), 0-3 (2), 1-3 (3) -> lấy 0-3 (2)
bước 2:  cây = {0,1,3}      ứng viên 0-2 (4), 2-3 (5)          -> lấy 0-2 (4)
bước 3:  cây = {0,1,2,3}    xong - n-1 = 3 edge, tổng trọng số 7

Truy vấn “edge rẻ nhất rời khỏi cây” chính là công việc của một priority queue — xem ./12-heaps-and-priority-queues.md.

import heapq


def prim(adj, start=0):
    """Minimum spanning tree bằng cách nuôi một cây duy nhất từ `start`.

    adj[u] là danh sách các cặp (v, w). Trả về (total_weight, mst_edges).
    Thời gian O(E log V), bộ nhớ O(V + E).

    Đây là bản "lazy": thay vì decrease-key (thứ heapq không có), nó push bản
    sao và loại bỏ phần tử cũ lúc pop.
    """
    n = len(adj)
    in_tree = [False] * n
    pq = [(0, start, -1)]                # (trọng số edge, vertex, vertex đi tới từ đó)
    total = 0
    mst = []

    while pq:
        w, u, p = heapq.heappop(pq)
        if in_tree[u]:
            continue                     # phần tử cũ: u đã vào cây rồi
        in_tree[u] = True
        if p != -1:                      # -1 đánh dấu phần tử khởi đầu nhân tạo
            total += w
            mst.append((w, p, u))
        for v, wv in adj[u]:
            if not in_tree[v]:
                heapq.heappush(pq, (wv, v, u))

    if len(mst) != n - 1:
        raise ValueError("graph is not connected - no spanning tree exists")
    return total, mst

Độ phức tạp: O(E log E) = O(E log V). Mỗi edge được push tối đa một lần từ mỗi đầu mút, nên heap chứa O(E) phần tử; mỗi lần push và pop tốn O(log E). Bộ nhớ là O(E) cho heap — cái giá của cách làm lazy.

Bản eager giữ đúng một phần tử heap cho mỗi vertex (kết nối rẻ nhất đã biết tới cây) và dùng decrease-key, cho bộ nhớ heap O(V) và thời gian O(E log V). Trong Python không có primitive decrease-key nên bản lazy là thứ bạn sẽ viết; trong C++ với std::priority_queue cũng vậy. Với Fibonacci heap, Prim là O(E + V log V), đạt cận tối ưu lý thuyết cho cách tiếp cận dựa trên so sánh.

Trên graph dense, hãy bỏ hẳn heap. Giữ một mảng key[] chứa “edge rẻ nhất từ cây tới vertex này” và chọn giá trị nhỏ nhất bằng quét tuyến tính:

def prim_dense(matrix):
    """Prim cho graph dense cho dưới dạng adjacency matrix (INF = không có edge).

    Thời gian O(V^2), bộ nhớ O(V) ngoài matrix. Khi E xấp xỉ V^2 thì cách này
    thắng bản dùng heap: O(V^2) < O(V^2 log V).
    """
    n = len(matrix)
    INF = float("inf")
    key = [INF] * n                      # trọng số edge rẻ nhất đã biết vào mỗi vertex
    parent = [-1] * n
    in_tree = [False] * n
    key[0] = 0
    total = 0

    for _ in range(n):
        u = -1
        for v in range(n):               # quét O(V) tìm vertex biên rẻ nhất
            if not in_tree[v] and (u == -1 or key[v] < key[u]):
                u = v
        if key[u] == INF:
            raise ValueError("graph is not connected - no spanning tree exists")
        in_tree[u] = True
        total += key[u]
        for v in range(n):               # relax cut, O(V)
            if not in_tree[v] and matrix[u][v] < key[v]:
                key[v] = matrix[u][v]
                parent[v] = u

    return total, parent

Sự giống nhau với Dijkstra không phải ngẫu nhiên, nhưng hai thuật toán không phải một. Cả hai đều nuôi lớn một tập theo kiểu greedy từ một priority queue. Khác biệt nằm ở đúng một dòng: key của Dijkstra là dist[u] + w (chi phí tính từ source), key của Prim chỉ là w (chi phí của riêng edge nối). Đó là lý do Dijkstra sinh ra shortest-path tree còn Prim sinh ra MST, và là lý do Dijkstra hỏng với trọng số âm còn Prim thì không quan tâm — Prim không bao giờ tích luỹ, nên chẳng có gì cho một trọng số âm phá hoại.

Kruskal hay Prim?

KruskalPrim (binary heap)Prim (mảng, dense)Borůvka
Thời gianO(E log E) = O(E log V)O(E log V)O(V²)O(E log V)
Bộ nhớ (ngoài input)O(V) DSU + O(E) sortO(E) heap lazy / O(V) eagerO(V)O(V)
Biểu diễn inputedge listadjacency listadjacency matrixedge list
Tốt nhất khisparse; trọng số đã sort hoặc là số nguyên nhỏsparse tới trung bình; đã sẵn adjacency listdense, E ≈ V²song song / phân tán
Input không liên thôngcho spanning forest miễn phíchỉ phủ component của startchỉ phủ component của vertex 0cho spanning forest miễn phí
Cầnsort + union-findpriority queuekhông cần gìunion-find
Cut property áp lêncomponent của mỗi edge được nhậncây đang lớn dầncây đang lớn dầnmọi component cùng lúc

Tóm gọn:

Thuật toán Borůvka (1926 — thuật toán MST đầu tiên từng được công bố, có trước cả hai thuật toán kia) rất đáng biết: ở mỗi vòng, mọi component đồng thời chọn edge đi ra rẻ nhất của riêng nó, và tất cả các edge đó được thêm vào cùng lúc. Mỗi vòng giảm ít nhất một nửa số component, nên có O(log V) vòng, mỗi vòng tốn O(E) — tổng cộng O(E log V). Vì công việc trong mỗi vòng song song hoá cực dễ, nó là nền tảng của các hiện thực MST song song và phân tán, và của những thuật toán nhanh nhất được biết về lý thuyết (thuật toán ngẫu nhiên O(E) kỳ vọng của Karger-Klein-Tarjan, và thuật toán tất định O(E α(V)) của Chazelle). Borůvka đòi hỏi trọng số edge đôi một khác nhau để đúng như phát biểu — khi có hoà, hai component có thể chọn “edge của nhau” và tạo thành cycle — sửa bằng cách phá hoà theo chỉ số edge.

Ứng dụng

Thiết kế mạng lưới. Chính là bài toán gốc thúc đẩy MST. Nối n văn phòng bằng đường thuê riêng, nối n ngôi nhà vào một đường ống nước chính, nối n linh kiện trên một PCB — tối thiểu hoá tổng chiều dài cáp trong khi vẫn giữ mọi thứ liên thông. Chú ý giả định nằm sẵn bên trong: MST tối ưu chi phí tổng mà không quan tâm gì tới độ dài path bên trong mạng. Nếu latency giữa hai node bất kỳ là quan trọng thì MST là hình dạng sai (nó có thể là một đường dài ngoằng), và bạn cần thứ khác — một shortest-path tree, hoặc một topology có dự phòng. MST cũng là topology có độ dự phòng nhỏ nhất: bỏ bất kỳ edge nào là mạng bị chia cắt. Các backbone thực tế cố ý thêm edge ngoài MST để chịu lỗi.

Single-linkage clustering. Để chia n điểm thành k cụm mà “khoảng cách giữa hai cụm” nghĩa là khoảng cách giữa hai phần tử gần nhau nhất của chúng: dựng MST trên graph khoảng cách đầy đủ, rồi xoá k − 1 edge nặng nhất. k component còn lại chính xác là các cụm single-linkage, và trọng số của edge nhẹ nhất bị xoá chính là spacing — khoảng cách nhỏ nhất giữa hai cụm — mà cách dựng này chứng minh được là lớn nhất có thể.

def single_linkage_clusters(n, edges, k):
    """Chia n điểm thành k cụm bằng cách cắt k-1 edge nặng nhất của MST.

    `edges` là danh sách (distance, i, j). Trả về (labels, spacing) với
    `spacing` là khoảng cách nhỏ nhất giữa hai cụm khác nhau.
    Thời gian O(E log E).
    """
    _, mst = kruskal(n, edges)
    mst.sort()                                    # tăng dần theo trọng số
    keep = mst[:len(mst) - (k - 1)]               # bỏ k-1 edge nặng nhất
    spacing = mst[len(mst) - (k - 1)][0] if k > 1 else float("inf")

    dsu = DSU(n)
    for w, u, v in keep:
        dsu.union(u, v)
    labels = [dsu.find(x) for x in range(n)]
    return labels, spacing

Lưu ý một điểm thực dụng: single-linkage bị hiện tượng chaining — một dải mỏng các điểm trung gian sẽ gộp hai cụm vốn tách biệt rõ ràng, bởi chỉ cặp gần nhất mới có tiếng nói. Đó là tính chất của hàm mục tiêu chứ không phải bug của thuật toán, và cũng là lý do complete-linkage hay Ward clustering thường được ưa dùng hơn trên dữ liệu thực.

TSP xấp xỉ. Với graph metric (trọng số thoả bất đẳng thức tam giác), dựng MST, nhân đôi mọi edge, rồi lấy một chu trình Euler với các đường tắt sẽ cho một tour tốn nhiều nhất gấp đôi tối ưu — bởi MST rẻ hơn tour tối ưu (xoá một edge bất kỳ của tour thì bạn có một spanning path, tức là một spanning tree). Thuật toán Christofides tinh chỉnh chính ý tưởng đó thành xấp xỉ 1.5.

Sinh mê cung. Gán trọng số ngẫu nhiên cho các bức tường của một lưới rồi tính MST: kết quả là một mê cung hoàn hảo — có đúng một đường đi giữa hai ô bất kỳ, theo đúng tính chất spanning tree. Kruskal ngẫu nhiên và Prim ngẫu nhiên là hai thuật toán sinh mê cung chuẩn mực, và cho ra kết cấu mê cung khác nhau rõ rệt (Kruskal cho nhiều ngõ cụt ngắn; Prim cho cấu trúc toả tròn hơn).

Phân đoạn ảnh. Thuật toán phân đoạn Felzenszwalb-Huttenlocher dựng graph trên các pixel với edge có trọng số theo độ chênh màu, rồi gộp các vùng theo thứ tự MST, kèm một ngưỡng thích ứng theo kích thước vùng. Union-find lo phần gộp, y hệt như trong Kruskal.

Phân tích mạch và phân loại học. Bài toán liên thông trọng số nhỏ nhất trên graph tương đồng là cùng một phép tính, dù vertex là gene, tài liệu, hay node mạng.

Best Practices

Tài liệu tham khảo

Part of the Data Structures & Algorithms Roadmap knowledge base.

Overview

You have a set of locations and a set of possible connections between them, each with a cost. You want every location reachable from every other, and you want to pay as little as possible. That is the minimum spanning tree (MST) problem, and it is one of the few genuinely important problems where the obvious greedy strategy is not merely a decent heuristic — it is provably optimal.

The applications are exactly what the framing suggests. Laying fibre to connect a set of buildings. Wiring a circuit board with the least total trace length. Designing a backbone network topology. But the MST also shows up where the “network” is metaphorical: single-linkage clustering is literally “build the MST and delete the k−1 heaviest edges”; image segmentation algorithms build an MST over a pixel-similarity graph; the standard 2-approximation for the metric travelling salesman problem starts by building an MST; maze generation is an MST over a grid with random edge weights.

What makes MST worth studying carefully is not the algorithms — Kruskal’s and Prim’s are each about fifteen lines — but why they are correct. Both are greedy, and greedy algorithms are usually wrong (see ./20-brute-force-greedy-and-randomised-algorithms.md). Here they are right, and the reason is two structural facts about spanning trees: the cut property and the cycle property. Understand those and both algorithms become obvious; skip them and you are memorizing code.

MSTs are defined on undirected, connected, weighted graphs. Direction changes the problem into finding a minimum spanning arborescence, a different and harder problem solved by the Chu-Liu/Edmonds algorithm. This note assumes the representations and traversals from ./13-graph-data-structures.md.

Fundamentals

Spanning trees

A spanning tree of a connected undirected graph G = (V, E) is a subgraph that:

  1. includes every vertex (“spanning”),
  2. is connected, and
  3. contains no cycle (“tree”).

Any two of those three conditions, plus an edge count of exactly V − 1, imply the third. This gives four equivalent characterizations, all worth having to hand:

Graph G: a square with one diagonal.

   0 --- 1        edge weights:  0-1 : 1
   | \   |                       0-2 : 4
   |  \  |                       0-3 : 2
   2 --- 3                       1-3 : 3
                                 2-3 : 5

Spanning tree T1 = {0-1, 0-3, 0-2}      Spanning tree T2 = {0-1, 1-3, 2-3}
weight 1 + 2 + 4 = 7 (this is the MST)  weight 1 + 3 + 5 = 9

   0 --- 1                                 0 --- 1
   | \                                           |
   |  \                                          |
   2    3                                 2 --- 3

Adding any non-tree edge to a spanning tree creates exactly one cycle (because there was already exactly one path between its endpoints). Removing any tree edge splits the tree into exactly two components. These two facts are the machinery behind every proof below.

A connected graph usually has an enormous number of spanning trees — the complete graph K_n has n^(n−2) of them by Cayley’s formula, which is 10^8 for just ten vertices. Enumerating them to find the cheapest is out of the question, which is why the greedy properties matter.

A minimum spanning tree is a spanning tree whose total edge weight is minimum among all spanning trees. If the graph is disconnected there is no spanning tree at all; what you get instead is a minimum spanning forest — one MST per connected component.

The cut property

A cut (S, V∖S) is any partition of the vertices into two non-empty sets. An edge crosses the cut if it has one endpoint in each side.

Cut property. For any cut, if e is the unique minimum-weight edge crossing that cut, then e belongs to every MST of the graph.

(With ties, the weaker statement holds: some minimum-weight crossing edge belongs to some MST.)

Proof (exchange argument). Suppose T is an MST that does not contain e = (u, v), with u ∈ S and v ∈ V∖S. T is spanning, so it contains a path from u to v. That path starts in S and ends outside it, so at some point it uses an edge f that also crosses the cut. Since e is the unique minimum crossing edge, w(e) < w(f). Now form T' = T − f + e. Removing f splits T into two pieces, and e reconnects them (it joins the same two sides of the cut), so T' is again a spanning tree — with weight w(T) − w(f) + w(e) < w(T). That contradicts T being minimum. ∎

Cut (S, V\S):        S  |  V\S
                        |
              u --------|-------- v      e, weight 2  <- unique minimum crossing edge
                        |
              a --------|-------- b      f, weight 7
                        |
              c --------|-------- d      g, weight 5

Every MST contains e. No proof by inspection needed - the exchange argument
above works for any cut whatsoever.

This single property justifies both algorithms. Prim’s grows a set S and repeatedly adds the cheapest edge leaving S — a cut, by construction. Kruskal’s adds the cheapest edge joining two different components — which is the cheapest edge crossing the cut that separates one of those components from everything else. Both are just the cut property applied over and over.

The cycle property

Cycle property. For any cycle C in the graph, if f is the unique maximum-weight edge on C, then f belongs to no MST.

Proof. Suppose an MST T contains f = (u, v). Remove f: T splits into two components, with u in one and v in the other. The rest of the cycle C forms a u-to-v path, which must cross back between the two components at some edge g ≠ f. Then T − f + g is a spanning tree with weight w(T) − w(f) + w(g) < w(T) since w(g) < w(f). Contradiction. ∎

The cycle property is the exclusion rule that mirrors the cut property’s inclusion rule. Kruskal’s uses it implicitly: when it skips an edge because both endpoints are already connected, that edge is the heaviest on the cycle it would close (all lighter edges of that cycle were considered earlier). This is also the basis of the “reverse-delete” algorithm — sort edges descending and delete each one whose removal keeps the graph connected — which is correct but slow and rarely used.

Together the two properties are what algorithm textbooks call the blue rule (colour a minimum crossing edge blue: it is in the MST) and the red rule (colour a maximum cycle edge red: it is out). Every MST algorithm is a scheduling policy over these two rules.

MST uniqueness

If all edge weights are distinct, the MST is unique.

Proof. Suppose T₁ ≠ T₂ are both MSTs. Let e be the minimum-weight edge in their symmetric difference — say e ∈ T₁ ∖ T₂ (the argument is symmetric). Adding e to T₂ creates exactly one cycle C. C cannot lie entirely inside T₁ (a tree has no cycles), so C contains some edge f ∉ T₁, and f ≠ e. Then f ∈ T₂ ∖ T₁, so f is also in the symmetric difference, and by the minimality of e with distinct weights, w(e) < w(f). Then T₂ − f + e is a spanning tree of strictly smaller weight than T₂. Contradiction. ∎

The converse does not hold: an MST can be unique even with repeated weights. What ties actually cause is ambiguity of the algorithm’s output, not necessarily multiple MSTs — two runs of Kruskal’s with different tie-breaking may emit different edge sets, though both will have the same total weight.

Practical consequence: if you need a deterministic, reproducible MST, break ties deterministically. Sorting edges by (weight, u, v) rather than by weight alone makes Kruskal’s output stable across runs and across Python versions.

Other properties worth knowing

Key Concepts

Kruskal’s algorithm — sort the edges, O(E log E)

Kruskal’s is the cut property applied globally: consider edges from cheapest to most expensive, and take each one unless it would create a cycle. “Would create a cycle” means “both endpoints are already in the same component”, which is exactly the query a disjoint-set union (union-find) structure answers in near-constant time — see ./16-disjoint-set-union-find.md for the full treatment of the structure used here.

class DSU:
    """Disjoint-set union with union by size and path halving.
    find/union are O(alpha(n)) amortized - effectively constant.
    Full treatment in ./16-disjoint-set-union-find.md."""

    def __init__(self, n):
        self.parent = list(range(n))     # every element starts in its own set
        self.size = [1] * n

    def find(self, x):
        while self.parent[x] != x:
            self.parent[x] = self.parent[self.parent[x]]   # path halving
            x = self.parent[x]
        return x

    def union(self, a, b):
        """Merge the sets of a and b. Returns False if they were already merged."""
        ra, rb = self.find(a), self.find(b)
        if ra == rb:
            return False                 # same component: this edge would close a cycle
        if self.size[ra] < self.size[rb]:
            ra, rb = rb, ra              # union by size: hang the small tree off the big
        self.parent[rb] = ra
        self.size[ra] += self.size[rb]
        return True


def kruskal(n, edges):
    """Minimum spanning tree of an undirected graph.

    `edges` is a list of (w, u, v) triples - the edge-list representation.
    Returns (total_weight, mst_edges). Raises if the graph is disconnected.
    Time O(E log E) dominated by the sort; space O(V + E).
    """
    edges = sorted(edges)                # (w, u, v) sorts by weight, then u, then v
    dsu = DSU(n)
    total = 0
    mst = []

    for w, u, v in edges:
        if dsu.union(u, v):              # different components -> safe to take
            total += w
            mst.append((w, u, v))
            if len(mst) == n - 1:
                break                    # a spanning tree has exactly n-1 edges

    if len(mst) != n - 1:
        raise ValueError("graph is not connected - no spanning tree exists")
    return total, mst

Why it is correct. When Kruskal’s accepts edge (u, v), consider the cut separating u’s current component from everything else. Every edge crossing that cut with weight less than w was already examined and rejected — but rejection only happens for edges inside a component, so no such crossing edge exists. Therefore (u, v) is a minimum-weight edge across that cut, and the cut property says it belongs to an MST. Every edge it rejects is the maximum-weight edge on the cycle it would close, excluded by the cycle property.

Complexity: O(E log E) time. The sort dominates at O(E log E); the E union-find operations cost O(E · α(V)) where α is the inverse Ackermann function — under 5 for any input that fits in the universe. Since E ≤ V², log E ≤ 2 log V, so O(E log E) = O(E log V); both forms appear in the literature and mean the same thing. Space is O(V) for the DSU plus O(E) for the sorted edge list.

When the sort is free, Kruskal’s becomes O(E α(V)) — effectively linear. That happens when weights are small integers (counting sort or bucket sort, see ./08-sorting-algorithms.md) or when edges arrive already sorted. This is Kruskal’s biggest practical advantage.

Two nice properties fall out of the structure:

Prim’s algorithm — grow one tree, O(E log V)

Prim’s is the cut property applied locally: start from an arbitrary vertex, and repeatedly add the cheapest edge that connects the tree built so far to a vertex outside it. The cut is (tree, everything else) and it changes at every step.

Graph G from above. Start at 0; the tree grows one vertex at a time and
the cut (tree | rest) moves with it.

step 0:  tree = {0}          cheapest edge leaving: 0-1 (w=1)  -> take it
step 1:  tree = {0,1}        candidates 0-2 (4), 0-3 (2), 1-3 (3) -> take 0-3 (2)
step 2:  tree = {0,1,3}      candidates 0-2 (4), 2-3 (5)          -> take 0-2 (4)
step 3:  tree = {0,1,2,3}    done - n-1 = 3 edges, total weight 7

The “cheapest edge leaving the tree” query is what a priority queue is for — see ./12-heaps-and-priority-queues.md.

import heapq


def prim(adj, start=0):
    """Minimum spanning tree by growing a single tree from `start`.

    adj[u] is a list of (v, w) pairs. Returns (total_weight, mst_edges).
    Time O(E log V), space O(V + E).

    This is the "lazy" variant: instead of decrease-key (which heapq lacks) it
    pushes duplicates and discards stale entries on pop.
    """
    n = len(adj)
    in_tree = [False] * n
    pq = [(0, start, -1)]                # (edge weight, vertex, vertex we came from)
    total = 0
    mst = []

    while pq:
        w, u, p = heapq.heappop(pq)
        if in_tree[u]:
            continue                     # stale entry: u joined the tree already
        in_tree[u] = True
        if p != -1:                      # -1 marks the artificial start entry
            total += w
            mst.append((w, p, u))
        for v, wv in adj[u]:
            if not in_tree[v]:
                heapq.heappush(pq, (wv, v, u))

    if len(mst) != n - 1:
        raise ValueError("graph is not connected - no spanning tree exists")
    return total, mst

Complexity: O(E log E) = O(E log V). Every edge is pushed at most once from each endpoint, so the heap holds O(E) entries; each push and pop costs O(log E). Space is O(E) for the heap — the price of the lazy approach.

The eager variant keeps one heap entry per vertex (its cheapest known connection to the tree) and uses decrease-key, giving O(V) heap space and O(E log V) time. In Python without a decrease-key primitive, the lazy version is what you write; in C++ with std::priority_queue the same applies. With a Fibonacci heap, Prim’s is O(E + V log V), matching the theoretical optimum for a comparison-based approach.

On a dense graph, drop the heap entirely. Keep a key[] array of “cheapest edge from the tree to this vertex” and pick the minimum by linear scan:

def prim_dense(matrix):
    """Prim's for a dense graph given as an adjacency matrix (INF = no edge).

    Time O(V^2), space O(V) beyond the matrix. When E is close to V^2 this beats
    the heap version: O(V^2) < O(V^2 log V).
    """
    n = len(matrix)
    INF = float("inf")
    key = [INF] * n                      # cheapest known edge weight into each vertex
    parent = [-1] * n
    in_tree = [False] * n
    key[0] = 0
    total = 0

    for _ in range(n):
        u = -1
        for v in range(n):               # O(V) scan for the cheapest fringe vertex
            if not in_tree[v] and (u == -1 or key[v] < key[u]):
                u = v
        if key[u] == INF:
            raise ValueError("graph is not connected - no spanning tree exists")
        in_tree[u] = True
        total += key[u]
        for v in range(n):               # O(V) relaxation of the cut
            if not in_tree[v] and matrix[u][v] < key[v]:
                key[v] = matrix[u][v]
                parent[v] = u

    return total, parent

The resemblance to Dijkstra is not a coincidence, but the two are not the same algorithm. Both grow a set greedily from a priority queue. The difference is one line: Dijkstra’s key is dist[u] + w (cost from the source), Prim’s key is just w (cost of the single connecting edge). That is why Dijkstra produces a shortest-path tree and Prim’s produces an MST, and why Dijkstra breaks on negative weights while Prim’s does not care — Prim’s never accumulates, so there is nothing for a negative weight to corrupt.

Kruskal’s or Prim’s?

Kruskal’sPrim’s (binary heap)Prim’s (dense array)Borůvka’s
TimeO(E log E) = O(E log V)O(E log V)O(V²)O(E log V)
Space (beyond input)O(V) DSU + O(E) sortO(E) lazy heap / O(V) eagerO(V)O(V)
Input representationedge listadjacency listadjacency matrixedge list
Best whensparse; weights pre-sorted or small integerssparse to medium; you already have an adjacency listdense, E ≈ V²parallel / distributed
Disconnected inputspanning forest for freeonly spans start’s componentonly spans vertex 0’s componentspanning forest for free
Needssorting + union-findpriority queuenothingunion-find
Cut property applied tothe component of each accepted edgethe growing treethe growing treeevery component at once

The short version:

Borůvka’s algorithm (1926 — the first MST algorithm ever published, predating both) is worth knowing about: in each round, every component simultaneously picks its own cheapest outgoing edge, and all those edges are added at once. Each round at least halves the number of components, so there are O(log V) rounds, each costing O(E) — total O(E log V). Because the per-round work is embarrassingly parallel, it is the basis of parallel and distributed MST implementations, and of the theoretically fastest known algorithms (the Karger-Klein-Tarjan randomized O(E) expected-time algorithm, and Chazelle’s O(E α(V)) deterministic one). Borůvka’s requires distinct edge weights to be correct as stated — with ties, two components can each pick “the other’s” edge and form a cycle — which is fixed by breaking ties on edge index.

Applications

Network design. The original motivating problem. Connect n offices with leased lines, n houses to a water main, n components on a PCB — minimize total cable while keeping everything connected. Note the assumption baked in: the MST optimizes total cost with no regard for path lengths within the network. If latency between any two nodes matters, an MST is the wrong shape (it can be a long path), and you want something else entirely — a shortest-path tree, or a topology with redundancy. The MST is also the minimum redundancy topology: remove any edge and the network partitions. Real backbones deliberately add edges beyond the MST for fault tolerance.

Single-linkage clustering. To split n points into k clusters where “distance between clusters” means the distance between their two closest members: build the MST over the complete distance graph, then delete the k − 1 heaviest edges. The k remaining components are exactly the single-linkage clusters, and the weight of the lightest deleted edge is the spacing — the minimum inter-cluster distance — which this construction provably maximizes.

def single_linkage_clusters(n, edges, k):
    """Split n points into k clusters by cutting the k-1 heaviest MST edges.

    `edges` is a list of (distance, i, j). Returns (labels, spacing) where
    `spacing` is the minimum distance between two different clusters.
    Time O(E log E).
    """
    _, mst = kruskal(n, edges)
    mst.sort()                                    # ascending by weight
    keep = mst[:len(mst) - (k - 1)]               # drop the k-1 heaviest edges
    spacing = mst[len(mst) - (k - 1)][0] if k > 1 else float("inf")

    dsu = DSU(n)
    for w, u, v in keep:
        dsu.union(u, v)
    labels = [dsu.find(x) for x in range(n)]
    return labels, spacing

Note the practical caveat: single-linkage suffers from chaining — a thin bridge of intermediate points merges two otherwise well-separated clusters, because only the single closest pair matters. That is a property of the objective, not a bug in the algorithm, and it is why complete-linkage or Ward clustering are often preferred on real data.

Approximate TSP. For a metric graph (weights satisfy the triangle inequality), building an MST, doubling every edge, and taking an Euler tour with shortcuts yields a tour at most twice the optimal — because the MST is cheaper than the optimal tour (delete any tour edge and you get a spanning path, hence a spanning tree). Christofides’ algorithm refines the same idea to a 1.5-approximation.

Maze generation. Assign random weights to the walls of a grid and compute the MST: the result is a perfect maze — exactly one path between any two cells, by the spanning-tree property. Randomized Kruskal’s and randomized Prim’s are two of the standard maze algorithms and produce visibly different maze textures (Kruskal’s gives many short dead ends; Prim’s gives a more radial structure).

Image segmentation. Felzenszwalb-Huttenlocher segmentation builds a graph over pixels with edges weighted by colour difference and merges regions in MST order, subject to a threshold that adapts to region size. Union-find does the merging, exactly as in Kruskal’s.

Circuit and taxonomy analysis. Minimum-weight connectivity over similarity graphs is the same computation whether the vertices are genes, documents, or network nodes.

Best Practices

References