Chương 3·Bài 38

DFS Tree

Mạng đường của một vùng có những con đường mà nếu sập thì vùng bị cắt đôi, và những thành phố mà nếu mất thì cũng vậy. Tìm chúng bằng cách thử bỏ từng cạnh rồi kiểm tra liên thông thì quá chậm. DFS làm được trong một lượt.

DFS (bài 30) trên đồ thị vô hướng chia cạnh thành hai loại: cạnh cây nối một đỉnh với một đỉnh mới, và cạnh ngược nối một đỉnh với một tổ tiên của nó. Không có cạnh ngang giữa hai nhánh. Nhờ cấu trúc đó, chỉ với thời điểm vào tintin và một giá trị lowlow (đỉnh cao nhất mà một cây con chạm tới bằng một cạnh ngược), ta tìm được mọi cầu (cạnh mà bỏ đi làm đồ thị mất liên thông) và mọi khớp (đỉnh như vậy).

Trên đồ thị có hướng, cùng ý tưởng chia đỉnh thành các thành phần liên thông mạnh, rồi nén mỗi thành phần thành một đỉnh để được một DAG.

Cầu và low

Chạy DFS, gán tinutin_u khi vào uu. Định nghĩa lowulow_u là tintin nhỏ nhất chạm tới được từ cây con của uu bằng cách đi xuống theo cạnh cây rồi dùng tối đa một cạnh ngược. Tính khi DFS lùi về: lowu=min⁡(tinu, tinw với mọi cạnh ngược u-w, lowv với mọi con v)low_u = \min(tin_u,\ tin_w \text{ với mọi cạnh ngược } u \text{-} w,\ low_v \text{ với mọi con } v).

Cạnh cây (u,v)(u, v) (với vv là con) là cầu khi và chỉ khi lowv>tinulow_v > tin_u: cây con của vv không có cạnh ngược nào lên tới uu hay cao hơn, nên bỏ cạnh (u,v)(u, v) thì cây con đó tách ra. Cạnh ngược không bao giờ là cầu vì nó nằm trên một chu trình. Khi đi xuống, bỏ qua đúng cạnh vừa đi tới (theo chỉ số cạnh, không theo đỉnh cha), để cạnh song song tới cha vẫn được tính là cạnh ngược.

Nhãn là tin/low. Cạnh đỏ là cầu, đỉnh cam là khớp; cạnh đậm là cạnh cây DFS, cạnh đứt là cạnh ngược.
C++
void dfs(ll u, ll pe) {
    tin[u] = low[u] = ++timer;
    for (pair<ll, ll> e : g[u]) {
        ll v = e.first, id = e.second;
        if (id == pe) continue;
        if (tin[v]) low[u] = min(low[u], tin[v]);
        else {
            dfs(v, id);
            low[u] = min(low[u], low[v]);
            if (low[v] > tin[u]) isb[id] = 1;
        }
    }
}

Hỏi nhanh

Cạnh cây (u,v)(u, v) với vv là con, tinu=5tin_u = 5 và lowv=5low_v = 5. Cạnh này có phải cầu không?

Ví dụNhững Tuyến Đường Không Thể Thiếu

Đề. Đồ thị vô hướng đơn liên thông n≤105n \le 10^5 đỉnh, m≤2⋅105m \le 2 \cdot 10^5 cạnh. In mọi cầu, đầu nhỏ trước, sắp theo thứ tự từ điển.

Lời giải. DFS tính tin và low như trên. Cạnh cây (u,v)(u, v) là cầu khi low[v] > tin[u]: không cạnh ngược nào từ cây con của vv chạm tới uu hay cao hơn, nên bỏ cạnh đó thì cây con tách ra.

Bước 1: DFS theo chỉ số cạnh. pe là chỉ số cạnh vừa đi xuống uu; bỏ qua đúng cạnh đó, mọi cạnh tới đỉnh đã thăm khác là cạnh ngược và cập nhật low[u] bằng tin[v]. Sau khi gọi xong con vv, low[u] nhận low[v]: cạnh ngược từ cây con của vv cũng là đường vòng của uu.

