C++信息学奥赛

广度优先搜索(BFS)详解

层次遍历,探索未知,寻找最短路径

广度优先搜索在信息学奥赛中的地位

广度优先搜索(Breadth-First Search,简称BFS)是一种图形搜索算法,它从起始节点开始,先访问所有距离为1的节点,再访问所有距离为2的节点,以此类推。这种按层次遍历的特性使BFS成为解决许多问题的理想选择。

在信息学奥赛中,BFS是解决最短路径、连通性、层次遍历等问题的核心算法。它广泛应用于图论、树结构、网格问题和状态空间搜索等场景。掌握BFS不仅能帮助你解决各类搜索问题,更能培养你系统化探索问题解空间的能力。

广度优先搜索基本概念

广度优先搜索(BFS)是一种图形遍历算法,它的核心思想是从起始节点开始,先访问该节点的所有邻居节点(距离为1的节点),然后再按同样的顺序访问这些邻居节点的未访问邻居(距离为2的节点),以此类推,直到所有可达节点都被访问为止。

BFS的特点

  • 层次遍历:按照距离起始节点的远近顺序访问节点
  • 队列实现:通常使用队列(FIFO)数据结构来管理待访问节点
  • 最短路径:在无权图中,BFS可以找到从起点到其他所有点的最短路径
  • 空间复杂度高:需要存储大量待访问节点,空间复杂度通常为O(V),其中V是节点数
  • 完备性:如果目标节点存在,BFS一定能找到它

BFS与DFS的对比

特性 广度优先搜索(BFS) 深度优先搜索(DFS)
数据结构 队列(Queue) 栈(Stack)或递归
遍历顺序 层次遍历 深度优先
空间复杂度 通常较高,O(V) 取决于深度,最坏O(V)
最短路径 可找到无权图最短路径 不能直接找到最短路径
适用场景 最短路径、层次遍历 连通性、拓扑排序、迷宫

BFS的直观演示

下图展示了BFS的遍历过程,从节点1开始,按层次依次访问所有可达节点:

  1. 首先访问起始节点1
  2. 然后访问距离为1的节点2、3
  3. 接着访问距离为2的节点4、5、6
  4. 最后访问距离为3的节点7

遍历顺序:1 → 2 → 3 → 4 → 5 → 6 → 7

BFS遍历过程示意图

广度优先搜索的核心原理

BFS的核心原理是按照距离起始节点的远近层次来遍历图中的节点。这种遍历方式保证了在访问距离为k的节点之前,所有距离小于k的节点都已经被访问过,这也是BFS能够找到无权图最短路径的原因。

BFS的基本步骤

1

初始化

选择一个起始节点,将其标记为已访问,并加入队列中。同时,需要一个数据结构(如数组或哈希表)来记录节点的访问状态,避免重复访问。

2

处理队列

当队列不为空时,取出队列头部的节点,并访问该节点。

3

访问邻居节点

找出当前节点的所有未访问邻居节点,将它们标记为已访问,并加入队列中。对于需要记录距离的问题,可以在此步骤中计算这些邻居节点的距离(当前节点距离 + 1)。

4

重复步骤

重复步骤2和步骤3,直到队列为空。此时,所有从起始节点可达的节点都已被访问。

BFS的时间和空间复杂度

时间复杂度

BFS的时间复杂度取决于图的表示方法和需要访问的节点数:

  • 使用邻接表表示图:O(V + E),其中V是节点数,E是边数
  • 使用邻接矩阵表示图:O(V²),因为需要检查每个节点的所有可能邻居
  • 在最坏情况下,BFS需要访问所有节点和边,因此时间复杂度为O(V + E)

空间复杂度

BFS的空间复杂度主要来自于队列存储和访问标记:

  • 队列存储:最坏情况下需要存储所有节点,O(V)
  • 访问标记:需要一个大小为V的数组或哈希表,O(V)
  • 总体空间复杂度:O(V)
  • 对于网格类问题,空间复杂度通常为O(rows × cols)

BFS的伪代码

