Tuyển sinh 🎓 Mình nhận đào tạo lập trình giải thuật thi đấu: thi vào lớp 10 chuyên tin · thi học sinh giỏi tin học cấp tỉnh/thành · thi vào đội tuyển quốc gia · Olympic tin học sinh viên / ICPC · phỏng vấn thuật toán. Bấm vào đây để xem thông tin khóa học → Xem khóa học
Thi thử miễn phí
🏆 Thi thử HSG Tin học tuần này trên ClueOJ
Mở công khai cho mọi người · đề do thầy Nguyễn Việt Nam Sơn biên soạn
Lịch thi, đề cũ & lời giải →

Bài 36: Thành phần liên thông mạnh, đỉnh khớp và cầu trong C++ và Python: num/low, Tarjan, Kosaraju

Bài 36: Thành phần liên thông mạnh, đỉnh khớp và cầu trong C++ và Python: num/low, Tarjan, Kosaraju
Series: Thuật toán chuyên sâu Độ khó: Khá Bài 36 / Thuật toán chuyên sâu Đồ thị DFS Tarjan
// Thuật toán chuyên sâu · Bài 36
Thành phần liên thông mạnh, đỉnh khớp và cầu
Chỉ với một lần DFS và hai con số num, low cho mỗi đỉnh, ta biết ngay con đường nào cắt đi sẽ chia đôi thành phố, trạm nào hỏng sẽ làm mạng mất liên lạc, và những nhóm đỉnh nào đi qua lại được với nhau.

Lời mở đầu

Ở bài 21 em đã dùng DFS để đếm thành phần liên thông: hai đỉnh cùng thành phần nếu đi được tới nhau. Nhưng đề thi HSG thường hỏi sâu hơn một bậc: “con đường nào mà bị sập thì mạng lưới bị chia cắt?”, “thành phố nào là điểm trung chuyển sống còn?”, hay với đường một chiều: “những nút nào đi qua đi lại được với nhau?”. Thử xoá từng cạnh rồi DFS lại thì tốn $O(m(n + m))$, quá chậm với $10^5$ cạnh.

Bài này dạy em một ý tưởng rất đẹp của Robert Tarjan: trong lúc DFS, ghi cho mỗi đỉnh thời điểm thăm num[u] và đỉnh thăm sớm nhất mà cây con của nó “với” tới được low[u]. Từ hai mảng ấy suy ra cùng lúc cầu, khớp và thành phần liên thông mạnh trong $O(n + m)$. Em nên ôn lại cây DFS ở bài 33 và đồ thị không có chu trình (DAG) ở bài 35, vì cuối bài ta sẽ nén mỗi thành phần liên thông mạnh thành một đỉnh và nhận được đúng một DAG để quy hoạch động.

Trong bài này bạn sẽ học: phân loại cạnh trên cây DFS (cạnh cây, cạnh ngược); ý nghĩa của num[u] và low[u]; điều kiện nhận biết cầu và đỉnh khớp, hai cái bẫy “gốc DFS” và “cạnh song song”; thuật toán Tarjan tìm thành phần liên thông mạnh với một ngăn xếp; thuật toán Kosaraju hai lần DFS; đồ thị nén là một DAG và các ứng dụng: thêm ít cung nhất, chọn ít đỉnh xuất phát nhất, giới thiệu 2-SAT; cách viết DFS không đệ quy trong Python. Có 2 khung chạy tay, 3 mô phỏng từng bước và 8 bài tập kèm gợi ý.

Lộ trình bài viết, bấm vào từng phần để nhảy tới:

Phần 1Cây DFS, cạnh ngược, num[u] và low[u]

Chạy DFS trên đồ thị vô hướng liên thông, mỗi lần đi từ u sang một đỉnh v chưa thăm ta được một cạnh cây. Các cạnh cây tạo thành cây DFS có gốc là đỉnh xuất phát. Mọi cạnh còn lại gọi là cạnh ngược: chúng luôn nối một đỉnh với một tổ tiên của nó trên cây. Đồ thị vô hướng không có “cạnh ngang” nối hai nhánh khác nhau, vì nếu có cạnh $u$–$v$ mà $v$ chưa thăm lúc đang ở $u$ thì DFS đã đi sang $v$ ngay và biến nó thành cạnh cây rồi.

Ta ghi thêm hai số cho mỗi đỉnh:

  • num[u]: thời điểm thăm $u$ (đỉnh thứ mấy được DFS chạm tới). Tổ tiên luôn có num nhỏ hơn con cháu.
  • low[u]: num nhỏ nhất mà từ cây con gốc $u$ “với” tới được, khi được đi xuống tuỳ ý theo cạnh cây rồi dùng tối đa một cạnh ngược. Nói nôm na: low[u] cho biết cây con của $u$ có đường vòng leo lên cao tới đâu mà không cần đi qua cạnh nối $u$ với cha.

Công thức tính low trong lúc DFS rất gọn:

$\text{low}[u] = \min\big(\text{num}[u],\ \text{num}[v] \text{ với mọi cạnh ngược } u\text{–}v,\ \text{low}[c] \text{ với mọi con } c \text{ của } u\big)$

123456781/12/13/14/45/46/47/78/8

Hình 1. DFS từ đỉnh 1 (xét kề theo thứ tự nhập). Tím đậm: cạnh cây, nét đứt cam: cạnh ngược 3–1 và 6–4. Số cạnh mỗi đỉnh là num/low.

ĐỉnhnumCách tính lowlow
88không có con, không có cạnh ngược8
77min(7, low[8] = 8)7
66cạnh ngược 6–4: min(6, num[4] = 4)4
55min(5, low[6] = 4, low[7] = 7)4
44min(4, low[5] = 4)4
33cạnh ngược 3–1: min(3, num[1] = 1, low[4] = 4)1
22min(2, low[3] = 1)1
11gốc1

Bảng 1. Tính low từ dưới lên (đỉnh duyệt xong trước được tính trước). Ô xanh: low nhỏ hơn num, tức cây con có đường vòng leo lên trên.

Hiểu low[u] bằng một câu
low[u] < num[u] nghĩa là: kể cả khi bỏ cạnh nối $u$ với cha, cả khối “$u$ và con cháu” vẫn còn đường khác nối lên phía trên. low[u] = num[u] nghĩa là khối đó chỉ còn treo lên phần trên đúng bằng cạnh cha. Toàn bộ bài này xoay quanh việc so sánh low của con với num của cha.

Phần 2Cầu và đỉnh khớp trên đồ thị vô hướng

Cầu là cạnh mà xoá đi thì số thành phần liên thông tăng lên. Đỉnh khớp (khớp) là đỉnh mà xoá đi (cùng các cạnh của nó) thì số thành phần liên thông tăng lên. Với cạnh cây $u$–$c$ ($u$ là cha của $c$):

  • $u$–$c$ là cầu khi và chỉ khi low[c] > num[u]: cây con gốc $c$ không có cạnh ngược nào lên tới $u$ hay cao hơn, nên cắt $u$–$c$ là nó rời ra. (Cạnh ngược không bao giờ là cầu, vì nó nằm trên một chu trình.)
  • $u$ (không phải gốc) là khớp khi có ít nhất một con $c$ với low[c] ≥ num[u]: cây con của $c$ leo cao nhất cũng chỉ tới $u$, bỏ $u$ đi là nó rời khỏi phần phía trên.
  • Gốc DFS là khớp khi và chỉ khi nó có từ 2 con trở lên trong cây DFS (các nhánh con không có cạnh ngang nối nhau, chỉ gặp nhau ở gốc).

Áp dụng vào Hình 1: cạnh 3–4 có low[4] = 4 > num[3] = 3 nên là cầu; 5–7 có low[7] = 7 > 5 và 7–8 có low[8] = 8 > 7 cũng là cầu. Còn 4–5 thì low[5] = 4 không lớn hơn num[4] = 4: cắt 4–5 vẫn còn đường 4–6–5. Về khớp: 3 (vì low[4] = 4 ≥ 3), 4 (low[5] = 4 ≥ 4), 5 (low[7] = 7 ≥ 5), 7 (low[8] = 8 ≥ 7). Gốc 1 chỉ có một con là 2 nên không là khớp.

123456781/12/13/14/45/46/47/78/8

Hình 2. Ba cầu (đỏ đậm) và bốn khớp (viền đỏ) của đồ thị ở Hình 1. Để ý 4 là khớp dù cạnh 4–5 không phải cầu: điều kiện khớp dùng ≥, điều kiện cầu dùng >.

Đọc tới đây chắc em vẫn thấy hơi rối: low thay đổi lúc nào, khi nào thì so sánh? Em bấm Tiếp từng bước trong mô phỏng dưới đây, xem num/low của từng đỉnh được điền dần, rồi thử thêm một cạnh (ví dụ 2 4) để thấy cầu 3–4 biến mất:

