Giải thuật và lập trình: §11. Bài toán tìm bộ ghép cực đại trên đồ thị hai phía

Các khóa học qua video:
Python SQL Server PHP C# Lập trình C Java HTML5-CSS3-JavaScript
Học trên YouTube <76K/tháng. Đăng ký Hội viên
Viết nhanh hơn - Học tốt hơn
Giải phóng thời gian, khai phóng năng lực

11.1. ĐỒ THỊ HAI PHÍA (BIPARTITE GRAPH)

Các tên gọi đồ thị hai phía một dạng đơn đồ thị vô hướng G = (V, E) mà tập đỉnh của nó có thể chia làm hai tập con X, Y rời nhau sao cho bất kỳ cạnh nào của đồ thị cũng nối một đỉnh của X với một đỉnh thuộc Y. Khi đó người ta còn ký hiệu G là (X∪Y, E) và gọi một tập (chẳng hạn tập X) là tập các đỉnh trái và tập còn lại là tập các đỉnh phải của đồ thị hai phía G. Các đỉnh thuộc X còn gọi là các X_đỉnh, các đỉnh thuộc Y gọi là các Y_đỉnh.


Đồ thị hai phía

Để kiểm tra một đồ thị liên thông có phải là đồ thị hai phía hay không, ta có thể áp dụng thuật toán sau:

Với một đỉnh v bất kỳ:

X := {v}; Y := ;
repeat
  Y := Y Kề(X);
  X := X Kề(Y);
until (XY ≠ ) or (X và Y là tối đại - không bổ sung được nữa);
if XY ≠ then < Không phải đồ thị hai phía >
else <Đây là đồ thị hai phía, X là tập các đỉnh trái: các đỉnh đến được từ v qua một số chẵn cạnh, Y là tập các đỉnh phải: các đỉnh đến được từ v qua một số lẻ cạnh>;

Đồ thị hai phía gặp rất nhiều mô hình trong thực tế. Chẳng hạn quan hệ hôn nhân giữa tập những người đàn ông và tập những người đàn bà, việc sinh viên chọn trường, thầy giáo chọn tiết dạy trong thời khoá biểu v.v...

11.2. Bài toán ghép đôi không trọng và các khái niệm

Cho một đồ thị hai phía G = (X∪Y, E) ở đây X là tập các đỉnh trái và Y là tập các đỉnh phải của G.

Một bộ ghép (matching) của G là một tập hợp các cạnh của G đôi một không có đỉnh chung.

Bài toán ghép đôi (matching problem) là tìm một bộ ghép lớn nhất (nghĩa là có số cạnh lớn nhất) của G

Xét một bộ ghép M của G.

Các đỉnh trong M gọi là các đỉnh đã ghép (matched vertices), các đỉnh khác là chưa ghép.

Các cạnh trong M gọi là các cạnh đã ghép, các cạnh khác là chưa ghép.

Nếu định hướng lại các cạnh của đồ thị thành cung, những cạnh chưa ghép được định hướng từ X sang Y, những cạnh đã ghép định hướng từ Y về X. Trên đồ thị định hướng đó: Một đường đi xuất phát từ một X_đỉnh chưa ghép gọi là đường pha, một đường đi từ một X_đỉnh chưa ghép tới một Y_đỉnh chưa ghép gọi là đường mở.

Một cách dễ hiểu, có thể quan niệm như sau:

Một đường pha (alternating path) là một đường đi đơn trong G bắt đầu bằng một X_đỉnh chưa ghép, đi theo một cạnh chưa ghép sang Y, rồi đến một cạnh đã ghép về X, rồi lại đến một cạnh chưa ghép sang Y... cứ xen kẽ nhau như vậy.

Một đường mở (augmenting path) là một đường pha. Bắt đầu từ một X_đỉnh chưa ghép kết thúc bằng một Y_đỉnh chưa ghép.

Ví dụ: với đồ thị hai phía trong hình Hình 82 và bộ ghép M = {(X1, Y1), (X2, Y2)}

X3 và Y3 là những đỉnh chưa ghép, các đỉnh khác là đã ghép.

Đường (X3, Y2, X2, Y1) là đường pha.

Đường (X3, Y2, X2, Y1, X1, Y3) là đường mở.


Đồ thị hai phía và bộ ghép M

11.3. Thuật toán đường mở