// BFS算法伪代码
void BFS(Graph graph, Node start) {
    // 初始化队列和访问标记
    Queue queue;
    boolean[] visited = new boolean[graph.size()];
    
    // 标记起始节点为已访问并加入队列
    visited[start] = true;
    queue.enqueue(start);
    
    // 当队列不为空时
    while (!queue.isEmpty()) {
        // 取出队首节点并访问
        Node current = queue.dequeue();
        process(current);
        
        // 遍历当前节点的所有邻居
        for (Node neighbor : graph.getNeighbors(current)) {
            // 如果邻居未被访问
            if (!visited[neighbor]) {
                // 标记为已访问并加入队列
                visited[neighbor] = true;
                queue.enqueue(neighbor);
                
                // 可选:记录距离或前驱节点
                distance[neighbor] = distance[current] + 1;
                predecessor[neighbor] = current;
            }
        }
    }
}

广度优先搜索的实现方法

BFS的实现需要根据具体问题和数据结构进行调整。在C++中,我们通常使用STL中的queue来实现队列功能,使用数组或vector来记录访问状态和距离信息。以下是几种常见场景的BFS实现方法:

1. 图的BFS实现

图可以用邻接表或邻接矩阵表示,邻接表在大多数情况下更为高效。以下是基于邻接表的图BFS实现:

// 基于邻接表的图BFS实现
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

// 图的BFS遍历
void bfsGraph(const vector<vector<int>>& adjList, int start, int n) {
    // 记录节点是否被访问
    vector<bool> visited(n, false);
    // 记录从起点到各节点的距离
    vector<int> distance(n, -1);
    // 记录前驱节点,用于还原路径
    vector<int> predecessor(n, -1);
    
    // 创建队列并加入起始节点
    queue<int> q;
    q.push(start);
    visited[start] = true;
    distance[start] = 0;  // 起点到自身的距离为0
    
    cout << "BFS遍历顺序: ";
    
    // 队列不为空时继续处理
    while (!q.empty()) {
        // 取出队首节点
        int current = q.front();
        q.pop();
        
        // 访问当前节点
        cout << current << " ";
        
        // 遍历所有邻居节点
        for (int neighbor : adjList[current]) {
            // 如果邻居未被访问
            if (!visited[neighbor]) {
                visited[neighbor] = true;
                q.push(neighbor);
                distance[neighbor] = distance[current] + 1;  // 距离+1
                predecessor[neighbor] = current;  // 记录前驱
            }
        }
    }
    cout << endl;
    
    // 输出各节点距离
    cout << "各节点距离: ";
    for (int i = 0; i < n; i++) {
        cout << distance[i] << " ";
    }
    cout << endl;
}

int main() {
    // 图的节点数
    int n = 7;
    
    // 构建邻接表
    vector<vector<int>> adjList(n);
    adjList[0].push_back(1);
    adjList[0].push_back(2);
    adjList[1].push_back(3);
    adjList[1].push_back(4);
    adjList[2].push_back(5);
    adjList[3].push_back(6);
    adjList[4].push_back(6);
    
    // 从节点0开始BFS
    bfsGraph(adjList, 0, n);
    
    return 0;
}

2. 树的层次遍历(BFS)

树是一种特殊的图(无环连通图),BFS可以实现树的层次遍历,即按节点深度逐层访问:

// 二叉树的层次遍历(BFS)
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

// 二叉树节点定义
struct TreeNode {
    int val;
    TreeNode *left;
    TreeNode *right;
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
};

// 二叉树的层次遍历
vector<vector<int>> levelOrder(TreeNode* root) {
    vector<vector<int>> result;
    if (root == nullptr) return result;
    
    // 创建队列并加入根节点
    queue<TreeNode*> q;
    q.push(root);
    
    // 队列不为空时继续处理
    while (!q.empty()) {
        // 当前层的节点数
        int levelSize = q.size();
        vector<int> currentLevel;
        
        // 处理当前层的所有节点
        for (int i = 0; i < levelSize; i++) {
            TreeNode* node = q.front();
            q.pop();
            
            // 访问当前节点
            currentLevel.push_back(node->val);
            
            // 将子节点加入队列
            if (node->left != nullptr) {
                q.push(node->left);
            }
            if (node->right != nullptr) {
                q.push(node->right);
            }
        }
        
        // 将当前层的结果加入总结果
        result.push_back(currentLevel);
    }
    
    return result;
}

