一、引入:为什么需要双端队列广搜?

在普通 BFS 中,我们通常用一个普通队列 Queue 来维护待扩展的状态。

普通 BFS 适用于:

每走一步的代价都相同,比如每次移动的代价都是 1。

例如在网格中,从起点走到终点,每次上下左右移动都算一步,这种问题可以直接用 BFS 求最短步数。

但是有些题目中,每次转移的代价不一定相同,可能是:

有的转移代价是 0
有的转移代价是 1

这时候普通 BFS 就不一定正确了。

比如:

从 A 到 B 代价为 1
从 A 到 C 代价为 0
从 C 到 B 代价为 0

如果普通 BFS 按“层数”扩展,可能会先认为 A 到 B 的距离是 1,但实际上 A -> C -> B 的总代价是 0。

这类问题就需要使用 双端队列广搜,也叫:0-1 BFS

01bfs.png

二、什么是 0-1 BFS?

1. 核心定义

0-1 BFS 是一种用于解决边权只有 0 和 1 的最短路算法。

它可以看作是:

普通 BFS 和 Dijkstra 的中间形态

它比普通 BFS 更强,因为它能处理 0/1 边权。

它比 Dijkstra 更简单、更快,因为它不需要优先队列,只需要一个双端队列。

2. 适用条件

0-1 BFS 适用于以下问题:

图中的边权只有 0 和 1

也就是说,从一个状态转移到另一个状态时,代价只能是:0 或 1

常见场景包括:

1. 当前操作不消耗代价,记为 0
2. 当前操作需要额外付出一次代价,记为 1
3. 顺着某个方向走代价为 0,改变方向代价为 1
4. 使用已有道路代价为 0,新建道路代价为 1
5. 某些特殊移动免费,普通移动花费 1

三、普通 BFS、0-1 BFS、Dijkstra 的区别

可以在博客中放一个对比表:

算法

适用边权

使用数据结构

时间复杂度

普通 BFS

所有边权都相同,通常都是 1

普通队列

O(V + E)

0-1 BFS

边权只有 0 和 1

双端队列

O(V + E)

Dijkstra

非负边权

优先队列

O(E log V)

其中:

V 表示点数
E 表示边数

0-1 BFS 的优势是:

在边权只有 0 和 1 的情况下,它可以用 O(V + E) 的复杂度求最短路。

四、核心思想

1. 为什么要用双端队列?

双端队列 Deque 的特点是:

可以从队头插入
也可以从队尾插入
可以从队头弹出

在 0-1 BFS 中:

如果边权是 0,就把新状态放到队头
如果边权是 1,就把新状态放到队尾

也就是:

代价低的状态优先处理
代价高的状态稍后处理

2. 为什么边权为 0 要放队头?

假设当前状态的距离是 dist[x]

如果有一条边:

x -> y,边权为 0

那么:

dist[y] = dist[x]

也就是说,y 和当前状态的距离一样短。

所以它应该尽快被处理,因此放到队头。

3. 为什么边权为 1 要放队尾?

如果有一条边:

x -> y,边权为 1

那么:

dist[y] = dist[x] + 1

它的距离比当前状态大 1。

所以它的优先级低一些,可以放到队尾,等当前这一层更优的状态处理完之后再处理。

五、算法流程

0-1 BFS 的整体流程如下:

1. 初始化 dist 数组,所有点的距离设为 INF
2. 起点距离设为 0
3. 将起点加入双端队列
4. 每次从队头取出一个状态
5. 遍历它的所有邻居
6. 如果可以松弛距离:
   - 如果边权是 0,把邻居加入队头
   - 如果边权是 1,把邻居加入队尾
7. 最后 dist[target] 就是最短距离

六、伪代码

dist[start] = 0
deque.push_front(start)

while deque is not empty:
    u = deque.pop_front()

    for each edge u -> v with weight w:
        if dist[v] > dist[u] + w:
            dist[v] = dist[u] + w

            if w == 0:
                deque.push_front(v)
            else:
                deque.push_back(v)

