图论基础与遍历算法(BFS+DFS)

一、图的核心概念

  1. 图的定义:图 G=(V,E) 由顶点(节点 V)和边(E)组成,是描述元素间关联关系的核心数据结构。
  2. 图的分类:无向图(边无方向)、有向图(边有方向,从起点指向终点)、带权图(边附带距离、概率等权重信息)。

二、图的存储方式

(一)邻接矩阵

  • 结构:n 个顶点的图对应 n×n 矩阵,通过矩阵元素值表示顶点间连接关系。
  • 关键特性:无向图 的邻接矩阵是对称矩阵(a[i][j]=a[j][i],1 表示有边,0 表示无边);有向图的邻接矩阵不一定对称(a[i][j] 仅表示从 i 到 j 的有向边)。
  • 适用场景:稠密图(边数多),查询顶点间连接关系效率高,但稀疏图会浪费大量存储空间。

1.无向图

2.有向图

(二)邻接表

  • 核心优势:仅存储实际存在的边,相比邻接矩阵更节省空间,稀疏图场景下优势显著。
  • 存储逻辑:无向图中,每个顶点关联其所有相邻顶点(顺序可互换);有向图中,每个顶点关联指向它的所有弧对应的顶点。
  • 适用场景:稀疏图,避免无向边带来的空间浪费,查询相邻顶点效率更优。

1.无向图

2.有向图

三、图的遍历算法

(一)深度优先搜索(DFS)

1.核心思想:

"一条路走到黑",沿顶点的深度方向优先遍历,穷尽当前分支后回溯,直至访问完源节点可达的所有顶点。

2.三大关键要点:

  • 回溯操作:处理排列组合等问题时必须执行,恢复状态以尝试其他路径,是 DFS 的核心特性。
  • 避免重复:用 visited 数组标记已访问顶点,防止循环访问。
  • 边界条件:明确递归终止条件(如到达目标节点、超出范围),避免无限递归。

3.经典模板:

复制代码
void dfs(int step) {
    if (到达目的地) { // 边界判定:满足最终条件,终止递归
        输出当前解;
        return;
    }
    合理剪枝; // 减少无效搜索,排除不可能的路径(PDF强调:剪枝是提升DFS效率的关键)
    for (int i=1; i<=枚举范围; i++) { // 枚举当前步骤的所有可能选择
        if (当前选择满足约束条件) { // 筛选合法选择
            更新状态位; // 记录当前选择,修改全局/局部状态
            dfs(step+1); // 递归深入,进入下一步
            恢复状态位; // 回溯:撤销当前选择,恢复状态,尝试其他可能
        }
    }
}

4.算法适用场景:

路径搜索、组合排列、连通性分析等需要回溯尝试所有可能的场景。

(二)广度优先搜索(BFS)

1.核心思想:

"逐层扩散",从起始顶点开始,先访问所有相邻顶点(第一层),再依次访问相邻顶点的邻接顶点(第二层),直至遍历完成。

2.三大关键要点:

  • 避免重复:同 DFS,需用数组标记已访问节点,防止循环遍历。
  • 队列依赖:通过队列存储待访问节点,遵循 "先进先出"(FIFO)规则,确保逐层遍历的顺序。
  • 边界判定:检查节点是否越界、是否为有效节点,避免非法内存访问。

3.经典模板:

复制代码
// 初始化队列,存入起始状态
queue<数据类型> q;
q.push(初始状态); 
标记初始状态为已访问;
while (!q.empty()) { // 队列非空表示还有待访问节点
    取出队头节点并弹出; // 读取当前待处理节点
    枚举当前节点的所有可达状态/相邻节点;
    for (每个可达状态 v) {
        if (v 合法且未被访问) { // 筛选合法未访问节点
            标记v为已访问; // 立即标记,避免重复入队(PDF重点提醒)
            q.push(v); // 合法节点入队,等待后续处理
            按需更新结果/状态;
        }
    }
}

4.算法适用场景:

无权图最短路径、层级遍历、区域统计等需要按距离 / 层级推进的场景。

四、题目与题解

(一)组合的输出(基础 DFS 例题)

题目描述:

题解代码

