01 图最短路

一般求解最短路径,高效的方法是 Dijkstra 算法,如果用优先队列实现则时间复杂度为 \(O(m \log m)\)\(m\) 为边数。但是在边权为 \(0\)\(1\) 的特殊图中,利用双端队列可以在 \(O(n+m)\) 时间内求得最短路径。Dijkstra 算法中优先队列的作用是保证单调性,实际上 \(0-1\) 图可以不使用优先队列的原因就在于只需要在队列的两端插入就可以保证单调性。

例题:P4667 [BalticOI 2011 Day1] Switch the Lamp On

时间限制为 150ms;内存限制为 125MB
问题描述:Casper 正在设计电路。有一种正方形的电路元件,在它的两组相对顶点中,有一组会用导线连接起来,另一组则不会。有 \(N \times M\) 个这样的元件(\(1 \le N,M \le 500\)),排列成 \(N\) 行,每行 \(M\) 个。电源连接到电路板的左上角,灯连接到电路板的右下角。只有在电源和灯之间有一条电线连接的情况下,灯才会亮。为了亮灯,任何数量的电路元件都可以转动 90°(两个方向)。
编写一个程序,求出最少需要旋转多少电路元件。

image

本题可以建模为最短路径问题。把起点 \(s\) 到终点 \(t\) 的路径长度记录为需要旋转的元件数量。从一个点到邻居点,如果元件不旋转就能到达,则距离为 \(0\);如果需要旋转元件才行,距离为 \(1\)。题目要求找出 \(s\)\(t\) 的最短路径。

如果用 Dijkstra 算法,复杂度为 \(O(NM \log NM)\),因为这个图的边的数量级是 \(NM\),而题目给的时间限制为 150ms,可能会超时。

在优先队列优化的 Dijkstra 算法中,优先队列的作用是在队列中找到距离起点最短的那个结点,并弹出它。使用优先队列的原因是,每个结点到起点的距离不同,需要用优先队列保证单调性。

本题是一种特殊情况,边权为 0 或 1。简单地说,就是“边权为 0,插到队头;边权为 1,插入队尾”,这样就省去了优先队列维护有序性的代价,从而减少了计算,优化了时间复杂度。这个操作用双端队列实现,这样保证了距离更近的点总是在队列的前面,队列中元素是单调的。每个结点只入队和出队一次,总的时间复杂度是线性的。

#include <cstdio>
#include <deque>
#include <utility>
#include <string>
using namespace std;
typedef pair<int, int> PII;
const int N = 505;
const int INF = 1e9;
int dis[N][N]; // dis记录从起点出发的最短路径
char s[N][N];
// 4个点
int d1[4][2] = {{-1, -1}, {-1, 1}, {1, -1}, {1, 1}};
// 4个电子元件
int d2[4][2] = {{-1, -1}, {-1, 0}, {0, -1}, {0, 0}};
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) scanf("%s", s[i] + 1);
    for (int i = 1; i <= n + 1; i++)
        for (int j = 1; j <= m + 1; j++)
            dis[i][j] = INF;
    deque<PII> q; q.push_back({1, 1}); dis[1][1] = 0;
    string match = "\\//\\"; // 注意反斜杠需要加转义字符
    while (!q.empty()) {
        PII cur = q.front(); q.pop_front(); // 弹出队头
        int x = cur.first, y = cur.second;
        for (int i = 0; i < 4; i++) { // 4个方向
            char ch = match[i];
            int px = x + d1[i][0], py = y + d1[i][1];
            int cx = x + d2[i][0], cy = y + d2[i][1];
            if (px >= 1 && px <= n + 1 && py >= 1 && py <= m + 1) {
                if (cx >= 1 && cx <= n && cy >= 1 && cy <= m) {
                    if (s[cx][cy] == ch && dis[x][y] < dis[px][py]) {
                        dis[px][py] = dis[x][y];
                        q.push_front({px, py});
                    } 
                    if (s[cx][cy] != ch && dis[x][y] + 1 < dis[px][py]) {
                        dis[px][py] = dis[x][y] + 1;
                        q.push_back({px, py});
                    }
                }
            }
        }
    }
    if (dis[n + 1][m + 1] == INF) printf("NO SOLUTION\n");
    else printf("%d\n", dis[n + 1][m + 1]);
    return 0;
}

