Chương 3·Bài 3.1

DFS and BFS

Có các thành phố và các con đường nối chúng. Từ thành phố A có tới được thành phố B không? Nếu tới được, phải đi qua ít nhất bao nhiêu con đường? Đó là bài toán trên đồ thị: mỗi thành phố là một đỉnh, mỗi con đường là một cạnh. Rất nhiều thứ khác cũng là đồ thị: các ô trên lưới cùng bước đi giữa chúng, các trạng thái cùng phép biến đổi giữa chúng.

Bài này dạy hai cách đi qua đồ thị, mỗi đỉnh đúng một lần. DFS (tìm kiếm theo chiều sâu) đi một mạch tới cùng rồi mới lùi lại. BFS (tìm kiếm theo chiều rộng) loang ra từng lớp, gần trước xa sau. Gần như mọi bài đồ thị về sau đều dựng trên hai phép duyệt này, kể cả các bài trên cây ở cuối bài.

IDanh sách kề và DFS

Một con đường đi được cả hai chiều là cạnh vô hướng; đường một chiều là cạnh có hướng. Đi từ đỉnh này sang đỉnh kia qua các cạnh là một đường đi; đường đi quay về chỗ cũ là một chu trình. Hai đỉnh liên thông khi có đường đi giữa chúng.

Để lưu đồ thị, mỗi đỉnh giữ danh sách các hàng xóm của nó: g[u] là các đỉnh nối với uu. Cạnh vô hướng thì thêm vào danh sách của cả hai đầu. Cách lưu này gọi là danh sách kề; nó tốn bộ nhớ đúng bằng số đỉnh cộng số cạnh, và muốn xét hàng xóm của uu thì chỉ việc đi qua g[u].

DFS đi như người thám hiểm mê cung mang theo cuộn chỉ: tới một đỉnh thì đánh dấu đã đến, gặp ngả nào chưa đi thì rẽ vào và đi tới cùng. Hết đường thì lần theo cuộn chỉ quay về ngả rẽ gần nhất, thử ngả khác. Mỗi đỉnh được ghé một lần, mỗi cạnh được xét hai lần (một lần từ mỗi đầu), nên cả lượt là O(n+m)O(n + m) với nn đỉnh và mm cạnh.

Số trên đỉnh là thời điểm vào/ra (tin/tout, bài 3.2); đỉnh xanh dương đang trên ngăn xếp đệ quy, cạnh đứt là cạnh ngược.
C++
void dfs(ll u) {
    vis[u] = 1;
    for (ll v : g[u])
        if (!vis[v]) dfs(v);
}

Một lượt DFS từ uu đánh dấu đúng những đỉnh tới được từ uu: cả thành phần liên thông chứa uu. Muốn đếm có bao nhiêu thành phần, cứ đi lần lượt các đỉnh; gặp đỉnh nào chưa đánh dấu thì đó là một thành phần mới, đếm nó rồi DFS từ đó.

DFS đệ quy sâu tới nn tầng. Với đồ thị cỡ 10610^6 đỉnh, nhất là lưới, nên đổi sang BFS hoặc tự giữ ngăn xếp để khỏi tràn bộ nhớ ngăn xếp. Đề có nhiều test thì chỉ xóa g[u] và vis[u] của những đỉnh đã dùng, đừng xóa cả mảng lớn mỗi test.

Ví dụSố Cụm Trong Mạng

Đề. Đồ thị vô hướng n≤100n \le 100 đỉnh, đếm số thành phần liên thông.

Lời giải. Duyệt u=1..nu = 1..n, gặp đỉnh chưa thăm thì tăng đếm và DFS từ đó. DFS đánh dấu đúng thành phần chứa uu, nên đỉnh chưa thăm tiếp theo chắc chắn thuộc một thành phần mới: nếu nó thuộc thành phần đã gặp thì DFS trước đó đã đánh dấu nó rồi. Mỗi thành phần được đếm đúng một lần.

Bước 1: danh sách kề. Cạnh vô hướng thêm vào cả hai đầu.

C++
for (ll i = 1; i <= m; i++) {
    ll u, v;
    cin >> u >> v;
    g[u].push_back(v);
    g[v].push_back(u);
}

Bước 2: DFS. Đánh dấu uu rồi gọi tiếp mọi hàng xóm chưa thăm. Khi hàm trả về, mọi đỉnh tới được từ uu đã được đánh dấu.

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

