Cây khung nhỏ nhất (MST)

Khái niệm

Cây khung (Spanning Tree) của đồ thị liên thông vô hướng là cây con bao gồm tất cả đỉnh và một tập con cạnh không tạo chu trình.

Cây khung nhỏ nhất (Minimum Spanning Tree — MST) là cây khung có tổng trọng số nhỏ nhất.

Tính chất:

  • Luôn có đúng V-1 cạnh (V là số đỉnh)
  • Liên thông tất cả V đỉnh
  • Không có chu trình

Ứng dụng: Thiết kế mạng điện/cáp internet tối thiểu chi phí, phân cụm (clustering), xấp xỉ bài toán TSP.

graph LR; A((A)) -- "1" --> B((B)); A -- "3" --> C((C)); B -- "4" --> C; B -- "2" --> D((D)); C -- "5" --> D; style A fill:#90EE90 style B fill:#90EE90 style C fill:#90EE90 style D fill:#90EE90

MST chọn các cạnh: A-B (1), B-D (2), A-C (3) → tổng = 6. Bỏ cạnh B-C (4) và C-D (5).

Thuật toán Kruskal

Sắp xếp cạnh theo trọng số tăng dần, thêm cạnh vào MST nếu không tạo chu trình (dùng DSU).

Độ phức tạp: O(E log E) chủ yếu do bước sắp xếp.

class DSU:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank   = [0] * n

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])
        return self.parent[x]

    def union(self, x, y):
        rx, ry = self.find(x), self.find(y)
        if rx == ry:
            return False
        if self.rank[rx] < self.rank[ry]:
            rx, ry = ry, rx
        self.parent[ry] = rx
        if self.rank[rx] == self.rank[ry]:
            self.rank[rx] += 1
        return True


def kruskal_mst(n, canh):
    """
    Tìm MST bằng thuật toán Kruskal.

    n   : số đỉnh (0-indexed)
    canh: [(u, v, w)] — danh sách cạnh có trọng số
    Trả về: (tong_trong_so, danh_sach_canh_mst)
    """
    canh_sap_xep = sorted(canh, key=lambda e: e[2])
    dsu  = DSU(n)
    mst  = []
    tong = 0

    for u, v, w in canh_sap_xep:
        if dsu.union(u, v):
            mst.append((u, v, w))
            tong += w
            if len(mst) == n - 1:
                break    # Đủ V-1 cạnh rồi

    if len(mst) < n - 1:
        return -1, []    # Đồ thị không liên thông

    return tong, mst


# Đồ thị 5 đỉnh
canh = [(0,1,2),(0,3,6),(1,2,3),(1,3,8),(1,4,5),(2,4,7),(3,4,9)]
tong, mst = kruskal_mst(5, canh)
print(f"Tổng MST: {tong}")               # 16
print("Các cạnh MST:")
for u, v, w in mst:
    print(f"  {u} - {v}: {w}")

Thuật toán Prim

Bắt đầu từ một đỉnh, liên tục tham lam thêm cạnh nhỏ nhất kết nối đỉnh trong MST với đỉnh ngoài MST, dùng min-heap.

Độ phức tạp: O((V + E) log V) — hiệu quả hơn Kruskal cho đồ thị dày.

import heapq
from collections import defaultdict

def prim_mst(n, canh):
    """
    Tìm MST bằng thuật toán Prim.
    Xuất phát từ đỉnh 0.
    """
    graph = defaultdict(list)
    for u, v, w in canh:
        graph[u].append((w, v))
        graph[v].append((w, u))

    trong_mst = [False] * n
    min_heap  = [(0, 0, -1)]   # (trọng_số, đỉnh, đỉnh_cha)
    tong = 0
    mst  = []

    while min_heap and len(mst) < n:
        w, u, cha = heapq.heappop(min_heap)
        if trong_mst[u]:
            continue
        trong_mst[u] = True
        tong += w
        if cha != -1:
            mst.append((cha, u, w))

        for w2, v in graph[u]:
            if not trong_mst[v]:
                heapq.heappush(min_heap, (w2, v, u))

    if len(mst) < n - 1:
        return -1, []    # Không liên thông

    return tong, mst


canh = [(0,1,2),(0,3,6),(1,2,3),(1,3,8),(1,4,5),(2,4,7),(3,4,9)]
tong, mst = prim_mst(5, canh)
print(f"Tổng MST (Prim): {tong}")     # 16
for u, v, w in mst:
    print(f"  {u} - {v}: {w}")

Bài tập luyện tập

Dễ — Kết nối N thành phố với chi phí tối thiểu

