Một số ứng dụng nâng cao của cây DFS (phần 1)
I. Cây DFS và bài toán định chiều đồ thị
1. Phân loại các cung trên cây
Trong quá trình duyệt đồ thị, với mỗi đỉnh ta có được đỉnh là đỉnh cha của đỉnh trên đường đi. Nếu xây dựng đồ thị con gồm các cạnh có dạng ta sẽ thu được một cây, gọi là cây . Hình vẽ dưới đây biểu diễn một cây với đỉnh, các cạnh nét liền là cạnh thuộc cây , các cạnh nét đứt là cạnh không thuộc cây .
Trên đồ thị có hướng, xét mọi cung được thăm và không được thăm trong quá trình ta có các loại cung sau:
- Cung của cây DFS (Tree Edges): Các cung được thăm trong quá trình (cung màu trắng nét liền).
- Cung xuôi (Forward edges): Cung không thuộc cây có dạng ; trong đó là cha của trong quá trình (cạnh xanh lá).
- Cung ngược (Back edges): Cung không thuộc cây và có dạng ; trong đó là cha của (cạnh đỏ) nhưng đã được thăm trước đó do một dây chuyền khác.
- Cung chéo (Cross edges): Cung không thuộc cây , trong đó và thuộc hai nhánh khác nhau của cùng một cây (cạnh tím). Cung này sinh ra do tồn tại một đỉnh đã duyệt trước cả và , và đỉnh này tạo ra hai nhánh khác nhau, một nhánh chứa và một nhánh chứa . Cung chéo chỉ có thể đi từ nhánh thăm sau tới nhánh thăm trước chứ không thể đi từ nhánh thăm trước tới nhánh sau trước (thật vậy, nếu cung chéo đi từ nhánh thăm trước sang nhánh thăm sau thì cung đó cũng chính là cung thuộc nhánh thăm trước).
Trên đồ thị vô hướng, chỉ tồn tại hai loại cung là cung thuộc cây và cung ngược (không thuộc cây ).
Đối với đồ thị vô hướng, nếu như trong quá trình , cứ khi duyệt qua một cạnh thì ta bỏ luôn chiều đi và định chiều lại cạnh thành cung , thì thu được một phép định chiều đồ thị gọi là phép định chiều .
2. Đánh số các đỉnh trên cây và ghi nhận cung ngược lên cao nhất
Một số định nghĩa mảng sử dụng:
- Số thứ tự duyệt đến của đỉnh trong quá trình .
- Số thứ tự nhỏ nhất (giá trị ) của một đỉnh tới được từ nhánh gốc bằng tối đa một cung ngược. Có nghĩa là, nếu trong nhánh gốc tồn tại nhiều cung ngược, thì ta ghi nhận cung ngược hướng lên đỉnh có thứ tự thăm nhỏ nhất. Ban đầu do đỉnh có thể tự đi tới chính nó.
- Đỉnh cha của đỉnh trên cây .
- Thời điểm duyệt xong nhánh gốc . Các đỉnh có số thứ tự thăm từ tới sẽ thuộc nhánh gốc .
Nhận xét: Trong quá trình từ đỉnh tới đỉnh xảy ra ba trường hợp:
- Nếu đỉnh chính bằng đỉnh đã gọi đệ quy tới ở trước đó thì bỏ qua.
- Nếu đỉnh chưa thăm thì thăm . Khi duyệt xong nhánh gốc ta đã xây dựng được một nhánh gốc là cây con của nhánh gốc . Do đó, các cung ngược đi ra từ nhánh gốc cũng là cung ngược đi ra từ nhánh gốc . Ta sẽ cực tiểu hóa:
- Nếu đỉnh đã thăm rồi, tức là là một cung ngược không thuộc cây . Ta cực tiểu hóa:
Cài đặt:
int time_dfs = 0;
void dfs(int u)
{
num[u] = low[u] = ++time_dfs; // Xác định thời điểm duyệt tới của đỉnh u.
for (int v: adj[u])
{
if (v == par[u]) // Đỉnh v là đỉnh cha của đỉnh u -> bỏ qua.
continue;
if (!num[v]) // Chưa duyệt v.
{
par[v] = u; // Gán cha của đỉnh v là đỉnh u.
dfs(v);
low[u] = min(low[u], low[v]); // Cực tiểu hóa low[u].
}
else // Đỉnh v đã duyệt qua, (u -> v) là cung ngược.
{
low[u] = min(low[u], num[v]);
}
}
tail[u] = time_dfs;
}
Hình vẽ dưới đây minh họa một đồ thị gồm đỉnh và các cạnh của nó:
Quá trình định chiều và đánh số đồ thị diễn ra như sau:
II. Bài toán tìm khớp và cầu của đồ thị
1. Giải thuật tìm khớp và cầu của đồ thị
Bài toán: Cho một đơn đồ thị vô hướng gồm đỉnh và cạnh . Hãy xác định số lượng đỉnh khớp và cạnh cầu của đồ thị?
Định nghĩa:
- Một đỉnh được gọi là đỉnh khớp nếu như khi loại bỏ đỉnh này và các cạnh liên thuộc với nó khỏi đồ thị thì số thành phần liên thông của đồ thị tăng lên (các đỉnh màu xanh lá cây).
- Một cạnh được gọi là cạnh cầu nếu như khi loại bỏ cạnh này khỏi đồ thị thì số thành phần liên thông của đồ thị tăng lên (các cạnh màu đỏ).
Nhận xét: Cạnh cầu của đồ thị không thể là cung ngược, do khi bỏ đi cung ngược này thì không ảnh hưởng gì tới tính liên thông của đồ thị. Do đó, cạnh cầu bắt buộc phải là cung thuộc cây .
Hướng giải quyết: Tiến hành dựng cây và định chiều lại đồ thị đã cho, đồng thời ghi nhận cung ngược lên cao nhất theo giải thuật ở phần . Xét cung là cung thuộc cây ta có:
- Nếu từ nhánh gốc không có cung ngược nào lên phía trên tức là từ chỉ có thể đi tới được các cạnh nội bộ của nhánh gốc thôi. Lúc này, nếu ta loại bỏ cạnh thì nhánh gốc sẽ bị chia cắt với các nhánh khác, do đó số thành phần liên thông của đồ thị sẽ tăng lên. Tức là cạnh là cầu khi và chỉ khi .
- Nếu từ nhánh gốc không có cung nào ngược lên phía trên tức là nhánh gốc kết nối với các đỉnh khác duy nhất thông qua cạnh . Khi đó, nếu bỏ đỉnh đi thì nhánh gốc cũng sẽ bị chia cắt với các nhánh khác, do đó đỉnh là đỉnh khớp khi và chỉ khi .
- Ngoài ra, nếu một đỉnh là gốc của một cây và cây này có ít nhất nhánh con, thì khi bỏ đỉnh đi, hai nhánh con sẽ bị chia cắt thành thành phần liên thông khác nhau. Khi đó u cũng là khớp. Để kiểm soát việc này, ta sử dụng thêm một mảng là số nhánh con của đỉnh . Mảng sẽ dùng để đánh đấu đỉnh có phải một khớp hay không, nếu có thì ngược lại .
Cài đặt:
#include <bits/stdc++.h>
#define task "GRAPH."
#define inf 1e9 + 7
#define x first
#define y second
using namespace std;
const int maxn = 1e5 + 10;
// Hai biến cnt_bridge, cnt_articulation dùng để đếm số cầu và khớp.
int n, m, time_dfs, cnt_bridge,cnt_articulation, low[maxn], number[maxn];
int is_articulation[maxn], cnt_child[maxn], par[maxn];
vector < int > adj[maxn];
void enter()
{
cin >> n >> m;
for (int i = 1; i <= M; ++i)
{
int u, v;
cin >> u >> v;
adj[u].push_back(v);
adj[v].push_back(u);
}
}
void dfs(int u)
{
low[u] = number[u] = ++time_dfs;
for (int v: adj[u])
{
if (v == par[u])
continue;
// Đỉnh v chưa thăm thì thăm nó và gắn cha của nó bằng u.
if (!number[v])
{
par[v] = u;
++cnt_child[u];
dfs(v);
low[u] = min(low[u], low[v]); // Cực tiểu hóa low[u].
if (low[v] == number[v]) // cung (u, v) là cầu.
++cnt_bridge;
// u là chốt và u có nhiều hơn 1 nhánh con => u là khớp.
if (par[u] == -1)
{
if (cnt_child[u] >= 2)
is_articulation[u] = 1;
}
// u là khớp nếu không có cung ngược hoặc cung chéo đi ra từ nhánh DFS gốc u.
else if (low[v] >= number[u])
{
is_articulation[u] = 1;
}
}
else
{
// Cực tiểu hóa low[u] theo number[v] nếu v đã thăm.
low[u] = min(low[u], number[v]);
}
}
}
void solution()
{
fill(par + 1, par + 1 + n, -1);
for (int u = 1; u <= n; ++u)
if (!number[u])
dfs(u);
for (int u = 1; u <= n; ++u)
cnt_articulation += is_articulation[u];
cout << cnt_articulation << ' ' << cnt_bridge;
}
main()
{
ios_base::sync_with_stdio(false);
cin.tie(0); cout.tie(0);
enter();
solution();
return 0;
}
2. Bài toán ví dụ
2.1. Bài toán 1
Nguồn bài: https://bit.ly/3Ft6bWe.
Tóm tắt đề bài: Cho đơn đồ thị vô hướng liên thông gồm đỉnh, cạnh . Cần định chiều lại các cạnh của đồ thị thành một chiều, sao cho đồ thị mới vẫn liên thông và số lượng cạnh hai chiều còn lại là ít nhất có thể?
Ý tưởng:
- Áp dụng giải thuật kết hợp với phép định chiều đồ thị, khi xét tới cạnh thì bỏ luôn chiều và định chiều nó thành cung .
- Tuy nhiên, đối với các cạnh là cầu của đồ thị, việc định chiều lại nó sẽ làm mất tính liên thông của đồ thị. Do đó, nếu sau khi duyệt xong nhánh gốc mà thấy cạnh là một cầu thì ta sẽ khôi phục lại chiều cho nó để giữ lại cạnh hai chiều.
Cài đặt:
#include <bits/stdc++.h>
#define int long long
#define task "StreetDirection."
#define inf 1e9 + 7
#define x first
#define y second
using namespace std;
const int maxn = 1001;
int n, m, time_dfs, testcase, low[maxn], num[maxn];
bool adj[maxn][maxn];
void reset_data(int n)
{
memset(adj, false, sizeof(adj));
fill(low + 1, low + 1 + n, 0);
fill(num + 1, num + 1 + n, 0);
time_dfs = 0;
}
void enter()
{
cin >> n >> m;
reset_data(n);
for (int i = 1; i <= m; ++i)
{
int u, v;
cin >> u >> v;
adj[u][v] = true;
adj[v][u] = true;
}
}
void dfs(int u, int par)
{
low[u] = num[u] = ++time_dfs;
for (int v = 1; v <= N; ++v)
{
if (v == par || !adj[u][v])
continue;
adj[v][u] = false; // Bỏ luôn chiều (v -> u), định chiều cạnh (u - v) thành cung (u -> v).
if (!num[v])
{
dfs(v, u);
low[u] = min(low[u], low[v]);
// Cạnh (u, v) là cầu thì phải giữ nguyên 2 chiều để đảm bảm tính liên thông.
if (low[v] == num[v])
adj[v][u] = true;
}
else
{
low[u] = min(low[u], num[v]);
}
}
}
void solution()
{
for (int u = 1; u <= n; ++u)
if (!num[u])
dfs(u, -1);
cout << ++testcase << endl << endl;
// In ra các cạnh được định chiều lại thành u -> v.
for (int u = 1; u <= n; ++u)
for (int v = 1; v <= n; ++v)
if (adj[u][v])
cout << u << ' ' << v << endl;
cout << "#\n";
}
main()
{
ios_base::sync_with_stdio(false);
cin.tie(0); cout.tie(0);
while (true)
{
enter();
if (n == 0 && m == 0)
break;
solution();
}
return 0;
}
2.2. Bài toán 2
Nguồn bài: https://bit.ly/3AAbtvj.
Tóm tắt đề bài: Cho một đa đồ thị vô hướng gồm đỉnh, cạnh . Độ kết dính giữa một cặp đỉnh bất kỳ là số lượng cạnh mà nếu bỏ đi sẽ khiến cho hai đỉnh này không còn liên thông nữa. Hãy tính tổng độ kết dính của mọi cặp đỉnh?
Ý tưởng:
- Với mọi cặp đỉnh của đồ thị, ta cần quan tâm tới số cạnh cầu trên đường đi từ tới . Những cạnh này khi bỏ đi sẽ làm và mất tính liên thông.
- Đặt là số đỉnh thuộc nhánh gốc . Ta xây dựng trong quá trình dựng cây DFS bằng công thức QHĐ đơn giản: với là con của và nhánh gốc đã duyệt xong.
- Đề bài yêu cầu in ra tổng độ kết dính, tức là tổng số cạnh cầu trên đường đi của mọi cặp đỉnh . Như vậy ta có thể thay đổi cách tính: Với mỗi cạnh cầu của đồ thị, số cặp đỉnh phải đi qua cầu này sẽ là:
(Các đỉnh trong nhánh gốc đi tới các đỉnh không thuộc nhánh gốc )
- Tổng tất cả các giá trị với mọi cạnh cầu sẽ là kết quả bài toán.
Cài đặt:
#include <bits/stdc++.h>
#define int long long
#define task "Weather."
#define inf 1e9 + 7
#define x first
#define y second
using namespace std;
const int maxn = 101;
int res, time_dfs, low[maxn], num[maxn], n_children[maxn];
vector < int > adj[maxN];
void enter()
{
cin >> n >> m;
for (int i = 1; i <= m; ++i)
{
int u, v;
cin >> u >> v;
adj[u].push_back(v);
adj[v].push_back(u);
}
}
void dfs(int u, int par)
{
num[u] = low[u] = ++time_dfs; // Tăng số thứ tự duyệt của đỉnh u.
n_children[u] = 1; // Ban đầu nhánh DFS gốc u chỉ có chính nó.
for (int v: adj[u])
{
if (v == par)
continue;
if (!num[v])
{
dfs(v, u);
low[u] = min(low[u], low[v]);
// Đếm số con nhánh cây DFS gốc u sau khi đã duyệt xong nhánh DFS gốc u.
n_children[u] += n_children[v];
// Nếu cung (u, v) là một cạnh cầu.
if (low[v] == number[v])
res += n_children[v] * (n - n_children[v]);
}
else
{
low[u] = min(low[u], num[v]);
}
}
}
void solution()
{
// Duyệt qua các đỉnh của đồ thị, đỉnh nào chưa thăm thì dựng cây DFS từ đỉnh đó.
for (int u = 1; u <= n; ++u)
if (!number[u])
dfs(u, -1);
cout << res;
}
main()
{
enter();
solution();
return 0;
}
©️ Tác giả: Vũ Quế Lâm từ Viblo
All rights reserved