Bước 3: đếm số lần phải bắt đầu.

C++
for (ll u = 1; u <= n; u++)
    if (!vis[u]) {
        ans++;
        dfs(u);
    }
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 105;
ll n, m, vis[N];
vector<ll> g[N];

void dfs(ll u) {
    vis[u] = 1;
    for (ll v : g[u])
        if (!vis[v]) dfs(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);
        g[v].push_back(u);
    }
    ll ans = 0;
    for (ll u = 1; u <= n; u++)
        if (!vis[u]) {
            ans++;
            dfs(u);
        }
    cout << ans << '\n';
}
7 4
1 2
2 3
4 5
6 7

IIBFS

BFS loang ra như vết mực thấm trên giấy. Đỉnh xuất phát cách chính nó 0 bước. Mọi hàng xóm của nó cách 1 bước. Hàng xóm của những đỉnh ấy mà chưa gặp thì cách 2 bước, và cứ thế. Lớp gần luôn được xử lý xong trước lớp xa, nên lần đầu tiên chạm tới một đỉnh cũng là lúc đi tới nó bằng ít cạnh nhất.

Để giữ đúng thứ tự gần trước xa sau, BFS dùng một hàng đợi: lấy đỉnh uu ở đầu hàng, mỗi hàng xóm vv chưa gặp nhận d[v] = d[u] + 1 rồi xếp vào cuối hàng.

Gõ danh sách cạnh để dựng đồ thị, kéo đỉnh để sắp lại. Đỉnh xanh dương đang trong hàng đợi, số trên đỉnh là d.

Đánh dấu một đỉnh ngay lúc xếp nó vào hàng, không phải lúc lấy ra, để mỗi đỉnh vào hàng đúng một lần. Muốn in đường đi, lưu par[v] = u khi vv được gặp từ uu. Từ đích lần theo par về nguồn, rồi đảo ngược là được đường đi. BFS chỉ cho đường ngắn nhất khi mọi cạnh dài như nhau; cạnh có độ dài khác nhau cần Dijkstra (bài 3.5).

Hỏi nhanh

Vì sao BFS nên đánh dấu một đỉnh đã thăm ngay khi đẩy nó vào hàng đợi?

Ví dụTuyến Truyền Tin Ngắn Nhất

Đề. n≤105n \le 10^5 máy, m≤2⋅105m \le 2 \cdot 10^5 kết nối hai chiều. In một tuyến từ máy 1 tới máy nn qua ít máy nhất, hoặc IMPOSSIBLE.

Lời giải. BFS từ máy 1 cho khoảng cách ngắn nhất (theo số cạnh) tới mọi máy. Để in được tuyến, ghi lại par[v]: đỉnh đã đưa vv vào hàng đợi. Mỗi đỉnh chỉ có một par, và đi theo par từ nn lùi về 1 là một đường ngắn nhất, vì mỗi bước lùi giảm khoảng cách đúng 1.

Bước 1: BFS có ghi cha. d[v] = -1 là chưa thăm. Đánh dấu ngay lúc đẩy vào hàng đợi, nên mỗi đỉnh vào hàng một lần.

C++
for (ll i = 1; i <= n; i++) d[i] = -1;
queue<ll> q;
q.push(1);
d[1] = 0;
while (!q.empty()) {
    ll u = q.front();
    q.pop();
    for (ll v : g[u])
        if (d[v] == -1) {
            d[v] = d[u] + 1;
            par[v] = u;
            q.push(v);
        }
}

Bước 2: truy vết. Máy nn không được thăm thì in IMPOSSIBLE. Ngược lại lần par từ nn về 1; đường lần ngược đi từ nn về 1 nên reverse trước khi in. Số máy trên đường là d[n] + 1, chính là path.size().

C++
vector<ll> path;
for (ll v = n; v != 1; v = par[v]) path.push_back(v);
path.push_back(1);
reverse(path.begin(), path.end());
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 1e5 + 5;
ll n, m, d[N], par[N];
vector<ll> g[N];

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);
    }
    for (ll i = 1; i <= n; i++) d[i] = -1;
    queue<ll> q;
    q.push(1);
    d[1] = 0;
    while (!q.empty()) {
        ll u = q.front();
        q.pop();
        for (ll v : g[u])
            if (d[v] == -1) {
                d[v] = d[u] + 1;
                par[v] = u;
                q.push(v);
            }
    }
    if (d[n] == -1) {
        cout << "IMPOSSIBLE\n";
        return 0;
    }
    vector<ll> path;
    for (ll v = n; v != 1; v = par[v]) path.push_back(v);
    path.push_back(1);
    reverse(path.begin(), path.end());
    cout << path.size() << '\n';
    for (ll v : path) cout << v << ' ';
    cout << '\n';
}
7 7
1 2
2 3
3 7
1 4
4 5
5 6
6 7