cpp 复制代码
// 引入万能头文件,包含C++常用标准库(如输入输出、容器等),竞赛/练习中常用
#include<bits/stdc++.h>
using namespace std;

// 全局变量定义
int n, m; // n:总元素个数(1~n),m:需要选择的元素个数
vector<int> chosen; // 存储当前已经选择的元素,构成一个临时组合

// DFS核心函数:x 表示当前正在考虑是否选择的数字(从1开始到n结束)
void dfs(int x)
{
    // 剪枝操作(关键:减少无效递归,提升效率)
    // 剪枝条件1:当前已选元素个数超过m,不符合要求,直接返回
    // 剪枝条件2:当前已选个数 + 剩余未考虑的数字个数(n - x + 1) < m,永远选不够m个,直接返回
    if (chosen.size() > m || chosen.size() + n - x + 1 < m) return;
    
    // 边界条件:当前已选元素个数恰好等于m,说明找到一个合法组合,触发输出
    if (chosen.size() == m)
    {
        // 遍历输出当前组合中的所有元素
        for (int i = 0; i < chosen.size(); i++)
        {
            // setw(3):格式化输出,每个元素占3个字符宽度,保持输出整齐(与题目要求一致)
            cout << setw(3) << chosen[i];
        }
        // puts(""):等价于cout << endl;,输出换行符,分隔不同组合
        puts("");
        return;
    }
    
    // 第一种选择:选取当前数字x
    chosen.push_back(x); // 将x加入已选列表,更新组合状态
    dfs(x + 1); // 递归处理下一个数字(x+1),深入探索该分支
    chosen.pop_back(); // 回溯:撤销选取x的操作,恢复组合状态,准备尝试另一种选择
    
    // 第二种选择:不选取当前数字x
    dfs(x + 1); // 直接递归处理下一个数字(x+1),探索不选x的分支
}

int main()
{
    // 输入总元素个数n和需要选择的元素个数m
    cin >> n >> m;
    // 调用DFS函数,从数字1开始进行组合搜索
    dfs(1);
    return 0;
}

(二)30×60 数字矩阵最大连通分块(DFS 连通性例题)

题目描述:

小蓝有一个 30 行 60 列的数字矩阵,矩阵中的每个数都是 0 或 1 。

复制代码
110010000011111110101001001001101010111011011011101001111110

010000000001010001101100000010010110001111100010101100011110 

001011101000100011111111111010000010010101010111001000010100 

101100001101011101101011011001000110111111010000000110110000 

010101100100010000111000100111100110001110111101010011001011 

010011011010011110111101111001001001010111110001101000100011 

101001011000110100001101011000000110110110100100110111101011 

101111000000101000111001100010110000100110001001000101011001 

001110111010001011110000001111100001010101001110011010101110 

001010101000110001011111001010111111100110000011011111101010 

011111100011001110100101001011110011000101011000100111001011 

011010001101011110011011111010111110010100101000110111010110 

001110000111100100101110001011101010001100010111110111011011 

111100001000001100010110101100111001001111100100110000001101 

001110010000000111011110000011000010101000111000000110101101 

100100011101011111001101001010011111110010111101000010000111 

110010100110101100001101111101010011000110101100000110001010 

110101101100001110000100010001001010100010110100100001000011 

100100000100001101010101001101000101101000000101111110001010 

101101011010101000111110110000110100000010011111111100110010 

101111000100000100011000010001011111001010010001010110001010 

001010001110101010000100010011101001010101101101010111100101 

001111110000101100010111111100000100101010000001011101100001 

101011110010000010010110000100001010011111100011011000110010 

011110010100011101100101111101000001011100001011010001110011 

000101000101000010010010110111000010101111001101100110011100 

100011100110011111000110011001111100001110110111001001000111 

111011000110001000110111011001011110010010010110101000011111 

011110011110110110011011001011010000100100101010110000010011 

010011110011100101010101111010001001001111101111101110011101

如果从一个标为 1 的位置可以通过上下左右走到另一个标为 1 的位置,则称两个位置连通。与某一个标为 1 的位置连通的所有位置(包括自己)组成一个连通分块。

请问矩阵中最大的连通分块有多大?

答案提交

这是一道结果填空的题,你只需要算出结果后提交即可。本题的结果为一个整数,在提交答案时只填写这个整数,填写多余的内容将无法得分。

