1. 算法思想

BFS(广度优先搜索)解决最短路问题的核心思想是通过分层遍历,优先访问距离起点更近的节点,从而在首次到达目标节点时得到最短路径,具体可总结为以下几点:

1. 适用场景

  • 针对无权图边权值均为 1 的图(包括网格、树等结构),此时 “路径长度” 等价于 “边的数量”,BFS 能高效找到从起点到目标节点的最短路径。

2. 核心逻辑:分层扩散与首次访问

  • 分层遍历:从起点开始,按照 “与起点的距离” 逐层访问节点 —— 先访问距离为 1 的所有节点,再访问距离为 2 的所有节点,以此类推。
  • 首次命中即最短:由于按距离递增顺序遍历,当目标节点首次被访问时,经过的路径必然是最短的(不存在更短路径,否则会更早被访问)。

3. 实现关键:队列与 visited 标记

  • 队列(Queue):作为 BFS 的核心数据结构,用于存储待访问的节点。每次从队头取出节点,将其未访问的邻接节点加入队尾,保证按 “距离递增” 的顺序处理节点。
  • visited 标记:记录已访问的节点,避免重复访问(防止循环遍历,同时确保每个节点只被距离最短的路径访问一次)。

4. 路径记录方式

  • 若需要输出具体路径,可额外维护一个 “前驱节点” 数组,记录每个节点是从哪个节点访问而来。当到达目标节点后,通过回溯前驱节点即可还原最短路径。

5. 算法步骤示例

  1. 初始化队列,将起点加入队列,并标记为已访问。
  2. 若队列不为空,取出队头节点,检查是否为目标节点:是则结束,返回当前距离;否则继续。
  3. 遍历该节点的所有邻接节点,若未被访问,则标记为已访问,记录其距离(当前节点距离 + 1),并加入队列。
  4. 重复步骤 2-3,直至找到目标节点或队列为空(无路径)。

综上,BFS 通过 “分层遍历 + 队列管理” 的方式,在无权图中以O(N+M)(N 为节点数,M 为边数)的时间复杂度高效求解最短路问题,核心优势是 “首次访问即最短” 的特性。

2. 例题

2.1 迷宫中离入口最近的出口

1926. 迷宫中离入口最近的出口 - 力扣(LeetCode)

核心思路如下:

1. 问题理解

  • 给定一个迷宫(二维字符数组),找到从起点 entrance 到任意边界出口的最短路径长度。
  • 出口条件:到达边界(第一行 / 列或最后一行 / 列)且非起点。
  • 移动规则:每次只能向上下左右四个方向移动一格,且只能走空地(.)。

2. BFS 算法设计

核心数据结构
  • 队列 q:存储待扩展的节点(坐标),保证按层次遍历。
  • 访问数组 vis:标记已访问的位置,避免重复访问。
分层扩展策略
  • 外层循环:每一轮处理当前队列中的所有节点,代表 “走一步”。
  • 内层循环:遍历当前节点的四个方向,若新位置合法(空地且未访问),则加入队列。
  • 步数记录:每扩展一层(外层循环),步数 ret 加 1。

3. 关键点分析

  1. 边界判断

    • 扩展新位置时,先检查是否在迷宫范围内且为空地(maze[x][y] == '.')。
    • 若新位置同时是边界且非起点,立即返回当前步数 ret
  2. 起点处理

    • 初始化时将起点标记为已访问,避免误判为出口。
  3. 方向数组优化

    • 使用 dx 和 dy 数组表示四个方向的偏移量,简化代码逻辑。

4. 复杂度分析

  • 时间复杂度:\(O(nm)\),其中 n 和 m 分别为迷宫的行数和列数。每个位置最多被访问一次。
  • 空间复杂度:\(O(nm)\),主要用于队列和访问数组。

5. 算法流程

  1. 初始化:将起点加入队列并标记为已访问。
  2. 逐层扩展
    • 每轮处理队列中的所有节点(当前层),步数加 1。
    • 对每个节点的四个方向进行检查,若新位置合法且未访问:
      • 若是边界出口,返回当前步数。
      • 否则标记为已访问并加入队列。
  3. 无解处理:若队列为空仍未找到出口,返回 -1

总结

该算法通过 BFS 的分层遍历特性,确保首次到达边界出口时路径最短。关键在于合理使用队列和访问标记,以及对边界条件的细致处理。