Khi đồ thị cỡ triệu đỉnh, cách lưu cũng đáng chú ý. vector cho mỗi đỉnh là nhiều khối nhớ nhỏ rải rác. Nếu đếm bậc trước rồi xếp mọi cạnh vào một mảng liên tiếp, BFS đọc bộ nhớ gọn hơn:

BFS trên đồ thị 1 triệu đỉnh, 4 triệu cạnh.mảng liên tiếp (CSR)672 msvector<ll> g[N]1764 ms · gấp 2,6 lần
BFS trên đồ thị 1 triệu đỉnh, 4 triệu cạnh, gồm cả dựng đồ thị.

Mảng liên tiếp nhanh hơn khoảng 2,6 lần. Với đồ thị cỡ 2⋅1052 \cdot 10^5 thì vector vẫn thừa nhanh; chỉ nghĩ tới cách này khi đồ thị cỡ triệu cạnh và hết thời gian.

IIILưới

Lưới n×mn \times m cũng là một đồ thị: mỗi ô là một đỉnh, nối với các ô chung cạnh không phải tường. Không cần dựng danh sách kề. Hai mảng dx, dy liệt kê bốn hướng lên, xuống, trái, phải; từ ô (x,y)(x, y), đi theo hướng kk tới ô (x+dxk,y+dyk)(x + dx_k, y + dy_k), miễn ô đó còn trong lưới và không phải tường.

Bấm vào ô để dựng hay phá tường, và xem BFS loang ra:

Bấm ô để dựng hoặc phá tường; màu càng đậm là ô càng xa ô xuất phát ở góc trên trái.

Lưới 1000×10001000 \times 1000 đã có 10610^6 đỉnh, nên DFS đệ quy rất dễ tràn ngăn xếp. Dùng BFS với hàng đợi các cặp tọa độ:

C++
ll dx[] = {-1, 1, 0, 0}, dy[] = {0, 0, -1, 1};
queue<pair<ll, ll>> q;
q.push({sx, sy});
d[sx][sy] = 0;
while (!q.empty()) {
    ll x = q.front().first, y = q.front().second;
    q.pop();
    for (ll k = 0; k < 4; k++) {
        ll nx = x + dx[k], ny = y + dy[k];
        if (nx < 0 || ny < 0 || nx >= n || ny >= m) continue;
        if (s[nx][ny] == '#' || d[nx][ny] != -1) continue;
        d[nx][ny] = d[x][y] + 1;
        q.push({nx, ny});
    }
}

Muốn in đường đi trên lưới thì không cần lưu cả ô cha: chỉ cần nhớ hướng kk đã đi để vào mỗi ô. Từ đích, lùi ngược theo hướng đã ghi là về được xuất phát, và các hướng gom được (đảo lại) chính là chuỗi bước đi.

IVHai màu và chu trình

Đồ thị hai phía. Có chia được các đỉnh thành hai nhóm, sao cho mọi cạnh đều nối hai nhóm khác nhau không? Cứ thử tô: đỉnh đầu tô màu 1, mọi hàng xóm của nó buộc phải màu 2, hàng xóm của chúng buộc phải màu 1, và cứ thế lan ra bằng BFS. Không có lựa chọn nào cả, nên nếu gặp một cạnh hai đầu cùng màu thì chắc chắn không chia được. Điều đó xảy ra đúng khi đồ thị có một chu trình độ dài lẻ: đi vòng quanh nó, màu đổi ở mỗi cạnh, số lần đổi lẻ nên về tới chỗ cũ thì màu đã ngược.

Thêm hay bớt một cạnh để tạo chu trình lẻ; cạnh đỏ nối hai đỉnh cùng màu.