Thuật toán đường mở để tìm một bộ ghép lớn nhất phát biểu như sau:

Bắt đầu từ một bộ ghép bất kỳ M (thông thường bộ ghép được khởi gán bằng bộ ghép rỗng hay được tìm bằng các thuật toán tham lam).

Sau đó đi tìm một đường mở, nếu tìm được thì mở rộng bộ ghép M như sau: Trên đường mở, loại bỏ những cạnh đã ghép khỏi M và thêm vào M những cạnh chưa ghép. Nếu không tìm được đường mở thì bộ ghép hiện thời là lớn nhất.

<Khởi tạo một bộ ghép M>;
while <đường mở xuất phát từ x tới một đỉnh y chưa ghép ∈Y> do
  <Dọc trên đường mở, xoá bỏ khỏi M các cạnh đã ghép và thêm vào M những cạnh chưa ghép, đỉnh x và y trở thành đã ghép, số cạnh đã ghép tăng lên 1>;

Như ví dụ trên, với bộ ghép hai cạnh M = {(X1, Y1), (X2, Y2)} và đường mở tìm được gồm các cạnh:

(X3, Y2) ∉ M

(Y2, X2) ∈ M

(X2, Y1) ∉ M

(Y1, X1) ∈ M

(X1, Y3) ∉ M

Vậy thì ta sẽ loại đi các cạnh (Y2, X2) và (Y1, X1) trong bộ ghép cũ và thêm vào đó các cạnh (X3, Y2), (X2, Y1), (X1, Y3) được bộ ghép 3 cạnh.

11.4. Cài đặt

11.4.1. Biểu diễn đồ thị hai phía

Giả sử đồ thị hai phía G = (X∪Y, E) có các X_đỉnh ký hiệu là X[1], X[2], ..., X[m] và các Y_đỉnh ký hiệu là Y[1], Y[2], ..., Y[n]. Ta sẽ biểu diễn đồ thị hai phía này bằng ma trận A cỡ mxn. Trong đó:

A[i, j] = TRUE ⇔ có cạnh nối đỉnh X[i] với đỉnh Y[j].

11.4.2. Biểu diễn bộ ghép

Để biểu diễn bộ ghép, ta sử dụng hai mảng: matchX[1..m] và matchY[1..n].

matchX[i] là đỉnh thuộc tập Y ghép với đỉnh X[i]

matchY[j] là đỉnh thuộc tập X ghép với đỉnh Y[j].

Tức là nếu như cạnh (X[i], Y[j]) thuộc bộ ghép thì matchX[i] = j và matchY[j] = i.

Quy ước rằng:

Nếu như X[i] chưa ghép với đỉnh nào của tập Y thì matchX[i] = 0

Nếu như Y[j] chưa ghép với đỉnh nào của tập X thì matchY[j] = 0.

Để thêm một cạnh (X[i], Y[j]) vào bộ ghép thì ta chỉ việc đặt matchX[i] := j và matchY[j] := i;

Để loại một cạnh (X[i], Y[j]) khỏi bộ ghép thì ta chỉ việc đặt matchX[i] := 0 và matchY[j] := 0;

11.4.3. Tìm đường mở như thế nào.

Vì đường mở bắt đầu từ một X_đỉnh chưa ghép, đi theo một cạnh chưa ghép sang tập Y, rồi theo một đã ghép để về tập X, rồi lại một cạnh chưa ghép sang tập Y ... cuối cùng là cạnh chưa ghép tới một Y_đỉnh chưa ghép. Nên có thể thấy ngay rằng độ dài đường mở là lẻ và trên đường mở số cạnh ∈ M ít hơn số cạnh ∉ M là 1 cạnh. Và cũng dễ thấy rằng giải thuật tìm đường mở nên sử dụng thuật toán tìm kiếm theo chiều rộng để đường mở tìm được là đường đi ngắn nhất, giảm bớt công việc cho bước tăng cặp ghép.

Ta khởi tạo một hàng đợi (Queue) ban đầu chứa tất cả các X_đỉnh chưa ghép. Thuật toán tìm kiếm theo chiều rộng làm việc theo nguyên tắc lấy một đỉnh v khỏi Queue và lại đẩy Queue những nối từ v chưa được thăm. Như vậy nếu thăm tới một Y_đỉnh chưa ghép thì tức là ta tìm đường mở kết thúc ở Y_đỉnh chưa ghép đó, quá trình tìm kiếm dừng ngay. Còn nếu ta thăm tới một đỉnh j ∈ Y đã ghép, dựa vào sự kiện: từ j chỉ có thể tới được matchY[j] theo duy nhất một cạnh đã ghép định hướng ngược từ Y về X, nên ta có thể đánh dấu thăm j, thăm luôn cả matchY[j], và đẩy vào Queue phần tử matchY[j] ∈ X (Thăm liền 2 bước).

