Cây khung cực tiểu - Kruskal và Prim
Cây khung nhỏ nhất với thuật toán Kruskal và PrimMai HiềnBài toán tìm cây khung tối thiểu (hay cây khung nhỏ nhất - Minimmum Spanning Tree - MST) của một đồ thị vô hướng là một bài toán rất nổi tiếng và có ứng dụng rất lớn, bài toán được phát biểu một cách cụ thể theo lý thuyết đồ thị như sau: Cho đồ thị vô hướng có trọng số G=(V,E) được cho trước dữ liệu (bằng một trong các cách: Ma trận kề, Danh sách cạnh, Danh sách liên kết .). Tìm cây khung nhỏ nhất của G hay tìm một tập cạnh ECK ⊂ E có n-1 cạnh, n đỉnh, không có một chu trình nào và có tổng trọng số của các cạnh là nhỏ nhất Đó là phát biểu đơn giản và cụ thể về đề bài và nó cũng chính là vấn đề mà chúng ta quan tâm. Hiện nay đã có rất nhiều thuật toán giải quyết bài toán MST trong các trường hợp cụ thể để giải quyết các bài toán thực tế. Các công trình nghiên cứu về đồ thị nói chung và Cây khung nói riêng vẫn tiếp tục được phát triển, nhằm mở rộng thêm phạm vi ứng dụng của đồ thị, các công bố mới nhất theo tôi được biết là vào cuối năm 2003 của một số giáo sư người Israel. Hai giải thuật kinh điển để tìm MST trong bài toán cơ bản chắc hẳn ai trong các bạn cũng từng được nghe đến đó là thuật toán Kruskal và Prim. Tạp chí của chúng ta có một chuyên mục rất hấp dẫn và 'độc nhất vô nhị' đó là 'Đề ra kỳ nàý, theo dõi chuyên mục tôi thấy Kruskal và Prim là một trong các giải thuật cần được các bạn học sinh 'học thuộc' khi có các giải thuật nền tảng như vậy các bạn học sinh mới có thể kết hợp chúng với nhau để giải quyết bài toán lớn, đặc biệt là các bài toán thi học sinh giỏi Tin, ngày nay không có các bài toán đơn thuần là Tìm đường đi ngắn nhất, Tìm cây khung, Quy hoạch động . nữa mà chúng thường kết hợp với nhau. Mục đích của bài báo này là giúp các bạn học sinh hiểu và nắm được 2 thuật toán này cũng như hiểu một số khái niệm khác về đồ thị từ đầu, thông qua các bảng mô tả và hình minh hoạ cụ thể. Tôi có làm một chương trình mô phỏng chi tiết 2 giải thuật này bằng ngôn ngữ Pascal khá hoàn chỉnh và đẹp mắt để các bạn tham khảo. Các bạn chú ý 2 khái niệm thuật toán và giải thuật có nghĩa như nhau (Algorithm) nên đôi khi chúng ta dùng lẫn lộn. 1. Vấn đề biểu diễn đồ thị:Đồ thị G như vậy có thể được biểu diễn theo nhiều cách. Tuy nhiên có hai cách thông dụng nhất, đơn giản cho việc lập trình, và càng đơn giản cho bài toán chúng ta đang xét đó là Ma trận kề và Danh sách cạnh. a, Ma trận kề (Adjacency Matrix): Giả sử G có n đỉnh, khi đó ma trận kề A có kích thước (n*n), nếu A[i,j]=0 nghĩa là đỉnh i và đỉnh j không có cạnh nối, nếu A[i,j]<>0 nghĩa là giữa đỉnh i và j có đường đi trực tiếp với trọng số chính là A[i,j]. Như vậy G vô hướng thì A[i,j]=A[j,i] với m-i i, j. Do đó ma trận A có thể được rút gọn một nửa để trở thành 'Tam giác kề' khi lưu trữ, nhưng khi cấp phát tĩnh bộ nhớ trong chương trình ta vẫn phải dùng ma trận A (n*n). Ma trận kề là cấu trúc dữ liệu truy xuất nhanh nhất và sử dụng đơn giản nhất trong các bài toán đồ thị, trừ một số trường hợp cá biệt nó kém CTDL khác. Chẳng hạn với giải thuật tìm chu trình Euler, CTDL Danh sách kề liên kết mới là hiệu quả nhất về m-i mặt. Tuy nhiên ma trận kề lưu trữ rất tốn bộ nhớ, giới hạn của vùng nhớ cơ sở là 64K, như vậy ma trận kề chỉ được tối đa 256*256 phần tử kiểu byte, chưa tính các biến khác phải dùng. b, Danh sách cạnh (Edges List): Giả sử G có m cạnh, khi đó danh sách cạnh L có m phần tử, mỗi phần tử có 3 trường thể hiện đỉnh đầu, đỉnh cuối (của cạnh) và trọng số giữa chúng. Danh sách cạnh như vậy có thể biểu diễn bằng một mảng một chiều m phần tử record 3 trường, hoặc 3 mảng một chiều rời nhau. Đây cũng là CTDL đơn giản, dễ sử dụng. Danh sách cạnh có những hạn chế như việc xác định 2 đỉnh có kề nhau hay không, loại bỏ cạnh, nhưng nó tốn ít bộ nhớ và trong một số trường hợp cụ thể nó tỏ ra rất ưu việt, như trong giải thuật Kruskal chẳng hạn. Nhận xét: Mỗi CTDL có những ưu điểm, nhược điểm khác nhau trong từng tình huống, từng giải thuật. Nhưng nhìn chung từ cách biểu diễn này hoàn toàn có thể biểu diễn sanh cách kia. Vậy chúng ta chọn CTDL nào cho bài toán này? Cây khung thực chất là một đồ thị con G' của G với cùng tập đỉnh nhưng số cạnh ít hơn (hoặc bằng nếu G đã là một cây khung) sao cho G' thoả mãn là: Liên thông, Không có chu trình và có n-1 cạnh. Ta biểu diễn G' bằng danh sách cạnh là thích hợp nhất. Còn G để đơn giản và nhanh chóng chúng ta dùng ma trận kề. 2. Thuật toán Kruskal: - Tư tưởng: Để xây dựng tập n-1 cạnh của cây khung nhỏ nhất? tạm gọi là tập K, Kruskal đề nghị cách kết nạp lần lượt các cạnh vào tập đó theo nguyên tắc như sau: Ưu tiên các cạnh có trọng số nhỏ hơn, kết nạp cạnh khi nó không tạo chu trình với tập cạnh đã kết nạp trước đó. Đó là một nguyên tắc chính xác và đúng đắn, đảm bảo tập K nếu thu đủ n-1 cạnh sẽ là cây khung nhỏ nhất. Chúng ta sẽ không chúng minh lại giải thuật này. - Khi lập trình để có được sự ưu tiên, cách tốt nhất là sắp xếp trước các cạnh theo trọng số tăng dần. Điều này cũng gợi ý cho chúng ta thấy nên sử dụng danh sách cạnh trong giải thuật Kruskal, tuy nhiên để thống nhất đầu vào với giải thuật Prim trong chương trình, chúng ta sẽ sử dụng ma trận kề sau đó chuyển thành danh sách cạnh. - Để kiểm tra xem cạnh đang xét có tạo chu trình không với tập cạnh đã kết nạp, chúng ta sử dụng một phương pháp đặc biệt mỗi khi kết nạp đó là: cho một đỉnh trở thành 'dad' của đỉnh kia. Với 2 đỉnh x, y bất kỳ, nếu 'dad cao nhất' của chúng bằng nhau thì cạnh nối x, y sẽ tạo nên chu trình (chu trình này đi qua 'dad' chung đó. Như sơ đồ dưới đây: - Từ hai điều trên dễ dàng có được thuật toán Kruskal như sau (ngôn ngữ giả Pascal): Procedure Kruskal_Algorithm; Begin K:= ∅ ; While (joinedEdges < n-1) or (E = ∅ ) do Begin GetEdges(x,y);{Lấy từ E đã sắp tăng dần trọng số} If not creatCycle(x,y) then Begin Union(x,y); {Thủ tụcTạo 'dad'} (x,y)->K; {Kết nạp cạnh (x,y) vào K} End; End; If joinEdges=n-1 then FoundTree else notFoundTree; End; Thủ tục này bằng Pascal như sau: procedure KRUSKAL_Algorithm; var i: integer; begin i:=1; numEdge:=0; W:=0;{Weight} repeat while creatCycle(e[i,1],e[i,2]) and (i<=m) do inc(i); if (i>m) or (numEdge>=n-1) then break; inc(numEdge); BE[numEdge,1]:=e[i,1]; BE[numEdge,2]:=e[i,2]; W:=W+e[i,3]; until false; end; (Mảng E là danh sách cạnh, mảng BE là mảng lưu kết quả ra) Ví dụ: Đồ thị Các cạnh được sắp thứ tự là: Các cạnh được kết nạp theo thứ tự trong bảng như sau: Cây khung nhỏ nhất có giá trị là: 42 3. Thuật toán Prim: - Do thuật toán Kruskal làm việc trên các cạnh nên sẽ kém hiệu quả nếu có quá nhiều cạnh? như các đồ thị dày (số cạnh m ≈ n(n-1)/2). - Đối nghịch với Kruskal, thuật toán Prim làm việc trên các đỉnh, sẽ hiệu quả hơn với các đồ thị dày. Có thế thấy đa số các đồ thị trong thực tế có số đỉnh không lớn còn số cạnh rất lớn nên Prim tỏ ra hiệu quả hơn và?đắt giá? hơn Kruskal, mặc dù cài đặt có phức tạp hơn. Ngoại lệ, trong các trường hợp số cạnh rất ít còn số đỉnh rất nhiều thì Prim kém hiệu quả hơn Kruskal. - Tư tưởng: Prim đề xuất cách xây dựng đồng thời tập đỉnh đã kết nạp (VH) và tập cạnh đã kết nạp T cho cây khung nhỏ nhất theo nguyên tắc như sau: Lần lượt kết nạp một đỉnh u thuộc VVH vào VH sao cho tồn tại v thuộc VH mà trọng số (u,v) là nhỏ nhất trong m-i cặp đỉnh nối VH và VVH, như trên sơ đồ: - Thuật toán Prim bằng ngôn ngữ giả Pascal như sau: Procedure Prim_Algorithm; Begin VH:={rootPrim}; {Bất kỳ!} T:= ∅ ; While |VH| < n do Begin FindMinEdge(u,v); v->VH; {Kết nạp đỉnh v vào v} (u,v)->T; {Kết nạp cạnh (u,v) vào K} End; End; - Khi lập trình để dễ dàng tìm được cặp (u,v) nhỏ nhất như vậy chúng ta sử dụng các mảng phụ trợ đặc biệt. Thủ tục đó bằng Pascal như sau: procedure PRIM_Algorithm; var j,u: integer; begin numEdge:=0; W:=0; orderPrim[1]:=rootPrim; repeat u:=0; for j:=1 to n do if (over[j]=0) and ( đ[j]if u=0 then break; { Ađ u:} over[u]:=1; inc(numEdge); orderPrim[numEdge+1]:=u; BE[numEdge,1]:=linkfrom[u]; BE[numEdge,2]:=u; W:=W+a[u,linkfrom[u]]; {Update from u:} for j:=1 to n do if (over[j]=0) and (a[u,j]>0) and ( đ[j]>a[u,j]) then begin đ [j]:=a[u,j]; linkfrom[j]:=u; end; until false; end; Ví dụ: Vẫn là đồ thị Chọn đỉnh 1 bắt đầu xây dựng, các bước xây dựng VH, T như sau: Kết quả thu được cây khung nhỏ nhất y như thuật toán Kruskal đã tìm được: 4. Chương trình mô phỏng 2 giải thuật: Tôi đã viết một chương trình hoàn chỉnh trong chế độ đồ hoạ của Turbo Pascal với các menu để nhập dữ liệu, hiện đồ thị và thực hiện từng bước giải thuật. Sơ đồ về đồ thị có thể được vẽ theo vô số cách khác nhau trên màn hình đồ hoạ, không thể có giải pháp nào tự động vẽ được đồ thị. Do đó chương trình sẽ có giai đoạn 'Định vị các đỉnh trên màn hình đồ hoạ' để người sử dụng dùng chuột tự chọn vị trí các đỉnh trên màn hình, sau đó mới có thể mô phỏng. Sau khi nhập dữ liệu, người dùng cần định vị các đỉnh trên màn hình đồ hoạ bằng cách dùng chuột click lên các vị trí đặt trên màn hình. Sau khi đã định vị, chương trình sẽ ghi nhớ vị trí này. Khi gọi giải thuật Kruskal hay Prim, phần chính là 2 giải thuật sẽ được thực hiện để đưa kết quả ra các mảng ra. Đến giai đoạn mô phỏng chương trình sẽ đọc mảng ra để mô tả từng bước của giải thuật, nếu là Kruskal thì từng cạnh được chọn sẽ hiện dần ra, còn Prim thì các đỉnh và cạnh được chọn cũng dần dần hiện ra. Chương trình có giao diện 2 ngôn ngữ Anh? Việt, sử dụng nhiều module cao cấp của BGI Graphics, tác giả đã hoàn thành chương trình cùng bạn Mạnh Đạt? một tác giả quen thuộc trên ISM. Rất mong nhận được sự đóng góp ý kiến của các bạn! Xin chân thành cảm ơn và chúc các bạn thành công với MST! Mã nguồn chương trình bạn có thể xin qua email của toà soạn (toasoan@thnt.com.vn) hoặc download trực tiếp qua website của tác giả (http://www.maihien.net.tf). . Cây khung nhỏ nhất với thuật toán Kruskal và PrimMai HiềnBài toán tìm cây khung tối thiểu (hay cây khung nhỏ nhất - Minimmum Spanning Tree - MST). nhiều thì Prim kém hiệu quả hơn Kruskal. - Tư tưởng: Prim đề xuất cách xây dựng đồng thời tập đỉnh đã kết nạp (VH) và tập cạnh đã kết nạp T cho cây khung nhỏ