Tìm chu trình trong đồ thị vô hướng. Các cạnh mà DFS đi xuống tạo thành một cây, gọi là cây DFS. Trong lúc DFS, nếu từ đỉnh đang đứng thấy một hàng xóm đã thăm mà không phải đỉnh vừa đi tới mình, thì hàng xóm đó là một tổ tiên trên đường đang đi. Cạnh ấy gọi là cạnh ngược; nó cùng đoạn đường trên cây DFS khép thành một chu trình. Lần par từ đỉnh hiện tại về tổ tiên đó là in được chu trình. Đồ thị có hướng cần thêm một trạng thái “đang trên đường đi”, sẽ gặp ở bài 3.3.

Hỏi nhanh

Đồ thị là một chu trình gồm 5 đỉnh. Tô hai màu bằng BFS sẽ thế nào?

Ví dụChia Hai Nhóm Tương Khắc

Đề. n≤105n \le 10^5 người, m≤2⋅105m \le 2 \cdot 10^5 cặp không được cùng nhóm. In nhóm (1 hoặc 2) của từng người, hoặc IMPOSSIBLE.

Lời giải. Bài hỏi đồ thị có hai phía không. Tô hai màu bằng BFS: mỗi thành phần bắt đầu ở một đỉnh chưa tô với màu 1, hàng xóm nhận màu ngược lại. Thành phần khác nhau không ràng buộc nhau. Gặp một cạnh hai đầu cùng màu nghĩa là có chu trình lẻ, không chia được.

Bước 1: tô một thành phần. col[v] = 0 là chưa tô; màu là 1 hoặc 2, nên 3 - c đổi màu. Mọi cạnh được kiểm khi lấy đầu mút ra khỏi hàng đợi.

C++
queue<ll> q;
q.push(s);
col[s] = 1;
while (!q.empty()) {
    ll u = q.front();
    q.pop();
    for (ll v : g[u]) {
        if (!col[v]) {
            col[v] = 3 - col[u];
            q.push(v);
        } else if (col[v] == col[u]) {
            cout << "IMPOSSIBLE\n";
            return 0;
        }
    }
}

Bước 2: mọi thành phần. Vòng ngoài chạy s=1..ns = 1..n, bỏ qua đỉnh đã tô.

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

const ll N = 1e5 + 5;
ll n, m, col[N];
vector<ll> g[N];

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);
    }
    for (ll s = 1; s <= n; s++) {
        if (col[s]) continue;
        queue<ll> q;
        q.push(s);
        col[s] = 1;
        while (!q.empty()) {
            ll u = q.front();
            q.pop();
            for (ll v : g[u]) {
                if (!col[v]) {
                    col[v] = 3 - col[u];
                    q.push(v);
                } else if (col[v] == col[u]) {
                    cout << "IMPOSSIBLE\n";
                    return 0;
                }
            }
        }
    }
    for (ll i = 1; i <= n; i++) cout << col[i] << ' ';
    cout << '\n';
}
6 4
1 2
2 3
4 5
5 6

VNhiều nguồn và đỉnh phụ

Hai mẹo nhỏ biến nhiều bài trông khác hẳn thành đúng một lần BFS.

Nhiều nguồn. Đề hỏi, với mọi đỉnh, khoảng cách tới đỉnh gần nhất thuộc một tập SS. Đừng BFS từ từng đỉnh. Hãy xếp cả tập SS vào hàng đợi ngay từ đầu, khoảng cách 0, rồi BFS một lần. Như thể có một đỉnh nguồn ảo nối tới mọi đỉnh của SS: lớp 0 là cả tập SS, lớp 1 là mọi đỉnh kề với SS, và cứ thế.

Nếu cạnh có hướng mà câu hỏi lại là “từ uu đi tới tập SS mất bao lâu”, thì BFS trên đồ thị ngược (đảo chiều mọi cạnh), xuất phát từ SS. Đường từ uu tới SS trong đồ thị gốc chính là đường từ SS tới uu trong đồ thị ngược.

Ví dụBước Nhảy Đổi Tính Chẵn Lẻ

Đề. Mảng n≤2⋅105n \le 2 \cdot 10^5 số, từ ii nhảy được tới i−aii - a_i hoặc i+aii + a_i nếu còn trong mảng. Với mỗi ii, in số bước ít nhất để tới một vị trí có aja_j khác tính chẵn lẻ với aia_i, hoặc −1-1.

