Chương 3·Bài 3.1

DFS and BFS

Bản đồ có các thành phố và các con đường. Hỏi: từ thành phố A có tới được thành phố B không, và nếu tới được thì đi qua ít đường nhất là bao nhiêu? Đó là bài toán trên đồ thị: các đỉnh (thành phố) và các cạnh (đường) nối chúng. Ô trên lưới và bước đi, trạng thái và phép biến đổi cũng là đồ thị.

Hai cách duyệt cơ bản đi qua mọi đỉnh tới được từ một đỉnh xuất phát, mỗi đỉnh một lần, trong O(n+m)O(n + m). Tìm kiếm theo chiều sâu (DFS) đi một mạch tới cùng rồi mới lùi. Tìm kiếm theo chiều rộng (BFS) loang ra theo từng lớp khoảng cách. Hầu hết bài đồ thị về sau đều dựng trên hai phép duyệt này.

Danh sách kề và DFS

Vài chữ dùng suốt chương. Cạnh vô hướng đi được cả hai chiều, cạnh có hướng chỉ đi theo chiều mũi tên. Bậc của một đỉnh là số cạnh nối với nó. Đường đi là dãy đỉnh mà hai đỉnh liền nhau có cạnh nối; chu trình là đường đi quay về đỉnh đầu. Hai đỉnh liên thông khi có đường đi giữa chúng.

Đồ thị nn đỉnh, mm cạnh lưu bằng danh sách kề: g[u] là các đỉnh nối với uu, mỗi cạnh vô hướng thêm vào cả hai đầu. Bộ nhớ O(n+m)O(n + m), duyệt hàng xóm của uu tốn đúng bậc của uu. Ma trận kề n×nn \times n chỉ hợp khi nn cỡ vài nghìn.

DFS từ uu: đánh dấu uu đã thăm, rồi gọi DFS cho từng hàng xóm chưa thăm. Mỗi đỉnh được gọi một lần, mỗi cạnh được xét hai lần, nên tổng O(n+m)O(n + m).

Gọi DFS từ mọi đỉnh chưa thăm, mỗi lần gọi từ ngoài là một thành phần liên thông mới. Đệ quy sâu tới nn tầng; với nn cỡ 10610^6 trên lưới, nên đổi sang BFS hoặc tự giữ ngăn xếp.

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);
}

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ó. 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

Độ phức tạp: mỗi đỉnh và mỗi cạnh được xét một lần: O(n+m)O(n + m).

BFS

BFS dùng một hàng đợi. Đỉnh xuất phát có khoảng cách 0; lấy đỉnh uu ở đầu hàng, mỗi hàng xóm vv chưa thăm nhận d[v] = d[u] + 1 và vào cuối hàng. Hàng đợi luôn chứa các đỉnh khoảng cách kk rồi tới k+1k + 1, không bao giờ lẫn lớp xa hơn, nên mọi đỉnh lớp kk được lấy ra trước mọi đỉnh lớp k+1k + 1 và d[v] là số cạnh ít nhất từ nguồn tới vv. Đánh dấu thăm lúc đẩy vào hàng, không phải lúc lấy ra, để mỗi đỉnh vào hàng một lần.

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.

Muốn in đường đi, lưu par[v] = u khi vv được thăm từ uu; từ đích lần par về nguồn rồi đảo ngược. BFS chỉ đúng khi mọi cạnh cùng độ dài; cạnh có trọng số 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

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

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 thì 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,5 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.

Lưới

Lưới n×mn \times m là đồ thị mà mỗi ô là một đỉnh, nối với các ô chung cạnh không bị chặn. Không cần dựng danh sách kề: giữ hai mảng dx, dy cho bốn hướng (hoặc tám hướng), và một hàm kiểm ô nằm trong lưới. Lưới 1000×10001000 \times 1000 có 10610^6 đỉnh, nên DFS đệ quy dễ tràn ngăn xếp; dùng BFS với hàng đợi các cặp tọa độ. Truy vết trên lưới chỉ cần nhớ hướng đã đi vào mỗi ô.

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.

Ví dụLối Ra Ngắn Nhất

Đề. Lưới n,m≤1000n, m \le 1000 có tường, một ô A và một ô B. In YES, độ dài và chuỗi bước LRUD của một đường ngắn nhất từ A tới B, hoặc NO.

Lời giải. BFS trên lưới từ A. Thay vì lưu ô cha, lưu hướng đã đi để vào mỗi ô: from[x][y] = k. Từ B lùi ngược theo hướng đã ghi là về được A, và các hướng gom được chính là chuỗi bước, theo thứ tự ngược.

Bước 1: tìm A, B và khởi tạo. from[x][y] = -1 là chưa thăm; ô xuất phát đặt 4 để khác −1-1 mà không trùng hướng nào. Hướng kk ứng với chữ "UDLR"[k], khớp với dx, dy.

C++
for (ll i = 0; i < n; i++) {
    cin >> s[i];
    for (ll j = 0; j < m; j++) {
        from[i][j] = -1;
        if (s[i][j] == 'A') ax = i, ay = j;
        if (s[i][j] == 'B') bx = i, by = j;
    }
}

Bước 2: BFS ghi hướng. dx, dy là bốn hướng lên, xuống, trái, phải. Ba điều kiện continue loại ô ra ngoài lưới, ô tường và ô đã thăm. Hàng đợi chứa thẳng cặp tọa độ, và mỗi ô mới được ghi hướng from[nx][ny] = k lúc thăm.