// 辅助函数:创建示例二叉树
TreeNode* createSampleTree() {
    TreeNode* root = new TreeNode(3);
    root->left = new TreeNode(9);
    root->right = new TreeNode(20);
    root->right->left = new TreeNode(15);
    root->right->right = new TreeNode(7);
    return root;
}

int main() {
    TreeNode* root = createSampleTree();
    vector<vector<int>> levels = levelOrder(root);
    
    cout << "二叉树的层次遍历结果:" << endl;
    for (const auto& level : levels) {
        for (int val : level) {
            cout << val << " ";
        }
        cout << endl;
    }
    
    return 0;
}

3. 网格中的BFS实现

在网格问题中(如迷宫求解),每个单元格视为一个节点,通常有上、下、左、右四个可能的邻居。以下是网格中BFS的实现:

// 网格中的BFS实现(迷宫最短路径)
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

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

// 网格BFS求解最短路径
int bfsGrid(const vector<vector<int>>& grid, 
            pair<int, int> start, 
            pair<int, int> end) {
    int rows = grid.size();
    if (rows == 0) return -1;
    int cols = grid[0].size();
    
    // 检查起点和终点是否合法
    if (grid[start.first][start.second] == 1 || 
        grid[end.first][end.second] == 1) {
        return -1;  // 起点或终点是障碍物
    }
    
    // 记录是否访问过
    vector<vector<bool>> visited(rows, vector<bool>(cols, false));
    // 记录距离
    vector<vector<int>> distance(rows, vector<int>(cols, -1));
    
    // 创建队列并加入起点
    queue<pair<int, int>> q;
    q.push(start);
    visited[start.first][start.second] = true;
    distance[start.first][start.second] = 0;
    
    // 队列不为空时继续处理
    while (!q.empty()) {
        auto current = q.front();
        q.pop();
        int x = current.first;
        int y = current.second;
        
        // 如果到达终点,返回距离
        if (x == end.first && y == end.second) {
            return distance[x][y];
        }
        
        // 探索四个方向
        for (int i = 0; i < 4; i++) {
            int nx = x + dx[i];
            int ny = y + dy[i];
            
            // 检查新位置是否合法且未被访问
            if (nx >= 0 && nx < rows && ny >= 0 && ny < cols && 
                grid[nx][ny] == 0 && !visited[nx][ny]) {
                visited[nx][ny] = true;
                distance[nx][ny] = distance[x][y] + 1;
                q.push({nx, ny});
            }
        }
    }
    
    // 无法到达终点
    return -1;
}

int main() {
    // 0表示可通行,1表示障碍物
    vector<vector<int>> grid = {
        {0, 0, 0, 0, 0},
        {0, 1, 1, 1, 0},
        {0, 0, 0, 0, 0},
        {0, 1, 1, 1, 0},
        {0, 0, 0, 0, 0}
    };
    
    pair<int, int> start = {0, 0};    // 起点
    pair<int, int> end = {4, 4};      // 终点
    
    int shortestPath = bfsGrid(grid, start, end);
    
    if (shortestPath != -1) {
        cout << "从起点到终点的最短路径长度: " << shortestPath << endl;
    } else {
        cout << "无法从起点到达终点" << endl;
    }
    
    return 0;
}

广度优先搜索经典例题解析

1

岛屿数量(LeetCode 200)

问题描述:给你一个由'1'(陆地)和'0'(水)组成的的二维网格,请你计算网格中岛屿的数量。岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。此外,你可以假设该网格的四条边均被水包围。

解题思路:

这是一个典型的连通分量问题,可以使用BFS解决。我们遍历整个网格,当遇到一个未访问的陆地('1')时,就启动一次BFS,将所有与它相连的陆地都标记为已访问,这样就能统计出岛屿的数量。

  1. 遍历网格中的每个单元格
  2. 当发现未访问的陆地('1')时,岛屿数量加1
  3. 对该陆地执行BFS,将所有相连的陆地都标记为已访问
  4. 继续遍历,直到所有单元格都被处理

代码实现

// 岛屿数量问题的BFS解法
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

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

