图遍历和搜索算法

2026-05-17

1. DFS 和 BFS

模板

DFS

#include <vector>
using namespace std;

constexpr int MAXN = 100005;

int n;
vector<int> adj[MAXN];
bool vis[MAXN];

void dfs(int u) {
    vis[u] = true;
    // previsit(u);
    for (int v : adj[u]) {
        if (!vis[v]) dfs(v);
    }
    // postvisit(u);
}

int main() {
    for (int i = 1; i <= n; ++i) {
        if (!vis[i]) dfs(i);
    }
}

BFS

#include <vector>
#include <queue>
using namespace std;

constexpr int MAXN = 100005;

int n;
vector<int> adj[MAXN];
bool vis[MAXN];

void bfs(int x) {
    queue<int> q;
    vis[x] = true;
    q.push(x);

    while (!q.empty()) {
        int u = q.front();
        q.pop();
        for (int v : adj[u]) {
            if (!vis[v]) {
                vis[v] = true;
                // visit(v);
                q.push(v);
            }
        }
    }
}

int main() {
    for (int i = 1; i <= n; ++i) {
        if (!vis[i]) bfs(i);
    }
}

分析

在无向图中,DFS 和 BFS 都可以遍历连通分量;在有向图中,它们可以用于遍历从某个起点可达的点集。如果只是需要遍历图,则 DFS 和 BFS 区别不大,可以混用。

DFS 的优势是可以用于寻找环,拓扑排序,强连通分量算法。

BFS 的优势是可以求无权图的单源最短路。

:如果 DFS 爆栈,可以改为手写栈的 DFS

例 1(Luogu P3916)

给出 个点, 条边的有向图,对于每个点 ,令 表示从点 出发,能到达的编号最大的点。请求出 的值。


考虑计算每个点对 的贡献。所有能到达点 的点 ,其 都是 ,在剩下的点中,所有能到达点 的点 ,其 都是 ,以此类推。

因此,反向建边并按节点编号从大到小 DFS 或 BFS

#include <iostream>
#include <vector>
using namespace std;

constexpr int MAXN = 100005;

vector<int> adj[MAXN];
bool vis[MAXN];
int A[MAXN];
int n, m;

void dfs(int u, int d) {
    vis[u] = true;
    A[u] = d;
    for (int v : adj[u]) {
        if (!vis[v]) dfs(v, d);
    }
}

int main() {
    cin >> n >> m;
    for (int i = 0; i < m; ++i) {
        int u, v;
        cin >> u >> v;
        adj[v].push_back(u);
    }
    for (int i = n; i >= 1; --i) {
        if (!vis[i]) dfs(i, i);
    }
    for (int i = 1; i <= n; i++) cout << A[i] << ' ';
}

时间复杂度为 ,空间复杂度为

2. 单源最短路

无权图

无权单源最短路用 BFS,核心代码如下:

#include <algorithm>

int n;
vector<int> adj[MAXN];
int dist[MAXN];

void bfs(int x) {
    fill(dist + 1, dist + n + 1, -1);
    queue<int> q;
    dist[x] = 0;
    q.push(x);

    while (!q.empty()) {
        int u = q.front();
        q.pop();
        for (int v : adj[u]) {
            if (dist[v] == -1) {
                dist[v] = dist[u] + 1;
                q.push(v);
            }
        }
    }
}

时间复杂度为

非负权图

非负权单源最短路用 dijkstra

由于 priority_queue 不支持 decreasekey 操作,可以在优先队列中存储一个点的多个副本,弹出时忽视垃圾值即可。

完整代码如下:

#include <iostream>
#include <vector>
#include <queue>
#include <functional>
#include <algorithm>
using namespace std;
using ll = long long;
struct edge {
    int v, w;
};

constexpr int MAXN = 100005;
constexpr ll INF = 4e18;

int n, m;
vector<edge> adj[MAXN];
ll dist[MAXN];