习题:P2937 [USACO09JAN] Laserphones S

给一个网格图,里边有两个 C,求一条拐弯最少的连接两个 C 的路径。(注意输入是先列数再行数)

解题思路

状态肯定不能只设计成现在在哪儿,因为转移跟方向也有关系。

假设现在在 \((x,y)\),方向为 \(d\),相当于用分层图形式,把原来的一个点拆成四个,初始时把起点 \(4\) 种方向的状态都当成起始状态。

接下来就是看要走的方向和当前方向是不是一致,决定边权是 \(0\) 还是 \(1\)。如果接下来走的方向和现在方向一样,边权是 \(0\);如果接下来走的方向和现在方向不一样,边权是 \(1\)

可以想象一下最短路计算过程中优先队列的变化过程:0......1 -> 1......2 -> 2......3 -> ......。由于边权只有 \(0\)\(1\) 两种,所以可以不用优先队列,改用双端队列:如果走的是边权为 \(0\) 的,更新后的点放到队首;如果走的是边权为 \(1\) 的,更新后的点放到队尾。

每个点最多入队两次,时间复杂度为 \(O(点数+边数)\)

这个问题叫做 01 最短路,这个做法可以看作是 Dijkstra 算法在特殊情况下的一种优化。

参考代码
#include <cstdio>
#include <deque>
#include <algorithm>
const int N = 105;
const int INF = 1e9;
char s[N][N];
int dx[4] = {-1, 0, 0, 1};
int dy[4] = {0, -1, 1, 0};
struct Node {
    int x, y, d;
};
int dis[N][N][4];
bool vis[N][N][4];
int main()
{
    int n, m; scanf("%d%d", &m, &n);
    for (int i = 1; i <= n; i++) scanf("%s", s[i] + 1);
    int x1, y1, x2, y2; x1 = y1 = x2 = y2 = 0;
    for (int i = 1; i <= n; i++) 
        for (int j = 1; j <= m; j++) {
            for (int dir = 0; dir < 4; dir++) dis[i][j][dir] = INF;
            if (s[i][j] == 'C') {
                if (x1 == 0) {
                    x1 = i; y1 = j;
                } else {
                    x2 = i; y2 = j;
                }
            }
        }
    std::deque<Node> q;
    for (int dir = 0; dir < 4; dir++) {
        q.push_back({x1, y1, dir});
        dis[x1][y1][dir] = 0;
    }
    while (!q.empty()) {
        Node tmp = q.front(); q.pop_front();
        int x = tmp.x, y = tmp.y, d = tmp.d;
        if (vis[x][y][d]) continue;
        vis[x][y][d] = true;
        for (int dir = 0; dir < 4; dir++) {
            int xx = x + dx[dir], yy = y + dy[dir];
            if (xx < 1 || xx > n || yy < 1 || yy > m || s[xx][yy] == '*') continue;
            int w = 1 - (dir == d);
            if (dis[x][y][d] + w < dis[xx][yy][dir]) {
                dis[xx][yy][dir] = dis[x][y][d] + w;
                if (w == 0) q.push_front({xx, yy, dir});
                else q.push_back({xx, yy, dir});
            }
        }
    }
    int ans = INF;
    for (int dir = 0; dir < 4; dir++) ans = std::min(ans, dis[x2][y2][dir]);
    printf("%d\n", ans);
    return 0;
}

习题:P13823 「Diligent-OI R2 C」所谓伊人

解题思路

首先分析点权交换的规则,规则是:如果 \(u\) 能到达 \(v\),则可以交换 \(p_u\)\(p_v\)。这个操作的本质是,点权可以在 \(u\)\(v\) 之间双向流动。

  • 如果有边 \(u \to v\),则 \(u\) 能到达 \(v\),可以交换 \(p_u, p_v\)
  • 如果有路径 \(u \to v \to w\),则 \(u\) 能到达 \(v\)\(v\) 能到达 \(w\)\(u\) 也能到达 \(w\),这意味着 \(p_u, p_v, p_w\) 之间可以互相交换。例如,想交换 \(p_u, p_w\),可以先交换 \(p_u, p_v\),再交换 \(p_v, p_w\)