// BFS函数:标记所有相连的陆地
void bfs(vector<vector<char>>& grid, vector<vector<bool>>& visited, int x, int y) {
    int rows = grid.size();
    int cols = grid[0].size();
    
    // 创建队列并加入起始陆地
    queue<pair<int, int>> q;
    q.push({x, y});
    visited[x][y] = true;
    
    // 队列不为空时继续处理
    while (!q.empty()) {
        auto current = q.front();
        q.pop();
        
        // 探索四个方向
        for (int i = 0; i < 4; i++) {
            int nx = current.first + dx[i];
            int ny = current.second + dy[i];
            
            // 检查新位置是否合法、未被访问且是陆地
            if (nx >= 0 && nx < rows && ny >= 0 && ny < cols && 
                grid[nx][ny] == '1' && !visited[nx][ny]) {
                visited[nx][ny] = true;
                q.push({nx, ny});
            }
        }
    }
}

// 计算岛屿数量
int numIslands(vector<vector<char>>& grid) {
    if (grid.empty() || grid[0].empty()) return 0;
    
    int rows = grid.size();
    int cols = grid[0].size();
    int count = 0;
    
    // 记录是否访问过
    vector<vector<bool>> visited(rows, vector<bool>(cols, false));
    
    // 遍历整个网格
    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            // 发现未访问的陆地
            if (grid[i][j] == '1' && !visited[i][j]) {
                count++;  // 岛屿数量加1
                bfs(grid, visited, i, j);  // BFS标记所有相连陆地
            }
        }
    }
    
    return count;
}

int main() {
    vector<vector<char>> grid = {
        {'1','1','0','0','0'},
        {'1','1','0','0','0'},
        {'0','0','1','0','0'},
        {'0','0','0','1','1'}
    };
    
    cout << "岛屿数量: " << numIslands(grid) << endl;  // 应该输出3
    
    return 0;
}

算法分析

  • 时间复杂度:O(rows × cols),每个单元格最多被访问一次
  • 空间复杂度:O(rows × cols),最坏情况下队列需要存储整个网格的节点
  • 优化点:可以直接修改原网格标记访问状态(如将'1'改为'0'),节省visited数组的空间
2

打开转盘锁(LeetCode 752)

问题描述:你有一个带有四个圆形拨轮的转盘锁。每个拨轮都有10个数字:'0', '1', ..., '9'。每个拨轮可以自由旋转:例如把'9'变为'0','0'变为'9'。每次旋转都只能旋转一个拨轮的一位数字。锁的初始数字为'0000',一个代表四个拨轮的数字的字符串。列表deadends包含了一组死亡数字,一旦拨轮的数字和列表里的任何一个元素相同,这个锁将会被永久锁定,无法再旋转。字符串target代表可以解锁的数字,计算出从初始数字'0000'开始到解锁数字target所需的最少旋转次数,如果无论如何不能解锁,返回-1。

解题思路:

这是一个典型的最短路径问题,可以使用BFS解决。每个状态是一个4位数字的字符串,每次旋转一个拨轮的一位数字相当于移动到相邻状态。我们需要找到从"0000"到目标状态的最短路径,同时避开死亡状态。

  1. 使用BFS探索所有可能的状态,按层次遍历保证最短路径
  2. 使用集合存储死亡状态和已访问状态,提高查询效率
  3. 每个状态可以生成8个相邻状态(4个拨轮,每个拨轮可以加1或减1)
  4. 如果到达目标状态,返回当前步数;如果队列为空仍未到达,返回-1

代码实现

// 打开转盘锁问题的BFS解法
#include <iostream>
#include <vector>
#include <queue>
#include <unordered_set>
#include <string>
using namespace std;

// 旋转数字:向上旋转(+1)
char up(char c) {
    return c == '9' ? '0' : c + 1;
}

// 旋转数字:向下旋转(-1)
char down(char c) {
    return c == '0' ? '9' : c - 1;
}

// 获取当前状态的所有相邻状态
vector<string> getNeighbors(string s) {
    vector<string> neighbors;
    
    for (int i = 0; i < 4; i++) {
        char c = s[i];
        
        // 向上旋转
        s[i] = up(c);
        neighbors.push_back(s);
        
        // 向下旋转
        s[i] = down(c);
        neighbors.push_back(s);
        
        // 恢复原字符
        s[i] = c;
    }
    
    return neighbors;
}