def chi_phi_ket_noi_toi_thieu(n, ket_noi):
    """
    Tìm chi phí tối thiểu để kết nối n thành phố (đánh số 1..n).
    ket_noi: [(tp1, tp2, chi_phi)]
    Trả về -1 nếu không thể kết nối tất cả.
    """
    # Chuyển về 0-indexed
    canh = [(u-1, v-1, w) for u, v, w in ket_noi]

    class DSU_:
        def __init__(self, n):
            self.p = list(range(n))
        def find(self, x):
            if self.p[x] != x: self.p[x] = self.find(self.p[x])
            return self.p[x]
        def union(self, x, y):
            px, py = self.find(x), self.find(y)
            if px == py: return False
            self.p[px] = py; return True

    dsu  = DSU_(n)
    canh = sorted(canh, key=lambda e: e[2])
    tong = 0
    cnt  = 0
    for u, v, w in canh:
        if dsu.union(u, v):
            tong += w
            cnt  += 1
    return tong if cnt == n - 1 else -1

print(chi_phi_ket_noi_toi_thieu(4, [(1,2,3),(1,3,1),(2,3,4),(2,4,2),(3,4,5)]))  # 6
print(chi_phi_ket_noi_toi_thieu(3, [(1,2,1),(2,3,2)]))                          # 3
print(chi_phi_ket_noi_toi_thieu(4, [(1,2,1),(2,3,2)]))                          # -1 (không liên thông)

Trung bình — MST với một số cạnh bắt buộc

def mst_voi_canh_bat_buoc(n, canh_tu_do, canh_bat_buoc):
    """
    Tìm MST khi một số cạnh bắt buộc phải có.
    Chiến lược: Thêm cạnh bắt buộc trước, kiểm tra chu trình,
    rồi Kruskal cho cạnh còn lại.
    """
    class DSU_:
        def __init__(self, n):
            self.p, self.r = list(range(n)), [0]*n
        def find(self, x):
            if self.p[x]!=x: self.p[x]=self.find(self.p[x])
            return self.p[x]
        def union(self, x, y):
            px,py=self.find(x),self.find(y)
            if px==py: return False
            if self.r[px]<self.r[py]: px,py=py,px
            self.p[py]=px
            if self.r[px]==self.r[py]: self.r[px]+=1
            return True

    dsu  = DSU_(n)
    tong = 0
    cnt  = 0

    # Bước 1: Thêm cạnh bắt buộc
    for u, v, w in canh_bat_buoc:
        if not dsu.union(u, v):
            return -1, []    # Cạnh bắt buộc tạo chu trình → vô nghiệm
        tong += w
        cnt  += 1

    # Bước 2: Kruskal cho cạnh tự do
    for u, v, w in sorted(canh_tu_do, key=lambda e: e[2]):
        if dsu.union(u, v):
            tong += w
            cnt  += 1
    return (tong if cnt == n-1 else -1), cnt

canh_tu_do   = [(0,1,5),(0,2,3),(1,3,2),(2,3,4)]
canh_bat_buoc = [(1,2,10)]     # Phải có cạnh 1-2
tong, cnt = mst_voi_canh_bat_buoc(4, canh_tu_do, canh_bat_buoc)
print(f"Tổng: {tong}, Số cạnh: {cnt}")   # 17, 3

Khó — Maximum Spanning Tree (Cây khung lớn nhất)

def max_spanning_tree(n, canh):
    """
    Tìm cây khung LỚN NHẤT.
    Trick: sắp xếp cạnh giảm dần thay vì tăng dần.
    """
    class DSU_:
        def __init__(self, n):
            self.p, self.r = list(range(n)), [0]*n
        def find(self, x):
            if self.p[x]!=x: self.p[x]=self.find(self.p[x])
            return self.p[x]
        def union(self, x, y):
            px,py=self.find(x),self.find(y)
            if px==py: return False
            if self.r[px]<self.r[py]: px,py=py,px
            self.p[py]=px
            if self.r[px]==self.r[py]: self.r[px]+=1
            return True

    canh_sap_xep = sorted(canh, key=lambda e: e[2], reverse=True)  # Giảm dần
    dsu  = DSU_(n)
    tong = 0
    cnt  = 0
    mst  = []
    for u, v, w in canh_sap_xep:
        if dsu.union(u, v):
            tong += w
            cnt  += 1
            mst.append((u,v,w))
    return (tong if cnt == n-1 else -1), mst

canh = [(0,1,2),(0,3,6),(1,2,3),(1,3,8),(1,4,5),(2,4,7),(3,4,9)]
tong, mst = max_spanning_tree(5, canh)
print(f"Tổng Max-ST: {tong}")  # 25
for e in mst: print(f"  {e}")

Kết luận

Kruskal phù hợp khi đồ thị thưa (E nhỏ), còn Prim hiệu quả hơn khi đồ thị dày (E ~ V²). Cả hai đều dùng chiến lược tham lam. Tiếp theo hãy học Đồ thị hai phía (Bipartite) — cấu trúc đặc biệt với nhiều ứng dụng thực tế.

Bình luận