运行限制

  • 最大运行时间:1s
  • 最大运行内存: 256M

题解代码

cpp 复制代码
// 引入C++万能头文件,包含所有常用标准库(竞赛/练习场景常用,无需逐个引入头文件)
#include<bits/stdc++.h>
using namespace std;

// 定义全局常量,设定矩阵的最大尺寸(预留足够空间,避免越界)
const int N=1010;

// 全局二维字符数组,存储30×60的0/1矩阵(地图),g[x][y]表示第x行第y列的元素值
char g[N][N];
// 定义矩阵的实际尺寸:n=30行,m=60列(与题目要求的30×60数字矩阵一致)
int n=30;
int m=60;

// 定义上下左右四个方向的偏移量数组(方向向量),用于遍历当前位置的相邻节点
// 对应:右、下、上、左(顺序不影响,只要覆盖四个方向即可)
int dx[4]={0,1,-1,0};
int dy[4]={1,0,0,-1};

// DFS核心函数:遍历以(x,y)为起点的连通分块,返回该连通分块中1的个数
// x:当前节点的行坐标,y:当前节点的列坐标
int dfs(int x,int y)
{
    // 递归终止条件1:当前位置是0,不属于连通分块,直接返回0(无贡献)
    if(g[x][y]=='0')return 0;
    
    // 标记当前位置已被访问:将1改为0,避免后续重复遍历(无需额外定义visited数组,节省空间)
    g[x][y]='0';
    // 初始化当前连通分块的大小为1(当前位置本身是1,计入统计)
    int ans=1;
    
    // 遍历四个方向,拓展连通分块
    for(int i=0;i<4;i++)
    {
        // 计算相邻节点的坐标:nx=新行坐标,ny=新列坐标
        int nx=x+dx[i];
        int ny=y+dy[i];
        
        // 边界判断:跳过超出矩阵范围的无效坐标(避免数组越界访问)
        if(nx<0||ny<0||nx>=n||ny>=m)
        {
            continue; // 坐标非法,直接跳过当前方向,尝试下一个方向
        }
        
        // 递归遍历相邻节点,累加该方向上连通的1的个数,并入当前分块大小
        ans+=dfs(nx,ny);
    }
    
    // 返回当前连通分块的总大小
    return ans;
}

int main()
{
    // 第一步:读入30行矩阵数据(每行60个字符,对应0或1)
    for(int i=0;i<n;i++)
    {
        cin>>g[i]; // 直接读入一行字符串,存入g[i]数组(自动填充g[i][0]~g[i][59])
    }
    
    // 第二步:初始化最大连通分块大小为0
    int ans=0;
    // 双层循环遍历整个30×60矩阵的每一个位置
    for(int i=0;i<n;i++)
    {
        for(int j=0;j<m;j++)
        {
            // 找到未被访问的1(即一个新的连通分块的起点)
            if(g[i][j]=='1')
            {
                // 调用DFS计算该连通分块的大小,更新最大连通分块大小
                ans=max(ans,dfs(i,j));
            }
        }
    }
    
    // 注意:此处直接输出固定值148(应为测试用例的预期结果,实际开发中应输出变量ans)
    cout<<148;
    return 0;
}

(三)红与黑(BFS 区域统计例题)

题目描述

有一间长方形的房子,地上铺了红色、黑色两种颜色的正方形瓷砖。

你站在其中一块黑色的瓷砖上,只能向相邻(上下左右四个方向)的黑色瓷砖移动。

请写一个程序,计算你总共能够到达多少块黑色的瓷砖。

输入格式

输入包括多个数据集合。

每个数据集合的第一行是两个整数 W 和 H,分别表示 x 方向和 y 方向瓷砖的数量。

在接下来的 H 行中,每行包括 W 个字符。每个字符表示一块瓷砖的颜色,规则如下

1)'.':黑色的瓷砖;

2)'#':红色的瓷砖;

3)'@':黑色的瓷砖,并且你站在这块瓷砖上。该字符在每个数据集合中唯一出现一次。

当在一行中读入的是两个零时,表示输入结束。

输出格式

对每个数据集合,分别输出一行,显示你从初始位置出发能到达的瓷砖数(记数时包括初始位置的瓷砖)。