// 计算打开转盘锁的最少步数
int openLock(vector<string>& deadends, string target) {
    // 存储死亡状态
    unordered_set<string> dead(deadends.begin(), deadends.end());
    // 存储已访问状态
    unordered_set<string> visited;
    
    // 初始状态
    string start = "0000";
    
    // 检查初始状态是否为死亡状态
    if (dead.count(start)) return -1;
    // 检查目标状态是否为死亡状态
    if (dead.count(target)) return -1;
    // 检查初始状态是否就是目标状态
    if (start == target) return 0;
    
    // BFS队列
    queue<string> q;
    q.push(start);
    visited.insert(start);
    
    // 记录步数
    int steps = 0;
    
    // 队列不为空时继续处理
    while (!q.empty()) {
        steps++;
        int size = q.size();
        
        // 处理当前层的所有状态
        for (int i = 0; i < size; i++) {
            string current = q.front();
            q.pop();
            
            // 获取所有相邻状态
            vector<string> neighbors = getNeighbors(current);
            
            for (string neighbor : neighbors) {
                // 如果是目标状态,返回步数
                if (neighbor == target) {
                    return steps;
                }
                
                // 如果不是死亡状态且未被访问
                if (!dead.count(neighbor) && !visited.count(neighbor)) {
                    visited.insert(neighbor);
                    q.push(neighbor);
                }
            }
        }
    }
    
    // 无法到达目标状态
    return -1;
}

int main() {
    vector<string> deadends = {"0201","0101","0102","1212","2002"};
    string target = "0202";
    
    cout << "最少旋转次数: " << openLock(deadends, target) << endl;  // 应该输出6
    
    return 0;
}

算法分析

  • 时间复杂度:O(10^4) = O(1),因为最多有10^4种可能的状态
  • 空间复杂度:O(10^4) = O(1),用于存储访问过的状态和队列
  • 优化点:可以使用双向BFS(从起点和终点同时开始搜索)进一步提高效率
3

二叉树的层序遍历(LeetCode 102)

问题描述:给你二叉树的根节点root,返回其节点值的层序遍历。(即逐层地,从左到右访问所有节点)。

解题思路:

二叉树的层序遍历是BFS的典型应用。我们可以使用队列存储每一层的节点,通过控制每次处理的节点数量来区分不同的层。

  1. 将根节点加入队列
  2. 当队列不为空时,记录当前队列的大小(即当前层的节点数)
  3. 处理当前层的所有节点,将它们的子节点加入队列
  4. 将当前层的节点值存入结果集
  5. 重复步骤2-4,直到队列为空

代码实现

// 二叉树的层序遍历BFS解法
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

// 二叉树节点定义
struct TreeNode {
    int val;
    TreeNode *left;
    TreeNode *right;
    TreeNode() : val(0), left(nullptr), right(nullptr) {}
    TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
    TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
};

// 二叉树的层序遍历
vector<vector<int>> levelOrder(TreeNode* root) {
    vector<vector<int>> result;
    if (root == nullptr) return result;
    
    // 创建队列并加入根节点
    queue<TreeNode*> q;
    q.push(root);
    
    // 队列不为空时继续处理
    while (!q.empty()) {
        // 当前层的节点数
        int levelSize = q.size();
        vector<int> currentLevel;
        
        // 处理当前层的所有节点
        for (int i = 0; i < levelSize; i++) {
            TreeNode* node = q.front();
            q.pop();
            
            // 访问当前节点
            currentLevel.push_back(node->val);
            
            // 将子节点加入队列
            if (node->left != nullptr) {
                q.push(node->left);
            }
            if (node->right != nullptr) {
                q.push(node->right);
            }
        }
        
        // 将当前层的结果加入总结果
        result.push_back(currentLevel);
    }
    
    return result;
}

// 辅助函数:创建示例二叉树
TreeNode* createSampleTree() {
    TreeNode* root = new TreeNode(3);
    root->left = new TreeNode(9);
    root->right = new TreeNode(20);
    root->right->left = new TreeNode(15);
    root->right->right = new TreeNode(7);
    return root;
}