class Solution {
public:
typedef pair<int, int> PII;
    int nearestExit(vector<vector<char>>& maze, vector<int>& entrance) {
        int n = maze.size(), m = maze[0].size();
        int dx[4] = {0, 0, 1, -1};
        int dy[4] = {1, -1, 0, 0};
        int vis[100][100];
        memset(vis, 0, sizeof(vis));
        int ret = 0;

        queue<PII> q;
        q.push({entrance[0], entrance[1]});
        vis[entrance[0]][entrance[1]] = true;
        
        while(q.size())
        {
            ++ret;

            int sz = q.size();
            for(int j = 0; j < sz; ++j) // 走完每步能走的所有格子算一步
            {
                // 将每次能走的格子带进去,
                auto [a, b] = q.front();
                q.pop();
                for(int i = 0; i < 4; ++i)
                {
                    int x = a + dx[i], y = b + dy[i];
                    if(x >= 0 && x < n && y >= 0 && y < m && maze[x][y] == '.' && !vis[x][y])
                    {
                        if(x == 0 || x == n - 1 || y == 0 || y == m - 1) return ret;
                        vis[x][y] = true;
                        q.push({x, y});
                    }
                }
            }
        }

        return -1;
    }
};

2.2 最小基因变化

433. 最小基因变化 - 力扣(LeetCode)

核心思路如下:

1. 问题理解

  • 从起始基因 startGene 出发,每次只能变异一个字符,求变为目标基因 endGene 的最少变异次数。
  • 合法变异条件:每次变异后的新基因必须存在于基因库 bank 中。

2. BFS 算法设计

核心数据结构
  • 队列 q:存储待扩展的基因序列,保证按层次遍历。
  • 哈希集合 vis:记录已访问的基因,避免重复处理。
  • 哈希集合 hash:快速判断某个基因是否存在于基因库中。
分层扩展策略
  • 外层循环:每一轮处理当前队列中的所有基因,代表 “变异一步”。
  • 内层循环:对每个基因的每个位置尝试替换为其他三种碱基(A/C/G/T),生成新基因。
  • 步数记录:每扩展一层(外层循环),步数 ret 加 1。

3. 关键点分析

  1. 变异生成

    • 对每个基因的每个位置,依次替换为 ACGT 中的其他字符,生成新基因。
    • 例如,对位置 i 的字符,尝试替换为 ACGT,检查是否合法。
  2. 合法性检查

    • 新基因必须存在于基因库 bank 中(通过 hash 判断)。
    • 新基因未被访问过(通过 vis 判断)。
  3. 提前终止条件

    • 若当前变异直接得到 endGene,立即返回当前步数 ret

4. 复杂度分析

  • 时间复杂度:\(O(N \times L)\),其中 N 为基因库大小,L 为基因长度(本题中 \(L=8\))。每个基因最多被扩展一次,每次扩展需尝试 \(L \times 3\) 种变异。
  • 空间复杂度:\(O(N)\),主要用于存储队列和访问集合。

5. 算法流程

  1. 初始化

    • 将起始基因加入队列,并标记为已访问。
    • 检查目标基因是否存在于基因库中,若不存在直接返回 -1
  2. 逐层扩展

    • 每轮处理队列中的所有基因(当前层),步数加 1。
    • 对每个基因的每个位置生成所有可能的变异:
      • 若变异合法且未访问:
        • 若是目标基因,返回当前步数。
        • 否则标记为已访问并加入队列。
  3. 无解处理:若队列为空仍未找到目标基因,返回 -1

总结

该算法通过 BFS 的分层遍历特性,确保首次到达目标基因时变异次数最少。关键在于合理生成变异并利用哈希集合快速判断合法性,避免无效搜索。

class Solution {
public:
    int minMutation(string startGene, string endGene, vector<string>& bank) {
        unordered_set<string> vis; // 标记已搜索过的
        unordered_set<string> hash(bank.begin(), bank.end());

        if(startGene == endGene) return 0;
        if(!hash.count(endGene)) return -1;
        string change("ACGT"); 

        queue<string> q;
        q.push(startGene);
        int ret = 0;

        while(q.size())
        {
            ++ret;
            int sz = q.size();
            while(sz--)
            {
                string t = q.front();
                q.pop();
                for(int i = 0; i < 8; ++i) // 每次修改一个字符,查看修改后的字符串是否在基因库里面
                {
                    string tmp = t;
                    for(int j = 0; j < 4; ++j)
                    {
                        tmp[i] = change[j];
                        if(hash.count(tmp) && !vis.count(tmp))
                        {
                            if(tmp == endGene) return ret;
                            q.push(tmp);
                            vis.insert(tmp);
                        }
                    }
                }
                
            }
        }

        return -1;
    }
};