C++
void dfs(ll u, ll pe) {
    tin[u] = low[u] = ++timer;
    for (pair<ll, ll> e : g[u]) {
        ll v = e.first, id = e.second;
        if (id == pe) continue;
        if (tin[v]) low[u] = min(low[u], tin[v]);
        else {
            dfs(v, id);
            low[u] = min(low[u], low[v]);
            if (low[v] > tin[u]) isb[id] = 1;
        }
    }
}

Bước 2: gom và sắp. Đưa đầu nhỏ lên trước rồi sắp theo cặp để in đúng thứ tự đề đòi.

C++
for (ll i = 1; i <= m; i++)
    if (isb[i]) res.push_back({min(eu[i], ev[i]), max(eu[i], ev[i])});
sort(res.begin(), res.end());

Đệ quy sâu tới 10510^5 tầng; thường vẫn chạy được với ngăn xếp mặc định của trình chấm, nhưng đồ thị lớn hơn thì nên viết bằng ngăn xếp tường minh như bài 76.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 1e5 + 5, M = 2e5 + 5;
ll n, m, timer, tin[N], low[N], eu[M], ev[M], isb[M];
vector<pair<ll, ll>> g[N];

void dfs(ll u, ll pe) {
    tin[u] = low[u] = ++timer;
    for (pair<ll, ll> e : g[u]) {
        ll v = e.first, id = e.second;
        if (id == pe) continue;
        if (tin[v]) low[u] = min(low[u], tin[v]);
        else {
            dfs(v, id);
            low[u] = min(low[u], low[v]);
            if (low[v] > tin[u]) isb[id] = 1;
        }
    }
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> m;
    for (ll i = 1; i <= m; i++) {
        cin >> eu[i] >> ev[i];
        g[eu[i]].push_back({ev[i], i});
        g[ev[i]].push_back({eu[i], i});
    }
    dfs(1, 0);
    vector<pair<ll, ll>> res;
    for (ll i = 1; i <= m; i++)
        if (isb[i]) res.push_back({min(eu[i], ev[i]), max(eu[i], ev[i])});
    sort(res.begin(), res.end());
    cout << res.size() << '\n';
    for (pair<ll, ll> p : res) cout << p.first << ' ' << p.second << '\n';
}
6 7
1 2
2 3
3 1
3 4
4 5
5 6
6 4

Độ phức tạp: O(n+m)O(n + m) cộng phần sắp kết quả.

Cách ngây thơ là bỏ từng cạnh rồi kiểm liên thông bằng một lượt DFS:

Đếm cầu của đồ thị 8000 đỉnh, 10 nghìn cạnh, trung vị 5 lần chạy, i7-10850H, g++ -O2.DFS với low0,5 msbỏ từng cạnh rồi DFS2357 ms · gấp 4714 lần
Tìm cầu trong đồ thị 8000 đỉnh, 10 nghìn cạnh, đo trên i7-10850H, g++ -O2.

DFS với lowlow nhanh hơn cỡ hàng nghìn lần, và khoảng cách còn tăng theo cỡ đồ thị vì một bên O(n+m)O(n + m), bên kia O(m(n+m))O(m(n + m)).

Khớp

Đỉnh uu không phải gốc là khớp khi và chỉ khi nó có một con vv với lowv≥tinulow_v \ge tin_u: cây con của vv không có cạnh ngược nào vượt lên trên uu, nên bỏ uu thì cây con đó tách khỏi phần phía trên. Lần này dấu bằng được phép, vì cạnh ngược về chính uu cũng mất khi bỏ uu.

Gốc không có phía trên, nên cần quy tắc riêng: gốc là khớp khi và chỉ khi nó có ít nhất hai con trong cây DFS. Các cây con của gốc không nối với nhau bằng cạnh ngang (DFS không có cạnh ngang), nên bỏ gốc thì chúng tách rời.

Với đồ thị nhiều thành phần, gọi DFS từ mọi đỉnh chưa thăm, mỗi lần là một gốc mới. Hai điều kiện cầu và khớp có thể tính trong cùng một vòng, và chỉ khác nhau ở dấu: > cho cầu, >= cho khớp.