MÔ PHỎNGnum/low trên cây DFS: tìm cầu và khớpNhập n (≤ 10) và các cạnh vô hướng "u v". Cạnh đôi (lặp lại) được phép. Cạnh đỉnh ghi num/low.
đỉnh đang xétđang trong lời gọi đệ quyđã duyệt xongcầu / khớp (viền đỏ)chưa thăm
Xem code C++ / Python: tìm cầu và khớp bằng num/low (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int n, m, timer_ = 0;
int num[100005], low[100005], child[100005];
bool isCut[100005];
vector<pair<int, int>> adj[100005];       // (đỉnh kề, chỉ số cạnh)
vector<pair<int, int>> bridges;

void dfs(int u, int pe){                  // pe: chỉ số cạnh cha (-1 nếu u là gốc)
    num[u] = low[u] = ++timer_;
    for(auto &e : adj[u]){                // e = (đỉnh kề, chỉ số cạnh)
        int v = e.first;
        if(e.second == pe) continue;      // bỏ đúng cạnh cha, KHÔNG bỏ theo đỉnh
        if(num[v]){                       // cạnh ngược
            low[u] = min(low[u], num[v]);
        } else {                          // cạnh cây
            child[u]++;
            dfs(v, e.second);
            low[u] = min(low[u], low[v]);
            if(low[v] > num[u]) bridges.push_back({min(u, v), max(u, v)});
            if(pe != -1 && low[v] >= num[u]) isCut[u] = true;
        }
    }
    if(pe == -1 && child[u] >= 2) isCut[u] = true;   // gốc: khớp khi có từ 2 con
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    cin >> n >> m;
    for(int i = 0; i < m; i++){
        int u, v;
        cin >> u >> v;
        adj[u].push_back({v, i}); adj[v].push_back({u, i});
    }
    for(int u = 1; u <= n; u++) if(!num[u]) dfs(u, -1);
    vector<int> cuts;
    for(int u = 1; u <= n; u++) if(isCut[u]) cuts.push_back(u);
    cout << cuts.size();
    for(int u : cuts) cout << " " << u;
    sort(bridges.begin(), bridges.end());
    cout << "\n" << bridges.size() << "\n";
    for(auto b : bridges) cout << b.first << " " << b.second << "\n";
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    adj = [[] for _ in range(n + 1)]              # (đỉnh kề, chỉ số cạnh)
    for i in range(m):
        u, v = int(data[2 + 2 * i]), int(data[3 + 2 * i])
        adj[u].append((v, i)); adj[v].append((u, i))
    num = [0] * (n + 1); low = [0] * (n + 1)
    pe = [-1] * (n + 1)                           # chỉ số cạnh cha
    par = [0] * (n + 1)
    it = [0] * (n + 1)                            # đã xét tới cạnh thứ mấy của u
    is_cut = [False] * (n + 1)
    bridges = []
    timer = 0
    for s in range(1, n + 1):
        if num[s]:
            continue
        timer += 1; num[s] = low[s] = timer
        root_child = 0
        stack = [s]                               # ngăn xếp thay cho đệ quy
        while stack:
            u = stack[-1]
            if it[u] < len(adj[u]):               # còn cạnh chưa xét: xét tiếp 1 cạnh
                v, idx = adj[u][it[u]]
                it[u] += 1
                if idx == pe[u]:
                    continue
                if num[v]:                        # cạnh ngược
                    if num[v] < low[u]:
                        low[u] = num[v]
                else:                             # cạnh cây: "gọi đệ quy" = đẩy v vào
                    timer += 1; num[v] = low[v] = timer
                    pe[v] = idx; par[v] = u
                    stack.append(v)
            else:                                 # u xong: "trả về" cho cha
                stack.pop()
                if stack:
                    p = par[u]
                    if low[u] < low[p]:
                        low[p] = low[u]
                    if low[u] > num[p]:
                        bridges.append((min(u, p), max(u, p)))
                    if p == s:
                        root_child += 1
                    elif low[u] >= num[p]:
                        is_cut[p] = True
        if root_child >= 2:
            is_cut[s] = True
    cuts = [u for u in range(1, n + 1) if is_cut[u]]
    bridges.sort()
    out = [" ".join(map(str, [len(cuts)] + cuts)), str(len(bridges))]
    out += ["%d %d" % b for b in bridges]
    print("\n".join(out))

main()

Phần quan trọng nhất là hàm dfs. Em chạy tay từng dòng với đồ thị Hình 1, để ý mảng low thay đổi ngay sau mỗi cạnh ngược và mỗi lần quay về từ con:

C++▶ Chạy tay từng dòng codedfs(u, pe): pe là chỉ số cạnh cha. Ô vàng: đỉnh u đang xét, ô đỏ: đỉnh đã được xác định là khớp.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int n, m, timer_ = 0;
5int num[100005], low[100005];
6bool isCut[100005];
7vector<pair<int, int>> adj[100005]; // (đỉnh kề, chỉ số cạnh)
8vector<pair<int, int>> bridges;
9
10void dfs(int u, int pe){
11 num[u] = low[u] = ++timer_;
12 int child = 0;
13 for(auto e : adj[u]){
14 int v = e.first, id = e.second;
15 if(id == pe) continue;
16 if(num[v]){
17 low[u] = min(low[u], num[v]);
18 } else {
19 child++;
20 dfs(v, id);
21 low[u] = min(low[u], low[v]);
22 if(low[v] > num[u]) bridges.push_back({min(u, v), max(u, v)});
23 if(pe != -1 && low[v] >= num[u]) isCut[u] = true;
24 }
25 }
26 if(pe == -1 && child >= 2) isCut[u] = true;
27}
28
29int main(){
30 cin >> n >> m;
31 for(int i = 0; i < m; i++){
32 int u, v;
33 cin >> u >> v;
34 adj[u].push_back({v, i}); adj[v].push_back({u, i});
35 }
36 for(int u = 1; u <= n; u++) if(!num[u]) dfs(u, -1);
37 vector<int> cuts;
38 for(int u = 1; u <= n; u++) if(isCut[u]) cuts.push_back(u);
39 cout << cuts.size();
40 for(int u : cuts) cout << " " << u;
41 sort(bridges.begin(), bridges.end());
42 cout << "\n" << bridges.size() << "\n";
43 for(auto b : bridges) cout << b.first << " " << b.second << "\n";
44 return 0;
45}
Hai cái bẫy kinh điển
1. Gốc DFS. Với gốc, điều kiện low[c] ≥ num[gốc] luôn đúng (không có gì thăm sớm hơn gốc), nên nếu áp dụng điều kiện thường thì gốc luôn bị tính là khớp. Phải xét riêng: gốc là khớp khi có ≥ 2 con.
2. Cạnh song song. Nhiều bạn bỏ qua cha bằng if(v == parent) continue;. Nếu giữa $u$ và cha có hai cạnh thì cạnh thứ hai thực chất là một cạnh ngược, nhưng cách viết trên bỏ luôn nó, và cạnh đôi bị báo nhầm là cầu. Cách đúng: lưu chỉ số cạnh và chỉ bỏ đúng cạnh vừa đi xuống (id == pe). Với khớp thì cạnh song song không ảnh hưởng, nhưng cứ dùng chỉ số cạnh cho chắc.

DFS trong Python: vì sao phải khử đệ quy? Python mặc định chỉ cho đệ quy sâu khoảng 1000 tầng. Đồ thị là một đường thẳng $10^5$ đỉnh thì DFS phải sâu $10^5$ tầng, chương trình chết với lỗi RecursionError. Tăng sys.setrecursionlimit thôi chưa đủ: ngăn xếp thật của hệ điều hành vẫn bị tràn và chương trình bị “crash” không báo lỗi. Có hai cách chữa. Cách thứ nhất: tăng giới hạn và chạy trong một luồng có ngăn xếp lớn:

import sys, threading

def main():
    sys.setrecursionlimit(1 << 20)        # cho phép đệ quy rất sâu...
    # ... đọc dữ liệu, gọi dfs(u) đệ quy như bản C++ ...

threading.stack_size(1 << 27)             # ...và cấp cho luồng mới 128 MB ngăn xếp
threading.Thread(target=main).start()

Cách này tiện nhưng chậm và một số máy chấm không cho tạo luồng. Cách thứ hai, an toàn nhất, là tự quản lý ngăn xếp như code Python ở trên. Mẹo nằm ở mảng it[u]: “đỉnh u đã xét tới cạnh thứ mấy”. Mỗi vòng lặp chỉ nhìn đỉnh trên cùng của ngăn xếp:

  • nếu nó còn cạnh chưa xét thì xét một cạnh; gặp đỉnh chưa thăm thì đẩy vào (tương đương gọi dfs(v));
  • nếu đã hết cạnh thì bật ra và cập nhật cho cha low[cha] = min(low[cha], low[u]) (tương đương trả về từ lời gọi đệ quy).
Khuôn mẫu DFS không đệ quy
Phần “sau khi gọi đệ quy xong” của bản C++ (cập nhật low cho cha, kiểm tra cầu/khớp, cộng kích thước cây con…) phải được chuyển xuống nhánh bật đỉnh khỏi ngăn xếp. Viết theo khuôn này thì mọi thuật toán num/low trong bài đều chạy được với $n = 10^6$ trong Python. Với C++, đệ quy sâu cỡ $10^5$ chạy tốt trên các máy chấm cấp ngăn xếp lớn (Codeforces, VNOJ…), nhưng với ngăn xếp mặc định 8 MB của Linux thì hàm dfs tìm cầu ở trên đã tràn ngăn xếp khi đồ thị là một đường thẳng $10^5$ đỉnh (thầy đã thử). Khi không chắc máy chấm, em khử đệ quy theo đúng khuôn này cho cả C++.

Phần 3Thành phần liên thông mạnh: thuật toán Tarjan

Trên đồ thị có hướng, hai đỉnh $u$, $v$ thuộc cùng một thành phần liên thông mạnh (TPLT mạnh, tiếng Anh SCC) nếu đi được từ $u$ tới $v$ và từ $v$ về $u$. Mỗi đỉnh thuộc đúng một TPLT mạnh; một đỉnh không nằm trên chu trình nào thì tự nó là một TPLT mạnh. Hình 3 có bốn thành phần: {1, 2, 3}, {4, 5, 6}, {7}, {8}. Đỉnh 7 đi được tới 8 nhưng 8 không quay về được, nên chúng ở hai thành phần khác nhau.

123456781/12/13/14/45/56/67/68/6

Hình 3. Bốn TPLT mạnh tô bốn màu. Cung xám nét đứt nối hai thành phần khác nhau. Số cạnh đỉnh là num/low khi Tarjan chạy từ đỉnh 1.

Tarjan vẫn dùng num/low, thêm một ngăn xếp st chứa các đỉnh “đã thăm nhưng chưa được xếp vào thành phần nào”:

  • Thăm $u$: gán num[u] = low[u] = ++timer và đẩy $u$ vào st.
  • Cung $u \to v$ với $v$ chưa thăm: gọi dfs(v) rồi low[u] = min(low[u], low[v]).
  • Cung $u \to v$ với $v$ còn trong ngăn xếp: $v$ là tổ tiên hoặc cùng thành phần đang dở dang, đi tới $v$ là quay về được phía trên, nên low[u] = min(low[u], num[v]).
  • Cung $u \to v$ với $v$ đã thăm và đã rời ngăn xếp: $v$ thuộc một thành phần đã chốt xong, từ đó không có đường về $u$. Bỏ qua.
  • Duyệt xong $u$ mà low[u] = num[u]: $u$ là đỉnh thăm sớm nhất (“gốc”) của thành phần của nó. Bật khỏi ngăn xếp cho tới khi gặp $u$: tất cả các đỉnh bật ra là trọn một TPLT mạnh.

Vì sao các đỉnh nằm phía trên $u$ trong ngăn xếp đúng là thành phần của $u$? Chúng đều là con cháu của $u$ được thăm sau $u$. Đỉnh nào trong số đó thuộc thành phần khác thì thành phần ấy đã có gốc riêng (low = num) và đã bị bật ra trước rồi. Những đỉnh còn lại thì low < num, tức chúng quay lên được tới $u$, mà $u$ cũng đi xuống được tới chúng.

Thứ tự bật ra trên Hình 3 là {8}, {7}, {4, 5, 6}, {1, 2, 3}: thành phần “cuối dòng” bật ra trước. Mô phỏng dưới đây vẽ cả ngăn xếp: em để ý đỉnh 6 có cung tới 7 nhưng 7 đã rời ngăn xếp nên low[6] không bị kéo xuống. Thử đổi đồ thị, ví dụ thêm cung 8 1 để cả đồ thị thành một khối:

MÔ PHỎNGTarjan: num/low và ngăn xếp tìm thành phần liên thông mạnhNhập n (≤ 10) và các cung có hướng "u v" (từ u tới v). Theo dõi ngăn xếp và lúc một thành phần bật ra.
đỉnh đang xétcòn trong ngăn xếpđã thuộc một TPLT mạnh (mỗi màu một thành phần)chưa thăm
Xem code C++ / Python: thuật toán Tarjan tìm thành phần liên thông mạnh (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int n, m, timer_ = 0, cnt = 0;
int num[100005], low[100005], comp[100005];
bool onStk[100005];
vector<int> adj[100005];
vector<int> st;                                   // ngăn xếp của Tarjan

void dfs(int u){
    num[u] = low[u] = ++timer_;
    st.push_back(u); onStk[u] = true;
    for(int v : adj[u]){
        if(!num[v]){                              // cung cây
            dfs(v);
            low[u] = min(low[u], low[v]);
        } else if(onStk[v]){                      // v còn trong ngăn xếp: cùng vòng
            low[u] = min(low[u], num[v]);
        }                                         // v đã thuộc TPLT khác: bỏ qua
    }
    if(low[u] == num[u]){                         // u là gốc của một TPLT mạnh
        cnt++;
        while(true){
            int v = st.back(); st.pop_back();
            onStk[v] = false; comp[v] = cnt;
            if(v == u) break;
        }
    }
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    cin >> n >> m;
    for(int i = 0; i < m; i++){
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
    }
    for(int u = 1; u <= n; u++) if(!num[u]) dfs(u);
    vector<vector<int>> g(cnt + 1);
    for(int u = 1; u <= n; u++) g[comp[u]].push_back(u);   // u tăng dần nên mỗi nhóm đã tăng
    sort(g.begin() + 1, g.end());                 // xếp các nhóm theo đỉnh nhỏ nhất
    cout << cnt << "\n";
    for(int c = 1; c <= cnt; c++){
        for(int u : g[c]) cout << u << " ";
        cout << "\n";
    }
    return 0;
}
import sys

def tarjan(n, adj):
    """Trả về (cnt, comp): comp[u] = số hiệu TPLT mạnh của u (1..cnt),
    đánh số theo thứ tự bật ra khỏi ngăn xếp (= ngược thứ tự topo)."""
    num = [0] * (n + 1); low = [0] * (n + 1); comp = [0] * (n + 1)
    on = [False] * (n + 1); it = [0] * (n + 1)
    stk = []; timer = 0; cnt = 0
    for s in range(1, n + 1):
        if num[s]:
            continue
        timer += 1; num[s] = low[s] = timer
        stk.append(s); on[s] = True
        call = [s]                                # ngăn xếp lời gọi tự quản lý
        while call:
            u = call[-1]
            if it[u] < len(adj[u]):
                v = adj[u][it[u]]; it[u] += 1
                if not num[v]:                    # cung cây: "gọi" dfs(v)
                    timer += 1; num[v] = low[v] = timer
                    stk.append(v); on[v] = True
                    call.append(v)
                elif on[v] and num[v] < low[u]:   # v còn trong ngăn xếp
                    low[u] = num[v]
            else:                                 # u xong: "trả về"
                call.pop()
                if call and low[u] < low[call[-1]]:
                    low[call[-1]] = low[u]
                if low[u] == num[u]:              # bật một TPLT mạnh
                    cnt += 1
                    while True:
                        v = stk.pop(); on[v] = False; comp[v] = cnt
                        if v == u:
                            break
    return cnt, comp

def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    adj = [[] for _ in range(n + 1)]
    for i in range(m):
        adj[int(data[2 + 2 * i])].append(int(data[3 + 2 * i]))
    cnt, comp = tarjan(n, adj)
    groups = [[] for _ in range(cnt + 1)]
    for u in range(1, n + 1):
        groups[comp[u]].append(u)
    out = [str(cnt)] + [" ".join(map(str, g)) for g in sorted(groups[1:])]
    print("\n".join(out))

main()

Khung chạy tay dưới đây cho em thấy từng dòng của hàm dfs trong Tarjan, kèm nội dung ngăn xếp st và mảng comp (số hiệu thành phần):

C++▶ Chạy tay từng dòng codeTarjan: st là ngăn xếp các đỉnh chưa được xếp vào thành phần nào. Ô vàng: đỉnh u, ô xanh lá: đã có thành phần.
Dữ liệu mẫu:
1#include <bits/stdc++.h>
2using namespace std;
3
4int n, m, timer_ = 0, cnt = 0;
5int num[100005], low[100005], comp[100005];
6bool onStk[100005];
7vector<int> adj[100005];
8vector<int> st; // ngăn xếp của Tarjan
9
10void dfs(int u){
11 num[u] = low[u] = ++timer_;
12 st.push_back(u); onStk[u] = true;
13 for(int v : adj[u]){
14 if(!num[v]){
15 dfs(v);
16 low[u] = min(low[u], low[v]);
17 } else if(onStk[v]){
18 low[u] = min(low[u], num[v]);
19 }
20 }
21 if(low[u] == num[u]){
22 cnt++;
23 while(true){
24 int v = st.back(); st.pop_back();
25 onStk[v] = false; comp[v] = cnt;
26 if(v == u) break;
27 }
28 }
29}
30
31int main(){
32 cin >> n >> m;
33 for(int i = 0; i < m; i++){
34 int u, v;
35 cin >> u >> v;
36 adj[u].push_back(v);
37 }
38 for(int u = 1; u <= n; u++) if(!num[u]) dfs(u);
39 vector<vector<int>> g(cnt + 1);
40 for(int u = 1; u <= n; u++) g[comp[u]].push_back(u);
41 sort(g.begin() + 1, g.end()); // xếp các nhóm theo đỉnh nhỏ nhất
42 cout << cnt << "\n";
43 for(int c = 1; c <= cnt; c++){
44 for(int u : g[c]) cout << u << " ";
45 cout << "\n";
46 }
47 return 0;
48}
So với num/low của cầu và khớp
Hai chỗ khác nhau cần nhớ: (1) đồ thị có hướng nên không phải bỏ cạnh cha; (2) cung tới đỉnh đã thăm chỉ được dùng để cập nhật low khi đỉnh đó còn trong ngăn xếp. Quên điều kiện onStk[v] là lỗi phổ biến nhất: khi đó cung 6→7 ở Hình 3 sẽ kéo low[6] xuống 4 và gộp nhầm.

Phần 4Kosaraju: hai lần DFS, và đồ thị nén là một DAG

Kosaraju là cách thứ hai, dễ hiểu hơn và không cần low:

  1. Lượt 1: DFS trên đồ thị $G$, mỗi đỉnh duyệt xong thì ghi vào danh sách order (thứ tự xong).
  2. Đảo chiều mọi cung, được đồ thị $G^T$.
  3. Lượt 2: lấy các đỉnh theo thứ tự xong giảm dần; gặp đỉnh chưa được gán thì DFS trên $G^T$ từ nó, mọi đỉnh tới được (chưa gán) tạo thành một TPLT mạnh.

Vì sao đúng? Đỉnh xong muộn nhất thuộc một thành phần nguồn (không có cung từ thành phần khác đi vào). Trên $G^T$, các cung ra khỏi thành phần nguồn đều đã bị đảo thành cung đi vào, nên DFS từ đó chỉ lan trong đúng thành phần của nó. Gán xong thì coi như xoá thành phần ấy, đỉnh xong muộn nhất còn lại lại thuộc một thành phần nguồn của phần còn lại, cứ thế tiếp tục.

Em xem hai lượt chạy trên cùng đồ thị Hình 3. Ở lượt 2, hình được vẽ với mọi cung đã đảo chiều:

MÔ PHỎNGKosaraju: hai lần DFS, lần hai trên đồ thị đảo chiềuCùng đồ thị với khung Tarjan. Lượt 1 ghi thứ tự xong, lượt 2 đảo cung và gom từng thành phần.
đỉnh đang xétđang duyệt (lượt 1)đã xong lượt 1đã gán thành phần (mỗi màu một thành phần)
Xem code C++ / Python: thuật toán Kosaraju hai lần DFS (bấm để mở)
#include <bits/stdc++.h>
using namespace std;

int n, m, cnt = 0;
vector<int> adj[100005], radj[100005], order_;
bool vis[100005];
int comp[100005];

void dfs1(int u){                       // lượt 1: trên G, ghi thứ tự xong
    vis[u] = true;
    for(int v : adj[u]) if(!vis[v]) dfs1(v);
    order_.push_back(u);
}

void dfs2(int u){                       // lượt 2: trên G đảo, gom một TPLT
    comp[u] = cnt;
    for(int v : radj[u]) if(!comp[v]) dfs2(v);
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    cin >> n >> m;
    for(int i = 0; i < m; i++){
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        radj[v].push_back(u);           // cung đảo chiều
    }
    for(int u = 1; u <= n; u++) if(!vis[u]) dfs1(u);
    for(int i = n - 1; i >= 0; i--){    // xét theo thứ tự xong giảm dần
        int u = order_[i];
        if(!comp[u]){ cnt++; dfs2(u); }
    }
    vector<vector<int>> g(cnt + 1);
    for(int u = 1; u <= n; u++) g[comp[u]].push_back(u);
    sort(g.begin() + 1, g.end());
    cout << cnt << "\n";
    for(int c = 1; c <= cnt; c++){
        for(int u : g[c]) cout << u << " ";
        cout << "\n";
    }
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    adj = [[] for _ in range(n + 1)]
    radj = [[] for _ in range(n + 1)]
    for i in range(m):
        u, v = int(data[2 + 2 * i]), int(data[3 + 2 * i])
        adj[u].append(v)
        radj[v].append(u)                     # cung đảo chiều
    # lượt 1: DFS không đệ quy trên G, ghi thứ tự xong
    order = []
    vis = [False] * (n + 1)
    it = [0] * (n + 1)
    for s in range(1, n + 1):
        if vis[s]:
            continue
        vis[s] = True
        stack = [s]
        while stack:
            u = stack[-1]
            if it[u] < len(adj[u]):
                v = adj[u][it[u]]; it[u] += 1
                if not vis[v]:
                    vis[v] = True
                    stack.append(v)
            else:
                stack.pop()
                order.append(u)               # u xong
    # lượt 2: trên G đảo, theo thứ tự xong giảm dần
    comp = [0] * (n + 1)
    cnt = 0
    for s in reversed(order):
        if comp[s]:
            continue
        cnt += 1
        comp[s] = cnt
        stack = [s]
        while stack:                          # lượt 2 chỉ cần biết ai tới được
            u = stack.pop()
            for v in radj[u]:
                if not comp[v]:
                    comp[v] = cnt
                    stack.append(v)
    groups = [[] for _ in range(cnt + 1)]
    for u in range(1, n + 1):
        groups[comp[u]].append(u)
    out = [str(cnt)] + [" ".join(map(str, g)) for g in sorted(groups[1:])]
    print("\n".join(out))

main()
Tiêu chíTarjanKosaraju
Số lần DFS12 (thêm đồ thị đảo)
Bộ nhớ thêmnum, low, ngăn xếpdanh sách kề đảo chiều
Thứ tự thành phần tìm đượcngược thứ tự topođúng thứ tự topo
Dễ code saiquên onStkít chỗ sai
Độ phức tạp$O(n + m)$$O(n + m)$

Bảng 2. Hai thuật toán cho cùng các thành phần, chỉ khác thứ tự đánh số.

Đồ thị nén. Gom mỗi TPLT mạnh thành một đỉnh, giữ lại các cung nối hai thành phần khác nhau, ta được đồ thị nén (condensation). Đồ thị nén luôn là DAG: nếu có chu trình giữa các thành phần thì đi vòng được qua chúng, tức chúng phải gộp thành một thành phần, vô lý. Thế là mọi kỹ thuật của bài 35 (sắp xếp topo, quy hoạch động trên DAG, đường đi dài nhất) dùng được cho mọi đồ thị có hướng, kể cả có chu trình.

A = {1, 2, 3}B = {4, 5, 6}C = {7}D = {8}nguồn (vào = 0)đích (ra = 0)

Hình 4. Đồ thị nén của Hình 3. A là thành phần nguồn duy nhất, D là thành phần đích duy nhất.

Đánh số thành phần để quy hoạch động không cần sắp xếp topo
Với Tarjan, nếu có cung từ thành phần số $x$ sang thành phần số $y$ ($x \ne y$) thì luôn có $y < x$ (thành phần đích bật ra trước). Vậy duyệt số hiệu thành phần tăng dần chính là duyệt theo thứ tự “con trước, cha sau” trên DAG, em quy hoạch động thẳng mà không cần topo sort nữa (bài tập 7). Với Kosaraju thì ngược lại: số hiệu tăng dần là đúng thứ tự topo.

Phần 5Ứng dụng: thêm cung, chọn đỉnh xuất phát, 2-SAT

Hầu hết bài TPLT mạnh trong đề thi đều đi theo một kịch bản: nén đồ thị, rồi đếm hay quy hoạch động trên DAG nén. Gọi $s$ là số thành phần nguồn (bán bậc vào bằng 0) và $t$ là số thành phần đích (bán bậc ra bằng 0) của đồ thị nén:

Câu hỏiĐáp ánVì sao
Ít đỉnh xuất phát nhất để đi tới mọi đỉnh$s$mỗi thành phần nguồn không ai đi vào được, bắt buộc chọn một đỉnh trong nó; chọn đủ $s$ đỉnh đó là tới được tất cả
Thêm ít cung nhất để cả đồ thị liên thông mạnh$\max(s, t)$, riêng 1 thành phần thì 0mỗi nguồn cần thêm một cung vào, mỗi đích cần thêm một cung ra; nối vòng các đích về các nguồn là đủ
Đi từ $u$, được thăm tối đa bao nhiêu đỉnh / nhặt tối đa bao nhiêu điểmQHĐ trên DAG nénvào một thành phần là đi hết được cả thành phần đó
Có thể định hướng mọi cạnh vô hướng để được đồ thị liên thông mạnh?được khi và chỉ khi liên thông và không có cầuđịnh hướng cạnh cây đi xuống, cạnh ngược đi lên

Bảng 3. Các dạng câu hỏi hay gặp. Với Hình 4: $s = 1$ (A), $t = 1$ (D), nên chỉ cần xuất phát từ 1 đỉnh của A và thêm 1 cung, ví dụ 8 → 1.

Giới thiệu 2-SAT. Có $n$ biến đúng/sai và nhiều điều kiện dạng “$a$ hoặc $b$” (mỗi vế là một biến hoặc phủ định của nó). Có gán được giá trị để mọi điều kiện đều đúng không? Mỗi điều kiện $a \lor b$ tương đương hai phép kéo theo: “nếu không $a$ thì phải $b$” và “nếu không $b$ thì phải $a$”. Lập đồ thị $2n$ đỉnh (mỗi biến có đỉnh “$x$ đúng” và “$x$ sai”), mỗi điều kiện thêm hai cung $\lnot a \to b$, $\lnot b \to a$. Kết luận rất gọn:

Vô nghiệm khi và chỉ khi có biến $x$ mà “$x$ đúng” và “$x$ sai” cùng một TPLT mạnh.

Vì khi đó $x \Rightarrow \lnot x$ và $\lnot x \Rightarrow x$, gán kiểu gì cũng mâu thuẫn. Ngược lại, nếu không có biến nào như vậy thì luôn có nghiệm: với Tarjan, gán $x$ đúng khi comp[x đúng] < comp[x sai] (chọn vế nằm “sau” trong thứ tự topo). Bài toán chọn phương án thoả nhiều ràng buộc đôi một như xếp lịch, chọn đội hình “A đi thì B không đi” đều quy về 2-SAT, em làm thử ở bài tập 8.

Checklist khi gặp bài đồ thị có “cắt”, “hỏng”, “một chiều”
• “Bỏ một cạnh thì mất liên lạc” → cầu. “Bỏ một đỉnh” → khớp. Cả hai: một lần DFS num/low.
• “Đi qua đi lại được”, “đường một chiều”, “lan truyền”, “chu trình trên đồ thị có hướng” → TPLT mạnh rồi nén thành DAG.
• Đồ thị vô hướng mà chỉ hỏi “có đi được tới nhau không” thì DSU hoặc BFS ở bài 30, bài 21 là đủ, không cần num/low.
• Python: luôn viết DFS không đệ quy khi $n$ lớn.
Luyện tập trên web
Các bài dưới đây trên blog đều có lời giải chi tiết bằng C++ và Python, xếp từ dễ tới khó. Em hãy tự làm trước rồi mới xem lời giải nhé.Xem toàn bộ bài cùng chủ đề: nhãn Đồ thị.

Bài tậpLuyện cầu, khớp và thành phần liên thông mạnh

Bài 1Đường huyết mạchDễ

Mạng giao thông có $n$ thành phố và $m$ con đường hai chiều ($1 \le n \le 10^5$, $0 \le m \le 2 \cdot 10^5$). Giữa hai thành phố có thể có nhiều con đường, không có đường nối một thành phố với chính nó. Một con đường gọi là huyết mạch nếu nó bị sập thì có hai thành phố đang đi được tới nhau trở thành không đi được. Đếm số đường huyết mạch.

Input: dòng đầu $n, m$; $m$ dòng sau mỗi dòng hai số $u, v$. Output: số đường huyết mạch.

Input
6 7
1 2
2 3
3 1
3 4
4 5
4 5
5 6
Output
2

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Đường huyết mạch chính là cầu. Dùng code phần 2, bỏ phần khớp. Chú ý cặp đường 4–5 bị lặp: nếu bỏ qua cha theo đỉnh thì 4–5 bị đếm nhầm là cầu, nên phải bỏ theo chỉ số cạnh. Hai cầu là 3–4 và 5–6.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int num[100005], low[100005], timer_ = 0, cnt = 0;
vector<pair<int, int>> adj[100005];

void dfs(int u, int pe){
    num[u] = low[u] = ++timer_;
    for(auto e : adj[u]){
        int v = e.first, id = e.second;
        if(id == pe) continue;                 // bỏ đúng cạnh cha theo chỉ số
        if(num[v]) low[u] = min(low[u], num[v]);
        else {
            dfs(v, id);
            low[u] = min(low[u], low[v]);
            if(low[v] > num[u]) cnt++;
        }
    }
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    for(int i = 0; i < m; i++){
        int u, v;
        cin >> u >> v;
        adj[u].push_back({v, i}); adj[v].push_back({u, i});
    }
    for(int u = 1; u <= n; u++) if(!num[u]) dfs(u, -1);
    cout << cnt;
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    adj = [[] for _ in range(n + 1)]
    for i in range(m):
        u, v = int(data[2 + 2 * i]), int(data[3 + 2 * i])
        adj[u].append((v, i)); adj[v].append((u, i))
    num = [0] * (n + 1); low = [0] * (n + 1)
    pe = [-1] * (n + 1); par = [0] * (n + 1); it = [0] * (n + 1)
    timer = cnt = 0
    for s in range(1, n + 1):
        if num[s]:
            continue
        timer += 1; num[s] = low[s] = timer
        stack = [s]
        while stack:
            u = stack[-1]
            if it[u] < len(adj[u]):
                v, idx = adj[u][it[u]]; it[u] += 1
                if idx == pe[u]:
                    continue
                if num[v]:
                    low[u] = min(low[u], num[v])
                else:
                    timer += 1; num[v] = low[v] = timer
                    pe[v] = idx; par[v] = u
                    stack.append(v)
            else:
                stack.pop()
                if stack:
                    p = par[u]
                    low[p] = min(low[p], low[u])
                    if low[u] > num[p]:
                        cnt += 1
    print(cnt)

main()
Bài 2Câu lạc bộ khép kínDễ

Có $n$ người ($1 \le n \le 2 \cdot 10^5$) và $m$ quan hệ “$u$ nhắn tin được cho $v$” ($0 \le m \le 2 \cdot 10^5$, có hướng). Một câu lạc bộ là một nhóm lớn nhất có thể mà hai người bất kỳ trong nhóm đều chuyển tin được cho nhau (có thể qua người trung gian). Mỗi người thuộc đúng một câu lạc bộ. In ra số câu lạc bộ và số thành viên của câu lạc bộ đông nhất.

Input
8 8
1 2
2 1
2 3
3 4
4 5
5 3
6 7
7 6
Output
4 3

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Câu lạc bộ là TPLT mạnh. Chạy Tarjan, đếm số đỉnh của từng thành phần. Bốn câu lạc bộ: {1, 2}, {3, 4, 5}, {6, 7}, {8}; đông nhất 3 người. Người 8 không nhắn được cho ai vẫn là một câu lạc bộ 1 người.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int num[200005], low[200005], comp[200005], timer_ = 0, cnt = 0;
bool onStk[200005];
vector<int> adj[200005], st;

void dfs(int u){
    num[u] = low[u] = ++timer_;
    st.push_back(u); onStk[u] = true;
    for(int v : adj[u]){
        if(!num[v]){ dfs(v); low[u] = min(low[u], low[v]); }
        else if(onStk[v]) low[u] = min(low[u], num[v]);
    }
    if(low[u] == num[u]){
        cnt++;
        while(true){
            int v = st.back(); st.pop_back();
            onStk[v] = false; comp[v] = cnt;
            if(v == u) break;
        }
    }
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    for(int i = 0; i < m; i++){
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
    }
    for(int u = 1; u <= n; u++) if(!num[u]) dfs(u);
    vector<int> sz(cnt + 1, 0);
    for(int u = 1; u <= n; u++) sz[comp[u]]++;
    cout << cnt << " " << *max_element(sz.begin() + 1, sz.end());
    return 0;
}
import sys

def tarjan(n, adj):                   # Tarjan không đệ quy, như phần 3
    num = [0] * (n + 1); low = [0] * (n + 1); comp = [0] * (n + 1)
    on = [False] * (n + 1); it = [0] * (n + 1)
    stk = []; timer = cnt = 0
    for s in range(1, n + 1):
        if num[s]:
            continue
        timer += 1; num[s] = low[s] = timer; stk.append(s); on[s] = True
        call = [s]
        while call:
            u = call[-1]
            if it[u] < len(adj[u]):
                v = adj[u][it[u]]; it[u] += 1
                if not num[v]:
                    timer += 1; num[v] = low[v] = timer
                    stk.append(v); on[v] = True; call.append(v)
                elif on[v] and num[v] < low[u]:
                    low[u] = num[v]
            else:
                call.pop()
                if call and low[u] < low[call[-1]]:
                    low[call[-1]] = low[u]
                if low[u] == num[u]:
                    cnt += 1
                    while True:
                        v = stk.pop(); on[v] = False; comp[v] = cnt
                        if v == u:
                            break
    return cnt, comp

def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    adj = [[] for _ in range(n + 1)]
    for i in range(m):
        adj[int(data[2 + 2 * i])].append(int(data[3 + 2 * i]))
    cnt, comp = tarjan(n, adj)
    sz = [0] * (cnt + 1)
    for u in range(1, n + 1):
        sz[comp[u]] += 1
    print(cnt, max(sz))

main()
Bài 3Phá một trạmTrung bình

Mạng điện gồm $n$ trạm ($2 \le n \le 10^5$) và $m$ đường dây hai chiều ($n - 1 \le m \le 2 \cdot 10^5$), mạng liên thông, có thể có đường dây lặp. Nếu một trạm bị hỏng (cùng mọi đường dây nối với nó) thì $n - 1$ trạm còn lại bị chia thành mấy vùng liên thông? Tìm trạm hỏng gây ra nhiều vùng nhất và số vùng đó. Nhiều trạm như nhau thì in trạm có số hiệu nhỏ nhất.

Input
7 7
1 2
1 3
2 3
3 4
4 5
4 6
6 7
Output
4 3

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Mở rộng điều kiện khớp: với đỉnh $u$ không phải gốc, mỗi con $c$ có low[c] ≥ num[u] là một vùng bị tách ra, cộng thêm 1 vùng phía trên chứa cha. Với gốc, số vùng bằng số con trong cây DFS. Đỉnh không phải khớp cho đúng 1 vùng. Ví dụ: bỏ trạm 4 còn {1, 2, 3}, {5}, {6, 7} là 3 vùng.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int num[100005], low[100005], piece[100005], timer_ = 0;
vector<int> adj[100005];

void dfs(int u, int p){
    num[u] = low[u] = ++timer_;
    bool parentSkipped = false;
    for(int v : adj[u]){
        if(v == p && !parentSkipped){ parentSkipped = true; continue; }   // bỏ 1 cạnh cha
        if(num[v]) low[u] = min(low[u], num[v]);
        else {
            dfs(v, u);
            low[u] = min(low[u], low[v]);
            if(low[v] >= num[u]) piece[u]++;     // cây con của v tách ra
        }
    }
    if(p != 0) piece[u]++;                       // + phần phía trên (chứa cha)
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    for(int i = 0; i < m; i++){
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v); adj[v].push_back(u);
    }
    dfs(1, 0);
    int best = 1;
    for(int u = 2; u <= n; u++) if(piece[u] > piece[best]) best = u;
    cout << best << " " << piece[best];
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    adj = [[] for _ in range(n + 1)]
    for i in range(m):
        u, v = int(data[2 + 2 * i]), int(data[3 + 2 * i])
        adj[u].append((v, i)); adj[v].append((u, i))
    num = [0] * (n + 1); low = [0] * (n + 1); piece = [0] * (n + 1)
    pe = [-1] * (n + 1); par = [0] * (n + 1); it = [0] * (n + 1)
    timer = 1; num[1] = low[1] = 1
    stack = [1]
    while stack:
        u = stack[-1]
        if it[u] < len(adj[u]):
            v, idx = adj[u][it[u]]; it[u] += 1
            if idx == pe[u]:
                continue
            if num[v]:
                low[u] = min(low[u], num[v])
            else:
                timer += 1; num[v] = low[v] = timer
                pe[v] = idx; par[v] = u
                stack.append(v)
        else:
            stack.pop()
            if u != 1:
                piece[u] += 1                    # phần phía trên (chứa cha)
                p = par[u]
                low[p] = min(low[p], low[u])
                if low[u] >= num[p]:
                    piece[p] += 1                # cây con của u tách ra
    best = 1
    for u in range(2, n + 1):
        if piece[u] > piece[best]:
            best = u
    print(best, piece[best])

main()
Bài 4Tin đồn lan truyềnTrung bình

Có $n$ học sinh ($1 \le n \le 2 \cdot 10^5$) và $m$ quan hệ “$u$ nghe được tin thì sẽ kể cho $v$” ($0 \le m \le 2 \cdot 10^5$). Thầy muốn báo tin trực tiếp cho ít học sinh nhất sao cho cuối cùng cả lớp đều biết. Hỏi cần báo cho mấy bạn?

Input
6 6
1 2
2 3
3 1
4 3
5 6
6 5
Output
2

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Nén đồ thị. Một thành phần có cung đi vào thì sẽ có người khác kể cho, còn thành phần nguồn (không có cung vào từ thành phần khác) thì bắt buộc phải báo trực tiếp ít nhất một người trong đó. Đáp án là số thành phần nguồn. Ví dụ: các thành phần {1, 2, 3}, {4}, {5, 6}; {1, 2, 3} có cung từ 4 đi vào, nên chỉ cần báo cho 4 và một người trong {5, 6}.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int num[200005], low[200005], comp[200005], timer_ = 0, cnt = 0;
bool onStk[200005];
vector<int> adj[200005], st;

void dfs(int u){
    num[u] = low[u] = ++timer_;
    st.push_back(u); onStk[u] = true;
    for(int v : adj[u]){
        if(!num[v]){ dfs(v); low[u] = min(low[u], low[v]); }
        else if(onStk[v]) low[u] = min(low[u], num[v]);
    }
    if(low[u] == num[u]){
        cnt++;
        while(true){
            int v = st.back(); st.pop_back();
            onStk[v] = false; comp[v] = cnt;
            if(v == u) break;
        }
    }
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    vector<pair<int, int>> E(m);
    for(auto &e : E){
        cin >> e.first >> e.second;
        adj[e.first].push_back(e.second);
    }
    for(int u = 1; u <= n; u++) if(!num[u]) dfs(u);
    vector<int> indeg(cnt + 1, 0);
    for(auto e : E)
        if(comp[e.first] != comp[e.second]) indeg[comp[e.second]]++;
    int ans = 0;
    for(int c = 1; c <= cnt; c++) if(indeg[c] == 0) ans++;
    cout << ans;
    return 0;
}
import sys

def tarjan(n, adj):                   # Tarjan không đệ quy, như phần 3
    num = [0] * (n + 1); low = [0] * (n + 1); comp = [0] * (n + 1)
    on = [False] * (n + 1); it = [0] * (n + 1)
    stk = []; timer = cnt = 0
    for s in range(1, n + 1):
        if num[s]:
            continue
        timer += 1; num[s] = low[s] = timer; stk.append(s); on[s] = True
        call = [s]
        while call:
            u = call[-1]
            if it[u] < len(adj[u]):
                v = adj[u][it[u]]; it[u] += 1
                if not num[v]:
                    timer += 1; num[v] = low[v] = timer
                    stk.append(v); on[v] = True; call.append(v)
                elif on[v] and num[v] < low[u]:
                    low[u] = num[v]
            else:
                call.pop()
                if call and low[u] < low[call[-1]]:
                    low[call[-1]] = low[u]
                if low[u] == num[u]:
                    cnt += 1
                    while True:
                        v = stk.pop(); on[v] = False; comp[v] = cnt
                        if v == u:
                            break
    return cnt, comp

def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    adj = [[] for _ in range(n + 1)]
    E = []
    for i in range(m):
        u, v = int(data[2 + 2 * i]), int(data[3 + 2 * i])
        adj[u].append(v); E.append((u, v))
    cnt, comp = tarjan(n, adj)
    indeg = [0] * (cnt + 1)
    for u, v in E:
        if comp[u] != comp[v]:
            indeg[comp[v]] += 1
    print(sum(1 for c in range(1, cnt + 1) if indeg[c] == 0))

main()
Bài 5Biến thành mạng một khốiTrung bình

Thành phố có $n$ nút giao ($1 \le n \le 2 \cdot 10^5$) và $m$ đường một chiều ($0 \le m \le 2 \cdot 10^5$). Hỏi cần xây thêm ít nhất bao nhiêu đường một chiều để từ mọi nút giao đi được tới mọi nút giao khác?

Input
5 5
1 2
2 3
3 1
3 4
3 5
Output
2

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Nén đồ thị, đếm $s$ thành phần nguồn và $t$ thành phần đích. Nếu chỉ có 1 thành phần thì đáp án 0, ngược lại là $\max(s, t)$: mỗi nguồn cần ít nhất một cung đi vào và mỗi đích cần ít nhất một cung đi ra, mỗi cung mới lo được nhiều nhất một nguồn và một đích; và người ta chứng minh được luôn nối đủ bằng $\max(s, t)$ cung. Ví dụ: nguồn {1, 2, 3}, đích {4} và {5}; thêm 4 → 1 và 5 → 1 là xong.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int num[200005], low[200005], comp[200005], timer_ = 0, cnt = 0;
bool onStk[200005];
vector<int> adj[200005], st;

void dfs(int u){
    num[u] = low[u] = ++timer_;
    st.push_back(u); onStk[u] = true;
    for(int v : adj[u]){
        if(!num[v]){ dfs(v); low[u] = min(low[u], low[v]); }
        else if(onStk[v]) low[u] = min(low[u], num[v]);
    }
    if(low[u] == num[u]){
        cnt++;
        while(true){
            int v = st.back(); st.pop_back();
            onStk[v] = false; comp[v] = cnt;
            if(v == u) break;
        }
    }
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    vector<pair<int, int>> E(m);
    for(auto &e : E){
        cin >> e.first >> e.second;
        adj[e.first].push_back(e.second);
    }
    for(int u = 1; u <= n; u++) if(!num[u]) dfs(u);
    if(cnt == 1){ cout << 0; return 0; }          // đã liên thông mạnh
    vector<int> in(cnt + 1, 0), out(cnt + 1, 0);
    for(auto e : E){
        int a = comp[e.first], b = comp[e.second];
        if(a != b){ out[a]++; in[b]++; }
    }
    int src = 0, snk = 0;
    for(int c = 1; c <= cnt; c++){
        if(in[c] == 0) src++;
        if(out[c] == 0) snk++;
    }
    cout << max(src, snk);
    return 0;
}
import sys

def tarjan(n, adj):                   # Tarjan không đệ quy, như phần 3
    num = [0] * (n + 1); low = [0] * (n + 1); comp = [0] * (n + 1)
    on = [False] * (n + 1); it = [0] * (n + 1)
    stk = []; timer = cnt = 0
    for s in range(1, n + 1):
        if num[s]:
            continue
        timer += 1; num[s] = low[s] = timer; stk.append(s); on[s] = True
        call = [s]
        while call:
            u = call[-1]
            if it[u] < len(adj[u]):
                v = adj[u][it[u]]; it[u] += 1
                if not num[v]:
                    timer += 1; num[v] = low[v] = timer
                    stk.append(v); on[v] = True; call.append(v)
                elif on[v] and num[v] < low[u]:
                    low[u] = num[v]
            else:
                call.pop()
                if call and low[u] < low[call[-1]]:
                    low[call[-1]] = low[u]
                if low[u] == num[u]:
                    cnt += 1
                    while True:
                        v = stk.pop(); on[v] = False; comp[v] = cnt
                        if v == u:
                            break
    return cnt, comp

def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    adj = [[] for _ in range(n + 1)]
    E = []
    for i in range(m):
        u, v = int(data[2 + 2 * i]), int(data[3 + 2 * i])
        adj[u].append(v); E.append((u, v))
    cnt, comp = tarjan(n, adj)
    if cnt == 1:
        print(0)
        return
    din = [0] * (cnt + 1); dout = [0] * (cnt + 1)
    for u, v in E:
        a, b = comp[u], comp[v]
        if a != b:
            dout[a] += 1; din[b] += 1
    src = sum(1 for c in range(1, cnt + 1) if din[c] == 0)
    snk = sum(1 for c in range(1, cnt + 1) if dout[c] == 0)
    print(max(src, snk))

main()
Bài 6Con đường đắt giá nhấtKhá

Mạng $n$ thành phố ($2 \le n \le 10^5$), $m$ đường hai chiều ($n - 1 \le m \le 2 \cdot 10^5$), liên thông, có thể có đường lặp. Nếu một con đường bị sập, gọi thiệt hại là số cặp thành phố (không kể thứ tự) không còn đi được tới nhau. Tìm thiệt hại lớn nhất khi sập đúng một con đường.

Input
6 6
1 2
2 3
3 1
3 4
4 5
4 6
Output
9

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Chỉ khi sập cầu mới có thiệt hại. Với cầu cây $u$–$c$, phần bị tách ra chính là cây con gốc $c$ có sz[c] đỉnh, phần kia có $n - sz[c]$ đỉnh, thiệt hại $sz[c] \cdot (n - sz[c])$. Tính sz ngay trong DFS num/low. Kết quả có thể tới $2{,}5 \cdot 10^9$ nên dùng long long. Ví dụ: sập 3–4 tách {1, 2, 3} khỏi {4, 5, 6}: $3 \cdot 3 = 9$.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int n, m, num[100005], low[100005], sz[100005], timer_ = 0;
vector<pair<int, int>> adj[100005];
long long best = 0;

void dfs(int u, int pe){
    num[u] = low[u] = ++timer_;
    sz[u] = 1;
    for(auto e : adj[u]){
        int v = e.first, id = e.second;
        if(id == pe) continue;
        if(num[v]) low[u] = min(low[u], num[v]);
        else {
            dfs(v, id);
            sz[u] += sz[v];
            low[u] = min(low[u], low[v]);
            if(low[v] > num[u])                       // cắt cầu u-v: sz[v] đỉnh tách khỏi n - sz[v] đỉnh
                best = max(best, 1LL * sz[v] * (n - sz[v]));
        }
    }
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    cin >> n >> m;
    for(int i = 0; i < m; i++){
        int u, v;
        cin >> u >> v;
        adj[u].push_back({v, i}); adj[v].push_back({u, i});
    }
    dfs(1, -1);
    cout << best;
    return 0;
}
import sys

def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    adj = [[] for _ in range(n + 1)]
    for i in range(m):
        u, v = int(data[2 + 2 * i]), int(data[3 + 2 * i])
        adj[u].append((v, i)); adj[v].append((u, i))
    num = [0] * (n + 1); low = [0] * (n + 1); sz = [1] * (n + 1)
    pe = [-1] * (n + 1); par = [0] * (n + 1); it = [0] * (n + 1)
    timer = 1; num[1] = low[1] = 1
    best = 0
    stack = [1]
    while stack:
        u = stack[-1]
        if it[u] < len(adj[u]):
            v, idx = adj[u][it[u]]; it[u] += 1
            if idx == pe[u]:
                continue
            if num[v]:
                low[u] = min(low[u], num[v])
            else:
                timer += 1; num[v] = low[v] = timer
                pe[v] = idx; par[v] = u
                stack.append(v)
        else:
            stack.pop()
            if stack:
                p = par[u]
                sz[p] += sz[u]
                low[p] = min(low[p], low[u])
                if low[u] > num[p]:
                    best = max(best, sz[u] * (n - sz[u]))
    print(best)

main()
Bài 7Nhặt vàng trên đường một chiềuKhá

Có $n$ hang ($1 \le n \le 10^5$), hang $i$ chứa $a_i$ thỏi vàng ($0 \le a_i \le 10^9$), và $m$ đường hầm một chiều ($0 \le m \le 2 \cdot 10^5$). Em xuất phát ở hang 1, đi tuỳ ý theo đường hầm, được đi lại một hang nhiều lần nhưng vàng mỗi hang chỉ nhặt được một lần. Hỏi nhặt được nhiều nhất bao nhiêu thỏi vàng?

Input: dòng đầu $n, m$; dòng hai gồm $a_1 \ldots a_n$; $m$ dòng sau là các đường hầm $u \to v$.

Input
6 7
1 2 3 4 5 10
1 2
2 3
3 1
3 4
4 5
5 4
2 6
Output
16

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Vào một TPLT mạnh là đi vòng nhặt hết vàng của nó. Nén đồ thị, trọng số mỗi thành phần là tổng vàng, rồi tìm đường đi có tổng lớn nhất trên DAG nén bắt đầu từ thành phần chứa hang 1 (quy hoạch động như bài 35). Mẹo: với số hiệu Tarjan, cung giữa hai thành phần luôn đi từ số lớn sang số nhỏ, nên tính best[c] với $c$ tăng dần là xong. Ví dụ: {1, 2, 3} có 6 thỏi, rồi đi sang {6} được thêm 10, tổng 16 (đi sang {4, 5} chỉ được 9).
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int num[200005], low[200005], comp[200005], timer_ = 0, cnt = 0;
bool onStk[200005];
vector<int> adj[200005], st;

void dfs(int u){
    num[u] = low[u] = ++timer_;
    st.push_back(u); onStk[u] = true;
    for(int v : adj[u]){
        if(!num[v]){ dfs(v); low[u] = min(low[u], low[v]); }
        else if(onStk[v]) low[u] = min(low[u], num[v]);
    }
    if(low[u] == num[u]){
        cnt++;
        while(true){
            int v = st.back(); st.pop_back();
            onStk[v] = false; comp[v] = cnt;
            if(v == u) break;
        }
    }
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    int n, m;
    cin >> n >> m;
    vector<long long> a(n + 1);
    for(int i = 1; i <= n; i++) cin >> a[i];
    vector<pair<int, int>> E(m);
    for(auto &e : E){
        cin >> e.first >> e.second;
        adj[e.first].push_back(e.second);
    }
    for(int u = 1; u <= n; u++) if(!num[u]) dfs(u);
    vector<long long> val(cnt + 1, 0), best(cnt + 1, 0);
    vector<vector<int>> nxt(cnt + 1);
    for(int u = 1; u <= n; u++) val[comp[u]] += a[u];
    for(auto e : E){
        int x = comp[e.first], y = comp[e.second];
        if(x != y) nxt[x].push_back(y);              // luôn có y < x
    }
    for(int c = 1; c <= cnt; c++){                   // số hiệu tăng dần = ngược thứ tự topo
        long long add = 0;
        for(int d : nxt[c]) add = max(add, best[d]);
        best[c] = val[c] + add;
    }
    cout << best[comp[1]];
    return 0;
}
import sys

def tarjan(n, adj):                   # Tarjan không đệ quy, như phần 3
    num = [0] * (n + 1); low = [0] * (n + 1); comp = [0] * (n + 1)
    on = [False] * (n + 1); it = [0] * (n + 1)
    stk = []; timer = cnt = 0
    for s in range(1, n + 1):
        if num[s]:
            continue
        timer += 1; num[s] = low[s] = timer; stk.append(s); on[s] = True
        call = [s]
        while call:
            u = call[-1]
            if it[u] < len(adj[u]):
                v = adj[u][it[u]]; it[u] += 1
                if not num[v]:
                    timer += 1; num[v] = low[v] = timer
                    stk.append(v); on[v] = True; call.append(v)
                elif on[v] and num[v] < low[u]:
                    low[u] = num[v]
            else:
                call.pop()
                if call and low[u] < low[call[-1]]:
                    low[call[-1]] = low[u]
                if low[u] == num[u]:
                    cnt += 1
                    while True:
                        v = stk.pop(); on[v] = False; comp[v] = cnt
                        if v == u:
                            break
    return cnt, comp

def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    a = [0] + [int(x) for x in data[2:2 + n]]
    adj = [[] for _ in range(n + 1)]
    E = []
    pos = 2 + n
    for i in range(m):
        u, v = int(data[pos + 2 * i]), int(data[pos + 2 * i + 1])
        adj[u].append(v); E.append((u, v))
    cnt, comp = tarjan(n, adj)
    val = [0] * (cnt + 1)
    for u in range(1, n + 1):
        val[comp[u]] += a[u]
    nxt = [[] for _ in range(cnt + 1)]
    for u, v in E:
        if comp[u] != comp[v]:
            nxt[comp[u]].append(comp[v])           # luôn sang số hiệu nhỏ hơn
    best = [0] * (cnt + 1)
    for c in range(1, cnt + 1):                     # ngược thứ tự topo
        best[c] = val[c] + max((best[d] for d in nxt[c]), default=0)
    print(best[comp[1]])

main()
Bài 8Xếp đội hình (2-SAT)Khó

Có $n$ bạn ($1 \le n \le 10^5$), mỗi bạn hoặc được chọn hoặc không. Có $m$ yêu cầu ($1 \le m \le 2 \cdot 10^5$), mỗi yêu cầu cho hai số nguyên $a, b$ ($1 \le |a|, |b| \le n$): số dương $x$ nghĩa là “bạn $x$ được chọn”, số âm $-x$ nghĩa là “bạn $x$ không được chọn”, và yêu cầu đòi ít nhất một trong hai điều đó đúng. In YES nếu có cách chọn thoả mọi yêu cầu, ngược lại in NO.

Input
3 4
1 2
-1 2
-2 3
-1 -3
Output
YES

📝 Nộp bài, chấm tự động trên ClueOJ ↗

💡 Gợi ý
Lập đồ thị $2n$ đỉnh như phần 5: đỉnh $2x$ là “$x$ được chọn”, đỉnh $2x + 1$ là “$x$ không được chọn”. Yêu cầu $(a, b)$ thêm hai cung: phủ định của $a$ → $b$, phủ định của $b$ → $a$. Chạy Tarjan, vô nghiệm khi có $x$ mà hai đỉnh của nó cùng thành phần. Ví dụ: hai yêu cầu đầu ép bạn 2 được chọn, yêu cầu 3 ép bạn 3 được chọn, yêu cầu 4 ép bạn 1 không được chọn: chọn {2, 3} thoả hết. Nếu thay yêu cầu cuối bằng -2 -3 thì thành vô nghiệm.
Xem code tham khảo (C++ / Python)
#include <bits/stdc++.h>
using namespace std;

int num[200005], low[200005], comp[200005], timer_ = 0, cnt = 0;
bool onStk[200005];
vector<int> adj[200005], st;

void dfs(int u){
    num[u] = low[u] = ++timer_;
    st.push_back(u); onStk[u] = true;
    for(int v : adj[u]){
        if(!num[v]){ dfs(v); low[u] = min(low[u], low[v]); }
        else if(onStk[v]) low[u] = min(low[u], num[v]);
    }
    if(low[u] == num[u]){
        cnt++;
        while(true){
            int v = st.back(); st.pop_back();
            onStk[v] = false; comp[v] = cnt;
            if(v == u) break;
        }
    }
}

int n, m;
int node(int x){                       // literal x > 0: x đúng; x < 0: x sai
    return x > 0 ? 2 * x : 2 * (-x) + 1;
}

int main(){
    ios::sync_with_stdio(false); cin.tie(nullptr);
    cin >> n >> m;
    for(int i = 0; i < m; i++){
        int a, b;
        cin >> a >> b;                  // mệnh đề (a hoặc b)
        adj[node(-a)].push_back(node(b));   // không a thì phải b
        adj[node(-b)].push_back(node(a));   // không b thì phải a
    }
    for(int u = 2; u <= 2 * n + 1; u++) if(!num[u]) dfs(u);
    for(int x = 1; x <= n; x++)
        if(comp[2 * x] == comp[2 * x + 1]){ cout << "NO"; return 0; }
    cout << "YES";
    return 0;
}
import sys

def tarjan(n, adj):                   # Tarjan không đệ quy, như phần 3
    num = [0] * (n + 1); low = [0] * (n + 1); comp = [0] * (n + 1)
    on = [False] * (n + 1); it = [0] * (n + 1)
    stk = []; timer = cnt = 0
    for s in range(1, n + 1):
        if num[s]:
            continue
        timer += 1; num[s] = low[s] = timer; stk.append(s); on[s] = True
        call = [s]
        while call:
            u = call[-1]
            if it[u] < len(adj[u]):
                v = adj[u][it[u]]; it[u] += 1
                if not num[v]:
                    timer += 1; num[v] = low[v] = timer
                    stk.append(v); on[v] = True; call.append(v)
                elif on[v] and num[v] < low[u]:
                    low[u] = num[v]
            else:
                call.pop()
                if call and low[u] < low[call[-1]]:
                    low[call[-1]] = low[u]
                if low[u] == num[u]:
                    cnt += 1
                    while True:
                        v = stk.pop(); on[v] = False; comp[v] = cnt
                        if v == u:
                            break
    return cnt, comp

def main():
    data = sys.stdin.buffer.read().split()
    n, m = int(data[0]), int(data[1])
    N = 2 * n + 1                        # đỉnh 2x: "x đúng", 2x + 1: "x sai"
    node = lambda x: 2 * x if x > 0 else 2 * (-x) + 1
    adj = [[] for _ in range(N + 1)]
    for i in range(m):
        a, b = int(data[2 + 2 * i]), int(data[3 + 2 * i])
        adj[node(-a)].append(node(b))    # không a thì phải b
        adj[node(-b)].append(node(a))    # không b thì phải a
    cnt, comp = tarjan(N, adj)           # đỉnh 1 không dùng, không sao
    ok = all(comp[2 * x] != comp[2 * x + 1] for x in range(1, n + 1))
    print("YES" if ok else "NO")

main()
Rất vui khi anh em nào đã đọc đến những dòng cuối cùng này. Anh em đọc chắc cũng mỏi mắt luôn chứ 😅 vậy anh em nghĩ người viết ra hết đống ở trên còn thế nào nữa 🤪. Vậy nên đừng tiếc gì cho mình một lời bình luận hoặc chia sẻ bài viết để động viên mình nè. Chúc anh em học tốt và gặt hái nhiều thành công 😇
Bản quyền thuộc về Thầy Nguyễn Việt Nam Sơn. Bài viết, lời giải, hình minh hoạ và mã nguồn trên trang là công sức biên soạn của thầy qua nhiều năm giảng dạy. Vui lòng không sao chép, đăng lại hay sử dụng dưới mọi hình thức khi chưa được sự đồng ý của tác giả. Nếu thấy hữu ích, hãy chia sẻ bằng đường link bài gốc nhé. Cảm ơn bạn!
Tự học thuật toán: khó ở đâu?
Đọc xong một bài giải là bước đầu. Phần lớn học sinh tự học sẽ gặp những trở ngại sau:
  • Không biết học gì trước, học gì sau, học lan man nên kiến thức bị hổng.
  • Bí bài không ai gỡ, ngồi cả buổi vẫn không ra rồi bỏ cuộc.
  • Code chạy được nhưng không đủ điểm, không biết sai ở đâu, chậm ở đâu.
  • Không có đề thi thử và người chấm, nên không biết mình đang ở mức nào.
  • Dễ nản giữa chừng khi không có ai theo sát.
Học cùng thầy Nam Sơn, các trở ngại đó được gỡ bỏ:
  • Lộ trình 111 buổi video sắp sẵn từ dòng code đầu tiên đến đề HSG thật của các tỉnh.
  • Thầy trực tiếp gỡ bí, chữa bài cho từng em qua nhóm Zalo riêng.
  • Hệ thống chấm bài tự động 24/7 và thi thử HSG hằng tuần để biết chính xác mình đang ở đâu.
  • Đóng học phí một lần, thầy kèm lâu dài: từ thi chuyên Tin đến HSG quốc gia và cả khi lên đại học.

Nhận xét