Chương 3·Bài 30

DFS and BFS

Đồ thị gồm các đỉnh và các cạnh nối chúng: thành phố và đường, ô trên lưới và bước đi, trạng thái và phép biến đổi. 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 31); đỉ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;
using 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

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

BFS theo lớp khoảng cách

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 34).

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;
using 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

O(n+m)O(n + m).

Lưới là đồ thị

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ụNhững Vùng Trống

Đề. Lưới n,m≤1000n, m \le 1000 gồm . và #. Đếm số vùng . liên thông bốn hướng.

Lời giải. Như đếm thành phần liên thông, chỉ đổi đồ thị thành lưới: mỗi ô . là một đỉnh, nối với các ô . chung cạnh. Gặp ô . chưa thăm thì tăng đếm và loang hết vùng của nó. Dùng BFS thay cho DFS đệ quy vì một vùng có thể có 10610^6 ô, quá sâu cho ngăn xếp.

Bước 1: loang một vùng. dx, dy là bốn hướng lên, xuống, trái, phải. Hai dòng continue loại ô ra ngoài lưới, ô tường và ô đã thăm. Hàng đợi chứa thẳng cặp tọa độ, không cần đánh số ô.

C++
void bfs(ll sx, ll sy) {
    queue<pair<ll, ll>> q;
    q.push({sx, sy});
    vis[sx][sy] = 1;
    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] == '#' || vis[nx][ny]) continue;
            vis[nx][ny] = 1;
            q.push({nx, ny});
        }
    }
}

Bước 2: đếm vùng. Vòng ngoài đếm số lần phải bắt đầu một vùng mới.

C++
for (ll i = 0; i < n; i++)
    for (ll j = 0; j < m; j++)
        if (s[i][j] == '.' && !vis[i][j]) {
            ans++;
            bfs(i, j);
        }
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

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

void bfs(ll sx, ll sy) {
    queue<pair<ll, ll>> q;
    q.push({sx, sy});
    vis[sx][sy] = 1;
    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] == '#' || vis[nx][ny]) continue;
            vis[nx][ny] = 1;
            q.push({nx, ny});
        }
    }
}

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

    cin >> n >> m;
    for (ll i = 0; i < n; i++) cin >> s[i];
    ll ans = 0;
    for (ll i = 0; i < n; i++)
        for (ll j = 0; j < m; j++)
            if (s[i][j] == '.' && !vis[i][j]) {
                ans++;
                bfs(i, j);
            }
    cout << ans << '\n';
}
5 7
##..###
#...#.#
###.#.#
#..##..
#..##..

Mỗi ô vào hàng đợi một lần: O(nm)O(nm).

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. Như loang vùng, chỉ thêm from[nx][ny] = k lúc thăm ô mới.

C++
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;
using 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#
######

O(nm)O(nm).

Tô hai màu và tìm 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 32.

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;
using 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

O(n+m)O(n + m).

Ví dụMột Chuyến Đi Khép Kín

Đề. Đồ thị vô hướng đơn n≤105n \le 10^5 đỉnh, m≤2⋅105m \le 2 \cdot 10^5 cạnh. In một chu trình đơn (đỉnh đầu lặp lại ở cuối), hoặc IMPOSSIBLE.

Lời giải. DFS từ mọi đỉnh chưa thăm. Ở đỉnh uu, gặp hàng xóm vv đã thăm và khác par[u] thì vv là tổ tiên của uu: đồ thị vô hướng không có cạnh ngang trong DFS. Khi đó chu trình là v,u,par[u],…,vv, u, \mathrm{par}[u], \dots, v. Đồ thị đơn nên không có hai cạnh giữa cùng một cặp đỉnh, cạnh tới cha chính là cạnh vừa đi xuống, và chu trình tìm được có ít nhất ba đỉnh.

Bước 1: DFS dừng sớm. dfs trả về bool để dừng mọi lời gọi ngay khi đã tìm được chu trình. Bỏ qua đúng đỉnh cha; gặp đỉnh đã thăm khác cha thì lần par từ uu lên tới vv để ghi chu trình, đặt vv ở cả đầu và cuối như đề yêu cầu.