Input: file văn bản MATCH.INP

• Dòng 1: chứa hai số m, n (m, n ≤ 100) theo thứ tự là số X_đỉnh và số Y_đỉnh cách nhau ít nhất một dấu cách

• Các dòng tiếp theo, mỗi dòng ghi hai số i, j cách nhau ít nhất một dấu cách thể hiện có cạnh nối hai đỉnh (X[i], Y[j]) .

Output: file văn bản MATCH.OUT, ghi bộ ghép cực đại tìm được.

 

P_4_11_1.PAS * Thuật toán đường mở tìm bộ ghép cực đại

program MatchingProblem;
const
  InputFile = 'MATCH.INP';
  OutputFile = 'MATCH.OUT';
  max = 100;
var
  m, n: Integer;
  a: array[1..max, 1..max] of Boolean;
  matchX, matchY: array[1..max] of Integer;
  Trace: array[1..max] of Integer;

procedure Enter;
var
  i, j: Integer;
  f: Text;
begin
  Assign(f, InputFile);
  Reset(f);
  FillChar(a, SizeOf(a), False);
  ReadLn(f, m, n);
  while not SeekEof(f) do
  begin
    ReadLn(f, i, j);
    a[i, j] := True;
  end;
  Close(f);
end;

procedure Init; {Khởi tạo bộ ghép rỗng}
begin
  FillChar(matchX, SizeOf(matchX), 0);
  FillChar(matchY, SizeOf(matchY), 0);
end;

{Tìm đường mở, nếu thấy trả về một Y_đỉnh chưa ghép là đỉnh kết thúc đường mở, nếu không thấy trả về 0}
function FindAugmentingPath: Integer;
var
  Queue: array[1..max] of Integer;
  i, j, first, last: Integer;
begin
  FillChar(Trace, SizeOf(Trace), 0); {Trace[j] = X_đỉnh liền trước Y[j] trên đường mở}
  last := 0; {Khởi tạo hàng đợi rỗng}
  for i := 1 to m do {Đẩy tất cả những X_đỉnh chưa ghép vào hàng đợi}
    if matchX[i] = 0 then
    begin
      Inc(last);
      Queue[last] := i;
    end;
  {Thuật toán tìm kiếm theo chiều rộng}
  first := 1;
  while first <= last do
  begin
    i := Queue[first];
    Inc(first); {Lấy một X_đỉnh ra khỏi Queue (X[i])}
    for j := 1 to n do {Xét những Y_đỉnh chưa thăm kề với X[i] qua một cạnh chưa ghép}
      if (Trace[j] = 0) and a[i, j] and (matchX[i] <> j) then
      begin {lệnh if trên hơi thừa đk matchX[i] <> j, điều kiện Trace[j] = 0 đã bao hàm luôn điều kiện này rồi}
        Trace[j] := i; {Lưu vết đường đi}
        if matchY[j] = 0 then {Nếu j chưa ghép thì ghi nhận đường mở và thoát ngay}
        begin
          FindAugmentingPath := j;
          Exit;
        end;
        Inc(last); {Đẩy luôn matchY[j] vào hàng đợi}
        Queue[last] := matchY[j];
      end;
  end;
  FindAugmentingPath := 0; {Ở trên không Exit được tức là không còn đường mở}
end;

{Nới rộng bộ ghép bằng đường mở kết thúc ở f∈Y}

procedure Enlarge(f: Integer);
var
  x, next: Integer;
begin
  repeat
    x := Trace[f];
    next := matchX[x];
    matchX[x] := f;
    matchY[f] := x;
    f := next;
  until f = 0;
end;

procedure Solve; {Thuật toán đường mở}
var
  finish: Integer;
begin
  repeat
    finish := FindAugmentingPath; {Đầu tiên thử tìm một đường mở}
    if finish <> 0 then Enlarge(finish); {Nếu thấy thì tăng cặp và lặp lại}
  until finish = 0; {Nếu không thấy thì dừng}