Ví dụNhững Thành Phố Trọng Yếu

Đề. Đồ thị vô hướng đơn liên thông n≤105n \le 10^5 đỉnh, m≤2⋅105m \le 2 \cdot 10^5 cạnh. In số khớp và các khớp tăng dần.

Lời giải. Hai quy tắc khớp như trên. Đỉnh không phải gốc là khớp khi có con vv với low[v] >= tin[u]: dấu bằng được phép vì cạnh ngược chạm đúng uu cũng mất khi bỏ uu. Gốc là khớp khi có từ hai con trở lên trong cây DFS. Đồ thị đơn nên bỏ qua đỉnh cha là đủ; có cạnh song song thì phải bỏ theo chỉ số cạnh như ví dụ trước.

Bước 1: DFS có đếm con. child đếm số con trong cây DFS, chỉ dùng cho gốc; kiểm quy tắc gốc sau khi duyệt hết hàng xóm.

C++
void dfs(ll u, ll p) {
    tin[u] = low[u] = ++timer;
    ll child = 0;
    for (ll v : g[u]) {
        if (v == p) continue;
        if (tin[v]) low[u] = min(low[u], tin[v]);
        else {
            dfs(v, u);
            low[u] = min(low[u], low[v]);
            child++;
            if (p && low[v] >= tin[u]) cut[u] = 1;
        }
    }
    if (!p && child >= 2) cut[u] = 1;
}

Bước 2: in các khớp tăng dần. Duyệt u=1..nu = 1..n thì tự tăng dần.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 1e5 + 5;
ll n, m, timer, tin[N], low[N], cut[N];
vector<ll> g[N];

void dfs(ll u, ll p) {
    tin[u] = low[u] = ++timer;
    ll child = 0;
    for (ll v : g[u]) {
        if (v == p) continue;
        if (tin[v]) low[u] = min(low[u], tin[v]);
        else {
            dfs(v, u);
            low[u] = min(low[u], low[v]);
            child++;
            if (p && low[v] >= tin[u]) cut[u] = 1;
        }
    }
    if (!p && child >= 2) cut[u] = 1;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> m;
    for (ll i = 1; i <= m; i++) {
        ll u, v;
        cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);
    }
    dfs(1, 0);
    vector<ll> res;
    for (ll u = 1; u <= n; u++)
        if (cut[u]) res.push_back(u);
    cout << res.size() << '\n';
    for (ll u : res) cout << u << ' ';
    cout << '\n';
}
6 7
1 2
2 3
3 1
3 4
4 5
5 6
6 4

Độ phức tạp: O(n+m)O(n + m).

Liên thông mạnh

Trên đồ thị có hướng, hai đỉnh liên thông mạnh khi mỗi đỉnh đi tới được đỉnh kia. Quan hệ này chia đỉnh thành các thành phần liên thông mạnh (strongly connected component, SCC). Thuật toán Kosaraju dùng hai lần DFS:

  1. DFS trên đồ thị gốc, ghi các đỉnh theo thứ tự xong (đã duyệt hết các đỉnh đi ra).
  2. DFS trên đồ thị ngược, lấy đỉnh theo thứ tự xong giảm dần; mỗi lần gọi từ ngoài thu được đúng một SCC.

Đỉnh xong muộn nhất thuộc một SCC “nguồn” của đồ thị nén. Trên đồ thị ngược, từ nó chỉ tới được các đỉnh trong chính SCC đó, vì các cung vào SCC nguồn trong đồ thị gốc không tồn tại. Lấy SCC đó ra rồi lặp lại với phần còn lại. Tổng O(n+m)O(n + m). Thuật toán Tarjan làm trong một lần DFS bằng ngăn xếp và lowlow, cho các SCC theo thứ tự tô pô ngược; Kosaraju cho chúng theo thứ tự tô pô xuôi.

Mỗi màu là một thành phần liên thông mạnh; số # là thứ tự tìm ra ở lượt hai.

Hỏi nhanh

Trong Kosaraju, nếu ở lần hai ta duyệt các đỉnh theo thứ tự xong tăng dần thay vì giảm dần, chuyện gì có thể xảy ra?