C++
bool dfs(ll u) {
    vis[u] = 1;
    for (ll v : g[u]) {
        if (v == par[u]) continue;
        if (vis[v]) {
            cyc.push_back(v);
            for (ll x = u; x != v; x = par[x]) cyc.push_back(x);
            cyc.push_back(v);
            return 1;
        }
        par[v] = u;
        if (dfs(v)) return 1;
    }
    return 0;
}

Bước 2: thử từ mọi đỉnh. Đồ thị có thể không liên thông; thành phần nào có chu trình thì in ngay và kết thúc.

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

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

bool dfs(ll u) {
    vis[u] = 1;
    for (ll v : g[u]) {
        if (v == par[u]) continue;
        if (vis[v]) {
            cyc.push_back(v);
            for (ll x = u; x != v; x = par[x]) cyc.push_back(x);
            cyc.push_back(v);
            return 1;
        }
        par[v] = u;
        if (dfs(v)) return 1;
    }
    return 0;
}

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 u = 1; u <= n; u++)
        if (!vis[u] && dfs(u)) {
            cout << cyc.size() << '\n';
            for (ll x : cyc) cout << x << ' ';
            cout << '\n';
            return 0;
        }
    cout << "IMPOSSIBLE\n";
}
6 5
1 2
2 3
3 4
4 1
5 6

O(n+m)O(n + m).

BFS từ nhiều nguồn và đồ thị ngược

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;
using 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

Hai lần BFS, mỗi lần O(n)O(n) vì mỗi đỉnh có tối đa hai cạnh.

Thêm đỉnh phụ cho trạng thái

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.

Ví dụÍt Tuyến Nhất Giữa Hai Ga

Đề. Mạng n≤2⋅105n \le 2 \cdot 10^5 ga, mỗi đường nối thuộc một tuyến có màu; các đường cùng màu liên thông với nhau. Tìm số tuyến ít nhất để đi từ ga bb tới ga ee.

Lời giải. Số tuyến không phải số đường nối, nên BFS thẳng trên ga không đúng. Thêm một đỉnh cho mỗi tuyến và nối ga với tuyến chứa nó: đi từ ga lên tuyến, rồi từ tuyến xuống một ga khác, tốn 2 cạnh cho mỗi lần dùng một tuyến. Một tuyến liên thông nên lên ở bất kỳ ga nào của tuyến cũng tới được mọi ga của tuyến. Đường đi luôn xen kẽ ga, tuyến, ga, …, nên tới ga ee thì khoảng cách chẵn và số tuyến là d[e] / 2; b=eb = e thì bằng 0.

Bước 1: đỉnh tuyến. id[c] là chỉ số đỉnh của tuyến màu cc, đánh từ n+1n + 1 trở đi. Mỗi đường (u,v,c)(u, v, c) thêm hai cạnh ga với tuyến, không thêm cạnh ga với ga nào cả. Mảng cỡ 4⋅1054 \cdot 10^5 đủ cho nn ga và tối đa mm tuyến.

C++
if (!id.count(c)) id[c] = ++k;
g[u].push_back(id[c]);
g[id[c]].push_back(u);
g[v].push_back(id[c]);
g[id[c]].push_back(v);

Bước 2: BFS thường từ ga bb, rồi in d[e] / 2.

Bước 3: nhiều bộ test. Cuối mỗi bộ xóa danh sách kề của mọi đỉnh đã dùng, cả ga lẫn tuyến.

C++
for (ll i = 1; i <= k; i++) g[i].clear();
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const ll N = 4e5 + 5;
ll d[N];
vector<ll> g[N];

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

    ll T;
    cin >> T;
    while (T--) {
        ll n, m;
        cin >> n >> m;
        map<ll, ll> id;
        ll k = n;
        for (ll i = 1; i <= m; i++) {
            ll u, v, c;
            cin >> u >> v >> c;
            if (!id.count(c)) id[c] = ++k;
            g[u].push_back(id[c]);
            g[id[c]].push_back(u);
            g[v].push_back(id[c]);
            g[id[c]].push_back(v);
        }
        ll b, e;
        cin >> b >> e;
        for (ll i = 1; i <= k; i++) d[i] = -1;
        queue<ll> q;
        q.push(b);
        d[b] = 0;
        while (!q.empty()) {
            ll u = q.front();
            q.pop();
            for (ll v : g[u])
                if (d[v] == -1) {
                    d[v] = d[u] + 1;
                    q.push(v);
                }
        }
        cout << d[e] / 2 << '\n';
        for (ll i = 1; i <= k; i++) g[i].clear();
    }
}
1
6 5
1 2 7
2 3 7
3 4 8
4 5 8
5 6 9
1 6

