Bài 21.4: Thuật toán tìm khớp dựa trên DFS

Bài 21.4: Thuật toán tìm khớp dựa trên DFS
Chào mừng các bạn quay trở lại với chuỗi bài viết về Cấu trúc dữ liệu và Giải thuật! Hôm nay, chúng ta sẽ cùng nhau dấn thân vào một chủ đề thú vị trong lý thuyết đồ thị: tìm kiếm các cạnh đặc biệt mà việc loại bỏ chúng có thể gây ra hậu quả lớn đến cấu trúc liên thông của đồ thị. Trong ngữ cảnh của bài viết này, chúng ta sẽ tập trung vào việc tìm kiếm các cầu (bridge) trong đồ thị. Mặc dù tiêu đề sử dụng thuật ngữ "khớp" (đôi khi được dùng để chỉ đỉnh khớp - articulation point), thuật toán dựa trên DFS mà chúng ta thảo luận ở đây là phương pháp kinh điển để tìm ra các cầu trong đồ thị vô hướng. Cầu là những cạnh mà việc loại bỏ chúng sẽ làm tăng số lượng thành phần liên thông của đồ thị.
Hãy tưởng tượng bạn có một mạng lưới giao thông (các đỉnh là thành phố, các cạnh là con đường). Một "cầu" trong mạng lưới này chính là một con đường mà nếu nó bị sập, sẽ có ít nhất hai nhóm thành phố không thể di chuyển đến nhau được nữa. Việc xác định các "cầu" như vậy là cực kỳ quan trọng trong nhiều ứng dụng thực tế, từ thiết kế mạng lưới, phân tích mạng xã hội, cho đến tìm kiếm các điểm yếu trong hạ tầng.
Vậy, làm thế nào để chúng ta có thể tự động xác định những cạnh "yếu đuối" này trong một đồ thị lớn? Câu trả lời nằm ở việc tận dụng sức mạnh của thuật toán Tìm kiếm theo chiều sâu (Depth-First Search - DFS).
Tại sao DFS lại phù hợp để tìm Cầu?
DFS có một đặc điểm tuyệt vời là nó "đào sâu" vào một nhánh của đồ thị trước khi quay lui. Khi thực hiện DFS trên một đồ thị vô hướng, chúng ta có thể xây dựng một cây DFS (DFS tree). Các cạnh của đồ thị có thể được phân loại thành hai loại đối với cây DFS này:
- Cạnh cây (Tree Edges): Các cạnh mà DFS đi qua để di chuyển từ một đỉnh chưa thăm đến một đỉnh mới.
- Cạnh ngược (Back Edges): Các cạnh nối một đỉnh hiện tại đang thăm với một đỉnh tổ tiên của nó trong cây DFS (không phải là cha trực tiếp).
Các cầu trong đồ thị có một mối liên hệ đặc biệt với cấu trúc cây DFS và các cạnh ngược. Một cạnh (u, v) trong cây DFS (v là con của u) là một cây cầu nếu và chỉ nếu không có cạnh ngược nào từ cây con gốc v hoặc bất kỳ đỉnh nào trong cây con đó nối tới u hoặc bất kỳ tổ tiên nào của u.
Nói cách khác, nếu toàn bộ cây con dưới v chỉ có thể kết nối với phần còn lại của đồ thị bên trên u duy nhất qua cạnh (u, v), thì (u, v) chính là một cây cầu.
Thuật toán: Sử dụng tin
và low
Để biến ý tưởng trên thành thuật toán cụ thể, chúng ta cần theo dõi hai thông tin quan trọng cho mỗi đỉnh u
trong quá trình DFS:
- Thời gian khám phá (
tin[u]
): Thời điểm (bước thời gian) mà đỉnhu
được lần đầu tiên thăm bởi DFS. Chúng ta dùng một bộ đếm thời gian tăng dần trong quá trình duyệt. - Thời gian thăm lại thấp nhất (
low[u]
): Thời điểm khám phá (tin
) thấp nhất mà đỉnhu
hoặc bất kỳ đỉnh nào trong cây con gốcu
có thể với tới thông qua các cạnh ngược (và có thể là các cạnh cây xuống các đỉnh khác rồi dùng cạnh ngược từ đó).
Chúng ta khởi tạo tin[u]
và low[u]
bằng giá trị tin[u]
khi lần đầu thăm u
. Sau đó, khi DFS thăm các đỉnh lân cận v
của u
:
- Nếu
v
là cha củau
trong cây DFS: Bỏ qua cạnh này để tránh xử lý sai. - Nếu
v
đã được thăm (và không phải cha): Đây là một cạnh ngược (u, v). Đỉnhu
có thể kết nối với đỉnhv
(đã thăm) qua cạnh này. Thời điểm thăm thấp nhất màu
có thể đạt tới qua cạnh này làtin[v]
. Do đó, chúng ta cập nhậtlow[u] = min(low[u], tin[v])
. - Nếu
v
chưa được thăm: Đây là một cạnh cây (u, v). Chúng ta đệ quy gọi DFS chov
. Sau khi gọi đệ quy trả về, chúng ta đã có giá trịlow[v]
(thời điểm thăm lại thấp nhất mà cây conv
có thể đạt tới). Đỉnhu
có thể đạt tới bất kỳ đỉnh nào màv
có thể đạt tới. Do đó, chúng ta cập nhậtlow[u] = min(low[u], low[v])
. Sau khi cập nhậtlow[u]
, chúng ta kiểm tra điều kiện để xác định cầu: Nếulow[v] > tin[u]
, thì cạnh (u, v) là một cây cầu.
Điều kiện low[v] > tin[u]
nghĩa là gì? Nó có nghĩa là thời điểm thăm lại thấp nhất mà cây con gốc v
có thể đạt tới (low[v]
) vẫn lớn hơn thời điểm mà đỉnh u
được khám phá (tin[u]
). Điều này chỉ xảy ra khi không có bất kỳ cạnh ngược nào từ cây con gốc v
kết nối được tới u
hoặc bất kỳ tổ tiên nào của u
. Do đó, cách duy nhất để đi từ cây con gốc v
lên phần còn lại của đồ thị trên u
là thông qua cạnh (u, v). Loại bỏ cạnh này sẽ chia tách đồ thị.
Chi Tiết Thuật Toán Bằng Pseudocode
Khởi tạo:
vector<vector<int>> adj; // Danh sách kề
vector<int> tin, low; // Thời gian khám phá và thời gian thăm lại thấp nhất
vector<bool> visited; // Mảng đánh dấu đã thăm
int timer; // Bộ đếm thời gian
vector<pair<int, int>> bridges; // Danh sách các cầu tìm được
Hàm DFS_Bridge(u, p): // u: đỉnh hiện tại, p: đỉnh cha trong cây DFS
visited[u] = true;
tin[u] = low[u] = timer++; // Gán thời gian khám phá và khởi tạo low
// Duyệt qua các đỉnh lân cận v của u
cho mỗi v trong adj[u]:
nếu v == p: // Nếu v là cha, bỏ qua
tiếp tục;
nếu visited[v]: // Nếu v đã thăm (cạnh ngược)
low[u] = min(low[u], tin[v]); // Cập nhật low[u] dựa trên thời gian khám phá của v
else: // Nếu v chưa thăm (cạnh cây)
DFS_Bridge(v, u); // Đệ quy thăm v
low[u] = min(low[u], low[v]); // Cập nhật low[u] dựa trên low[v] (lan truyền giá trị thấp nhất từ cây con)
// KIỂM TRA ĐIỀU KIỆN CẦU
nếu low[v] > tin[u]:
thêm cặp (u, v) vào danh sách bridges;
Hàm chính (Main):
Đọc số đỉnh N, số cạnh M và danh sách cạnh.
Khởi tạo adj với N đỉnh.
Thêm các cạnh vào adj.
Khởi tạo tin, low có kích thước N, giá trị -1.
Khởi tạo visited có kích thước N, giá trị false.
timer = 0;
bridges.clear();
// Duyệt qua tất cả các đỉnh để đảm bảo xử lý cả đồ thị không liên thông
cho i từ 0 đến N-1:
nếu không visited[i]:
DFS_Bridge(i, -1); // Bắt đầu DFS từ đỉnh i, cha là -1 (không có cha)
In ra danh sách bridges.
Minh Họa Bằng C++
Chúng ta sẽ cài đặt thuật toán này bằng C++.
#include <iostream>
#include <vector>
#include <algorithm>
#include <utility>
using namespace std;
vector<vector<int>> adj; // Danh sách kề của đồ thị
vector<int> tin, low; // Mảng lưu thời gian khám phá và thời gian thăm lại thấp nhất
vector<bool> visited; // Mảng đánh dấu đỉnh đã thăm
int timer; // Bộ đếm thời gian global
vector<pair<int, int>> bridges; // Danh sách lưu trữ các cầu tìm được
// Hàm DFS để tìm cầu
// u: đỉnh hiện tại
// p: đỉnh cha của u trong cây DFS (-1 nếu u là gốc của cây DFS)
void find_bridges_dfs(int u, int p = -1) {
visited[u] = true;
tin[u] = low[u] = timer++; // Gán thời gian khám phá và khởi tạo low[u]
// Duyệt qua tất cả các đỉnh lân cận v của u
for (int v : adj[u]) {
if (v == p) {
// Nếu v là cha của u trong cây DFS, bỏ qua
continue;
}
if (visited[v]) {
// Nếu v đã được thăm và không phải là cha của u,
// thì (u, v) là một cạnh ngược.
// low[u] có thể được cập nhật bằng tin[v]
low[u] = min(low[u], tin[v]);
} else {
// Nếu v chưa được thăm, (u, v) là một cạnh cây.
// Đệ quy gọi DFS cho v với u là cha
find_bridges_dfs(v, u);
// Sau khi đệ quy từ v trở về, cập nhật low[u].
// low[u] có thể đạt tới bất kỳ đỉnh nào mà low[v] có thể đạt tới
low[u] = min(low[u], low[v]);
// Kiểm tra điều kiện để xác định cầu:
// Nếu low[v] > tin[u], nghĩa là không có cạnh ngược nào
// từ cây con gốc v nối tới u hoặc bất kỳ tổ tiên nào của u.
// Vậy, (u, v) là một cầu.
if (low[v] > tin[u]) {
bridges.push_back({u, v});
}
}
}
}
// Hàm chính để khởi tạo và gọi DFS
void find_all_bridges(int n) {
adj.assign(n, vector<int>()); // Khởi tạo danh sách kề cho n đỉnh
tin.assign(n, -1); // Khởi tạo mảng tin với -1
low.assign(n, -1); // Khởi tạo mảng low với -1
visited.assign(n, false); // Khởi tạo mảng visited với false
timer = 0; // Reset bộ đếm thời gian
bridges.clear(); // Xóa danh sách cầu cũ
// Do đồ thị có thể không liên thông, cần duyệt qua tất cả các đỉnh.
// Nếu một đỉnh chưa được thăm, bắt đầu một đợt DFS mới từ đỉnh đó.
for (int i = 0; i < n; ++i) {
if (!visited[i]) {
find_bridges_dfs(i); // Bắt đầu DFS từ đỉnh i, cha là -1
}
}
}
// Hàm thêm cạnh vào đồ thị (đồ thị vô hướng)
void add_edge(int u, int v) {
adj[u].push_back(v);
adj[v].push_back(u);
}
int main() {
cout << "--- Tim cau (Bridge) trong do thi ---" << endl;
// Ví dụ 1: Đồ thị đơn giản
// 0 -- 1 -- 2 -- 3 -- 4
// | |
// -----
// Cầu: (2, 3), (3, 4)
cout << "\nVi du 1:" << endl;
int n1 = 5;
find_all_bridges(n1);
add_edge(0, 1);
add_edge(1, 2);
add_edge(2, 0); // Tạo chu trình 0-1-2
add_edge(2, 3); // Cau
add_edge(3, 4); // Cau
find_all_bridges(n1); // Chạy lại hàm tìm cầu sau khi thêm cạnh
cout << "So dinh: " << n1 << endl;
cout << "Cac cau tim duoc:";
if (bridges.empty()) {
cout << " Khong co cau nao." << endl;
} else {
cout << endl;
for (const auto& bridge : bridges) {
cout << "(" << bridge.first << ", " << bridge.second << ") ";
}
cout << endl;
}
// Ví dụ 2: Đồ thị phức tạp hơn
// 0 -- 1 -- 3 -- 4 -- 6
// | | | |
// 2 ---- 5 ----
// Cau: (1, 3), (4, 6)
cout << "\nVi du 2:" << endl;
int n2 = 7;
find_all_bridges(n2); // Reset
add_edge(0, 1);
add_edge(0, 2);
add_edge(1, 2); // Tao chu trình 0-1-2
add_edge(1, 3); // Cau
add_edge(3, 4);
add_edge(3, 5);
add_edge(4, 5); // Tao chu trình 3-4-5
add_edge(4, 6); // Cau
find_all_bridges(n2); // Chạy lại hàm tìm cầu sau khi thêm cạnh
cout << "So dinh: " << n2 << endl;
cout << "Cac cau tim duoc:";
if (bridges.empty()) {
cout << " Khong co cau nao." << endl;
} else {
cout << endl;
for (const auto& bridge : bridges) {
cout << "(" << bridge.first << ", " << bridge.second << ") ";
}
cout << endl;
}
// Ví dụ 3: Đồ thị chỉ có chu trình
// 0 -- 1 -- 2 -- 0
// Không có cầu nào
cout << "\nVi du 3:" << endl;
int n3 = 3;
find_all_bridges(n3); // Reset
add_edge(0, 1);
add_edge(1, 2);
add_edge(2, 0); // Tạo chu trình 0-1-2
find_all_bridges(n3); // Chạy lại hàm tìm cầu sau khi thêm cạnh
cout << "So dinh: " << n3 << endl;
cout << "Cac cau tim duoc:";
if (bridges.empty()) {
cout << " Khong co cau nao." << endl;
} else {
cout << endl;
for (const auto& bridge : bridges) {
cout << "(" << bridge.first << ", " << bridge.second << ") ";
}
cout << endl;
}
return 0;
}
Giải Thích Code C++
Các Biến Toàn Cục:
adj
:vector<vector<int>>
biểu diễn danh sách kề của đồ thị.adj[i]
là một vector chứa các đỉnh kề với đỉnhi
.tin
:vector<int>
lưu thời gian khám phá (tin[i]
) cho mỗi đỉnhi
.tin[i]
là giá trị củatimer
khi đỉnhi
được thăm lần đầu tiên.low
:vector<int>
lưu thời gian thăm lại thấp nhất (low[i]
) cho mỗi đỉnhi
.low[i]
là giá trịtin
nhỏ nhất có thể đạt tới từ đỉnhi
hoặc bất kỳ đỉnh nào trong cây con DFS củai
thông qua các cạnh ngược.visited
:vector<bool>
để đánh dấu đỉnh đã được thăm trong quá trình DFS.timer
: Một biến đếm thời gian đơn giản, tăng lên mỗi khi một đỉnh mới được khám phá lần đầu.bridges
:vector<pair<int, int>>
để lưu trữ danh sách các cặp đỉnh tạo thành một cây cầu.
Hàm
find_bridges_dfs(int u, int p)
:- Đây là hàm DFS cốt lõi.
u
là đỉnh hiện tại đang thăm,p
là đỉnh cha củau
trong cây DFS (được dùng để phân biệt cạnh cây đi lên với cạnh ngược). visited[u] = true;
: Đánh dấu đỉnhu
là đã thăm.tin[u] = low[u] = timer++;
: Gán thời gian khám phá chou
và khởi tạolow[u]
ban đầu bằng chínhtin[u]
. Tăngtimer
.- Vòng lặp duyệt lân cận: Duyệt qua từng đỉnh
v
kề vớiu
.if (v == p) continue;
: Nếuv
là cha, bỏ qua để không đi ngược lên cây DFS ngay lập tức qua cạnh đã dùng để đếnu
.if (visited[v])
: Nếuv
đã thăm. Điều này chỉ có thể xảy ra nếu(u, v)
là một cạnh ngược (vì nếuv
là con đã thăm thìv
đã phải được xử lý trong đệ quy từu
). Chúng ta cập nhậtlow[u] = min(low[u], tin[v])
. Điều này phản ánh rằng từu
, chúng ta có thể đi qua cạnh ngược(u, v)
để đến một đỉnh đã thămv
tại thời điểmtin[v]
.else
: Nếuv
chưa thăm. Đây là cạnh cây(u, v)
.find_bridges_dfs(v, u);
: Đệ quy gọi DFS chov
vớiu
làm cha.low[u] = min(low[u], low[v]);
: Sau khi gọi đệ quy chov
trả về,low[v]
chứa thời điểm thăm lại thấp nhất mà cây con gốcv
có thể đạt tới. Đỉnhu
có thể "kế thừa" khả năng này của cây conv
, nên chúng ta cập nhậtlow[u]
bằngmin(low[u], low[v])
.if (low[v] > tin[u])
: Đây là điều kiện kiểm tra cầu. Nếu giá trịlow
của cây conv
(thời điểm thăm lại sớm nhất từ cây conv
) lớn hơn thời điểm màu
được khám phá (tin[u]
), thì không có cách nào từ cây conv
đi ngược lênu
hoặc các tổ tiên củau
ngoại trừ qua cạnh(u, v)
. Do đó,(u, v)
là một cầu và được thêm vào danh sáchbridges
.
- Đây là hàm DFS cốt lõi.
Hàm
find_all_bridges(int n)
:- Hàm này chuẩn bị các cấu trúc dữ liệu (
adj
,tin
,low
,visited
,bridges
) cho một đồ thị cón
đỉnh và khởi tạotimer
. adj.assign(n, vector<int>());
: Tạo danh sách kề chon
đỉnh rỗng.- Các
assign
khác cũng khởi tạo các vector với kích thướcn
và giá trị mặc định. timer = 0; bridges.clear();
: Đảm bảo bộ đếm thời gian và danh sách cầu được reset cho mỗi lần tìm kiếm trên một đồ thị mới.- Vòng lặp chính:
for (int i = 0; i < n; ++i)
. Vòng lặp này cần thiết để đảm bảo thuật toán hoạt động đúng cho cả đồ thị không liên thông. Nếu một đỉnhi
chưa được thăm, nó là một phần của một thành phần liên thông mới mà DFS chưa tiếp cận. Chúng ta bắt đầu một đợt DFS mới từ đỉnhi
(với cha là -1 vì nó là gốc của cây DFS trong thành phần này).
- Hàm này chuẩn bị các cấu trúc dữ liệu (
Hàm
add_edge(int u, int v)
: Hàm tiện ích để thêm cạnh vào danh sách kề cho đồ thị vô hướng.Hàm
main()
:- Chứa các ví dụ minh họa. Đối với mỗi ví dụ, nó gọi
find_all_bridges()
để reset trạng thái, thêm các cạnh tương ứng với đồ thị ví dụ, sau đó gọi lạifind_all_bridges()
(hoặc bạn có thể tích hợp việc thêm cạnh vào một hàm setup riêng trước khi gọi DFS) và in kết quả.
- Chứa các ví dụ minh họa. Đối với mỗi ví dụ, nó gọi
Ví Dụ Minh Họa Chi Tiết Hơn
Hãy xem xét Ví dụ 1: Đồ thị: 0-1, 1-2, 2-0, 2-3, 3-4 Đỉnh: 0, 1, 2, 3, 4
Giả sử DFS bắt đầu từ đỉnh 0:
DFS(0, -1):
visited[0] = true
,tin[0] = low[0] = timer = 0
,timer
= 1.- Neighbors của 0: 1, 2.
- Thăm 1: Chưa thăm. Gọi
DFS(1, 0)
.
DFS(1, 0):
visited[1] = true
,tin[1] = low[1] = timer = 1
,timer
= 2.- Neighbors của 1: 0, 2.
- Thăm 0: Là cha (
p == 0
). Bỏ qua. - Thăm 2: Chưa thăm. Gọi
DFS(2, 1)
.
DFS(2, 1):
visited[2] = true
,tin[2] = low[2] = timer = 2
,timer
= 3.- Neighbors của 2: 1, 0, 3.
- Thăm 1: Là cha (
p == 1
). Bỏ qua. - Thăm 0: Đã thăm (
visited[0] == true
) và không phải cha. Đây là cạnh ngược (2, 0).low[2] = min(low[2], tin[0]) = min(2, 0) = 0
. - Thăm 3: Chưa thăm. Gọi
DFS(3, 2)
.
DFS(3, 2):
visited[3] = true
,tin[3] = low[3] = timer = 3
,timer
= 4.- Neighbors của 3: 2, 4.
- Thăm 2: Là cha (
p == 2
). Bỏ qua. - Thăm 4: Chưa thăm. Gọi
DFS(4, 3)
.
DFS(4, 3):
visited[4] = true
,tin[4] = low[4] = timer = 4
,timer
= 5.- Neighbors của 4: 3.
- Thăm 3: Là cha (
p == 3
). Bỏ qua. - Không còn lân cận nào. Trả về từ
DFS(4, 3)
.
Quay lại DFS(3, 2):
- Đã thăm 4 (con). Cập nhật
low[3] = min(low[3], low[4]) = min(3, 4) = 3
. - Kiểm tra cầu
(3, 4)
:low[4] (4) > tin[3] (3)
. Đúng! Cạnh (3, 4) là cầu. Thêm (3, 4) vàobridges
. - Không còn lân cận nào của 3. Trả về từ
DFS(3, 2)
.
- Đã thăm 4 (con). Cập nhật
Quay lại DFS(2, 1):
- Đã thăm 3 (con). Cập nhật
low[2] = min(low[2], low[3]) = min(0, 3) = 0
. (Giá trị low[2] vẫn là 0 do cạnh ngược (2,0)). - Kiểm tra cầu
(2, 3)
:low[3] (3) > tin[2] (2)
. Đúng! Cạnh (2, 3) là cầu. Thêm (2, 3) vàobridges
. - Không còn lân cận nào của 2. Trả về từ
DFS(2, 1)
.
- Đã thăm 3 (con). Cập nhật
Quay lại DFS(1, 0):
- Đã thăm 2 (con). Cập nhật
low[1] = min(low[1], low[2]) = min(1, 0) = 0
. (Giá trị low[1] là 0 do low[2] là 0). - Kiểm tra cầu
(1, 2)
:low[2] (0) > tin[1] (1)
. Sai!0
không lớn hơn1
. (1, 2) không phải cầu (vì có cạnh ngược (2, 0)). - Không còn lân cận nào của 1. Trả về từ
DFS(1, 0)
.
- Đã thăm 2 (con). Cập nhật
Quay lại DFS(0, -1):
- Đã thăm 1 (con). Cập nhật
low[0] = min(low[0], low[1]) = min(0, 0) = 0
. - Kiểm tra cầu
(0, 1)
:low[1] (0) > tin[0] (0)
. Sai!0
không lớn hơn0
. (0, 1) không phải cầu. - Đã thăm 2 (lân cận đã thăm, không phải cha). low[0] đã được cập nhật thành min(low[0], tin[2]) = min(0, 2) = 0 trước khi thăm 1.
- Không còn lân cận nào của 0. Trả về từ
DFS(0, -1)
.
- Đã thăm 1 (con). Cập nhật
Quá trình DFS hoàn thành. Các đỉnh 0, 1, 2, 3, 4 đều đã được thăm. Danh sách bridges
chứa (3, 4)
và (2, 3)
.
Độ Phức Tạp
- Thời gian: Thuật toán này về cơ bản là một biến thể của DFS, mỗi đỉnh và mỗi cạnh được thăm đúng một lần. Do đó, độ phức tạp thời gian là O(V + E), trong đó V là số đỉnh và E là số cạnh của đồ thị.
- Không gian: Chúng ta cần lưu trữ danh sách kề (O(V + E)), các mảng
tin
,low
,visited
(O(V)) và stack đệ quy cho DFS (trong trường hợp xấu nhất là O(V)). Do đó, độ phức tạp không gian là O(V + E).
Bài tập ví dụ:
Kiểm tra đường bay
Trong một dự án về hàng không, FullHouse Dev được giao nhiệm vụ kiểm tra tính khả thi của hệ thống đường bay. Họ cần phải xác định xem liệu có thể di chuyển giữa bất kỳ hai thành phố nào trong hệ thống hay không.
Bài toán
Có \(n\) thành phố và \(m\) đường bay một chiều. Nhiệm vụ của FullHouse Dev là kiểm tra xem liệu có thể di chuyển từ bất kỳ thành phố nào đến bất kỳ thành phố nào khác bằng cách sử dụng các đường bay hiện có hay không.
INPUT FORMAT:
- Dòng đầu tiên chứa hai số nguyên \(n\) và \(m\): số lượng thành phố và số lượng đường bay. Các thành phố được đánh số từ \(1\) đến \(n\).
- \(m\) dòng tiếp theo, mỗi dòng chứa hai số nguyên \(a\) và \(b\): thể hiện một đường bay một chiều từ thành phố \(a\) đến thành phố \(b\).
OUTPUT FORMAT:
- In ra "YES" nếu có thể di chuyển giữa mọi cặp thành phố.
- In ra "NO" nếu không thể, và in thêm hai số \(a\) và \(b\) thể hiện không thể di chuyển từ thành phố \(a\) đến thành phố \(b\).
- Nếu có nhiều đáp án, có thể in ra bất kỳ đáp án nào.
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 2
2 3
3 1
1 4
3 4
OUTPUT
NO
4 2
Giải thích
Trong ví dụ này, không thể di chuyển từ thành phố 4 đến thành phố 2 bằng bất kỳ tổ hợp đường bay nào. Do đó, đáp án là "NO" và một cặp thành phố không thể kết nối là 4 và 2. Ok, đây là hướng dẫn giải bài "Kiểm tra đường bay" một cách ngắn gọn bằng C++ sử dụng thuật toán đồ thị:
Biểu diễn đồ thị:
- Sử dụng danh sách kề (adjacency list) để lưu trữ đồ thị gốc (các đường bay một chiều). Vector of vectors
vector<vector<int>> adj;
là phù hợp. Kích thước làn+1
. - Bạn cũng cần danh sách kề cho đồ thị chuyển vị (transpose graph), nơi tất cả các cạnh bị đảo ngược chiều.
vector<vector<int>> rev_adj;
cũng có kích thướcn+1
. - Khi đọc đầu vào
a b
, thêm cạnha -> b
vàoadj
và cạnhb -> a
vàorev_adj
.
- Sử dụng danh sách kề (adjacency list) để lưu trữ đồ thị gốc (các đường bay một chiều). Vector of vectors
Kiểm tra tính liên thông mạnh:
- Một đồ thị có hướng liên thông mạnh (strongly connected) khi và chỉ khi bạn có thể đi từ bất kỳ đỉnh nào đến bất kỳ đỉnh nào khác.
- Cách kiểm tra hiệu quả là thực hiện hai lần duyệt đồ thị (ví dụ: DFS hoặc BFS) từ một đỉnh bất kỳ (chọn đỉnh 1 cho tiện):
- Lần 1: Duyệt DFS/BFS từ đỉnh 1 trên đồ thị gốc. Đếm số đỉnh có thể thăm được.
- Lần 2: Nếu lần 1 thăm được tất cả N đỉnh, tiếp tục duyệt DFS/BFS từ đỉnh 1 trên đồ thị chuyển vị. Đếm số đỉnh có thể thăm được.
Phân tích kết quả:
- Nếu lần duyệt đầu tiên (trên đồ thị gốc từ đỉnh 1) không thăm được tất cả N đỉnh: Điều này có nghĩa là có đỉnh
v
mà đỉnh 1 không thể đến được. Đồ thị không liên thông mạnh. In ra "NO" và cặp "1 v" (tìm một đỉnhv
đầu tiên không được thăm). - Nếu lần duyệt đầu tiên thăm được tất cả N đỉnh, nhưng lần duyệt thứ hai (trên đồ thị chuyển vị từ đỉnh 1) không thăm được tất cả N đỉnh: Điều này có nghĩa là có đỉnh
v
mà đỉnh 1 không thể đến được trong đồ thị chuyển vị. Tương đương, trong đồ thị gốc, đỉnhv
không thể đến được đỉnh 1. Đồ thị không liên thông mạnh. In ra "NO" và cặp "v 1" (tìm một đỉnhv
đầu tiên không được thăm trong lần duyệt thứ hai). - Nếu cả hai lần duyệt đều thăm được tất cả N đỉnh: Điều này đảm bảo rằng từ đỉnh 1 có thể đến mọi đỉnh khác, và mọi đỉnh khác có thể đến được đỉnh 1. Kết hợp với tính chất bắc cầu của đường đi, điều này chứng tỏ mọi cặp đỉnh đều có thể di chuyển qua lại. Đồ thị liên thông mạnh. In ra "YES".
- Nếu lần duyệt đầu tiên (trên đồ thị gốc từ đỉnh 1) không thăm được tất cả N đỉnh: Điều này có nghĩa là có đỉnh
Triển khai DFS/BFS:
- Viết một hàm DFS hoặc BFS thông thường nhận vào đỉnh bắt đầu, danh sách kề của đồ thị cần duyệt, và một mảng/vector
visited
để đánh dấu các đỉnh đã thăm. - Trong hàm này, đếm hoặc trả về số lượng đỉnh đã thăm được.
- Viết một hàm DFS hoặc BFS thông thường nhận vào đỉnh bắt đầu, danh sách kề của đồ thị cần duyệt, và một mảng/vector
Lưu ý khi tìm cặp (a, b):
- Nếu lần duyệt đầu tiên thất bại, bạn cần tìm một đỉnh
v
chưa được thăm. Cách đơn giản nhất là duyệt từ 1 đến N và tìm đỉnhi
đầu tiên cóvisited[i]
là false. Cặp là (1, i). - Nếu lần duyệt thứ hai thất bại, bạn cần tìm một đỉnh
v
chưa được thăm trong đồ thị chuyển vị. Cách đơn giản nhất là duyệt từ 1 đến N và tìm đỉnhi
đầu tiên cóvisited_rev[i]
là false. Cặp là (i, 1).
- Nếu lần duyệt đầu tiên thất bại, bạn cần tìm một đỉnh
Comments