其中 w 只能是:0 或 1

七、图结构版本

import java.util.*;

public class ZeroOneBFS {

    static class Edge {
        int to;
        int weight;

        Edge(int to, int weight) {
            this.to = to;
            this.weight = weight;
        }
    }

    static final int INF = 0x3f3f3f3f;

    public static int zeroOneBFS(List<Edge>[] graph, int start, int target) {
        int n = graph.length;
        int[] dist = new int[n];
        Arrays.fill(dist, INF);

        Deque<Integer> deque = new ArrayDeque<>();

        dist[start] = 0;
        deque.offerFirst(start);

        while (!deque.isEmpty()) {
            int cur = deque.pollFirst();

            for (Edge edge : graph[cur]) {
                int next = edge.to;
                int weight = edge.weight;

                if (dist[next] > dist[cur] + weight) {
                    dist[next] = dist[cur] + weight;

                    if (weight == 0) {
                        deque.offerFirst(next);
                    } else {
                        deque.offerLast(next);
                    }
                }
            }
        }

        return dist[target];
    }
}

八、网格图版本

很多算法竞赛题会把问题放在二维网格中,比如迷宫、地图、方向转换等。

网格图中通常需要:

dist[i][j] 表示从起点到 (i, j) 的最小代价
import java.util.*;

public class ZeroOneBFSGrid {

    static final int INF = 0x3f3f3f3f;

    static int[] dx = {-1, 1, 0, 0};
    static int[] dy = {0, 0, -1, 1};

    public static int zeroOneBFS(int[][] grid, int startX, int startY, int targetX, int targetY) {
        int m = grid.length;
        int n = grid[0].length;

        int[][] dist = new int[m][n];

        for (int i = 0; i < m; i++) {
            Arrays.fill(dist[i], INF);
        }

        Deque<int[]> deque = new ArrayDeque<>();

        dist[startX][startY] = 0;
        deque.offerFirst(new int[]{startX, startY});

        while (!deque.isEmpty()) {
            int[] cur = deque.pollFirst();
            int x = cur[0];
            int y = cur[1];

            for (int k = 0; k < 4; k++) {
                int nx = x + dx[k];
                int ny = y + dy[k];

                if (nx < 0 || nx >= m || ny < 0 || ny >= n) {
                    continue;
                }

                int weight = getWeight(grid, x, y, nx, ny);

                if (dist[nx][ny] > dist[x][y] + weight) {
                    dist[nx][ny] = dist[x][y] + weight;

                    if (weight == 0) {
                        deque.offerFirst(new int[]{nx, ny});
                    } else {
                        deque.offerLast(new int[]{nx, ny});
                    }
                }
            }
        }

        return dist[targetX][targetY];
    }

    private static int getWeight(int[][] grid, int x, int y, int nx, int ny) {
        // 根据题意决定移动代价
        //这里只是示例
        return 1;
    }
}

九、经典例子:改变方向的最小代价

1. 问题描述

假设有一个网格,每个格子中有一个方向箭头。

从当前格子出发,如果你按照箭头方向移动,代价为 0。

如果你不按照箭头方向移动,需要修改方向,代价为 1。

问从左上角走到右下角的最小代价是多少?

2. 为什么适合 0-1 BFS?

每次移动只有两种情况:

顺着箭头走:代价 0
不顺着箭头走:代价 1

因此边权只有 0 和 1,正好适合 0-1 BFS。

3. 转移方式

假设四个方向编号为:

1:右
2:左
3:下
4:上

如果当前格子的方向和本次移动方向一致:

weight = 0

否则:

weight = 1

4. 示例代码

import java.util.*;

class Solution {

    static final int INF = 0x3f3f3f3f;