// 打印层序遍历结果
void printResult(const vector<vector<int>>& result) {
    cout << "二叉树的层序遍历结果:" << endl;
    for (const auto& level : result) {
        for (int val : level) {
            cout << val << " ";
        }
        cout << endl;
    }
}

int main() {
    TreeNode* root = createSampleTree();
    vector<vector<int>> result = levelOrder(root);
    printResult(result);
    
    return 0;
}

算法分析

  • 时间复杂度:O(n),其中n是二叉树的节点数,每个节点恰好被访问一次
  • 空间复杂度:O(m),其中m是二叉树中某一层的最大节点数,最坏情况下为O(n)
  • 变种:可以通过反转结果实现自底向上的层序遍历,或记录每层节点数等信息
4

课程表(LeetCode 207)

问题描述:你这个学期必须选修numCourses门课程,记为0到numCourses-1。在选修某些课程之前需要一些先修课程,例如,想要学习课程0,你需要先学习课程1,表示为[0,1]。给定课程总数和一个先修课程的列表,判断是否可能完成所有课程的学习?

解题思路:

这是一个拓扑排序问题,可以使用BFS( Kahn's算法 )解决。我们需要判断有向图中是否存在环,如果不存在环,则可以完成所有课程;否则,不能。

  1. 构建有向图的邻接表,并计算每个节点的入度
  2. 将所有入度为0的节点加入队列(这些课程可以直接学习)
  3. 当队列不为空时,取出一个节点,将其所有邻居的入度减1
  4. 如果某个邻居的入度变为0,将其加入队列
  5. 记录已处理的节点数,如果等于课程总数,则无环,可以完成所有课程

代码实现

// 课程表问题的BFS解法(拓扑排序)
#include <iostream>
#include <vector>
#include <queue>
using namespace std;

// 判断是否可能完成所有课程
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
    // 构建邻接表
    vector<vector<int>> adj(numCourses);
    // 记录每个节点的入度
    vector<int> inDegree(numCourses, 0);
    
    // 构建图并计算入度
    for (const auto& edge : prerequisites) {
        int course = edge[0];      // 课程
        int prerequisite = edge[1];// 先修课程
        adj[prerequisite].push_back(course);  // 先修课程 -> 课程
        inDegree[course]++;        // 入度加1
    }
    
    // 创建队列,加入所有入度为0的节点
    queue<int> q;
    for (int i = 0; i < numCourses; i++) {
        if (inDegree[i] == 0) {
            q.push(i);
        }
    }
    
    // 记录已处理的节点数
    int processed = 0;
    
    // 队列不为空时继续处理
    while (!q.empty()) {
        int current = q.front();
        q.pop();
        processed++;
        
        // 处理当前节点的所有邻居
        for (int neighbor : adj[current]) {
            inDegree[neighbor]--;  // 入度减1
            if (inDegree[neighbor] == 0) {  // 如果入度变为0,加入队列
                q.push(neighbor);
            }
        }
    }
    
    // 如果所有节点都被处理,说明无环
    return processed == numCourses;
}

int main() {
    int numCourses1 = 2;
    vector<vector<int>> prerequisites1 = {{1,0}};
    cout << "是否可以完成所有课程(情况1): " << (canFinish(numCourses1, prerequisites1) ? "是" : "否") << endl;  // 是
    
    int numCourses2 = 2;
    vector<vector<int>> prerequisites2 = {{1,0}, {0,1}};
    cout << "是否可以完成所有课程(情况2): " << (canFinish(numCourses2, prerequisites2) ? "是" : "否") << endl;  // 否
    
    return 0;
}

算法分析

  • 时间复杂度:O(V + E),其中V是课程数,E是先修课程的数量
  • 空间复杂度:O(V + E),用于存储邻接表和入度数组
  • 应用:拓扑排序广泛应用于任务调度、课程安排等存在依赖关系的场景

广度优先搜索的优化技巧

BFS算法在处理大规模问题时可能会面临效率挑战,合理的优化可以显著提高算法性能。以下是一些常用的BFS优化技巧:

1. 双向BFS(Bidirectional BFS)