end;

procedure PrintResult; {In kết quả}
var
  i, Count: Integer;
  f: Text;
begin
  Assign(f, OutputFile);
  Rewrite(f);
  WriteLn(f, 'Match: ');
  Count := 0;
  for i := 1 to m do
    if matchX[i] <> 0 then
    begin
      Inc(Count);
      WriteLn(f, Count, ') X[', i, '] - Y[', matchX[i], ']');
    end;
  Close(f);
end;

begin
  Enter;
  Init;
  Solve;
  PrintResult;
end.

Khảo sát tính đúng đắn của thuật toán cho ta một kết quả khá thú vị:

Nếu ta thêm một đỉnh A và cho thêm m cung từ A tới tất cả những đỉnh của tập X, thêm một đỉnh B và nối thêm n cung từ tất cả các đỉnh của Y tới B. Ta được một mạng với đỉnh phát A và đỉnh thu B.


Mô hình luồng của bài toán tìm bộ ghép cực đại trên đồ thị hai phía

Nếu đặt khả năng thông qua của các cung đều là 1 sau đó tìm luồng cực đại trên mạng bằng thuật toán Ford-Fulkerson thì theo định lý về tính nguyên, luồng tìm được trên các cung đều phải là số nguyên (tức là bằng 1 hoặc 0). Khi đó dễ thấy rằng những cung có luồng 1 từ tập X tới tập Y sẽ cho ta một bộ ghép lớn nhất. Để chứng minh thuật toán đường mở tìm được bộ ghép lớn nhất sau hữu hạn bước, ta sẽ chứng minh rằng số bộ ghép tìm được bằng thuật toán đường mở sẽ bằng giá trị luồng cực đại nói trên, điều đó cũng rất dễ bởi vì nếu để ý kỹ một chút thì đường mở chẳng qua là đường tăng luồng trên đồ thị tăng luồng mà thôi, ngay cái tên augmenting path đã cho ta biết điều này. Vì vậy thuật toán đường mở ở trường hợp này là một cách cài đặt hiệu quả trên một dạng đồ thị đặc biệt, nó làm cho chương trình sáng sủa hơn nhiều so với phương pháp tìm bộ ghép dựa trên bài toán luồng và thuật toán Ford-Fulkerson thuần túy.

Người ta đã chứng minh được chi phí thời gian thực hiện giải thuật này trong trường hợp xấu nhất sẽ là O(n3) đối với đồ thị dày và O(n(n + m)logn) đối với đồ thị thưa. Tuy nhiên, cũng giống như thuật toán Ford-Fulkerson, trên thực tế phương pháp này hoạt động rất nhanh.

Bài tập

Bài 1

Có n thợ và n công việc (n ≤ 100), mỗi thợ thực hiện được ít nhất một việc. Như vậy một thợ có thể làm được nhiều việc, và một việc có thể có nhiều thợ làm được. Hãy phân công n thợ thực hiện n việc đó sao cho mỗi thợ phải làm đúng 1 việc hoặc thông báo rằng không có cách phân công nào thoả mãn điều trên.

Bài 2

Có n thợ và m công việc (n, m ≤ 100). Mỗi thợ cho biết mình có thể làm được những việc nào, hãy phân công các thợ làm các công việc đó sao cho mỗi thợ phải làm ít nhất 2 việc và số việc thực hiện

được là nhiều nhất.

Bài 3

Có n thợ và m công việc (n, m ≤ 100). Mỗi thợ cho biết mình có thể làm được những việc nào, hãy phân công thực hiện các công việc đó sao cho số công việc phân cho người thợ làm nhiều nhất thực hiện là cực tiểu.

Nguồn: Giáo trình Giải thuật và Lập trình - Lê Minh Hoàng - Đại học sư phạm Hà Nội
» Tiếp: §12. Bài toán tìm bộ ghép cực đại với trọng số cực tiểu trên đồ thị hai phía. Thuật toán Hungari
« Trước: §10. Bài toán luồng cực đại trên mạng
Các khóa học qua video:
Python SQL Server PHP C# Lập trình C Java HTML5-CSS3-JavaScript
Học trên YouTube <76K/tháng. Đăng ký Hội viên
Viết nhanh hơn - Học tốt hơn
Giải phóng thời gian, khai phóng năng lực
Copied !!!