    public int minCost(int[][] grid) {
        int m = grid.length;
        int n = grid[0].length;

        int[][] dist = new int[m][n];

        for (int i = 0; i < m; i++) {
            Arrays.fill(dist[i], INF);
        }

        // 方向顺序:右、左、下、上
        int[] dx = {0, 0, 1, -1};
        int[] dy = {1, -1, 0, 0};

        Deque<int[]> deque = new ArrayDeque<>();

        dist[0][0] = 0;
        deque.offerFirst(new int[]{0, 0});

        while (!deque.isEmpty()) {
            int[] cur = deque.pollFirst();
            int x = cur[0];
            int y = cur[1];

            for (int k = 0; k < 4; k++) {
                int nx = x + dx[k];
                int ny = y + dy[k];

                if (nx < 0 || nx >= m || ny < 0 || ny >= n) {
                    continue;
                }

                // grid[x][y] 的方向编号是 1 到 4
                // k 的编号是 0 到 3,所以要用 k + 1 进行比较
                int weight = grid[x][y] == k + 1 ? 0 : 1;

                if (dist[nx][ny] > dist[x][y] + weight) {
                    dist[nx][ny] = dist[x][y] + weight;

                    if (weight == 0) {
                        deque.offerFirst(new int[]{nx, ny});
                    } else {
                        deque.offerLast(new int[]{nx, ny});
                    }
                }
            }
        }

        return dist[m - 1][n - 1];
    }
}

十、正确性理解

0-1 BFS 的关键在于:

双端队列中靠前的状态,一定不会比靠后的状态距离更大。

因为每次扩展状态时,新的边权只有两种:0 或 1

如果是 0,说明新状态的距离和当前状态一样,因此放到队头。

如果是 1,说明新状态的距离比当前状态多 1,因此放到队尾。

这样就近似实现了 Dijkstra 中“小距离优先”的思想。

但因为边权只有 0 和 1,所以不需要优先队列,只需要双端队列即可。

十一、复杂度分析

设点数为 V,边数为 E

每条边在松弛时最多被有效处理有限次,因此整体复杂度为:

时间复杂度:O(V + E)
空间复杂度:O(V)

如果是二维网格,假设行数为 m,列数为 n,每个格子最多有 4 条边,则:

点数 V = m * n
边数 E ≈ 4 * m * n

所以复杂度可以写成:

时间复杂度:O(m * n)
空间复杂度:O(m * n)

十二、和 Dijkstra 的关系

0-1 BFS 本质上可以看成是 Dijkstra 在特殊边权下的优化版本。

Dijkstra 的做法是:

每次从优先队列中取出当前距离最小的点

0-1 BFS 的做法是:

使用双端队列维护相对有序的状态

因为边权只有 0 和 1,所以当前点扩展出去的新距离只可能是:

dist[cur]
dist[cur] + 1

因此双端队列就足够维护顺序。

如果边权不是 0 和 1,而是普通非负数,比如 2、5、10,那么就不能使用 0-1 BFS,应该使用 Dijkstra。

十三、常见错误

1. 把 0-1 BFS 写成普通 BFS

错误写法:

queue.offer(next);

这样会丢失“0 权边优先”的性质。

正确写法:

if (weight == 0) {
    deque.offerFirst(next);
} else {
    deque.offerLast(next);
}

2. 使用 visited 数组过早标记

普通 BFS 中经常使用:

visited[next] = true;

但在 0-1 BFS 中,不推荐一开始就用 visited 直接锁死状态。

因为一个点可能先被较差距离访问,之后又被更短距离更新。

更稳妥的写法是只依赖 dist 松弛:

if (dist[next] > dist[cur] + weight) {
    dist[next] = dist[cur] + weight;
}

3. 忘记初始化 dist

最短路问题中,dist 必须初始化为一个很大的数:

Arrays.fill(dist, INF);

起点初始化为:

dist[start] = 0;

4. 边权判断写反

尤其是网格方向题中,容易把方向编号弄错。

例如:

int weight = grid[x][y] == k + 1 ? 0 : 1;

这里要确认题目中的方向编号和自己数组中的方向顺序是否一致。

5. 把不适合的问题强行用 0-1 BFS

0-1 BFS 只能处理:边权为 0 或 1 的最短路

如果边权可能是:2、3、5、10

就不能直接用 0-1 BFS。

这时应该考虑:Dijkstra