void dijkstra(int s) {
    fill(dist + 1, dist + n + 1, INF);

    priority_queue<pair<ll, int>, vector<pair<ll, int>>, greater<pair<ll, int>>> q;

    dist[s] = 0;
    q.push({0, s});

    while (!q.empty()) {
        auto [d, u] = q.top();
        q.pop();

        if (d != dist[u]) continue;

        for (auto [v, w] : adj[u]) {
            if (dist[v] > dist[u] + w) {
                dist[v] = dist[u] + w;
                q.push({dist[v], v});
            }
        }
    }
}

int main() {
    cin >> n >> m;
    for (int i = 0; i < m; ++i) {
        int u, v, w;
        cin >> u >> v >> w;
        adj[u].push_back({v, w});
        // adj[v].push_back({u, w});
    }

    dijkstra(1);
}

时间复杂度为

一般的图

对于存在负权的情况,最短路需要用 Bellman–Ford 算法。

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

struct edge {
    int u, v, w;
};

constexpr int MAXN = 100005;
constexpr long long INF = 4e18;

int n, m;
vector<edge> edges;
long long dist[MAXN];

bool bellman_ford(int s) {
    fill(dist + 1, dist + n + 1, INF);

    dist[s] = 0;

    for (int i = 1; i <= n - 1; ++i) {
        bool updated = false;
        for (auto [u, v, w] : edges) {
            if (dist[u] == INF) continue;
            if (dist[v] > dist[u] + w) {
                dist[v] = dist[u] + w;
                updated = true;
            }
        }
        if (!updated) break;
    }

    for (auto [u, v, w] : edges) {
        if (dist[u] == INF) continue;
        if (dist[v] > dist[u] + w) {
            return false;
        }
    }

    return true;
}

int main() {
    cin >> n >> m;
    for (int i = 0; i < m; ++i) {
        int u, v, w;
        cin >> u >> v >> w;
        edges.push_back({u, v, w});
        // edges.push_back({v, u, w});
    }
    bool ok = bellman_ford(1);
}

时间复杂度为

0-1 BFS

如果边权只有 两种取值,那么可以用 0-1 BFS 实现,见 双端队列 BFS

时间复杂度为

3. 差分约束

差分约束系统是一个不等式组,包含 个变量 以及 个形如 的不等式,需要判断出不等式组是否有解,如果有解,给出一组解

每个约束条件 都可以变形成 ,这与单源最短路中的三角形不等式 非常相似。因此,我们可以把每个变量 看做图中的一个结点,对于每个约束条件 ,从结点 向结点 连一条长度为 的有向边。

可以设一个哨兵节点 ,向每一个点连一条权重为 的边,跑单源最短路,若图中存在负环,则给定的差分约束系统无解,否则, 为该差分约束系统的一组解。

可能存在负权边,所以用 Bellman–Ford 算法。

:等式约束可以拆成两个不等式约束。

4. DFS 搜索

现在开始搜索算法。

第一学期学习过 DFS 搜索算法。例如八皇后问题和数独问题 1784. T2(数独)

:这里的 DFS 搜索算法和图上的 DFS 都使用了递归函数,但未必有直接联系。学习图论后,有时会倾向于从图的角度思考 DFS 搜索算法,但这可能没有任何帮助,反而会造成困惑。

在 DFS 搜索中,优化搜索顺序和剪枝非常重要。剪枝分为 可行性剪枝最优性剪枝,在机考题 2923. 沙威玛传奇 中均涉及。这里再放一道 DFS 剪枝题。

例 2(Luogu P14377)

从小到大给定一组素数 。求出不超过 的数中,形如

的最大数

输入的第一行包含两个整数

第二行包含 个升序的素数


容易想到 DFS 遍历满足目标形式的数。搜索树根节点上的数为 ,每一层引入一个新的质数。根节点在第一层的儿子有

满足

作为例子, 在第二层的儿子有

满足


分别存在 中。

DFS 函数签名为 void dfs(int x, long long prod)prod 是调用栈上已经选择的素数的乘积,x 代表现在正在确定 的指数。如果选定指数为 ,则调用 ,继续确定 的指数