Ví dụPhân Vùng Tương Hỗ

Đề. Đồ thị có hướng n≤105n \le 10^5 đỉnh, m≤2⋅105m \le 2 \cdot 10^5 cung. In số SCC và nhãn SCC của từng đỉnh.

Lời giải. Kosaraju, hai lần DFS. Lần một trên đồ thị gốc ghi thứ tự xong. Lần hai duyệt theo thứ tự xong giảm dần trên đồ thị ngược: mỗi đỉnh chưa có nhãn mở một SCC mới, và DFS trên đồ thị ngược từ nó chỉ lan được trong đúng SCC đó, vì các SCC tới được từ nó trên đồ thị ngược đều đã có nhãn từ trước.

Bước 1: đồ thị gốc và đồ thị ngược. Dựng cùng lúc khi đọc cung.

C++
g[u].push_back(v);
rg[v].push_back(u);

Bước 2: thứ tự xong. dfs1 đẩy uu vào ord khi đã duyệt xong mọi đỉnh đi ra từ nó; gọi từ mọi đỉnh chưa thăm.

C++
void dfs1(ll u) {
    vis[u] = 1;
    for (ll v : g[u])
        if (!vis[v]) dfs1(v);
    ord.push_back(u);
}

Bước 3: gán nhãn trên đồ thị ngược. comp dùng 0 làm “chưa có nhãn”, nên nhãn bắt đầu từ 1.

C++
void dfs2(ll u) {
    comp[u] = k;
    for (ll v : rg[u])
        if (!comp[v]) dfs2(v);
}
C++
for (ll i = n - 1; i >= 0; i--)
    if (!comp[ord[i]]) {
        k++;
        dfs2(ord[i]);
    }
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 1e5 + 5;
ll n, m, k, vis[N], comp[N];
vector<ll> g[N], rg[N], ord;

void dfs1(ll u) {
    vis[u] = 1;
    for (ll v : g[u])
        if (!vis[v]) dfs1(v);
    ord.push_back(u);
}

void dfs2(ll u) {
    comp[u] = k;
    for (ll v : rg[u])
        if (!comp[v]) dfs2(v);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> m;
    for (ll i = 1; i <= m; i++) {
        ll u, v;
        cin >> u >> v;
        g[u].push_back(v);
        rg[v].push_back(u);
    }
    for (ll u = 1; u <= n; u++)
        if (!vis[u]) dfs1(u);
    for (ll i = n - 1; i >= 0; i--)
        if (!comp[ord[i]]) {
            k++;
            dfs2(ord[i]);
        }
    cout << k << '\n';
    for (ll u = 1; u <= n; u++) cout << comp[u] << ' ';
    cout << '\n';
}
6 7
1 2
2 3
3 1
3 4
4 5
5 4
5 6

Độ phức tạp: O(n+m)O(n + m).

Nén đồ thị

Nén mỗi SCC thành một đỉnh và giữ các cung nối hai SCC khác nhau: được một DAG, vì một chu trình giữa các SCC sẽ gộp chúng thành một. Nhiều bài trên đồ thị có hướng tổng quát trở thành bài trên DAG: số đỉnh nguồn cần chọn để tới được mọi nơi là số SCC có bậc vào 0; đường thu nhiều giá trị nhất là đường dài nhất trên DAG với trọng số SCC bằng tổng các đỉnh trong đó. Nhãn của Kosaraju đã theo thứ tự tô pô, nên quy hoạch động chỉ cần duyệt nhãn theo thứ tự ngược.

Ví dụHành Trình Thu Thập

Đề. Đồ thị có hướng n≤105n \le 10^5 đỉnh có giá trị dương, m≤2⋅105m \le 2 \cdot 10^5 cung. Đi từ đỉnh tùy ý theo các cung, mỗi đỉnh thu giá trị một lần. Tìm tổng lớn nhất.

Lời giải. Trong một SCC đi được hết mọi đỉnh rồi rời đi, nên mỗi SCC có giá trị bằng tổng các đỉnh của nó, và bài thành tìm đường có tổng lớn nhất trên đồ thị nén (không chu trình). Kosaraju gán nhãn theo thứ tự tô pô xuôi: mọi cung nén đi từ nhãn nhỏ sang nhãn lớn, nên quy hoạch động duyệt nhãn từ lớn về nhỏ.