C++
queue<pair<ll, ll>> q;
q.push({ax, ay});
from[ax][ay] = 4;
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] == '#' || from[nx][ny] != -1) continue;
        from[nx][ny] = k;
        q.push({nx, ny});
    }
}

Bước 3: truy vết. Đứng ở B, lấy hướng k đã vào ô này, ghi chữ dir[k], rồi lùi một bước ngược hướng đó. Chữ cái gom được theo thứ tự từ B về A, nên đảo lại trước khi in.

C++
string path;
for (ll x = bx, y = by; x != ax || y != ay;) {
    ll k = from[x][y];
    path += dir[k];
    x -= dx[k];
    y -= dy[k];
}
reverse(path.begin(), path.end());
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long

const ll N = 1005;
ll n, m, from[N][N];
ll dx[] = {-1, 1, 0, 0}, dy[] = {0, 0, -1, 1};
string s[N], dir = "UDLR";

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

    cin >> n >> m;
    ll ax = 0, ay = 0, bx = 0, by = 0;
    for (ll i = 0; i < n; i++) {
        cin >> s[i];
        for (ll j = 0; j < m; j++) {
            from[i][j] = -1;
            if (s[i][j] == 'A') ax = i, ay = j;
            if (s[i][j] == 'B') bx = i, by = j;
        }
    }
    queue<pair<ll, ll>> q;
    q.push({ax, ay});
    from[ax][ay] = 4;
    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] == '#' || from[nx][ny] != -1) continue;
            from[nx][ny] = k;
            q.push({nx, ny});
        }
    }
    if (from[bx][by] == -1) {
        cout << "NO\n";
        return 0;
    }
    string path;
    for (ll x = bx, y = by; x != ax || y != ay;) {
        ll k = from[x][y];
        path += dir[k];
        x -= dx[k];
        y -= dy[k];
    }
    reverse(path.begin(), path.end());
    cout << "YES\n" << path.size() << '\n' << path << '\n';
}
5 6
######
#A...#
###.##
#...B#
######

Độ phức tạp: O(nm)O(nm).

Hai màu và chu trình

Đồ thị hai phía. Một đồ thị chia được đỉnh thành hai phía, mọi cạnh nối hai phía khác nhau, khi và chỉ khi nó không có chu trình độ dài lẻ. Kiểm bằng cách duyệt từng thành phần và tô màu: đỉnh đầu màu 1, hàng xóm của đỉnh màu cc nhận màu 3−c3 - c. Gặp một cạnh hai đầu cùng màu là vô nghiệm; không gặp thì cách tô đó là một cách chia.

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: đỉnh gọi DFS cho vv là cha của vv, các đỉnh trên đường từ đỉnh xuất phát xuống uu là tổ tiên của uu. Trong DFS, cạnh tới một đỉnh đã thăm mà không phải cha là một cạnh ngược: nó nối đỉnh hiện tại với một tổ tiên, và cùng đường đi trên cây DFS tạo thành 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 ba trạng thái (chưa thăm, đang trên ngăn xếp, đã xong), sẽ gặp ở bài 3.3.

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.

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

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

Nhiều nguồn

Muốn biết 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, mà đẩy cả tập SS vào hàng đợi với 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. Khi cạnh có hướng mà câu hỏi lại là “từ uu tới gần nhất tập SS”, BFS trên đồ thị ngược (đảo chiều mọi cạnh) từ SS: đường từ uu tới SS trong đồ thị gốc 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

Độ phức tạp: hai lần BFS, mỗi lần O(n)O(n) vì mỗi đỉnh có tối đa hai cạnh.

Đỉnh phụ

Khi chi phí không nằm trên cạnh mà ở việc đổi một thứ gì đó (đổi tuyến, đổi màu, đổi phương tiện), thêm đỉnh mới cho thứ đó. Mỗi tuyến xe thành một đỉnh, nối với mọi ga trên tuyến: đi từ ga lên tuyến rồi xuống ga khác là hai cạnh, nên số tuyến đã dùng bằng nửa khoảng cách BFS. Số cạnh mới bằng số cặp (ga, tuyến), vẫn O(m)O(m), trong khi nối thẳng mọi cặp ga cùng tuyến có thể tới O(n2)O(n^2) cạnh.

Khi đi thi

  • Cạnh cùng độ dài, hỏi ít bước nhất: BFS; chỉ cần đi hết, đếm hay tô: DFS hay BFS đều được. Lưới lớn thì BFS bằng hàng đợi để khỏi tràn ngăn xếp.
  • Nhiều test trong một input: xóa danh sách kề và mảng thăm đúng phần đã dùng, không memset cả mảng lớn mỗi test.
  • Đổi trạng thái tốn chi phí thì thêm đỉnh (tuyến, màu, số lần đã dùng một quyền); hỏi “từ mọi đỉnh tới tập SS” thì BFS nhiều nguồn, có hướng thì trên đồ thị ngược.

Tóm tắt

  • Danh sách kề; DFS và BFS đều O(n+m)O(n + m), đánh dấu để mỗi đỉnh được xử lý một lần.
  • BFS cho số cạnh ít nhất từ nguồn; mảng par cho đường đi.
  • Lưới là đồ thị ẩn với dx, dy; truy vết bằng hướng đã đi vào ô.
  • Tô hai màu kiểm đồ thị hai phía; cạnh ngược trong DFS cho chu trình.
  • BFS nhiều nguồn, đồ thị ngược và đỉnh phụ mở rộng BFS cho nhiều bài.

Bài tập

Đọc thêm