由此可以推断,只要两个点在图的无向版本中是连通的,它们之间就可以通过一系列交换来互换点权。因此,对于任何一个点 \(i\),它能获得的最大权,就是它所在的无向连通分量中的最大点权。

目标是把连通分量中的最大值 \(\text{maxp}\),从其所在的源点 \(s\) 移动到目标点 \(t\),求最少交换次数。

一次交换 \(\text{swap}(u,v)\) 的前提是存在 \(u \to \cdots \to v\) 的路径,这次交换的代价是 1。

考虑将 \(\text{maxp}\)\(s\) 移动到 \(t\),如果存在路径 \(s \to \cdots \to t\),可以直接执行 \(\text{swap}(s,t)\),代价为 1。如果存在路径 \(t \to \cdots \to s\),也可以执行 \(\text{swap}(t, s)\),代价同样为 1。

如果路径不是直接的,比如 \(s \to \cdots \to u\)\(t \to \cdots \to u\)。可以先 \(\text{swap}(s,u)\)(代价 1),此时 \(\text{maxp}\) 到了 \(u\) 点。然后,由于存在 \(t \to \cdots \to u\) 的路径,可以 \(\text{swap}(t,u)\)(代价为 1),将 \(\text{maxp}\)\(u\) 移动到 \(t\),总代价为 2。

这启发我们把问题建模成一个最短路问题,需要找到一种代价模型,使得从所有源点 \(s\) 出发到所有其他点的最短路是最少交换次数。

这个问题的代价不是简单的 1,关键在于:沿着一条已经建立的“交换路径”继续传递值是无代价的

例如,存在路径 \(s \to u \to v\),想把 \(\text{maxp}\)\(s\) 移动到 \(v\)

  • 可以直接 \(\text{swap}(s,v)\),因为 \(s\) 可以到达 \(v\),代价为 1。
  • 也可以看作 \(\text{swap}(s,u)\)(代价 1),然后 \(\text{maxp}\) 到了 \(u\)。此时,由于 \(s\) 已经和路径上的点建立了联系,可以认为将 \(\text{maxp}\)\(u\) 移动到 \(v\) 是这次交换的延续,不产生新代价。

这表明,一次交换的代价为 1,而沿着同方向路径的传递代价为 0,这正是 0-1 BFS 的典型应用场景。

  • 状态:需要定义 BFS 的状态,仅仅一个节点 \(u\) 是不够的,因为到达 \(u\) 的方式(顺着边来,还是逆着边来)会影响后续的代价。因此,状态定义为 \((u,d)\),表示 \(\text{maxp}\) 到达了节点 \(u\)\(d=0\) 表示是顺着有向边方向到达的,\(d=1\) 表示是逆着有向边方向到达的。
  • 代价
    • 代价为 1(开启一次新的交换)
      1. 从一个源点 \(s\) 开始移动到邻居。
      2. 改变移动方向,例如,顺着 \(u \to v\) 到达 \(v\) 后,又逆着 \(w \to v\) 到达 \(w\)
    • 代价为 0(延续一次交换):从非源点 \(u\) 继续沿着同方向移动,例如,顺着 \(w \to u\) 到达 \(u\) 后,继续顺着 \(u \to v\) 到达 \(v\)
参考代码
#include <cstdio>
#include <vector>
#include <deque>
#include <utility>
#include <algorithm>
using namespace std;
using pi = pair<int, int>;
const int N = 5e5 + 5;
const int INF = 1e9;

int p[N], maxp[N], dis[N][2], start[N];
vector<int> g[N], fwd[N], bwd[N], sc[N];
int sc_cnt;
bool vis[N];

// DFS 用于找出图中的所有(无向)连通分量
void dfs(int u, int id) {
    sc[id].push_back(u); // 将节点 u 加入到编号为 id 的连通分量中
    maxp[id] = max(maxp[id], p[u]); // 更新该连通分量的最大点权
    vis[u] = true;
    for (int v : g[u]) { // g 是无向图邻接表
        if (!vis[v]) {
            dfs(v, id);
        }
    }
}