O((n+m)log⁡m)O((n + m) \log m), phần log⁡\log do map nén màu.

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

1
Các Nút Chưa Nhận Tín Hiệucanuc80k3002BFS từ nhà 1, in các nhà không được thăm theo thứ tự tăng, không có thì in Connected.
LQDOJ
2
Bổ Sung Liên Kết Tối Thiểucanuc80k3003Mỗi thành phần chọn một đại diện; nối các đại diện liên tiếp, số đường là số thành phần trừ 1.
LQDOJ
3
Chu Trình Một Màucanuc80k3008DFS trên các ô cùng màu, nhớ ô cha; gặp ô cùng màu đã thăm mà không phải cha là có chu trình.
LQDOJ
4
Tuyến Đến Điểm Cuối An Toàncanuc80k3009DFS từ gốc giữ số mèo liên tiếp đến hiện tại; vượt mm thì dừng nhánh, đếm các lá tới được.
LQDOJ
5
Cụm Thiết Bị Tám Hướngcanuc80k3010Đếm thành phần trên lưới với tám hướng thay cho bốn.
LQDOJ
6
Hồ Có Thể Tích Lớn Nhấtcanuc80k3012Mỗi vùng ô dương liên thông là một hồ; BFS cộng độ sâu từng vùng, lấy lớn nhất.
LQDOJ
7
Hai Lớp Phủ Cạnhcanuc80k3013Tô hai màu; mỗi phía là một tập phủ đỉnh, không chia được thì in −1-1.
LQDOJ
8
Đếm Thành Phần Vòngcanuc80k3014Thành phần là một vòng khi mọi đỉnh trong nó có bậc đúng 2; DFS kiểm bậc từng thành phần.
LQDOJ
9
Chu Trình Trong Mạng Một Lối Racanuc80k3017Mỗi đỉnh đúng một cạnh ra: đi từ một đỉnh tới khi gặp lại đỉnh đã đi, đoạn từ lần gặp đầu là chu trình.
LQDOJ
10
Thoát Khỏi Vùng Nguy Hiểmcanuc80k3019BFS nhiều nguồn từ mọi quái vật cho thời điểm sớm nhất chúng tới mỗi ô; BFS của A chỉ bước vào ô mình tới sớm hơn hẳn, tới biên là thoát.
LQDOJ
11
Mốc Cao Nhất Có Thể Tớicanuc80k3021Nén các tầng bằng map, BFS từ tầng 1 trên đồ thị thang, in tầng lớn nhất tới được.
LQDOJ
12
Kiểm Tra Khả Năng Đi Mọi Hướngcanuc80k3029Mạnh liên thông khi từ 1 tới được mọi đỉnh và mọi đỉnh tới được 1: BFS trên đồ thị và đồ thị ngược, in một cặp hỏng.
LQDOJ
13
Đếm Cạnh Có Thể Bổ Sungcanuc80k3032Mỗi thành phần phải hai phía; số cặp thêm được là cặp hai phía khác nhau cùng thành phần cộng cặp khác thành phần, trừ MM cạnh đã có.
LQDOJ
14
Thứ Tự Khám Phá Nhỏ Nhấtcanuc80k3035Thay hàng đợi bằng hàng đợi ưu tiên lấy đỉnh nhỏ nhất: thứ tự thăm nhỏ nhất theo từ điển.
LQDOJ
15
Thành Phần Khi Các Cặp Bị Cấmcanuc80k3049Đồ thị bù: BFS giữ tập các đỉnh chưa thăm; lấy uu ra thì duyệt tập đó, đỉnh không bị cấm với uu thì thăm và xóa khỏi tập.
LQDOJ

Đọc thêm