2.3 单词接龙

127. 单词接龙 - 力扣(LeetCode)

核心思路如下:

1. 问题理解

  • 从起始单词 beginWord 出发,每次只能改变一个字母,求变为目标单词 endWord 的最短转换序列长度。
  • 合法转换条件:每次转换后的新单词必须存在于字典 wordList 中。

2. BFS 算法设计

核心数据结构
  • 队列 q:存储待扩展的单词,保证按层次遍历。
  • 哈希集合 vis:记录已访问的单词,避免重复处理。
  • 哈希集合 hash:快速判断某个单词是否存在于字典中。
分层扩展策略
  • 外层循环:每一轮处理当前队列中的所有单词,代表 “转换一步”。
  • 内层循环:对每个单词的每个位置尝试替换为其他 25 个字母,生成新单词。
  • 步数记录:每扩展一层(外层循环),步数 ret 加 1。初始步数为 1(包含起始单词)。

3. 关键点分析

  1. 单词生成

    • 对每个单词的每个位置,依次替换为 a 到 z 的其他字母,生成新单词。
    • 例如,对位置 i 的字母,尝试替换为除原字母外的所有可能字母。
  2. 合法性检查

    • 新单词必须存在于字典 wordList 中(通过 hash 判断)。
    • 新单词未被访问过(通过 vis 判断)。
  3. 提前终止条件

    • 若当前转换直接得到 endWord,立即返回当前步数 ret

4. 复杂度分析

  • 时间复杂度:\(O(N \times L \times 26)\),其中 N 为字典大小,L 为单词长度(本题中循环固定为 8 次)。每个单词最多被扩展一次,每次扩展需尝试 \(L \times 25\) 种转换。
  • 空间复杂度:\(O(N)\),主要用于存储队列和访问集合。

5. 算法流程

  1. 初始化

    • 将起始单词加入队列,并标记为已访问。
    • 检查目标单词是否存在于字典中,若不存在直接返回 0。
  2. 逐层扩展

    • 每轮处理队列中的所有单词(当前层),步数加 1。
    • 对每个单词的每个位置生成所有可能的转换:
      • 若转换合法且未访问:
        • 若是目标单词,返回当前步数。
        • 否则标记为已访问并加入队列。
  3. 无解处理:若队列为空仍未找到目标单词,返回 0。

总结

该算法通过 BFS 的分层遍历特性,确保首次到达目标单词时转换序列最短。关键在于合理生成转换并利用哈希集合快速判断合法性,避免无效搜索。

class Solution {
public:
    int ladderLength(string beginWord, string endWord, vector<string>& wordList) {
        unordered_set<string> vis;
        unordered_set<string> hash(wordList.begin(), wordList.end());

        if(beginWord == endWord) return 0;
        if(!hash.count(endWord)) return 0; 

        queue<string> q;
        q.push(beginWord);
        int ret = 1;

        while(q.size())
        {
            ++ret;
            int sz = q.size();

            while(sz--)
            {
                string tmp = q.front();
                q.pop();

                for(int i = 0; i < 8; ++i)
                {
                    string t = tmp;
                    for(char ch = 'a'; ch <= 'z'; ++ch)
                    {
                        t[i] = ch;
                        
                        if(hash.count(t) && !vis.count(t))
                        {
                            if(t == endWord) return ret;
                            q.push(t);
                            vis.insert(t);
                        }
                    }
                }
            }
        }

        return 0;
    }
};

2.4 为高尔夫比赛砍树

675. 为高尔夫比赛砍树 - 力扣(LeetCode)

核心思路及细节如下:

一、核心目标

从固定起点 (0,0) 出发,按树的高度从小到大依次砍伐所有树,计算总最短路径步数。若起点是障碍(0)、无法到达某棵树,返回 -1

二、核心思路拆解

1. 树的收集与排序(按高度从小到大)
  • 数据结构:使用小顶堆(priority_queue)配合自定义比较器 cmp,确保每次取出的是当前未砍伐的树中最矮的一棵。
    • cmp 比较器逻辑:通过全局变量 fore 访问树高,return fore[x1][y1] > fore[x2][y2] 实现小顶堆(堆顶为最小树高)。
  • 收集条件:仅收集 forest[i][j] > 1 的树(隐含逻辑:1 可能不视为树,或题目中树高至少为 2),存入堆中自动排序。