int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &p[i]);
    }
    for (int i = 1; i <= m; i++) {
        int u, v; scanf("%d%d", &u, &v);
        // g 用于建无向图,找连通分量
        g[u].push_back(v); g[v].push_back(u);
        // fwd 和 bwd 用于建有向图,跑 0-1 BFS
        fwd[u].push_back(v); // 正向边
        bwd[v].push_back(u); // 反向边
    }

    // 1. 找出所有连通分量,并记录每个分量中的最大点权
    for (int i = 1; i <= n; i++) {
        if (!vis[i]) {
            dfs(i, sc_cnt);
            sc_cnt++;
        }
    }

    // 初始化所有距离为无穷大
    for (int i = 1; i <= n; i++) {
        dis[i][0] = dis[i][1] = INF;
    }

    // 2. 对每个连通分量分别执行 0-1 BFS
    for (int i = 0; i < sc_cnt; i++) {
        deque<pi> q; // 双端队列,用于 0-1 BFS
        // 找到分量中所有拥有最大权值的点,作为 BFS 的源点
        for (int x : sc[i]) {
            if (p[x] == maxp[i]) {
                // 源点距离为 0,可以作为顺向或逆向路径的起点
                q.push_back({x, 0});
                q.push_back({x, 1});
                dis[x][0] = dis[x][1] = 0;
                start[x] = 1; // 标记为最大权值的初始源点
            }
        }

        // Lambda 表达式,用于更新距离
        auto update = [&](int u, int d1, int v, int d2, int val) {
            if (dis[u][d1] + val < dis[v][d2]) {
                dis[v][d2] = dis[u][d1] + val;
                if (val) q.push_back({v, d2}); // 代价为 1,入队尾
                else q.push_front({v, d2});   // 代价为 0,入队首
            }
        };

        // 0-1 BFS 过程
        while (!q.empty()) {
            int u = q.front().first;
            int dir = q.front().second; // 0: 顺向到达 u, 1: 逆向到达 u
            q.pop_front();

            // 遍历 u 的所有正向出边 u -> v
            for (int v : fwd[u]) {
                // 尝试更新 v,新状态是顺向到达 v (dir=0)
                // 代价计算:如果是在一条顺向路径上延续(dir=0),且u不是源点,则代价为0。
                // 否则(u是源点或之前是逆向路径),开启一条新路径,代价为1。
                update(u, dir, v, 0, dir == 0 ? start[u] : 1);
            }
            // 遍历 u 的所有反向入边 v -> u
            for (int v : bwd[u]) {
                // 尝试更新 v,新状态是逆向到达 v (dir=1)
                // 代价计算:逻辑同上,对称
                update(u, dir, v, 1, dir == 0 ? 1 : start[u]);
            }
        }
    }

    // 3. 输出结果
    for (int i = 1; i <= n; i++) {
        printf("%d ", min(dis[i][0], dis[i][1]));
    }
    return 0;
}

习题:SP22393 KATHTHI

参考代码
#include <cstdio>
#include <utility>
#include <deque>
using namespace std;
typedef pair<int, int> PII;
const int N = 1005;

char s[N][N];    
int dis[N][N];   

// 四个方向的位移数组:上、左、右、下
int dx[4] = {-1, 0, 0, 1};
int dy[4] = {0, -1, 1, 0};