实际代码中,每次循环让 prod *= p[x],然后调用 dfs(x - 1, prod)

直接跑这样的暴力搜索会 TLE,在调用 dfs(x - 1, prod) 前,需要做一个重要的最优性剪枝。

我们希望继续向下递归确实有希望更新 ans,但有些时候可以确定没有这个希望,此时就应该剪枝,下面推导这个条件:

将来在 的基础上累积乘的 一定满足 ,所以

如果 ,那么 ans 不会被更新,因此,如果 ,接下来无论如何都不可能更新 ans 了,继续往下走都是无效分支,这等价于

由于 ,这等价于

在 C++ 代码中,这就是 n/prod == ans/prod

#include <iostream>
using namespace std;

int k;
long long n;
int p[25];
long long ans = 1;

void dfs(int x, unsigned long long prod) {
    if (ans < prod) ans = prod;
    if (x == -1) return;
    while (true) {
        if (n / prod == ans / prod) return;
        dfs(x - 1, prod);
        if (prod > n / p[x]) break;
        prod *= p[x];
    }
}

int main() {
    cin >> k >> n;
    for (int i = 0; i < k; ++i) cin >> p[i];
    dfs(k - 1, 1);
    cout << ans;
}

按质数从大到小遍历,能在搜索图上更浅处快速增加 prod,更早触发剪枝,减少遍历的总节点数。按质数从小到大遍历也会导致 TLE

5. 最短路搜索

如果题目涉及最小步数,最少步骤,或最小代价,则可以考虑把问题抽象成一个图上的最短路问题。

每个需要遍历的状态对应图上的顶点,状态到状态的转移则对应图上的边,转移的代价是边权。

如果空间足够存所有顶点,那么可以用刚刚提到的最短路算法解决此类问题,包括 BFS, dijkstra, Bellman–Ford, 0-1 BFS 四种算法。

  1. 如果顶点是 这样连续的数,那么可以直接套用对应模板,用数组存储这些数。
  2. 如果顶点数量少,但范围大,那么考虑离散化或哈希(unordered_map/unordered_set/手写哈希)。
  3. 如果顶点不是数,则考虑状态压缩,建立顶点到数的映射,转化为前两种情况。

例 3(Luogu P1135)

层楼和一架电梯。电梯位于第 层楼时,向上或向下移动的层数等于一个固定的数字 。如果到达的层数不合法,即不在 之间,相应的操作就无法进行。问:从第 楼到第 楼至少操作几次电梯?如果无法到达,输出


状态空间可以视为一个有向图,每个楼层是一个顶点。边 存在,当且仅当 。每个顶点的出度至多为

现在需要找到从 的最短路径。这是无权单源最短路,用 BFS

时间复杂度为 ,空间复杂度为

利用启发式函数剪枝

在此类问题中,常常有一个确定的目标点,此时就可以利用启发式函数剪枝。

假设需要找到从状态 A 到 状态 B 的最短路径。

如果任给一个状态 X,可以算出 X 至少需要 步才能到达 B,那么函数 就是一个启发式函数,也叫估价函数。

例 4(Luogu P2324 骑士精神)

在一个 的棋盘上有 个白色的骑士和 个黑色的骑士,且有一个空位。在任何时候一个骑士都能按照骑士的走法(它可以走到和他横坐标相差为 ,纵坐标相差为 或者横坐标相差为 ,纵坐标相差为 的格子)移动到空位上。

给定一个初始的棋盘,怎样才能经过移动变成如下目标棋盘

为了体现出骑士精神,他们必须以最小的步数完成任务。

如果能在 15 步以内(包括 15 步)到达目标状态,则输出步数,否则输出 -1

本题为多测,有 组数据


这就是典型的可以用启发式函数剪枝的例子。

对于当前状态下棋盘的每个骑士,如果它不在正确的位置上,就为估价函数贡献 ,这样估价函数就统计了错误骑士的数量。每次移动至多归位一个骑士,所以它确实是步数的一个下界。