Bước 1: Kosaraju như các ví dụ trước.

Bước 2: dựng đồ thị nén. val[c] là tổng giá trị các đỉnh của SCC cc; dg[c] là các SCC kề sau cc. Có thể có cung lặp, không sao vì chỉ lấy max.

C++
for (ll u = 1; u <= n; u++) {
    val[comp[u]] += w[u];
    for (ll v : g[u])
        if (comp[u] != comp[v]) dg[comp[u]].push_back(comp[v]);
}

Bước 3: đường tổng lớn nhất. dp[c] là tổng lớn nhất của một đường bắt đầu ở SCC cc; duyệt cc từ kk về 1 thì dp của các SCC kề sau đã xong. Đáp án là max mọi dp[c], vì được xuất phát ở đỉnh tùy ý.

C++
for (ll c = k; c >= 1; c--) {
    dp[c] = 0;
    for (ll d : dg[c]) dp[c] = max(dp[c], dp[d]);
    dp[c] += val[c];
    ans = max(ans, dp[c]);
}
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 1e5 + 5;
ll n, m, k, w[N], vis[N], comp[N], val[N], dp[N];
vector<ll> g[N], rg[N], dg[N], ord;

void dfs1(ll u) {
    vis[u] = 1;
    for (ll v : g[u])
        if (!vis[v]) dfs1(v);
    ord.push_back(u);
}

void dfs2(ll u) {
    comp[u] = k;
    for (ll v : rg[u])
        if (!comp[v]) dfs2(v);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> m;
    for (ll i = 1; i <= n; i++) cin >> w[i];
    for (ll i = 1; i <= m; i++) {
        ll u, v;
        cin >> u >> v;
        g[u].push_back(v);
        rg[v].push_back(u);
    }
    for (ll u = 1; u <= n; u++)
        if (!vis[u]) dfs1(u);
    for (ll i = n - 1; i >= 0; i--)
        if (!comp[ord[i]]) {
            k++;
            dfs2(ord[i]);
        }
    for (ll u = 1; u <= n; u++) {
        val[comp[u]] += w[u];
        for (ll v : g[u])
            if (comp[u] != comp[v]) dg[comp[u]].push_back(comp[v]);
    }
    ll ans = 0;
    for (ll c = k; c >= 1; c--) {
        dp[c] = 0;
        for (ll d : dg[c]) dp[c] = max(dp[c], dp[d]);
        dp[c] += val[c];
        ans = max(ans, dp[c]);
    }
    cout << ans << '\n';
}
6 7
4 2 7 3 5 1
1 2
2 3
3 1
3 4
4 5
5 4
5 6

Độ phức tạp: O(n+m)O(n + m).

Định hướng cạnh

Đồ thị vô hướng liên thông định hướng được thành đồ thị liên thông mạnh khi và chỉ khi nó không có cầu (định lý Robbins). Một cầu, theo hướng nào, cũng chỉ cho đi một chiều giữa hai phía. Ngược lại, khi không có cầu, định hướng theo DFS: cạnh cây từ cha xuống con, cạnh ngược từ con cháu lên tổ tiên. Từ gốc đi xuống được mọi đỉnh; từ mỗi đỉnh, cây con của nó luôn có một cạnh ngược vượt lên trên (lowv<tinvlow_v < tin_v vì không có cầu), nên từng bước leo được về gốc.

Ví dụĐịnh Hướng Bền Vững

Đề. Đồ thị vô hướng đơn liên thông n≤105n \le 10^5 đỉnh, m≤3⋅105m \le 3 \cdot 10^5 cạnh. Định hướng mọi cạnh để được đồ thị liên thông mạnh, in hướng theo thứ tự cạnh; không được thì in 0.