Lời giải. Bước nhảy là cạnh có hướng i→i±aii \to i \pm a_i. Với vị trí aia_i lẻ, đáp án là khoảng cách từ ii tới tập các vị trí chẵn. BFS từ từng ii thì quá chậm; đảo chiều mọi cạnh, khoảng cách từ ii tới tập nguồn trên đồ thị gốc bằng khoảng cách từ tập nguồn tới ii trên đồ thị ngược, và BFS nhiều nguồn tính được nó cho mọi ii một lần. Làm hai lần: nguồn là các vị trí chẵn, rồi nguồn là các vị trí lẻ.

Bước 1: đồ thị ngược. rg[t] chứa mọi ii nhảy được tới tt.

C++
for (ll i = 1; i <= n; i++) {
    if (i - a[i] >= 1) rg[i - a[i]].push_back(i);
    if (i + a[i] <= n) rg[i + a[i]].push_back(i);
}

Bước 2: BFS nhiều nguồn. Nguồn là mọi vị trí có aia_i chia 2 dư pp, khoảng cách 0, cùng vào hàng đợi trước khi BFS. Chỉ ghi ans[i] cho các vị trí có tính chẵn lẻ khác pp, đúng những vị trí cần tới tập nguồn này.

C++
void bfs(ll p) {
    queue<ll> q;
    for (ll i = 1; i <= n; i++) {
        d[i] = -1;
        if (a[i] % 2 == p) {
            d[i] = 0;
            q.push(i);
        }
    }
    while (!q.empty()) {
        ll u = q.front();
        q.pop();
        for (ll v : rg[u])
            if (d[v] == -1) {
                d[v] = d[u] + 1;
                q.push(v);
            }
    }
    for (ll i = 1; i <= n; i++)
        if (a[i] % 2 != p) ans[i] = d[i];
}
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 2e5 + 5;
ll n, a[N], d[N], ans[N];
vector<ll> rg[N];

void bfs(ll p) {
    queue<ll> q;
    for (ll i = 1; i <= n; i++) {
        d[i] = -1;
        if (a[i] % 2 == p) {
            d[i] = 0;
            q.push(i);
        }
    }
    while (!q.empty()) {
        ll u = q.front();
        q.pop();
        for (ll v : rg[u])
            if (d[v] == -1) {
                d[v] = d[u] + 1;
                q.push(v);
            }
    }
    for (ll i = 1; i <= n; i++)
        if (a[i] % 2 != p) ans[i] = d[i];
}

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

    cin >> n;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    for (ll i = 1; i <= n; i++) {
        if (i - a[i] >= 1) rg[i - a[i]].push_back(i);
        if (i + a[i] <= n) rg[i + a[i]].push_back(i);
    }
    bfs(0);
    bfs(1);
    for (ll i = 1; i <= n; i++) cout << ans[i] << ' ';
    cout << '\n';
}
5
1 2 1 2 1

Đỉnh phụ. Có khi cái tốn chi phí không phải là đi qua một cạnh, mà là đổi một thứ gì đó: đổi tuyến xe, đổi màu, đổi phương tiện. Khi đó thêm một đỉnh mới cho chính thứ được đổi. Ví dụ mỗi tuyến xe buýt thành một đỉnh, nối với mọi ga mà tuyến đi qua. Lên một tuyến rồi xuống ở ga khác là hai cạnh, nên số tuyến phải đi bằng nửa khoảng cách BFS. Số cạnh mới chỉ bằng số cặp (ga, tuyến); nếu nối thẳng mọi cặp ga cùng tuyến thì có thể tới cỡ n2n^2 cạnh.

VIQuy hoạch động trên cây

Cây là đồ thị liên thông không có chu trình: nn đỉnh, đúng n−1n - 1 cạnh, và giữa hai đỉnh bất kỳ có đúng một đường đi. Chọn một đỉnh làm gốc, chẳng hạn đỉnh 1, rồi treo cây xuống. Lúc này mỗi đỉnh khác gốc có đúng một cha là đỉnh ngay phía trên, các đỉnh ngay phía dưới là con, và cả phần treo dưới một đỉnh là cây con của nó.

Cây treo từ gốc 1: đỉnh 2 có cha là 1, hai con là 4 và 5; phần tô xanh là cây con của 2.4752631gốccây con của 2
Cây treo từ gốc 1: đỉnh 2 có cha là 1, hai con là 4 và 5; phần tô xanh là cây con của 2.

