Một số ứng dụng nâng cao của cây DFS (phần 2)
III. Bài toán tìm thành phần liên thông mạnh - giải thuật Tarjan
1. Định nghĩa thành phần liên thông mạnh
Đối với đồ thị có hướng, ta có ba định nghĩa về tính liên thông:
- được gọi là liên thông mạnh (strongly connected) nếu với mọi cặp đỉnh phân biệt , ta có đến được và đến được .
- được gọi là liên thông yếu (weakly connected) nếu như đồ thị vô hướng nền của nó là liên thông (tức là hủy bỏ chiều của các cạnh trên đồ thị).
- được gọi là liên thông một phần (unilaterally connected) nếu như với mọi cặp đỉnh phân biệt có ít nhất một đỉnh đến được đỉnh còn lại.
Như vậy, có thể hiểu một thành phần liên thông mạnh của một đồ thị có hướng là một đồ thị con tối đại liên thông mạnh (nghĩa là nếu thêm vào thành phần liên thông này một tập hợp đỉnh khác sẽ làm mất đi tính liên thông mạnh). Nếu mỗi thành phần liên thông mạnh được co lại thành một đỉnh, thì đồ thị sẽ trở thành một đồ thị có hướng không chu trình. Giải thuật Tarjan là một giải thuật rất được ưa thích để tìm các thành phần liên thông mạnh trên đồ thị.
2. Các định lý quan trọng
Có ba định lý rất quan trọng sử dụng trong quá trình phát triển giải thuật Tarjan:
Định lý 1
Nếu và là hai đỉnh thuộc thành phần liên thông mạnh thì mọi đường đi từ tới cũng giống như đường đi từ tới . Tất cả các đỉnh trung gian trên đường đi đó đều phải thuộc .
Chứng minh: Nếu và là hai đỉnh thuộc thì tức là có một đường đi từ đến và một đường khác đi từ về . Suy ra với một đỉnh nằm trên đường đi từ tới thì tới được tới được mà có đường tới nên cũng tới được . Vậy nằm trong thành phần liên thông mạnh chứa tức là thuộc . Tương tự với một đỉnh nằm trên đường đi từ tới .
Định lý 2
Với một thành phần liên thông mạnh bất kỳ, sẽ tồn tại một đỉnh sao cho mọi đỉnh của đều thuộc nhánh gốc . Đặt đỉnh này là đỉnh thăm đầu tiên trong quá trình dfs trên thành phần ta có là chốt của .
Chứng minh: Trước hết, nhắc lại một thành phần liên thông mạnh là một đồ thị con liên thông mạnh của đồ thị ban đầu thoả mãn tính chất tối đại tức là việc thêm vào thành phần đó một tập hợp đỉnh khác sẽ làm mắt đi tính liên thông mạnh. Trong số các đỉnh của chọn là đỉnh được thăm đầu tiên theo thuật toán tìm kiếm theo chiều sâu. Ta sẽ chứng minh nằm trong nhánh gốc . Thật vậy, với một đỉnh bất kỳ của do liên thông mạnh nên phải tồn tại một đường đi từ tới :
Từ định lý tất cả các đỉnh đều thuộc nên chúng sẽ phải thăm sau đỉnh . Khi hàm được gọi thì tất cả các đỉnh đều chưa thăm; vì hàm sẽ liệt kê tất cả những đỉnh chưa thăm đến được từ bằng cách xây dựng nhánh gốc của cây nên các đỉnh sẽ thuộc nhánh gốc của cây . Bởi chọn là đỉnh bất kỳ trong nên ta có điều phải chứng minh. Đỉnh trong chứng minh định lý - đỉnh thăm trước tất cả các đỉnh khác trong - gọi là chốt của thành phần . Mỗi thành phần liên thông mạnh có duy nhất một chốt. Xét về vị trí trong cây tìm kiếm DFS, chốt của một thành phân liên thông là đỉnh nằm cao nhất so với các đỉnh khác thuộc thành phần đó, hay nói cách khác: là tiền bối của tất cả các đỉnh thuộc thành phần đó.
Định lý 3
Luôn tìm được đỉnh chốt thỏa mãn: Quá trình tìm kiếm theo chiều sâu bắt đầu từ không thăm được bất kỳ một chốt nào khác (tức là nhánh gốc không chứa một chốt nào ngoài ). Chẳng hạn, ta chọn a là chốt được thăm sau cùng trong một dây chuyền đệ quy hoặc chọn là chốt thăm sau tất cả các chốt khác. Với chốt như vậy thì các đỉnh thuộc nhánh gốc chính là thành phần liên thông mạnh chứa .
Chứng minh: Với mọi đỉnh nằm trong nhánh gốc xét là chốt của thành phần liên thông mạnh chứa . Ta sẽ chứng minh . Thật vậy, theo định lý phải nằm trong nhánh gốc . Vậy nằm trong cả nhánh gốc và nhánh gốc . Giả sử phản chứng rằng khác thì sẽ có hai trường hợp xảy ra:
- Trường hợp : Nhánh gốc chứa nhánh gốc có nghĩa là hàm sẽ do hàm gọi tới, điều này mâu thuẫn với giả thiết rằng là chốt mà quá trình tìm kiếm theo chiều sâu bắt đầu từ không thăm một chốt nào khác.
- Trường hợp : Nhánh gốc nằm trong nhánh gốc có nghĩa là nằm trên một đường đi từ tới . Do và thuộc cùng một thành phần liên thông mạnh nên theo định lý cũng phải thuộc thành phần liên thông mạnh đó. Vậy thì thành phần liên thông mạnh này có hai chốt và . Điều này vô lý.
Theo định lý ta đã có thành phân liên thông mạnh chứa nằm trong nhánh gốc theo chứng minh trên ta lại có: Mọi đỉnh trong nhánh gốc nằm trong thành phân liên thông mạnh chứa . Kết hợp lại được: Nhánh gốc chính là thành phần liên thông mạnh chứa a.
3. Giải thuật Tarjan
3.1. Ý tưởng
Giải thuật Tarjan có thể phát biểu như sau: Chọn là chốt không là tiền bối của một chốt nào khác, chọn lấy thành phần liên thông mạnh thứ nhất là nhánh gốc . Sau đó loại bỏ nhánh DFS gốc ra khỏi cây lại tìm thấy một chốt khác mà nhánh gốc không chứa chốt nào khác, lại chọn lấy thành phần liên thông mạnh thứ hai là nhánh DFS gốc Tương tự như vậy cho thành phần liên thông mạnh thứ ba, thứ tư, v.v… Có thể hình dung thuật toán Tarjan “bẻ” cây tại vị trí các chốt để thu được các nhánh rời rạc, mỗi nhánh là một thành phần liên thông mạnh.
Mô hình thuật toán:
void dfs(u)
{
{Đánh_dấu_đã_thăm_u};
// Duyệt các đỉnh v kề với u.
for (v in adj[u])
if {v_chưa_thăm}
dfs(v);
if {u_là_chốt}
{
{Liệt_kê_thành_phần_liên_thông_mạnh_chốt_u};
{Loại_bỏ_các_đỉnh_đã_liệt_kê_khỏi_đồ_thị};
}
}
main()
{
{Đánh_dấu_mọi_đỉnh_v_đều_chưa_thăm};
// Duyệt mọi đỉnh u thuộc tập đỉnh V của đồ thị G.
for (u in V)
if (u_chưa_thăm)
dfs(u);
}
3.2. Xác định một đỉnh có phải là chốt hay không
Ý tưởng thì khá dễ hiểu, tuy nhiên, làm thế nào để xác định được một đỉnh có phải là chốt hay không thì lại là câu chuyện khác. Để làm được như thế, ta dựa vào các nhận xét dưới đây:
- Nhận xét 1: Xét một nhánh gốc . Nếu như trong nhánh này không có cung ngược hoặc cung chéo nào đi ra khỏi nhánh đó thì chắc chắn là chốt. Điều này dễ dàng chứng minh bởi vì từ ta chỉ đến được các đỉnh thuộc nhánh đó mà thôi, do đó là chốt.
- Nhận xét 2: Nếu từ một đỉnh nào đó của nhánh gốc có một cung ngược tới đỉnh bất kỳ là cha của thì không thể là chốt. Bởi vì nếu như vậy, ta sẽ có chu trình: nên thuộc cùng một thành phần liên thông mạnh. Mà lại được thăm trước trong khi đang giả sử là chốt (đỉnh được duyệt đầu tiên trong TPLT mạnh) nên điều này mâu thuẫn.
- Nhận xét 3: Trong trường hợp nếu giữa hai nhánh gốc và gốc tồn tại một cung chéo thì cũng không thể là chốt. Thật vậy, cung chéo này buộc phải đi từ nhánh thăm sau tới nhánh thăm trước (tính chất cây ), tức là nhánh gốc phải thăm trước nhánh gốc suy ra phải thăm trước và cũng phải thăm trước . Lúc này, tồn tại hai khả năng có thể xảy ra:
- Nếu thuộc nhánh đã duyệt trước thì sẽ được duyệt xong trước khi thăm tới . Vậy khi quá trình gọi tới thì toàn bộ các đỉnh trong nhánh gốc đã được duyệt xong (nói cách khác là đã bị hủy đi), nên sẽ không tiến hành tiếp từ đỉnh sang đỉnh nữa. Lúc này cung bị hủy.
- Nếu là cha của thì ta có: tới được lại nằm trong nhánh gốc nên đến được mà đến được do là một cung, lại đến được do thuộc thành phần liên thông mạnh gốc . Như vậy ta có chu trình: suy ra và thuộc cùng một thành phần liên thông mạnh. Mà đã duyệt trước nên không thể là một chốt nữa.
Tựu chung lại, đỉnh là một chốt khi và chỉ khi không tồn tại cung ngược hoặc cung chéo nối từ nhánh gốc sang một nhánh gốc khác. Nói cách khác, là chốt nếu như không tồn tại cung nối từ nhánh gốc tới một đỉnh đã thăm trước .
4. Cài đặt
Dựa vào ý tưởng đánh số các đỉnh trên cây và ghi nhận cung ngược lên cao nhất, ta có thể kiểm tra được từ một đỉnh có tồn tại cung nối thuộc nhánh gốc tới một đỉnh thăm trước hay không. Nếu có thì sẽ không thể là chốt, ngược lại thì là chốt. Cụ thể như sau:
- Sử dụng mảng là số thứ tự duyệt của đỉnh và là giá trị nhỏ nhất mà từ nhánh gốc có thể tới được bằng một cung (giống với cách xây dựng cây ). Ban đầu tăng lên và khởi gán trong quá trình .
- Sử dụng thêm mảng \text{is_deleted}[u] để đánh dấu đỉnh đã bị loại khỏi đồ thị hay chưa. nếu đã xóa và ngược lại.
- Dựa vào nhận xét số ta có một cách cài đặt tiện lợi sử dụng cấu trúc dữ liệu
stack
như sau: Khi duyệt xong một nhánh gốc trước khi thoát khỏi hàm ta kiểm tra có phải là chốt không bằng cách so sánh và . Nếu thì từ chỉ tới được các đỉnh thuộc nhánh gốc từ đó là chốt và các đỉnh thuộc nhánh gốc chính là các đỉnh thuộc thành phần liên thông mạnh chốt là . Trong quá trình khi thăm tới một đỉnh ta sẽ đẩy ngay nó vàostack
. Như vậy khi duyệt xong đỉnh thì các đỉnh thuộc nhánh gốc sẽ nằm liên tiếp ngay cạnh nhau trongstack
. Nếu là chốt, ta chỉ cần lấy ra các đỉnh này tới khi lấy được đỉnh toàn bộ các đỉnh đó chính là thành phần liên thông mạnh chứa .
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e5 + 10;
int cnt_components, time_dfs, number[maxn], low[maxn];
stack < int > vertex;
bool is_deleted[maxn];
vector < int > adj[maxn];
void dfs(int u) // Giải thuật Tarjan
{
number[u] = ++time_dfs;
low[u] = num[u];
for (int v: adj[u])
{
if (is_deleted[v]) // Đỉnh v đã xóa rồi thì bỏ qua.
continue;
if (!number[v]) // đỉnh v chưa được đánh số => chưa được thăm.
{
dfs(v); // Thăm v.
low[u] = min(low[u], low[v]); // Cực tiểu hóa low[u].
}
else // Nếu v đã thăm.
{
low[u] = min(low[u], num[v]); // Cực tiểu hóa low[u] theo num[v].
}
}
// Bắt đầu kiểm tra đỉnh u có phải chốt hay không.
if (low[u] == num[u])
{
// In ra chỉ số của thành phần liên thông này.
cout << "Strongly Connected Component " << ++cnt_components << ": ";
// Liệt kê thành phần liên thông mạnh chốt là u.
int v;
do
{
v = vertex.top(); // Lấy các đỉnh từ trong stack ra.
cout << v << ‘ ‘;
vertex.pop();
is_deleted[v] = 1;
}
while (v != u); // Nếu đã lấy ra chốt u thì kết thúc TPLTM chốt u.
cout << endl;
}
}
main()
{
// Nhập dữ liệu đồ thị.
int n, m;
cin >> n >> m;
for (int i = 1; i <= m; ++i)
{
int u, v;
cin >> u >> v;
adj[u].push_back(v);
}
// Duyệt qua các đỉnh, nếu đỉnh nào chưa thăm thì bắt đầu dfs từ đỉnh đó.
for (int u = 1; u <= N; ++u)
if (!number[u])
dfs(u);
}
Giả sử với dữ liệu đồ thị như hình dưới đây:
Chạy chương trình trên sẽ cho ra kết quả:
Strongly Connected Component 1: 7 6 5
Strongly Connected Component 2: 4 3 2
Strongly Connected Component 3: 11 10 9 8
Strongly Connected Component 4: 1
Đánh giá độ phức tạp: Bởi vì giải thuật Tarjan chỉ là sửa đổi một chút từ giải thuật các phép toán vào/ra của ngăn xếp được thực hiện không quá lần, nên thời gian thực hiện giải thuật vẫn là trong trường hợp đồ thị được biểu diễn bằng danh sách kề, nếu dùng ma trận kề và nếu dùng danh sách cạnh.
5. Bài toán ví dụ
Nguồn bài: https://bit.ly/3lv0MG9.
Tóm tắt đề bài: Cho đa đồ thị có hướng gồm đỉnh, cạnh . Hai đỉnh và được gọi là thân thiện nếu như từ tới được và từ cũng tới được . Cần thêm vào đồ thị một số cung sao cho các cặp đỉnh thân thiện đều có đường nối trực tiếp với nhau và số cung thêm vào là ít nhất có thể (cung thêm vào không được trùng với các cung đã có)?
Ý tưởng:
- Nhận xét: Các cặp thành phố thân thiện chỉ có thể là các cặp thành phố nằm trong cùng một TPLT mạnh.
- Từ nhận xét trên, bước ta tìm các TPLT mạnh của đồ thị, với mỗi TPLT mạnh, số cung tối đa cần thêm là cung, với là số đỉnh thuộc TPLT mạnh đó. Ta sẽ đếm số cung nối trực tiếp đã có sẵn trong TPLT mạnh đó (gọi là ), tính toán ra số cạnh tối thiểu cần thêm trong TPLT mạnh này là .
- Lưu ý đồ thị đã cho là đa đồ thị, mỗi cặp đỉnh có thể có nhiều cạnh nối trực tiếp, do đó cần lưu ý khi xử lý để không trừ cạnh giữa cặp đỉnh nhiều lần. Ví dụ có cạnh nối từ đỉnh tới đỉnh thì cũng chỉ tính là cung trực tiếp đã có và chỉ trừ đi lần thôi.
Cài đặt:
#include <bits/stdc++.h>
#define int long long
#define task "NewRoads."
#define inf 1e9 + 7
#define x first
#define y second
using namespace std;
const int maxn = 1e5 + 10;
int n, m, time_dfs, minimum_new_roads, cnt_comps;
int par[maxn], component_num[maxn], num[maxn], low[maxn];
bool is_deleted[maxn];
stack < int > vertex;
vector < int > adj[maxn], component[maxn];
void enter()
{
cin >> N >> M;
for (int i = 1; i <= M; ++i)
{
int u, v;
cin >> u >> v;
adj[u].push_back(v);
}
}
// Đếm số cạnh cần thêm trong 1 TPLT mạnh có K đỉnh.
int cntNewRoads(vector < int > component)
{
// par[v] = u: Tồn tại một cung (u -> v). Mảng này để kiểm soát các cung nối cùng 1
// cặp đỉnh, tránh trừ nhiều lần.
int k = component.size(), had_directed_edges = 0;
for (int u: component)
{
for (int v: adj[u])
if (par[v] != u && componentNum[v] == componentNum[u])
{
par[v] = u;
++had_directed_edges;
}
}
return k * (k - 1) - had_directed_edges;
}
// Xác định các TPLT mạnh bằng giải thuật Tarjan.
void tarjan(int u)
{
num[u] = low[u] = ++time_dfs;
vertex.push(u);
for (int v: adj[u])
{
if (is_deleted[v])
continue;
if (num[v])
{
low[u] = min(low[u], num[v]);
}
else
{
tarjan(v);
low[u] = min(low[u], low[v]);
}
}
if (low[u] == num[u])
{
vector < int > component_vertexes;
int v;
++cnt_comps;
do
{
v = vertex.top();
vertex.pop();
is_deleted[v] = true;
// Nạp đỉnh v vào danh sách các đỉnh trong tplt thứ cnt.
component_vertexes.push_back(v);
// Đánh số hiệu cho v: Nằm trong thành phần liên thông thứ cnt.
component_num[v] = cnt_comps;
}
while (v != u);
minimum_new_roads += cnt_new_roads(component_vertexes);
}
}
void solution()
{
for (int u = 1; u <= N; ++u)
if (!num[u])
tarjan(u);
cout << minimum_new_roads;
}
main()
{
ios_base::sync_with_stdio(false);
cin.tie(0); cout.tie(0);
enter();
solution();
return 0;
}
IV. Tài liệu tham khảo
- https://vnoi.info/wiki/algo/graph-theory/Depth-First-Search-Tree.md#thuật-toán-tarjan.
- Sách Giải thuật và Lập trình - thầy Lê Minh Hoàng.
©️ Tác giả: Vũ Quế Lâm từ Viblo
All Rights Reserved