双向BFS是对传统BFS的重要优化,特别适用于寻找两点之间最短路径的问题。它同时从起点和终点开始进行BFS,当两个搜索前沿相遇时停止。

双向BFS的优势

  • 时间复杂度更低:传统BFS的复杂度为O(b^d),双向BFS可降至O(b^(d/2)),其中b是分支因子,d是最短路径长度
  • 空间复杂度更低:需要存储的节点数量显著减少

双向BFS的实现步骤

  1. 创建两个队列,分别存储从起点和终点开始的搜索
  2. 创建两个集合,分别记录从两个方向已访问的节点
  3. 每次迭代选择规模较小的队列进行扩展,以平衡两边的搜索
  4. 检查新扩展的节点是否已被另一侧访问,如果是则找到最短路径
  5. 当任意一个队列为空时,说明不存在路径

2. 状态压缩与表示优化

在处理状态空间较大的问题时,高效的状态表示和压缩可以显著减少内存占用和访问时间。

使用位运算压缩状态

对于可以用二进制表示的状态,使用整数或位集(bitset)存储,例如:

// 使用整数表示棋盘状态(如8皇后问题)
// 每一位代表一行是否有皇后
int state = 0b10100010;  // 表示第2、6、8行有皇后

使用哈希表存储状态

对于复杂状态,使用哈希表(unordered_set)存储已访问状态,提供O(1)的平均查询时间。

使用编码/解码函数

将复杂状态编码为字符串或整数,便于存储和比较,例如将二维坐标编码为单个整数:

// 将二维坐标(x,y)编码为单个整数
int encode(int x, int y, int cols) {
    return x * cols + y;
}

3. 访问标记与剪枝策略

合理的访问标记和剪枝可以避免不必要的状态扩展,提高算法效率。

使用距离数组替代访问标记

在需要记录距离的问题中,可以用距离数组同时实现访问标记功能,未访问的节点距离设为-1或无穷大。

分层标记访问状态

不要在生成节点时立即标记为已访问,而是在处理完当前层后再标记,避免同一层的节点无法相互访问。

启发式剪枝

结合问题特性,对明显不可能到达目标的状态进行剪枝,例如在路径搜索中,如果当前路径长度已经超过已知最短路径,则可以剪枝。

4. 数据结构选择与优化

选择合适的数据结构可以显著提高BFS的效率,特别是在处理大规模问题时。

队列的选择

标准队列(queue)通常足够,但在某些情况下,使用双端队列(deque)可以优化插入和删除操作。对于优先级BFS(如0-1BFS),双端队列是必要的。

0-1 BFS优化

对于边权只有0或1的图,可以使用双端队列优化BFS:权值为0的边对应的节点加入队列前端,权值为1的边对应的节点加入队列后端,这样可以保证队列始终按距离排序。

内存优化

对于网格类问题,可以直接修改原网格标记访问状态,避免使用额外的visited数组。例如,将访问过的陆地标记为水,节省内存空间。

广度优先搜索的应用场景

1. 图论问题

BFS是图论中最基础也最常用的算法之一,可解决多种图相关问题:

最短路径问题

在无权图或边权相同的图中,BFS可以找到从起点到其他所有节点的最短路径

连通分量问题

如岛屿数量问题,使用BFS可以找出图中所有的连通分量

拓扑排序

如课程表问题,使用BFS可以对有向无环图进行拓扑排序

二分图判断

使用BFS可以判断一个图是否为二分图(双色问题)

图论问题中的BFS应用

2. 树结构问题

树是一种特殊的图,BFS在树结构中有广泛应用:

层次遍历

按层次访问树的节点,是BFS在树结构中的典型应用

树的深度计算

使用BFS可以计算树的深度或某一节点的深度

最低公共祖先

结合父节点记录,BFS可以用于寻找两个节点的最低公共祖先

树的宽度计算

BFS可以计算树中每一层的节点数,从而找到树的最大宽度

树结构中的BFS应用

3. 网格问题

网格可以看作是一种特殊的图,每个单元格是一个节点,相邻单元格是邻居:

迷宫求解

找到从起点到终点的最短路径,避开障碍物

洪水填充