Rất nhiều câu hỏi trên cây có cùng một dáng: đáp án ở một đỉnh tính được từ đáp án ở các con của nó. Ví dụ đơn giản nhất là kích thước cây con, tức số đỉnh trong cây con của uu: bằng 1 (chính uu) cộng kích thước cây con của từng con. Cái khó duy nhất là thứ tự: phải tính con xong trước cha.

DFS đệ quy làm được điều này một cách tự nhiên, nhưng cây có thể là một đường dài 2⋅1052 \cdot 10^5 đỉnh, đệ quy sâu chừng ấy tầng dễ tràn ngăn xếp. Cách an toàn là BFS từ gốc rồi lấy thứ tự gặp đỉnh ord. Trong thứ tự này cha luôn đứng trước con, nên đi ngược ord thì con luôn xong trước cha:

C++
for (ll i = 1; i <= n; i++) sz[i] = 1;
for (ll i = n - 1; i >= 1; i--) sz[par[ord[i]]] += sz[ord[i]];

Khi mỗi đỉnh có hai lựa chọn, ta giữ hai con số. Bài kinh điển: chọn nhiều đỉnh nhất sao cho không có hai đỉnh được chọn nào nối với nhau bằng một cạnh. Gọi f1[u] là số đỉnh chọn được nhiều nhất trong cây con của uu khi chọn uu, f0[u] là con số đó khi không chọn uu.

  • Chọn uu thì không con nào được chọn: f1[u] bằng 1 cộng tổng f0 của các con.
  • Không chọn uu thì mỗi con tự do chọn hay không, lấy cách tốt hơn: f0[u] bằng tổng, qua các con, của số lớn hơn giữa f0 và f1.

Bấm chạy để xem các con số đi từ lá lên gốc; cuối cùng các đỉnh được chọn hiện màu cam:

Nhãn trên đỉnh là k/c: số đỉnh chọn được nhiều nhất trong cây con khi không chọn / khi chọn đỉnh đó.

Viết thành code, mỗi đỉnh xong thì đẩy phần của nó lên cha:

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 2e5 + 5;
ll n, par[N], vis[N], f0[N], f1[N];
vector<ll> g[N];

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

    cin >> n;
    for (ll i = 1; i < n; i++) {
        ll u, v;
        cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);
    }
    vector<ll> ord;
    queue<ll> q;
    q.push(1);
    vis[1] = 1;
    while (!q.empty()) {
        ll u = q.front();
        q.pop();
        ord.push_back(u);
        for (ll v : g[u])
            if (!vis[v]) {
                vis[v] = 1;
                par[v] = u;
                q.push(v);
            }
    }
    for (ll i = n - 1; i >= 0; i--) {
        ll u = ord[i];
        f1[u] += 1;
        if (i == 0) continue;
        f0[par[u]] += max(f0[u], f1[u]);
        f1[par[u]] += f0[u];
    }
    cout << max(f0[1], f1[1]) << '\n';
}
7
1 2
1 3
2 4
2 5
3 6
5 7

Bài ghép cặp nhiều cạnh nhất trên cây đi theo đúng khuôn này, chỉ đổi ý nghĩa hai con số. Khi trạng thái còn kèm một con số đếm, kiểu “chọn đúng kk đỉnh trong cây con”, ta gộp bảng của từng con vào bảng của cha như bài ba lô. Gộp sao cho mỗi bảng chỉ dài bằng kích thước cây con thì tổng cả cây chỉ O(n2)O(n^2), vì mỗi cặp đỉnh chỉ “gặp nhau” một lần ở chỗ hai nhánh của chúng hợp lại.

Hỏi nhanh

Đỉnh uu có hai con. Con thứ nhất có f0 = 3, f1 = 2; con thứ hai có f0 = 1, f1 = 4. Tính f0[u] và f1[u] (chọn nhiều đỉnh nhất, không hai đỉnh được chọn nào kề nhau).

Ví dụChọn Đại Diện Sửa Đường

Đề. Cây n≤105n \le 10^5 khu vực, trụ sở ở khu vực 1. Mỗi đường là loại 1 (bình thường) hoặc loại 2 (cần sửa). Chọn một khu vực nghĩa là cử người ở đó đi theo đường duy nhất về khu vực 1 và sửa mọi đường loại 2 trên đường đi. Chọn ít khu vực nhất để mọi đường loại 2 đều được sửa; in số lượng và danh sách.