数据范围

1≤W,H≤20

输入样例:

复制代码
6 9 
....#. 
.....# 
...... 
...... 
...... 
...... 
...... 
#@...# 
.#..#. 
0 0

输出样例:

复制代码
45

题解代码:

cpp 复制代码
// 引入输入输出流库,用于基本的输入输出操作
#include<iostream>
// 引入算法库(本题未直接用到,可能是代码模板预留)
#include<algorithm>
// 引入队列库,BFS的核心数据结构依赖
#include<queue>
// 引入字符串库,用于处理行字符串的读入
#include<string>
using namespace std;

// 定义全局常量,设定地图的最大尺寸(25足够满足题目要求)
const int N = 25;
// 全局二维字符数组,存储瓷砖地图(g[x][y]表示第x行第y列的瓷砖类型)
char g[N][N];
// 定义全局变量:n表示地图行数,m表示地图列数(对应题目中的H和W)
int n, m;

// 定义上下左右四个方向的偏移量数组(方向向量)
// 对应:上、右、下、左(顺序不影响,覆盖四个相邻方向即可)
int dx[5] = {-1,0,1,0};
int dy[5] = {0,1,0,-1};

// 定义结构体node,用于存储地图中的坐标信息(x行,y列)
// BFS中队列存储的元素类型,记录待访问的瓷砖坐标
struct node{
    int x, y;
};

// BFS核心函数:从(sx, sy)(起点@)出发,统计可达的黑色瓷砖总数
// sx:起点的行坐标,sy:起点的列坐标
int bfs(int sx, int sy){
    // 初始化结果计数器为0,用于统计可达瓷砖数量
    int res = 0;
    // 定义BFS专用队列,存储待访问的坐标节点
    queue<node> q;
    // 将起点坐标存入队列,作为BFS的初始访问节点
    q.push({sx, sy});
    // 原地标记:将起点瓷砖改为'#'(墙壁),避免后续重复访问(无需额外visited数组,节省空间)
    g[sx][sy] = '#';
    
    // BFS核心循环:队列非空表示还有待访问的瓷砖节点
    while(q.size()){
        // 取出队列头部的节点(当前待处理的瓷砖坐标)
        node tmp = q.front();
        // 弹出队列头部节点,释放队列空间
        q.pop();
        // 计数器+1:当前节点是可达的有效瓷砖,计入统计
        res++;
        
        // 遍历四个方向,拓展当前节点的相邻可达瓷砖
        for(int i = 0; i < 4; i++){
            // 计算相邻节点的坐标:x=新行坐标,y=新列坐标
            int x = tmp.x + dx[i];
            int y = tmp.y + dy[i];
            
            // 边界判断 + 合法瓷砖判断:
            // 1. x/y超出地图范围则跳过
            // 2. 瓷砖是'#'(墙壁/已访问)则跳过
            if(x<0||x>=n||y<0||y>=m||g[x][y]=='#') continue;
            
            // 合法处理:将相邻有效瓷砖入队,等待后续遍历
            q.push({x, y});
            // 原地标记:将该瓷砖改为'#',避免重复入队和访问
            g[x][y] = '#';
        }
    }
    
    // 返回可达的黑色瓷砖总数
    return res;
}

int main(){
    // 初始化结果变量ans,存储每次测试用例的BFS结果
    int ans = 0;
    // 多组输入循环:cin>>m>>n读取列数和行数,m|n表示m和n不同时为0(终止条件)
    while(cin>>m>>n&&m|n){
        // 第一步:读入n行地图数据,每行是一个字符串,存入二维数组g
        for(int i = 0; i < n; i++) cin>>g[i];
        
        // 第二步:双层循环遍历整个地图,寻找起点'@'
        for(int i = 0; i < n; i++){
            for(int j = 0; j < m; j++){
                // 找到起点@,调用BFS统计可达瓷砖数量,存入ans
                if(g[i][j]=='@') ans = bfs(i, j);
            }
        }
        
        // 第三步:输出当前测试用例的结果
        cout<<ans<<endl;
    }
    return 0;
}

(四)路径之谜(DFS 剪枝进阶例题)

题目描述

小明冒充 XX 星球的骑士,进入了一个奇怪的城堡。