如图像渲染问题,将连通的相同颜色区域替换为新颜色

多源BFS

从多个起点同时开始BFS,如计算网格中每个单元格到最近0的距离

最短路径障碍消除

可以消除一定数量的障碍物,寻找最短路径

网格问题中的BFS应用

4. 状态空间搜索

许多问题可以抽象为状态空间中的搜索问题,BFS可以用于寻找从初始状态到目标状态的最短路径:

拼图游戏

如八数码问题,寻找从初始状态到目标状态的最少移动步数

密码破解

如打开转盘锁问题,寻找从初始密码到目标密码的最少旋转次数

基因序列变换

从一个基因序列变换为另一个,每次只能改变一个字符

单词接龙

从一个单词变换为另一个,每次只能改变一个字母

状态空间搜索中的BFS应用

广度优先搜索练习题

1

图像渲染(LeetCode 733)

问题描述:有一幅以二维整数数组表示的图画,每一个整数表示该图画的像素值大小,数值在0到65535之间。给你一个坐标(sr, sc)表示图像渲染开始的像素值(行 ,列)和一个新的颜色值newColor,让你重新上色这幅图像。为了完成上色工作,从初始坐标开始,记录初始坐标的像素值,然后上下左右四个方向上像素值与初始坐标相同的相连像素点,接着再记录这四个方向上符合条件的像素点与他们对应四个方向上像素值与初始坐标相同的相连像素点,……,重复该过程。将所有有记录的像素点的颜色值改为新的颜色值。最后返回经过上色渲染后的图像。

提示:

  • 典型的洪水填充问题,使用BFS或DFS均可解决
  • 注意处理新颜色与原颜色相同的情况,避免无限循环
  • 需要记录初始颜色,然后将所有连通的相同颜色像素替换为新颜色
2

课程表II(LeetCode 210)

问题描述:现在你总共有n门课需要学习,记为0到n-1。有些课程需要先修其他课程,例如,想要学习课程0,你需要先学习课程1,表示为[0,1]。给定课程总数和一个先修课程的列表,返回你为了学完所有课程所安排的学习顺序。可能会有多个正确的顺序,你只要返回一种就可以了。如果不可能完成所有课程,返回一个空数组。

提示:

  • 基于BFS的拓扑排序问题,是课程表问题的扩展
  • 在计算是否可以完成所有课程的基础上,记录课程的学习顺序
  • 将每次从队列中取出的节点依次加入结果列表
3

最短的桥(LeetCode 934)

问题描述:在给定的二维二进制数组A中,存在两座岛。(岛是由四面相连的1形成的一个最大组。)现在,我们可以将0变为1,以使两座岛连接起来,变成一座岛。返回必须翻转的0的最小数目。

提示:

  • 两步BFS:首先找到第一个岛屿的所有节点,然后从这些节点开始BFS,寻找到达第二个岛屿的最短路径
  • 可以使用不同的标记区分两个岛屿和海洋
  • 第一次BFS找到第一个岛屿,第二次BFS计算扩展到第二个岛屿的最小步数
4

单词接龙(LeetCode 127)

问题描述:字典wordList 中从单词 beginWord和 endWord 的转换序列是一个按下述规格形成的序列:序列中第一个单词是 beginWord。序列中每个相邻的单词只差一个字母。最后一个单词是 endWord。序列中的所有单词都在 wordList 中。给你两个单词 beginWord和 endWord 和一个字典 wordList,找出从 beginWord 到 endWord 的最短转换序列中的单词数目。如果不存在这样的转换序列,返回0。

提示:

  • 使用BFS寻找最短路径,每个单词是一个节点,相差一个字母的单词之间有边
  • 可以使用双向BFS优化性能
  • 构建单词到模式的映射可以高效地找到相邻单词(如将"hot"映射为"*ot"、"h*t"、"ho*")
5

01矩阵(LeetCode 542)

问题描述:给定一个由0和1组成的矩阵,找出每个元素到最近的0的距离。两个相邻元素间的距离为1。

提示:

  • 多源BFS问题,所有0都是起点
  • 首先将所有0加入队列,然后同时向外扩展,计算每个1到最近0的距离
  • 可以直接在原矩阵上记录距离,节省空间