由于题目要求:如果能在 15 步以内(包括 15 步)到达目标状态,则输出步数,否则输出 -1,那么可以对每个即将入队的状态计算启发式函数,如果 ,就放弃这个状态。

考虑状态如何压缩:用 bit 可以表示空格的位置,再用 bit 可以表示黑白骑士的位置,共 bit,可以压缩进 个字节。

状态空间大小为 ,暴力 BFS 会空间不足而 MLE,但启发式函数剪枝非常强大,导致实际上只需要探索非常少的节点,可以 AC

6. DFS 和最短路

在某些情况下,即使已经用上了启发式函数剪枝,缩小了搜索的范围,空间仍然不够,那就必须放弃图上的最短路算法,使用 DFS

可以把图展开为搜索树。搜索树上的每一个顶点对应原图上的一个 walk,于是遍历搜索树相当于遍历原图上的 walk。当然,不得不遍历到重复的节点,浪费一些时间。

如果题目给出了类似 “保证存在 步以内的解法” 的约束,那么就只需要搜索 步以内的 walk,从而可以把搜索树限制在 层,DFS 遍历整棵搜索树,找到步数最少的节点。


如果题目没有给出类似约束,但可以推导出一个较小的步数限制,那么就可以用它来限制搜索树。

如果题目没有给出约束,且无法推导出一个较小的步数限制,那么就需要迭代加深深度优先搜索(Iterative Deepening Depth-First Search, IDDFS),逐步加大深度限制。


深度限制常常配合启发式函数剪枝。使用了启发式函数剪枝的 IDDFS 被称为 IDA*( Iterative Deepening A*)


骑士精神的常见题解其实是 IDA* 而非刚刚的 BFS,IDA* 代码如下:

#include <iostream>
using namespace std;
constexpr int MAXD = 15;

int T;
int limit;
int tar[5][5] = {
    {1,1,1,1,1},
    {0,1,1,1,1},
    {0,0,2,1,1},
    {0,0,0,0,1},
    {0,0,0,0,0}
};
int st[5][5];
int dx[8] = {-2, -2, -1, -1, 1, 1, 2, 2};
int dy[8] = {-1, 1, -2, 2, -2, 2, -1, 1};

inline bool in_bound(int m, int n) {
    return 0 <= m && m <= 4 && 0 <= n && n <= 4;
}

bool dfs(int a, int b, int d, int error, int pre) {
    if (d + error - 1 > limit) return false;
    if (error == 0) return true;
    for (int i = 0; i < 8; ++i) {
        // 剪枝
        if (pre != -1 && i == (7 - pre)) continue;

        int nx = a + dx[i];
        int ny = b + dy[i];

        if (!in_bound(nx, ny)) continue;

        int knight = st[nx][ny];
        int new_error = error;
        if (knight == tar[a][b]) new_error--;
        if (knight == tar[nx][ny]) new_error++;

        swap(st[a][b], st[nx][ny]);
        if (dfs(nx, ny, d + 1, new_error, i)) return true;
        swap(st[a][b], st[nx][ny]);
    }
    return false;
}

void solve() {
    int u, v;
    for (int i = 0; i < 5; ++i) {
        for (int j = 0; j < 5; ++j) {
            char c;
            cin >> c;
            if (c == '*') {
                st[i][j] = 2;
                u = i, v = j;
            }
            else st[i][j] = c - '0';
        }
    }
    int error = 0;
    for (int i = 0; i < 5; ++i) {
        for (int j = 0; j < 5; ++j) {
            if (!(i == u && j == v)) error += (st[i][j] != tar[i][j]);
        }
    }
    if (error == 0) {
        cout << "0\n";
        return;
    }
    for (limit = 1; limit <= MAXD; ++limit) {
        if (dfs(u, v, 1, error, -1)) {
            cout << limit << '\n';
            return;
        }
    }
    cout << "-1\n";
}

int main() {
    cin >> T;
    for (int i = 0; i < T; ++i) solve();
}

当然,所有 IDDFS 和 IDA* 适用的地方,几乎都可以简单地用带有深度限制的 DFS 通过:

#include <iostream>
using namespace std;
constexpr int MAXD = 15;

int T;
int limit;
int tar[5][5] = {
    {1,1,1,1,1},
    {0,1,1,1,1},
    {0,0,2,1,1},
    {0,0,0,0,1},
    {0,0,0,0,0}
};
int st[5][5];
int dx[8] = {-2, -2, -1, -1, 1, 1, 2, 2};
int dy[8] = {-1, 1, -2, 2, -2, 2, -1, 1};
int ans;

inline bool in_bound(int m, int n) {
    return 0 <= m && m <= 4 && 0 <= n && n <= 4;
}

void dfs(int a, int b, int d, int error, int pre) {
    if (d + error - 1 > limit) return;
    if (error == 0) {
        limit = d - 2;
        ans = d - 1;
        return;
    }
    for (int i = 0; i < 8; ++i) {
        // 剪枝
        if (pre != -1 && i == (7 - pre)) continue;

        int nx = a + dx[i];
        int ny = b + dy[i];

        if (!in_bound(nx, ny)) continue;

        int knight = st[nx][ny];
        int new_error = error;
        if (knight != tar[nx][ny]) new_error--;
        if (knight != tar[a][b]) new_error++;

        swap(st[a][b], st[nx][ny]);
        dfs(nx, ny, d + 1, new_error, i);
        swap(st[a][b], st[nx][ny]);
    }
}

void solve() {
    int u, v;
    for (int i = 0; i < 5; ++i) {
        for (int j = 0; j < 5; ++j) {
            char c;
            cin >> c;
            if (c == '*') {
                st[i][j] = 2;
                u = i, v = j;
            }
            else st[i][j] = c - '0';
        }
    }
    int error = 0;
    for (int i = 0; i < 5; ++i) {
        for (int j = 0; j < 5; ++j) {
            if (!(i == 2 && j == 2)) error += (st[i][j] != tar[i][j]);
        }
    }
    if (error == 0) {
        cout << "0\n";
        return;
    }

    limit = MAXD;
    ans = -1;
    dfs(u, v, 1, error, -1);
    cout << ans << '\n';
}

int main() {
    cin >> T;
    for (int i = 0; i < T; ++i) solve();
}

7. 折半搜索

折半搜索,也称为 Meet in the middle 算法。思想是把搜索过程分成两半,然后合并结果

例 5(Luogu P11919)

一个底面尺寸为 ,高为 的长方体水箱的对角线长度为 。这里, 均为正整数。

求出有多少个本质不同的水箱满足以下条件:

定义两个水箱 本质不同,当且仅当 或者


自然的想法是枚举 ,计算 是否是整数,代码如下:

#include <iostream>
#include <cmath>
using namespace std;

int n;
long long ans;

int main() {
    cin >> n;
    for (int a = 1; a <= n-1; ++a) {
        int t1 = n * n - a * a;
        for (int b = a; b * b <= t1; ++b) {
            int t2 = t1 - b * b;
            for (int h = 1; h * h <= t2; ++h) {
                int sum = a * a + b * b + h * h;
                int d = sqrt(sum);
                if (d * d == sum) ++ans;
            }
        }
    }
    cout << ans;
}

这个算法的时间复杂度是 ,只能拿 分,需要更好的办法。

考虑将问题转化为解整数方程

这就是 ,只需要先枚举 ,计算 所有的值及其对应数量,再枚举 统计答案即可。

的范围是 ,可以用一个 cnt 数组统计。

#include <iostream>
using namespace std;

int n;
long long ans;
constexpr int MAX = 25000005;
int cnt[MAX];

int main() {
    cin >> n;
    for (int a = 1; a <= n - 1; ++a) {
        int t1 = n*n - a*a;
        for (int b = a; b*b <= t1; ++b) {
            ++cnt[a*a + b*b];
        }
    }
    for (int k = 1; k <= n; ++k) {
        for (int h = 1; h <= k; ++h) {
            ans += cnt[k*k - h*h];
        }
    }
    cout << ans;
}

时间复杂度为 ,空间复杂度为