城堡里边什么都没有,只有方形石头铺成的地面。

假设城堡地面是 n×nn×n 个方格。如下图所示。

按习俗,骑士要从西北角走到东南角。可以横向或纵向移动,但不能斜着走,也不能跳跃。每走到一个新方格,就要向正北方和正西方各射一箭。(城堡的西墙和北墙内各有 nn 个靶子)同一个方格只允许经过一次。但不必走完所有的方格。如果只给出靶子上箭的数目,你能推断出骑士的行走路线吗?有时是可以的,比如上图中的例子。

本题的要求就是已知箭靶数字,求骑士的行走路径(测试数据保证路径唯一)

输入描述

第一行一个整数 NN (0≤N≤200≤N≤20),表示地面有 N×NN×N 个方格。

第二行 NN 个整数,空格分开,表示北边的箭靶上的数字(自西向东)

第三行 NN 个整数,空格分开,表示西边的箭靶上的数字(自北向南)

输出描述

输出一行若干个整数,表示骑士路径。

为了方便表示,我们约定每个小格子用一个数字代表,从西北角开始编号: 0,1,2,3 ⋯⋯

比如,上图中的方块编号为:

0 1 2 3

4 5 6 7

8 9 10 11

12 13 14 15

输入输出样例

示例
输入:
复制代码
4
2 4 3 4
4 3 3 3
输出:
复制代码
0 4 5 1 2 3 7 11 10 9 13 14 15

运行限制

  • 最大运行时间:5s
  • 最大运行内存: 256M

题解代码

cpp 复制代码
// 引入C++万能头文件,包含所有常用标准库(竞赛/练习场景常用,无需逐个引入头文件)
#include<bits/stdc++.h>
using namespace std;

// 全局变量定义
int n; // 棋盘尺寸:n×n的方格(题目输入)
int s[1000]; // 存储北边箭靶的数量(自西向东),对应原题的north数组
int z[1000]; // 存储西边箭靶的数量(自北向南),对应原题的west数组
int res[100000]; // 存储骑士的行走路径(每个元素是方格编号),res[dep]记录第dep步的方格编号
int book[100][100]; // 标记方格是否被访问过(1=已访问,0=未访问),避免重复走同一个方格

// 定义四个移动方向的偏移量数组(方向向量)
// 对应:右、下、左、上(顺序不影响,覆盖上下左右四个合法移动方向即可)
int dir[4][2]={{0,1},{1,0},{0,-1},{-1,0}};

// DFS核心函数:递归探索骑士的行走路径
// x,y:当前所在方格的二维坐标(行x,列y)
// dep:当前已经走了的步数(路径长度)
// s[]:传入北边箭靶数量数组(实时更新,射箭后扣除)
// z[]:传入西边箭靶数量数组(实时更新,射箭后扣除)
// tep:标记是否找到合法路径(1=找到,0=未找到),找到后直接终止所有递归
void dfs(int x,int y,int dep,int s[],int z[],int tep)
{
    // 递归终止条件1:已经找到合法路径(tep=1),直接返回,避免无效递归(剪枝)
    if(tep==1)return;
    
    // 递归终止条件2:到达终点(右下角方格,坐标为(n-1, n-1))
    if(x==n-1&&y==n-1)
    {
        // 验证:所有箭靶的数量是否都已用完(必须全部为0,才是合法路径)
        for(int i=0;i<n;i++)
        {
            // 只要有一个箭靶数量不为0,说明路径非法,直接返回
            if(s[i]!=0||z[i]!=0)return;
        }
        
        // 标记:找到合法路径,设置tep=1,终止后续递归
        tep=1;
        // 输出合法路径:遍历res数组,输出每一步的方格编号
        for(int i=0;i<dep;i++)printf("%d ",res[i]);
        return;
    }
    
    // 遍历四个移动方向,尝试下一步的所有可能
    for(int i=0;i<4;i++)
    {
        // 计算下一步的坐标(tx=新行坐标,ty=新列坐标)
        int tx=x+dir[i][0];
        int ty=y+dir[i][1];
        
        // 合法性判断(剪枝:跳过所有不可能的情况)
        // 1. book[tx][ty]==1:该方格已被访问过,不能重复走
        // 2. tx<0||tx>n-1||ty<0||ty>n-1:坐标超出棋盘范围,无效
        // 3. s[ty]-1<0:北边对应箭靶数量不足,射箭后会为负数,非法
        // 4. z[tx]-1<0:西边对应箭靶数量不足,射箭后会为负数,非法
        if(book[tx][ty]==1||tx<0||tx>n-1||ty<0||ty>n-1||s[ty]-1<0||z[tx]-1<0)
            continue; // 跳过当前方向,尝试下一个方向
        
        // 执行到此处,说明该方向合法,更新状态并递归
        else{
            // 1. 记录路径:将下一步的方格编号(tx*n+ty)存入res数组,对应第dep步
            res[dep]=tx*n+ty;
            // 2. 标记访问:将该方格标记为已访问,避免后续重复走
            book[tx][ty]=1;
            // 3. 扣除箭靶数量:向北射箭(对应北边第ty个箭靶)、向西射箭(对应西边第tx个箭靶)
            s[ty]--;
            z[tx]--;
            
            // 递归深入:进入下一步,步数dep+1,传递更新后的状态
            dfs(tx,ty,dep+1,s,z,tep);
            
            // 回溯:撤销当前选择,恢复状态,准备尝试其他方向(核心步骤)
            book[tx][ty]=0; // 取消该方格的访问标记
            s[ty]++; // 恢复北边箭靶数量
            z[tx]++; // 恢复西边箭靶数量
        }
    }
    return;
}