Lời giải. Định lý Robbins: đồ thị vô hướng liên thông định hướng được thành liên thông mạnh khi và chỉ khi nó không có cầu. Cách định hướng lấy thẳng từ cây DFS: cạnh cây hướng từ cha xuống con, cạnh ngược hướng từ đỉnh dưới lên tổ tiên. Không có cầu thì từ gốc đi xuống được mọi đỉnh theo cạnh cây, và từ mỗi đỉnh leo về được gốc bằng các cạnh ngược, nên đồ thị liên thông mạnh.

Bước 1: định hướng lúc xét cạnh lần đầu. Mỗi cạnh xuất hiện ở hai danh sách kề; used[id] cho biết đã định hướng chưa, lần đầu xét thì ghi u → v với uu là đỉnh đang đứng. Lần đầu xét cạnh tới vv chưa thăm là cạnh cây, đúng hướng cha xuống con. Lần đầu xét cạnh tới vv đã thăm thì vv là tổ tiên còn trên ngăn xếp (nếu vv là con cháu đã xong thì cạnh đã được xét từ phía vv), nên hướng đi lên tổ tiên.

C++
if (!used[id]) {
    used[id] = 1;
    da[id] = u;
    db[id] = v;
}

Bước 2: tìm cầu trong cùng lượt DFS. Có cầu thì không định hướng được, in 0.

C++
if (tin[v]) low[u] = min(low[u], tin[v]);
else {
    dfs(v, id);
    low[u] = min(low[u], low[v]);
    if (low[v] > tin[u]) bad = 1;
}
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 1e5 + 5, M = 3e5 + 5;
ll n, m, timer, tin[N], low[N], da[M], db[M], used[M];
bool bad;
vector<pair<ll, ll>> g[N];

void dfs(ll u, ll pe) {
    tin[u] = low[u] = ++timer;
    for (pair<ll, ll> e : g[u]) {
        ll v = e.first, id = e.second;
        if (id == pe) continue;
        if (!used[id]) {
            used[id] = 1;
            da[id] = u;
            db[id] = v;
        }
        if (tin[v]) low[u] = min(low[u], tin[v]);
        else {
            dfs(v, id);
            low[u] = min(low[u], low[v]);
            if (low[v] > tin[u]) bad = 1;
        }
    }
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> m;
    for (ll i = 1; i <= m; i++) {
        ll u, v;
        cin >> u >> v;
        g[u].push_back({v, i});
        g[v].push_back({u, i});
    }
    dfs(1, 0);
    if (bad) {
        cout << 0 << '\n';
        return 0;
    }
    for (ll i = 1; i <= m; i++) cout << da[i] << ' ' << db[i] << '\n';
}
5 6
1 2
2 3
3 1
3 4
4 5
5 3

Độ phức tạp: O(n+m)O(n + m).

Khi đi thi

  • “Bỏ một cạnh / một đỉnh thì còn liên thông không”: cầu và khớp bằng tintin, lowlow. Có cạnh song song thì bỏ qua theo chỉ số cạnh, không theo đỉnh cha.
  • Đồ thị có hướng có chu trình mà bài lại cần DAG (quy hoạch động, đếm nguồn, đường dài nhất): nén SCC trước. Kosaraju cho nhãn theo thứ tự tô pô.
  • DFS đệ quy sâu tới nn tầng; n=105n = 10^5 thường ổn trên máy chấm Linux, 10610^6 thì nên tự giữ ngăn xếp.

Tóm tắt

  • DFS vô hướng chỉ có cạnh cây và cạnh ngược; lowulow_u là tintin nhỏ nhất chạm được từ cây con qua một cạnh ngược.
  • Cầu: cạnh cây (u,v)(u, v) với lowv>tinulow_v > tin_u; khớp: con vv với lowv≥tinulow_v \ge tin_u (không phải gốc), hoặc gốc có ít nhất hai con.
  • Kosaraju: thứ tự xong trên đồ thị gốc, rồi DFS trên đồ thị ngược theo thứ tự xong giảm dần; mỗi lượt một SCC, theo thứ tự tô pô.
  • Nén SCC cho DAG: SCC nguồn, đường dài nhất, đếm.
  • Không có cầu thì định hướng theo DFS cho đồ thị liên thông mạnh.

Bài tập

Đọc thêm