int main()
{
    int t; scanf("%d", &t);
    while (t--) {
        int n, m; scanf("%d%d", &n, &m);
        for (int i = 1; i <= n; i++) scanf("%s", s[i] + 1);

        // 初始化距离数组为一个极大值
        for (int i = 1; i <= n; i++)
            for (int j = 1; j <= m; j++)
                dis[i][j] = n * m;

        // --- 0-1 BFS 核心逻辑 ---
        deque<PII> q; // 双端队列,用于0-1 BFS

        // 将起点 (1,1) 入队
        q.push_back({1, 1}); 
        dis[1][1] = 0; // 起点距离为0

        while (!q.empty()) {
            PII cur = q.front(); q.pop_front(); // 从队首取出当前节点
            int x = cur.first, y = cur.second;

            // 遍历四个方向的邻居
            for (int i = 0; i < 4; i++) {
                int xx = x + dx[i], yy = y + dy[i];
                // 检查邻居是否在网格范困内
                if (xx >= 1 && xx <= n && yy >= 1 && yy <= m) {
                    // 情况1:边权为 0 (字符相同)
                    if (s[xx][yy] == s[x][y] && dis[x][y] < dis[xx][yy]) {
                        dis[xx][yy] = dis[x][y];
                        q.push_front({xx, yy}); // 0权边,推入队首
                    }
                    // 情况2:边权为 1 (字符不同)
                    if (s[xx][yy] != s[x][y] && dis[x][y] + 1 < dis[xx][yy]) {
                        dis[xx][yy] = dis[x][y] + 1;
                        q.push_back({xx, yy}); // 1权边,推入队尾
                    }
                } 
            }
        }

        // 输出到终点 (n,m) 的最短距离
        printf("%d\n", dis[n][m]);
    }
    return 0;
}

习题:CF1340C Nastya and Unexpected Guest

解题思路

这是一个在特定约束下的最短路问题,可以通过图论建模来解决。

问题的关键在于,位置不仅和所在的安全岛有关,还和当前绿灯过去了多久有关,因为绿灯剩余时间决定了还能走多远。

因此,可以定义一个状态 \((i,j)\),表示:

  • 当前位于第 \(i\) 个安全岛(安全岛按坐标排序后的编号)
  • 当前这个绿灯周期已经过去了 \(j\)

从一个状态 \((i,j)\) 出发,可以尝试移动到相邻的安全岛 \(i'\)(即 \(i-1\)\(i+1\))。

  • 移动耗时:从第 \(i\) 个安全岛到第 \(i'\) 个安全岛的距离为 \(\text{dist} = |d_{i'} - d_i|\),这会消耗 \(\text{dist}\) 秒的绿灯时间。
  • 新状态:到达 \(i'\) 后,当前绿灯周期已用时间会变为 \(j' = j + \text{dist}\)

接下来,分析 \(j'\) 的值:

  1. \(j' \lt g\):在当前红绿灯周期内成功到达了 \(i'\),此时状态变为 \((i',j')\),这个转移没有跨越一个完整的红绿灯周期
  2. \(j' = g\):恰好在绿灯结束的瞬间到达了 \(i'\),此时,需要在 \(i'\) 等待一个完整的红灯周期(\(r\) 秒)。等待结束后,新的绿灯周期开始,状态变为 \((i',0)\),这个转移跨越了一个完整的红绿灯周期\(g+r\) 秒)。
  3. \(j' \gt g\):这意味着在当前红绿灯周期内,无法从 \(i\) 移动到 \(i'\),这是一个非法的移动。

可以发现,状态转移的“代价”有两种:

  • 代价为 0:在同一个绿灯周期内移动,总时间增加 \(\text{dist}\)
  • 代价为 1:跨越一个红绿灯周期,总时间增加 \(\text{dist} + r\)

目标是最小化总时间,注意到总时间由两部分组成:完整的红绿灯周期数最后一个不满周期的绿灯时间

总时间 = 周期数 * (g + r) + 最后一个绿灯耗时