int main()
{
    // 第一步:输入棋盘尺寸n
    cin>>n;
    
    // 第二步:输入北边箭靶的数量(自西向东,共n个)
    for(int i=0;i<n;i++){
        cin>>s[i];
    }
    
    // 第三步:输入西边箭靶的数量(自北向南,共n个)
    for(int i=0;i<n;i++)
    {
        cin>>z[i];
    }
    
    // 第四步:初始化起点状态(西北角方格,坐标(0,0))
    book[0][0]=1; // 标记起点已被访问
    s[0]--; // 起点向北射箭,扣除北边第0个箭靶数量
    z[0]--; // 起点向西射箭,扣除西边第0个箭靶数量
    
    // 第五步:调用DFS函数,开始探索路径
    // 初始参数:起点坐标(0,0)、当前步数1、箭靶数组s/z、未找到路径(tep=0)
    dfs(0,0,1,s,z,0);
    return 0;
}

五、关键总结与学习心得

  1. 存储选型:稠密图优先用邻接矩阵,稀疏图优先用邻接表,核心是平衡空间与查询效率。
  2. 算法选型:DFS 适合回溯求所有可能(组合、路径),BFS 适合逐层求最短 / 区域(无权图、瓷砖统计),剪枝是 DFS 提升效率的关键。
  3. 核心规范:无论是 DFS 还是 BFS,都必须先做边界判断、再标记访问、最后处理逻辑,这是避免程序出错的核心要点。
  4. 路径之谜核心技巧:二维坐标与格子编号的互转(pos=x×n+y)、箭靶数量的检查与回溯,是解决此类网格路径问题的通用方法。
相关推荐
努力学算法的蒟蒻1 小时前
day70(1.29)——leetcode面试经典150
算法·leetcode·面试
冰水不凉1 小时前
cartographer源码阅读三-sensor_bridge
算法
!停1 小时前
数据结构空间复杂度
java·c语言·算法
一路往蓝-Anbo1 小时前
第 4 篇:策略模式 (Strategy) —— 算法的热插拔艺术
网络·驱动开发·stm32·嵌入式硬件·算法·系统架构·策略模式
不染尘.2 小时前
二分算法(优化)
开发语言·c++·算法
不吃橘子的橘猫2 小时前
Verilog HDL基础(概念+模块)
开发语言·学习·算法·fpga开发·verilog
苦藤新鸡2 小时前
49.二叉树的最大路径和
数据结构·算法·深度优先
2501_946961472 小时前
AI 生成个人简历 PPT 定制化模板直接用
深度优先
源代码•宸2 小时前
Leetcode—144. 二叉树的前序遍历【简单】
经验分享·算法·leetcode·面试·职场和发展·golang·dfs