Lời giải. Treo cây từ khu vực 1. Xét một đường loại 2 nối vv với cha của nó. Người đi qua đường này phải xuất phát từ đâu đó trong cây con của vv. Vậy có hai trường hợp:

  • Cây con của vv đã có người được chọn: người đó đi về gốc chắc chắn đi qua đường này, không cần làm gì thêm.
  • Chưa có ai: bắt buộc chọn thêm một người trong cây con của vv. Chọn ngay vv là tốt nhất, vì người ở vv sửa được đúng đường này, và mọi đường phía trên mà người ở sâu hơn sửa được thì người ở vv cũng sửa được.

Ghi has[v] = 1 khi cây con của vv đã có người được chọn. Đi ngược thứ tự BFS để con xong trước cha; mỗi đỉnh xong thì báo lên cha nếu cây con mình đã có người. Mỗi lần chọn đều bắt buộc, nên số người là ít nhất. Đề cho in danh sách theo thứ tự bất kỳ; ta sắp lại cho gọn.

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

const ll N = 1e5 + 5;
ll n, par[N], typ[N], vis[N], has[N];
vector<pair<ll, ll>> g[N];

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

    cin >> n;
    for (ll i = 1; i < n; i++) {
        ll u, v, t;
        cin >> u >> v >> t;
        g[u].push_back({v, t});
        g[v].push_back({u, t});
    }
    vector<ll> ord;
    queue<ll> q;
    q.push(1);
    vis[1] = 1;
    while (!q.empty()) {
        ll u = q.front();
        q.pop();
        ord.push_back(u);
        for (pair<ll, ll> e : g[u])
            if (!vis[e.first]) {
                vis[e.first] = 1;
                par[e.first] = u;
                typ[e.first] = e.second;
                q.push(e.first);
            }
    }
    vector<ll> ans;
    for (ll i = n - 1; i >= 1; i--) {
        ll v = ord[i];
        if (typ[v] == 2 && !has[v]) {
            has[v] = 1;
            ans.push_back(v);
        }
        if (has[v]) has[par[v]] = 1;
    }
    sort(ans.begin(), ans.end());
    cout << ans.size() << '\n';
    for (ll v : ans) cout << v << ' ';
    cout << '\n';
}
7
1 2 2
2 3 1
2 4 2
1 5 1
5 6 2
6 7 1

VIIĐường kính và tâm

Đường kính của cây là đường đi dài nhất giữa hai đỉnh. Tìm nó chỉ cần hai lần BFS:

  1. BFS từ một đỉnh bất kỳ, lấy đỉnh xa nhất, gọi là aa.
  2. BFS từ aa, lấy đỉnh xa nhất, gọi là bb. Đường từ aa tới bb là một đường kính.
Lượt 1 BFS từ đỉnh 1 tìm đỉnh xa nhất a; lượt 2 BFS từ a tìm b; cạnh xanh là đường kính.

Vì sao đỉnh xa nhất ở lượt 1 lại là một đầu đường kính? Ý chính của chứng minh: giả sử không phải. Lấy một đường kính thật, nối đoạn đường đi tới aa với nửa dài hơn của đường kính ấy, ta luôn được một đường còn dài hơn đường kính, vô lý. Lý luận này cần mọi cạnh có độ dài không âm.

Một cách khác là quy hoạch động: ở mỗi đỉnh uu, giữ độ sâu lớn nhất của hai nhánh đi xuống dài nhất từ hai con khác nhau. Đường dài nhất có điểm cao nhất là uu bằng tổng hai nhánh đó; đường kính là giá trị lớn nhất qua mọi uu. Cách này dùng được cả khi cạnh có trọng số âm.

Tâm của cây là đỉnh nằm giữa đường kính: một đỉnh nếu đường kính có số cạnh chẵn, hai đỉnh kề nhau nếu lẻ. Đứng ở tâm thì khoảng cách tới đỉnh xa nhất là nhỏ nhất. Một hệ quả hay dùng: với mọi đỉnh uu, đỉnh xa uu nhất luôn là aa hoặc bb, nên chỉ cần thêm một lần BFS từ bb là có khoảng cách tới đỉnh xa nhất của mọi đỉnh.

Câu hỏi nhanh dành cho tài khoản Pro

Mở khóa bằng tài khoản Pro

Ví dụBa Đỉnh Phủ Nhiều Cạnh