为了最小化总时间,应该优先最小化周期数,这启发我们使用 0-1 BFS

  • 图的节点\((i,j)\) 状态。
  • 图的边权
    • \((i,j)\)\((i',j')\)(当 \(j' \lt g\)),边权为 0。
    • \((i,j)\)\((i',0)\)(当 \(j' = g\)),边权为 1。
  • \(\text{dis}_{i,j}\) 存储到达状态 \((i,j)\) 所需的最少完整周期数
  • BFS 队列:使用一个双端队列 deque
    • 当遇到 0 权边时,将新状态加入队首。
    • 当遇到 1 权边时,将新状态加入队尾。

这样可以保证总是优先扩展周期数最少的状态,符合最短路的要求。

参考代码
#include <cstdio>
#include <utility>
#include <deque>
#include <algorithm>
using namespace std;
// PII 用于存储状态 {安全岛编号, 绿灯已用时间}
typedef pair<int, int> PII;
const int M = 10005; // 安全岛数量上限
const int G = 1005;  // 绿灯时间上限

int d[M];        // 存储排序后的安全岛坐标
// dis[i][j]: 到达状态(安全岛i, 绿灯已用时j)所需的最少完整周期数
// 值为-1表示不可达
int dis[M][G];   
deque<PII> q;    // 0-1 BFS 使用的双端队列

// 松弛操作的辅助函数,用于更新状态和队列
// curm, curg: 当前状态 (current island, current green time)
// nxtm, nxtg: 下一个状态 (next island, next green time)
// g: 绿灯总时间
void relax(int curm, int curg, int nxtm, int nxtg, int g) {
    // 情况1:在同一个绿灯周期内到达下一个安全岛 (0权边)
    // 移动后,绿灯时间没有用完
    if (nxtg < g) {
        // 如果 nxtm 状态是第一次到达,或者找到了更少的周期数
        if (dis[nxtm][nxtg] == -1 || dis[curm][curg] < dis[nxtm][nxtg]) {
            dis[nxtm][nxtg] = dis[curm][curg]; // 周期数不变
            q.push_front({nxtm, nxtg}); // 0权边,优先处理,入队首
        }
    } 
    // 情况2:恰好用完一个绿灯周期 (1权边)
    // 移动后,绿灯时间恰好用完
    if (nxtg == g) {
        // 新状态是下一个周期的开始,即 (nxtm, 0)
        // 如果 (nxtm, 0) 状态是第一次到达,或者找到了更少的周期数
        if (dis[nxtm][0] == -1 || dis[curm][curg] + 1 < dis[nxtm][0]) {
            dis[nxtm][0] = dis[curm][curg] + 1; // 周期数+1
            q.push_back({nxtm, 0}); // 1权边,延后处理,入队尾
        }
    }
}

int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) scanf("%d", &d[i]);
    sort(d + 1, d + m + 1); // 对安全岛坐标排序
    int g, r; scanf("%d%d", &g, &r);

    // 初始化dis数组为-1,表示所有状态初始时都不可达
    for (int i = 1; i <= m; i++)
        for (int j = 0; j < g; j++)
            dis[i][j] = -1; 

    // --- 0-1 BFS ---
    // 起点状态:第1个安全岛,绿灯已用时0,周期数为0
    q.push_back({1, 0}); 
    dis[1][0] = 0;

    while (!q.empty()) {
        PII cur = q.front(); q.pop_front();
        int curm = cur.first; // 当前所在安全岛编号
        int curg = cur.second; // 当前绿灯已用时间

        // 尝试向左移动 (到前一个安全岛)
        if (curm > 1) {
            int nxtm = curm - 1;
            int dist = d[curm] - d[nxtm];
            int nxtg = curg + dist; // 计算新的绿灯已用时间
            if (nxtg <= g) relax(curm, curg, nxtm, nxtg, g);
        } 
        // 尝试向右移动 (到后一个安全岛)
        if (curm < m) {
            int nxtm = curm + 1;
            int dist = d[nxtm] - d[curm];
            int nxtg = curg + dist;
            if (nxtg <= g) relax(curm, curg, nxtm, nxtg, g);
        }
    }

    // --- 计算最终答案 ---
    int ans = -1;
    // 遍历终点(第m个安全岛)的所有可能状态
    for (int i = 0; i < g; i++) {
        // 如果该状态可达
        if (dis[m][i] != -1) {
            // 总时间 = 完整周期数 * (绿灯+红灯) + 最后一段绿灯时间
            int res = dis[m][i] * (g + r) + i;
            // 特殊情况: 如果最后一段绿灯时间为0 (i=0),
            // 说明恰好在一个周期的末尾到达终点,不需要再等红灯。
            // 但前提是至少经过了一个周期,dis[m][0]>0
            if (i == 0 && dis[m][i] > 0) res -= r;
            if (ans == -1 || res < ans) ans = res; 
        }
    }

    printf("%d\n", ans);
    return 0;
}
posted @ 2026-02-09 15:40  RonChen  阅读(2)  评论(0)    收藏  举报