例 6(Luogu P9234)

小蓝正在一个瓜摊上买瓜。瓜摊上共有 个瓜,每个瓜的重量为 。小蓝刀功了得,他可以把任何瓜劈成完全等重的两份,不过每个瓜只能劈一刀。

小蓝希望买到的瓜的重量的和恰好为

请问小蓝至少要劈多少个瓜才能买到重量恰好为 的瓜。如果无论怎样小蓝都无法得到总重恰好为 的瓜,请输出


每个瓜有买,不买,买一半三种状态,暴力搜索枚举买瓜情况的时间复杂度是

考虑折半搜索,将 个瓜均分为两部分,分别枚举劈瓜情况,得到可以获得的重量和该重量下最少的劈瓜次数,时间复杂度为 ,空间复杂度为

由于 ,不能再使用数组存储,改为 unordered_map

#include <iostream>
#include <algorithm>
#include <unordered_map>
#include <climits>
using namespace std;

int n;
long long m, a[31];
unordered_map<long long, int> mp;
int ans = INT_MAX;

void dfs1(long long sum, int x, int cnt) {
    if (sum > m || cnt > ans) return;
    if (sum == m) {
        if (ans > cnt) ans = cnt;
        return;
    }
    if (!(mp.count(sum) && mp[sum] < cnt)) mp[sum] = cnt;
    if (x > n/2) return;
    dfs1(sum, x+1, cnt);
    dfs1(sum+a[x]*2, x+1, cnt);
    dfs1(sum+a[x], x+1, cnt+1);
}

void dfs2(long long sum, int x, int cnt) {
    if (sum > m || cnt > ans) return;
    if (mp.count(m-sum) && ans > cnt+mp[m-sum]) ans = cnt+mp[m-sum];
    if (x > n) return;
    dfs2(sum, x+1, cnt);
    dfs2(sum+a[x]*2, x+1, cnt);
    dfs2(sum+a[x], x+1, cnt+1);
}
int main() {
    cin >> n >> m;
    m *= 2;
    for (int i = 1; i <= n; ++i) cin >> a[i];
    mp[0] = 0;
    dfs1(0,1,0);
    dfs2(0,n/2 + 1,0);
    cout << (ans > INT_MAX/2 ? -1 : ans);
}

但这不能 AC。经测试,如果要通过题面数据范围内的所有数据点,必须使用手写哈希,它在时间和空间上都比 unordered_map 更优秀。

:洛谷上所有没有手写哈希的题解都可以被 hack 掉,只是恰巧能过原题数据点。只有 P9234 [蓝桥杯 2023 省 A] 买瓜 题解 这份手写哈希的题解能稳定通过 hack

8. A* 算法

在非负权图中,要求 A 到 B 的最短路径,若直接使用 dijkstra 算法,可以作如下分析:

  1. 的顶点必然被探索
  2. 的顶点可能被探索
  3. 的顶点不可能被探索

最坏情况下会探索所有 的顶点

A* 算法利用启发式函数改进 dijkstra 算法。

在 dijkstra 中,每一步取出堆中 最小的 ,A* 算法将其改为每一步取出堆中 最小的 ,其中 是启发式函数。

如果 对所有 成立,则称 是可采纳的。可以证明,若 可采纳,那么第一次从优先队列中取出终点时可以得到最优解。这是使用 A* 算法的最低要求。

如果还有 恒成立,就说 是一致的。可以证明,若 一致,则每个点第一次从优先队列中弹出时, 就已经确定为起点到它的最短距离。若 不一致,则该性质未必成立。因此,若 不一致,则对于已经弹出的非终点状态,需要允许重新入队。

:一致性加上 可以推出可采纳性。

对于可采纳且一致的情况,可以对 A* 作如下分析:

  1. 的顶点必然被探索
  2. 的顶点可能被探索
  3. 的顶点不可能被探索

最坏情况下会探索所有 的顶点

这比 dijkstra 能做到的更好, 的顶点都可以免于被探索。 越有效(越大),这样的顶点就越多。

最新评论

--