Bài 19.4. Bài toán ghép cặp tối đa trên đồ thị hai phía
Bài 19.4. Bài toán ghép cặp tối đa trên đồ thị hai phía
Chào mừng các bạn quay trở lại với series "Cấu trúc dữ liệu và Giải thuật"! Hôm nay, chúng ta sẽ cùng nhau khám phá một bài toán vô cùng thú vị và có nhiều ứng dụng thực tế: Bài toán ghép cặp tối đa trên đồ thị hai phía (Maximum Bipartite Matching). Đừng để cái tên "lý thuyết đồ thị" làm bạn e ngại, đằng sau nó là những ý tưởng tuyệt vời để giải quyết nhiều vấn đề từ việc phân công công việc, ghép đôi hẹn hò, cho đến lập lịch thi đấu!
Hãy tưởng tượng bạn là người quản lý tại một công ty. Bạn có một danh sách các dự án cần hoàn thành và một danh sách các nhân viên. Mỗi nhân viên lại có kỹ năng phù hợp với một hoặc nhiều dự án khác nhau. Mục tiêu của bạn là phân công càng nhiều dự án cho nhân viên càng tốt, với điều kiện mỗi dự án chỉ được giao cho một nhân viên và mỗi nhân viên chỉ nhận một dự án duy nhất. Đây chính là một ví dụ điển hình của bài toán ghép cặp trên đồ thị hai phía!
Đồ thị Hai Phía là gì?
Trước khi đi sâu vào bài toán ghép cặp, chúng ta cần hiểu rõ về loại đồ thị mà chúng ta đang làm việc: đồ thị hai phía (Bipartite Graph).
Một đồ thị được gọi là hai phía nếu tập hợp các đỉnh của nó có thể được chia thành hai tập hợp không giao nhau (gọi là U và V) sao cho mọi cạnh trong đồ thị đều nối một đỉnh từ tập U với một đỉnh từ tập V. Quan trọng là, không có cạnh nào nối hai đỉnh cùng nằm trong tập U, và cũng không có cạnh nào nối hai đỉnh cùng nằm trong tập V.
Tưởng tượng tập U là các nhân viên và tập V là các dự án. Một cạnh nối nhân viên u thuộc U và dự án v thuộc V chỉ ra rằng nhân viên u có thể làm dự án v. Rõ ràng, một nhân viên không thể "nối" với một nhân viên khác (đều thuộc tập U), và một dự án cũng không thể "nối" với một dự án khác (đều thuộc tập V) trong ngữ cảnh này.
Cách đơn giản để kiểm tra một đồ thị có phải là hai phía hay không là thử tô màu 2 màu. Nếu bạn có thể tô màu các đỉnh bằng hai màu (ví dụ: đen và trắng) sao cho mọi cạnh đều nối một đỉnh màu đen với một đỉnh màu trắng, thì đồ thị đó là hai phía.
Ghép Cặp (Matching) là gì?
Trong một đồ thị (bất kỳ, không chỉ hai phía), một ghép cặp (Matching) là một tập hợp các cạnh sao cho không có hai cạnh nào trong tập hợp này có chung một đỉnh.
Trở lại ví dụ nhân viên - dự án: một ghép cặp là một tập hợp các cặp (nhân viên, dự án) đã được phân công, sao cho không có nhân viên nào được giao hai dự án và không có dự án nào được giao cho hai nhân viên.
Bài toán ghép cặp tối đa (Maximum Matching) là tìm một ghép cặp chứa số lượng cạnh lớn nhất có thể. Mục tiêu của chúng ta chính là tìm cách phân công được nhiều dự án nhất cho nhân viên thỏa mãn các điều kiện.
Đối với đồ thị hai phía, bài toán ghép cặp tối đa có những giải thuật rất hiệu quả, nổi bật nhất là giải thuật dựa trên việc tìm đường tăng (Augmenting Path).
Sức Mạnh của Đường Tăng (Augmenting Path)
Ý tưởng chính đằng sau nhiều giải thuật tìm ghép cặp tối đa trên đồ thị hai phía là sử dụng khái niệm đường tăng.
Một đường tăng đối với một ghép cặp hiện tại M là một đường đi đơn giản (không lặp lại đỉnh) trong đồ thị, thỏa mãn hai điều kiện:
- Nó bắt đầu và kết thúc tại các đỉnh chưa được ghép cặp (unmatched vertices).
- Các cạnh trên đường đi này xen kẽ giữa các cạnh không thuộc
Mvà các cạnh thuộcM.
Ví dụ: Giả sử bạn có đường đi u1 - v1 - u2 - v2 - u3, trong đó u1, u2, u3 thuộc U và v1, v2 thuộc V.
- Nếu cạnh
(u1, v1)không thuộc ghép cặpM. - Nếu cạnh
(v1, u2)(hoặc(u2, v1)) thuộc ghép cặpM(tức làv1đang ghép vớiu2). - Nếu cạnh
(u2, v2)không thuộc ghép cặpM. - Nếu cạnh
(v2, u3)(hoặc(u3, v2)) thuộc ghép cặpM(tức làv2đang ghép vớiu3). - Và giả sử
u1vàu3hiện chưa được ghép cặp.
Thì đường đi u1 - v1 - u2 - v2 - u3 là một đường tăng.
Tại sao đường tăng lại quan trọng? Bởi vì nếu ta tìm được một đường tăng, ta có thể cải thiện ghép cặp hiện tại bằng cách "lật" trạng thái của các cạnh trên đường đi đó:
- Các cạnh không thuộc
Mtrên đường đi sẽ được thêm vàoM. - Các cạnh thuộc
Mtrên đường đi sẽ bị loại bỏ khỏiM.
Kết quả của thao tác "lật" này sẽ là một ghép cặp mới có số lượng cạnh lớn hơn 1 so với ghép cặp cũ!
Ví dụ trên:
- Loại bỏ
(v1, u2)và(v2, u3)khỏiM. - Thêm
(u1, v1),(u2, v2)vàoM. - Đỉnh
u1giờ ghép vớiv1. Đỉnhu3giờ ghép vớiv2. Đỉnhu2(trước ghép vớiv1) giờ ghép vớiv2. À khoan! Ví dụ này hơi rối. Hãy xem xét một đường tăng đơn giản hơn trên đồ thị hai phía U-V:u1 - v1 - u2 - v2, nơiu1chưa ghép,v2chưa ghép,(u1, v1)không ghép,(v1, u2)ghép,(u2, v2)không ghép.- Trước:
u1chưa ghép,v1ghép vớiu2,v2chưa ghép. - Lật: Bỏ
(v1, u2), thêm(u1, v1), thêm(u2, v2). - Sau:
u1ghép vớiv1,u2ghép vớiv2. Số lượng ghép cặp tăng từ 1 lên 2. Cảu1vàu2(từ U),v1vàv2(từ V) đều được ghép.
- Trước:
Định lý quan trọng (Konig's Theorem liên quan): Số lượng cạnh trong một ghép cặp tối đa bằng kích thước của tập đỉnh phủ tối thiểu (minimum vertex cover). Quan trọng hơn cho giải thuật của chúng ta: Một ghép cặp M là tối đa khi và chỉ khi không tồn tại đường tăng nào đối với M.
Điều này cho chúng ta một giải thuật: Bắt đầu với một ghép cặp rỗng (hoặc bất kỳ). Lặp đi lặp lại việc tìm một đường tăng và cải thiện ghép cặp cho đến khi không thể tìm thấy đường tăng nào nữa.
Giải thuật tìm Ghép Cặp Tối Đa dùng DFS
Một trong những cách phổ biến và dễ cài đặt nhất để tìm đường tăng là sử dụng thuật toán tìm kiếm theo chiều sâu (DFS).
Chúng ta sẽ xét các đỉnh ở một phía của đồ thị hai phía, ví dụ tập U. Đối với mỗi đỉnh u trong U hiện chưa được ghép cặp, ta sẽ thử tìm một đường tăng bắt đầu từ u.
Hàm DFS sẽ có dạng bool dfs(int u) trả về true nếu tìm thấy và sử dụng được một đường tăng bắt đầu từ u, và false nếu không tìm được.
Để tìm đường tăng bắt đầu từ u (một đỉnh chưa ghép ở U):
- Duyệt qua tất cả các đỉnh
vthuộc V mà có cạnh nối vớiu. - Đối với mỗi
v:- Nếu
vchưa được thăm trong lần DFS này (để tránh chu trình hoặc thăm lại không cần thiết trong một lần tìm đường tăng): đánh dấuvđã thăm. - Kiểm tra trạng thái của
v:- Nếu
vhiện chưa được ghép cặp với bất kỳ đỉnh nào trong U (tức làmatch[v] == -1), ta đã tìm thấy một đường tăng đơn giản:u->v. Ta ghépuvớiv(match[v] = u), và trả vềtrue(tìm thấy đường tăng). - Nếu
vhiện đang được ghép cặp với một đỉnh khácu_primetrong U (match[v] = u_prime), ta thử xem liệuu_primecó thể tìm được một "đối tác" mới hay không bằng cách gọi đệ quydfs(u_prime). Nếudfs(u_prime)trả vềtrue(nghĩa làu_primetìm được đường tăng và ghép cặp với đỉnh khác), thì ta có thể "cướp"vchou. Ta ghépuvớiv(match[v] = u), và trả vềtrue.
- Nếu
- Nếu
- Nếu duyệt qua tất cả các đỉnh
vkề vớiumà không tìm được cách ghépu(hoặc không thể "giải phóng" đối tác hiện tại củav), thì không có đường tăng nào bắt đầu từuqua các đỉnhvđã xem xét trong lần DFS này. Hàm trả vềfalse.
Quá trình chính sẽ là:
- Khởi tạo mảng
match(ví dụ:match[v]lưu đỉnhumàvghép với, khởi tạo tất cả bằng -1). - Lặp qua từng đỉnh
utrong tập U. - Trước khi gọi
dfs(u), reset mảngvisitedcho tập V (đánh dấu tất cả là chưa thăm). Điều này là cần thiết vì mảngvisitedchỉ có ý nghĩa trong phạm vi một lần gọidfsđể tìm một đường tăng duy nhất bắt đầu từu. Khi bắt đầu tìm đường tăng từ đỉnhukhác, ta cần có cái nhìn "mới" về các đỉnh ở V. - Nếu
dfs(u)trả vềtrue, điều đó có nghĩa là ta đã tìm và sử dụng được một đường tăng, tăng số lượng ghép cặp lên 1.
Lặp lại bước 3 và 4 cho tất cả các đỉnh u trong U. Tổng số lần dfs(u) trả về true chính là kích thước của ghép cặp tối đa.
Ví dụ Minh Họa
Hãy xem xét một ví dụ nhỏ: Tập U = {1, 2, 3} (ví dụ: Nhân viên) Tập V = {1, 2, 3, 4} (ví dụ: Dự án) Các cạnh: (1, 1), (1, 2) (2, 1), (2, 3) (3, 3), (3, 4)
Đồ thị: U: 1 -- V: 1 | /| | / | 2 -/- 3 | | 4
Khởi tạo match cho V: match[1]=-1, match[2]=-1, match[3]=-1, match[4]=-1. Số ghép cặp = 0.
Xét đỉnh U=1:
visited= {false, false, false, false} cho V={1,2,3,4}.- Gọi
dfs(1):- Duyệt kề của 1:
v=1.v=1chưa thăm.visited[1] = true. match[1]là -1. Ghép 1 với 1:match[1] = 1. Trả vềtrue.
- Duyệt kề của 1:
- Số ghép cặp = 1.
match= {1:1, 2:-1, 3:-1, 4:-1}. (Đỉnh 1 ở V ghép với đỉnh 1 ở U)
- Gọi
Xét đỉnh U=2:
visited= {false, false, false, false}.- Gọi
dfs(2):- Duyệt kề của 2:
v=1.v=1chưa thăm.visited[1] = true. match[1]là 1 (đang ghép với U=1). Thửdfs(match[1])tức làdfs(1).- Gọi
dfs(1)(đệ quy từdfs(2)):visitedlà {true, false, false, false}. - Duyệt kề của 1:
v=1.v=1đã thăm. Bỏ qua. - Duyệt kề của 1:
v=2.v=2chưa thăm.visited[2] = true. match[2]là -1. Ghép 1 với 2:match[2] = 1. Trả vềtrue.- (Sau khi đệ quy
dfs(1)trả vềtrue,match= {1:-1, 2:1, 3:-1, 4:-1}. Nghĩa là U=1 giờ ghép với V=2, V=1 bị "giải phóng").
- Gọi
- Quay lại
dfs(2): vìdfs(1)trả vềtrue, ta có thể "cướp" V=1. Ghép 2 với 1:match[1] = 2. Trả vềtrue.
- Duyệt kề của 2:
- Số ghép cặp = 2.
match= {1:2, 2:1, 3:-1, 4:-1}. (V=1 ghép với U=2, V=2 ghép với U=1)
- Gọi
Xét đỉnh U=3:
visited= {false, false, false, false}.- Gọi
dfs(3):- Duyệt kề của 3:
v=3.v=3chưa thăm.visited[3] = true. match[3]là -1. Ghép 3 với 3:match[3] = 3. Trả vềtrue.
- Duyệt kề của 3:
- Số ghép cặp = 3.
match= {1:2, 2:1, 3:3, 4:-1}. (V=1 ghép U=2, V=2 ghép U=1, V=3 ghép U=3)
- Gọi
Đã xét hết các đỉnh ở U. Ghép cặp tối đa tìm được có kích thước là 3.
Mã Nguồn C++ Minh Họa
Dưới đây là cài đặt giải thuật DFS tìm ghép cặp tối đa trên đồ thị hai phía bằng C++. Chúng ta giả sử các đỉnh của tập U được đánh số từ 1 đến N, và các đỉnh của tập V được đánh số từ 1 đến M.
#include <iostream>
#include <vector>
#include <numeric> // For iota, though not strictly necessary here, good practice for sequence generation if needed elsewhere
#include <algorithm> // For fill
using namespace std;
const int MAXN = 105; // Số đỉnh tối đa của tập U (hoặc V), điều chỉnh nếu cần
const int MAXM = 105; // Số đỉnh tối đa của tập V (hoặc U), điều chỉnh nếu cần
// adj[u] lưu danh sách các đỉnh v thuộc tập V mà u thuộc tập U có cạnh nối tới
vector<int> adj[MAXN];
// match[v] lưu đỉnh u thuộc tập U mà đỉnh v thuộc tập V đang được ghép cặp
// Giá trị -1 nghĩa là v chưa được ghép cặp
int match[MAXM];
// visited[v] đánh dấu đỉnh v thuộc tập V đã được thăm trong lần DFS hiện tại hay chưa
// Cần reset mảng này trước mỗi lần gọi dfs từ một đỉnh u mới
bool visited[MAXM];
// N: Số đỉnh của tập U
// M: Số đỉnh của tập V
int N, M;
// Hàm DFS để tìm đường tăng bắt đầu từ đỉnh u thuộc tập U
bool dfs(int u) {
// Duyệt qua tất cả các đỉnh v thuộc tập V mà u có cạnh nối tới
for (int v : adj[u]) {
// Nếu v chưa được thăm trong lần DFS này
if (!visited[v]) {
visited[v] = true; // Đánh dấu v đã thăm
// Nếu v chưa được ghép cặp (match[v] == -1) HOẶC
// Nếu v đã được ghép cặp với đỉnh u_prime (match[v] == u_prime)
// VÀ đỉnh u_prime có thể tìm được một đường tăng khác (dfs(u_prime) trả về true)
// Điều kiện thứ hai này thể hiện việc u_prime "nhường" v lại cho u
if (match[v] == -1 || dfs(match[v])) {
match[v] = u; // Ghép đỉnh u với đỉnh v
return true; // Tìm thấy và sử dụng được một đường tăng
}
}
}
// Không tìm được đỉnh v nào để ghép cặp với u trong lần DFS này
return false;
}
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
// Đọc input: N - số đỉnh tập U, M - số đỉnh tập V
// K - số lượng cạnh
int K;
cin >> N >> M >> K;
// Đọc K cạnh và xây dựng danh sách kề
for (int i = 0; i < K; ++i) {
int u, v;
cin >> u >> v;
// Giả sử u thuộc U (1..N), v thuộc V (1..M)
adj[u].push_back(v);
}
// Khởi tạo mảng match: tất cả các đỉnh ở V đều chưa được ghép cặp
fill(match + 1, match + M + 1, -1); // Sử dụng 1-based indexing cho match[1..M]
int max_matching = 0;
// Duyệt qua từng đỉnh u thuộc tập U (từ 1 đến N)
for (int u = 1; u <= N; ++u) {
// Reset mảng visited trước khi tìm đường tăng cho đỉnh u hiện tại
// Mảng visited chỉ có ý nghĩa trong phạm vi 1 lần gọi dfs từ 1 đỉnh u
fill(visited + 1, visited + M + 1, false); // Sử dụng 1-based indexing cho visited[1..M]
// Nếu tìm được một đường tăng bắt đầu từ u
if (dfs(u)) {
max_matching++; // Tăng số lượng ghép cặp tối đa lên 1
}
}
// In kết quả
cout << max_matching << endl;
return 0;
}
Giải Thích Mã Nguồn
Thư viện và Hằng số:
iostream,vector,numeric,algorithm: Các thư viện cần thiết cho input/output, sử dụng vector, và các hàm tiện ích (nhưfill).MAXN,MAXM: Hằng số định nghĩa kích thước tối đa cho các tập đỉnh U và V. Cần điều chỉnh tùy theo giới hạn của bài toán. Chúng ta sử dụng 1-based indexing cho các đỉnh từ 1 đến N và 1 đến M.adj[MAXN]: Mảng cácvector<int>biểu diễn danh sách kề.adj[u]chứa danh sách các đỉnhvthuộc tập V mà có cạnh nối với đỉnhuthuộc tập U.match[MAXM]: Mảngmatch[v]lưu trữ đỉnhuthuộc tập U mà đỉnhvthuộc tập V hiện đang được ghép cặp cùng.match[v] = -1nếuvchưa được ghép. Kích thướcMAXMvì ta lưu ghép cặp cho các đỉnh thuộc tập V.visited[MAXM]: Mảng boolean để đánh dấu các đỉnh thuộc tập V đã được thăm trong lần gọi hàmdfshiện tại. Điều này ngăn chặn việc lặp vô hạn trong trường hợp có chu trình hoặc thăm lại không cần thiết. Kích thướcMAXMvì ta kiểm tra visited cho các đỉnh thuộc V.
Biến
N,M: Lưu số lượng đỉnh của tập U và tập V, được đọc từ input.Hàm
dfs(int u):- Đây là trái tim của giải thuật, cố gắng tìm một đường tăng bắt đầu từ đỉnh
uthuộc tập U. - Nó duyệt qua tất cả các đỉnh
vtrongadj[u](các đỉnh thuộc V kề vớiu). if (!visited[v]): Chỉ xử lý đỉnhvnếu nó chưa được thăm trong lần tìm đường tăng hiện tại.visited[v] = true;: Đánh dấuvđã thăm.if (match[v] == -1 || dfs(match[v])): Đây là logic chính của việc tìm đường tăng.match[v] == -1: Nếuvchưa được ghép, ta đã tìm thấy một đường tăng đơn giảnu->v. Ta có thể ghépuvớiv.dfs(match[v]): Nếuvđã được ghép với đỉnhu_prime = match[v], ta đệ quy gọidfs(u_prime)để xemu_primecó thể tìm một đường tăng khác (tìm đối tác mới) hay không. Nếuu_primethành công (hàmdfs(u_prime)trả vềtrue), điều đó có nghĩa làu_primeđã nhườngvlại và ghép với đỉnh khác. Khi đó, ta có thể ghépuvớiv.
- Nếu một trong hai điều kiện trên đúng, ta thực hiện
match[v] = u;(ghépuvớiv) và trả vềtruebáo hiệu tìm thấy đường tăng. - Nếu duyệt hết các đỉnh kề mà không thể ghép
u(hoặc "cướp" đối tác của chúng), hàm trả vềfalse.
- Đây là trái tim của giải thuật, cố gắng tìm một đường tăng bắt đầu từ đỉnh
Hàm
main():- Đọc số đỉnh
N,Mvà số cạnhK. - Đọc
Kcạnh và xây dựng danh sách kềadj. fill(match + 1, match + M + 1, -1);: Khởi tạo mảngmatchvới giá trị -1 cho tất cả các đỉnh từ 1 đến M của tập V. Sử dụng 1-based indexing nên bắt đầu từmatch + 1.max_matching = 0;: Biến đếm số lượng ghép cặp tối đa.- Vòng lặp
for (int u = 1; u <= N; ++u): Lặp qua từng đỉnhucủa tập U. fill(visited + 1, visited + M + 1, false);: Rất quan trọng! Reset mảngvisitedtrước mỗi lần gọidfs(u)cho một đỉnhumới. Điều này đảm bảo rằng việc tìm đường tăng choukhông bị ảnh hưởng bởi trạng thái thăm của các lần tìm đường tăng trước đó từ các đỉnhu'khác.if (dfs(u)): Nếudfs(u)trả vềtrue(nghĩa là tìm và sử dụng được một đường tăng), tăngmax_matchinglên 1.- Cuối cùng, in ra
max_matching.
- Đọc số đỉnh
Độ phức tạp: Với mỗi đỉnh u trong tập U (có N đỉnh), ta gọi hàm dfs(u). Hàm dfs(u) có thể duyệt qua tất cả các cạnh kề với u, và trong trường hợp xấu nhất có thể đệ quy sâu tới M lần (kích thước tập V) và duyệt qua các cạnh tương ứng. Tổng cộng, độ phức tạp của giải thuật DFS này là O(N E), trong đó E là số lượng cạnh của đồ thị. Đối với đồ thị hai phía dày đặc (nhiều cạnh), E có thể lên tới N M, khi đó độ phức tạp là O(N N M) hoặc O(M N M) tùy cách cài đặt và kích thước N, M. Một cách ước lượng phổ biến là O(VE) hoặc O(V^3) trong trường hợp tổng quát, trong đó V là tổng số đỉnh (N+M). Mặc dù không nhanh bằng thuật toán Hopcroft-Karp (O(E sqrt(V))), thuật toán DFS này đủ nhanh cho nhiều bài toán và dễ hiểu hơn.
Bài tập ví dụ:
Hành trình vòng quanh II
Trong một dự án phát triển cơ sở hạ tầng, FullHouse Dev được giao nhiệm vụ thiết kế một hành trình vòng quanh giữa các thành phố. Họ cần tìm cách bắt đầu từ một thành phố, đi qua một hoặc nhiều thành phố khác, và cuối cùng quay trở lại thành phố xuất phát. Mỗi thành phố trung gian trên hành trình phải là duy nhất.
Bài toán
FullHouse Dev nhận được thông tin về \(n\) thành phố và \(m\) chuyến bay. Nhiệm vụ của họ là thiết kế một hành trình vòng quanh bắt đầu từ một thành phố, đi qua một hoặc nhiều thành phố khác, và cuối cùng quay trở lại thành phố xuất phát. Mỗi thành phố trung gian trên hành trình phải là duy nhất.
INPUT FORMAT:
- Dòng đầu tiên chứa hai số nguyên \(n\) và \(m\): số lượng thành phố và chuyến bay. Các thành phố được đánh số từ 1 đến \(n\).
- Sau đó, có \(m\) dòng mô tả các chuyến bay. Mỗi dòng chứa hai số nguyên \(a\) và \(b\): có một chuyến bay từ thành phố \(a\) đến thành phố \(b\). Tất cả các chuyến bay là chuyến bay một chiều từ một thành phố đến một thành phố khác.
OUTPUT FORMAT:
- Đầu tiên in ra một số nguyên \(k\): số lượng thành phố trên hành trình. Sau đó in ra \(k\) thành phố theo thứ tự chúng sẽ được ghé thăm. Bạn có thể in ra bất kỳ giải pháp hợp lệ nào.
- Nếu không có giải pháp, in ra "IMPOSSIBLE".
Ràng buộc:
- \(1 \leq n \leq 10^5\)
- \(1 \leq m \leq 2 \cdot 10^5\)
- \(1 \leq a, b \leq n\)
Ví dụ
INPUT
4 5
1 3
2 1
2 4
3 2
3 4
OUTPUT
4
2 1 3 2
Giải thích
- Trong ví dụ này, FullHouse Dev có thể bắt đầu từ thành phố 2, đi đến thành phố 1, sau đó đến thành phố 3, và quay trở lại thành phố 2. Vì vậy, đáp án là 4 với hành trình 2 1 3 2. Okay, đây là hướng dẫn giải bài toán "Hành trình vòng quanh II" sử dụng C++ mà không cung cấp code hoàn chỉnh.
Ý tưởng chính:
Bài toán yêu cầu tìm một chu trình đơn (simple cycle) trong đồ thị có hướng. Một chu trình đơn là một đường đi bắt đầu và kết thúc tại cùng một đỉnh, đi qua các đỉnh trung gian là duy nhất (không lặp lại đỉnh nào ngoại trừ đỉnh xuất phát/kết thúc).
Thuật toán hiệu quả để tìm chu trình trong đồ thị có hướng là sử dụng Depth First Search (DFS - Tìm kiếm theo chiều sâu).
Các bước thực hiện:
Biểu diễn đồ thị: Sử dụng danh sách kề (adjacency list) để lưu trữ đồ thị. Vì các chuyến bay là một chiều, danh sách kề sẽ lưu các cạnh đi ra từ mỗi thành phố.
- Sử dụng
vector<int> adj[N]hoặcvector<vector<int>> adj(n + 1)trong C++.
- Sử dụng
Theo dõi trạng thái đỉnh trong DFS: Trong quá trình DFS trên đồ thị có hướng, cần theo dõi 3 trạng thái của mỗi đỉnh để phát hiện chu trình:
UNVISITED(Chưa thăm): Đỉnh chưa bao giờ được ghé thăm trong quá trình DFS hiện tại.VISITING(Đang thăm / Trong stack đệ quy): Đỉnh hiện đang nằm trong đường đi từ đỉnh bắt đầu DFS đến đỉnh hiện tại.VISITED(Đã thăm xong): DFS đã hoàn thành việc thăm tất cả các đỉnh có thể từ đỉnh này.- Sử dụng một mảng (ví dụ:
int state[N]) để lưu trạng thái của từng đỉnh. Khởi tạo tất cả trạng thái làUNVISITED.
Thực hiện DFS:
- Duyệt qua tất cả các đỉnh từ 1 đến n. Nếu một đỉnh chưa được thăm (
UNVISITED), bắt đầu một DFS mới từ đỉnh đó. - Trong hàm DFS cho đỉnh
u:- Đánh dấu trạng thái của
ulàVISITING. - Lưu lại đỉnh cha của
utrong cây DFS hiện tại. Sử dụng một mảngparent[N]. - Duyệt qua tất cả các đỉnh kề
vcủau:- Nếu
vcó trạng tháiUNVISITED: Đây là một cạnh cây (tree edge). Đặtparent[v] = uvà gọi đệ quydfs(v). Nếu cuộc gọi đệ quy này tìm thấy một chu trình, dừng ngay lập tức và trả về (lan truyền việc tìm thấy chu trình lên các lớp đệ quy cao hơn). - Nếu
vcó trạng tháiVISITING: Chu trình đã được phát hiện! Cạnh từuđếnvlà một cạnh ngược (back edge) trỏ tới một đỉnh đang nằm trong stack đệ quy (tức là một tổ tiên củau). Chu trình làv -> ... -> parent[u] -> u -> v. Lưu lại đỉnh bắt đầu chu trình (v) và đỉnh kết thúc cạnh gây ra chu trình (u). Đặt cờ hiệu báo đã tìm thấy chu trình và dừng các cuộc gọi đệ quy tiếp theo. - Nếu
vcó trạng tháiVISITED: Đây là một cạnh chéo hoặc cạnh tiến tới một nhánh đã hoàn thành. Không cần xử lý thêm.
- Nếu
- Sau khi duyệt qua tất cả đỉnh kề của
umà không phát hiện chu trình từu(hoặc chu trình đã được phát hiện và cờ hiệu được đặt), đánh dấu trạng thái củaulàVISITED.
- Đánh dấu trạng thái của
- Duyệt qua tất cả các đỉnh từ 1 đến n. Nếu một đỉnh chưa được thăm (
Xử lý khi tìm thấy chu trình:
- Khi phát hiện chu trình (trạng thái
VISITINGcủa đỉnhvkhi đi từu): Chu trình đi từv, ngược theo các đỉnh cha trong mảngparentđếnu, và cuối cùng là cạnhu -> v. - Để xây dựng đường đi của chu trình:
- Bắt đầu từ đỉnh
u(cycle_end). - Theo dấu mảng
parentngược vềv(cycle_start). Lưu lại các đỉnh trên đường đi này vào một danh sách (ví dụ:vector<int> path). - Danh sách
pathhiện đang chứa các đỉnh từungược vềv(ví dụ:[u, parent[u], ..., v]). Đảo ngược danh sách này để có thứ tự từvđếnu([v, ..., parent[u], u]). - Đường đi của chu trình hoàn chỉnh là
vtheo đường đi đã đảo ngược đếnu, và cuối cùng quay lạiv. Thêm đỉnhvvào cuối danh sách đã đảo ngược.
- Bắt đầu từ đỉnh
- Độ dài chu trình là số đỉnh trong danh sách cuối cùng.
- In độ dài chu trình và các đỉnh theo thứ tự.
- Khi phát hiện chu trình (trạng thái
Trường hợp không có giải pháp:
- Nếu sau khi hoàn thành DFS từ tất cả các đỉnh chưa thăm mà không tìm thấy chu trình (cờ hiệu
found_cyclevẫn là false), in ra "IMPOSSIBLE".
- Nếu sau khi hoàn thành DFS từ tất cả các đỉnh chưa thăm mà không tìm thấy chu trình (cờ hiệu
Lưu ý:
- Các đỉnh được đánh số từ 1 đến n. Cần xử lý chỉ mục (index) phù hợp nếu sử dụng mảng/vector 0-based.
- Bài toán yêu cầu "đi qua một hoặc nhiều thành phố khác", điều này có nghĩa là độ dài chu trình phải ít nhất là 3 (start -> intermediate -> start). Chu trình được phát hiện bởi DFS theo cách trên (
v -> ... -> u -> v) sẽ có độ dài ít nhất là 3 nếuukhông phải làvvàvcó ít nhất một con khácutrên đường đi từvđếnutrong cây DFS, hoặc độ dài là 3 nếuparent[u] == v. Cấu trúc phát hiện chu trình đảm bảo các đỉnh trung gian là duy nhất trong chu trình tìm được. - Bất kỳ chu trình hợp lệ nào cũng được chấp nhận, nên chu trình đầu tiên tìm thấy là đủ.
Tóm lại, các thành phần cần có trong code:
- Danh sách kề
adj. - Mảng
state(ví dụ: 0=unvisited, 1=visiting, 2=visited). - Mảng
parent. - Các biến để lưu
cycle_start,cycle_endvà cờ hiệufound_cycle. - Hàm
dfs(u). - Hàm
mainđể đọc input, khởi tạo, gọi DFS, và in output dựa trênfound_cycle.
Comments