2. 路径计算(BFS 求最短路径)
  • 核心逻辑:对每棵待砍伐的树,通过 BFS 计算从当前位置到目标树的最短路径步数,累加总步数。
  • BFS 细节
    • 起始位置判断:若当前位置就是目标树位置(如起点 (0,0) 是最矮树),直接返回 0 步(if (cur_x == i && cur_y == j) return 0)。
    • 队列与访问标记:用队列存储待访问节点,vis 数组标记已访问位置,避免重复遍历和循环路径。
    • 移动条件:仅允许移动到非障碍区域(forest[x][y] != 0),且未访问过的位置。
    • 步数计算:每轮 BFS 处理当前层所有节点时,步数 step 加 1(代表 “走一步”),找到目标树时立即返回当前步数。
3. 整体流程控制
  • 起点合法性检查:若 forest[0][0] == 0(起点是障碍),直接返回 -1
  • 循环砍伐:从堆中依次取出最矮树,调用 BFS 计算当前位置到目标树的步数,累加总步数 ret,并更新当前位置为目标树位置。
  • 障碍处理:若 BFS 返回 -1(无法到达某棵树),整体返回 -1
  • 访问标记重置:每处理完一棵树下,用 memset(vis, 0, sizeof(vis)) 重置访问标记,避免影响下一次 BFS。

三、关键细节说明

  1. 全局变量 fore 的作用:存储森林矩阵,供 cmp 比较器访问树高(因比较器无法直接访问 forest 参数,通过全局变量传递)。
  2. BFS 步数计算逻辑step 初始为 0,每轮循环(处理一层节点)后 step++,确保每一步对应实际移动距离。
  3. 边界检查x >= 0 && x < n && y >= 0 && y < m 确保移动不超出森林范围。
  4. 树高排序的必要性:按从小到大砍伐是题目隐含规则(如 “必须先砍矮树才能砍高树”),小顶堆是高效实现方式。
vector<vector<int>> fore;

class Solution {
    typedef pair<int, int> PII;
    int dx[4] = {0, 0, 1, -1};
    int dy[4] = {1, -1, 0, 0};
    bool vis[50][50] = {false};
    int n, m;
public:
    struct cmp
    {
        // 按照树的高度进行排序
        bool operator()(PII& p1, PII& p2)
        {
            auto& [x1, y1] = p1;
            auto& [x2, y2] = p2;
            return fore[x1][y1] > fore[x2][y2];
        }
    };

    int cutOffTree(vector<vector<int>>& forest) {
        fore = forest;
        n = forest.size(), m = forest[0].size();

        if(forest[0][0] == 0) return -1;

        // 找到所有树并排序
        priority_queue<PII, vector<PII>, cmp> tree;
        for(int i = 0; i < n; ++i)
        {
            for(int j = 0; j < m; ++j)
            {
                if(forest[i][j] > 1)
                    tree.push({i, j});
            }
        }
        
        int ret = 0;
        int cur_x = 0, cur_y = 0; // 记录起始坐标
        while(tree.size())
        {
            auto [sx, sy] = tree.top();
            tree.pop();

            int step = bfs(forest, sx, sy, cur_x, cur_y); // 记录起始位置到下一个位置的最短步数
            if(step == -1) return -1;

            ret += step;
            cur_x = sx, cur_y = sy;
            memset(vis, 0, sizeof(vis));
        }

        return ret;
    }
    

    int bfs(vector<vector<int>>& forest, int i, int j, int cur_x, int cur_y)
    {
        // 起始位置就是目标位置,也就是当{0, 0}位置是第一颗要砍的树时
        if (cur_x == i && cur_y == j) return 0;

        queue<PII> q;
        q.push({cur_x, cur_y});
        vis[cur_x][cur_y] = true;
        int step = 0;

        while(q.size())
        {

            int sz = q.size();
            ++step; // 走一步
            while(sz--)
            {
                auto [a, b] = q.front();
                q.pop();
                for(int k = 0; k < 4; ++k)
                {
                    int x = a + dx[k], y = b + dy[k];
                    if(x >= 0 && x < n && y >= 0 && y < m && forest[x][y] != 0 && !vis[x][y])
                    {
                        if(x == i && y == j) return step; // 找到下一个位置了
                        q.push({x, y});
                        vis[x][y] = true;
                    }
                }
            }
        }

        return -1; // 到达不了下一个位置
    }
};

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