Đề. Cây n≤2⋅105n \le 2 \cdot 10^5 đỉnh. Chọn ba đỉnh khác nhau a,b,ca, b, c sao cho hợp ba đường đi giữa từng cặp chứa nhiều cạnh nhất. In số cạnh đó và một bộ ba.

Lời giải. Hợp ba đường giữa ba đỉnh là phần cây nhỏ nhất nối cả ba. Lấy hai đầu của một đường kính làm aa và bb là tốt nhất: chúng đã phủ được đường dài nhất có thể. Khi đó thêm cc chỉ góp phần đường từ cc tới chỗ gần nhất trên đường kính. Vậy cần đỉnh xa đường kính nhất, và đó đúng là BFS nhiều nguồn: xếp mọi đỉnh trên đường kính vào hàng đợi với khoảng cách 0. Đáp án là độ dài đường kính cộng khoảng cách của cc.

Cả bài là ba lần BFS. Hàm bfs nhận một danh sách nguồn, nên dùng chung được cho cả hai lượt tìm đường kính lẫn lượt nhiều nguồn. Có một chỗ dễ sót: khi mọi đỉnh đều nằm trên đường kính, cc vẫn phải khác aa và bb, nên ta chọn cc trong các đỉnh khác hai đầu.

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

const ll N = 2e5 + 5;
ll n, d[N], par[N];
vector<ll> g[N];

void bfs(vector<ll> src) {
    for (ll i = 1; i <= n; i++) d[i] = -1;
    queue<ll> q;
    for (ll s : src) {
        d[s] = 0;
        par[s] = s;
        q.push(s);
    }
    while (!q.empty()) {
        ll u = q.front();
        q.pop();
        for (ll v : g[u])
            if (d[v] == -1) {
                d[v] = d[u] + 1;
                par[v] = u;
                q.push(v);
            }
    }
}

ll far() {
    ll best = 1;
    for (ll i = 2; i <= n; i++)
        if (d[i] > d[best]) best = i;
    return best;
}

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

    cin >> n;
    for (ll i = 1; i < n; i++) {
        ll u, v;
        cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);
    }
    bfs({1});
    ll a = far();
    bfs({a});
    ll b = far(), len = d[b];
    vector<ll> path;
    for (ll v = b; v != a; v = par[v]) path.push_back(v);
    path.push_back(a);
    bfs(path);
    ll c = 0;
    for (ll v = 1; v <= n; v++)
        if (v != a && v != b && (c == 0 || d[v] > d[c])) c = v;
    cout << len + d[c] << '\n' << a << ' ' << b << ' ' << c << '\n';
}
6
1 2
2 3
3 4
3 5
5 6

Khi đi thi

  • Cạnh dài như nhau, hỏi ít bước nhất thì BFS; chỉ cần đi hết, đếm hay tô thì DFS hay BFS đều được. Đồ thị hay lưới cỡ triệu đỉnh thì dùng BFS bằng hàng đợi để khỏi tràn ngăn xếp.
  • Hỏi khoảng cách “từ mọi đỉnh tới một tập” thì BFS nhiều nguồn, cạnh có hướng thì chạy trên đồ thị ngược; đổi tuyến, đổi màu tốn chi phí thì thêm đỉnh phụ.
  • Bài trên cây: treo từ gốc, tính đáp án từ con lên cha theo thứ tự BFS ngược thay vì đệ quy sâu; cần đường kính thì hai lần BFS, đỉnh xa nhất của mọi đỉnh luôn là một đầu đường kính.

Tóm tắt

  • Danh sách kề lưu đồ thị gọn; DFS và BFS đều O(n+m)O(n + m), đánh dấu để mỗi đỉnh chỉ được xử lý một lần.
  • BFS cho số cạnh ít nhất từ nguồn, mảng par cho lại đường đi; lưới là đồ thị ẩn với dx, dy.
  • Tô hai màu kiểm đồ thị hai phía, thất bại đúng khi có chu trình lẻ; cạnh ngược trong DFS cho chu trình.
  • BFS nhiều nguồn, đồ thị ngược và đỉnh phụ đưa nhiều bài về một lần BFS.
  • Trên cây, đi ngược thứ tự BFS thì con xong trước cha; mỗi lựa chọn ở đỉnh là một con số, như f0 và f1.
  • Đường kính bằng hai lần BFS; tâm nằm giữa đường kính.

Bài tập

Bài tập ngoài

Đọc thêm