前置知识
关于ACM
图论是在笔试还有面试中,通常都是以ACM模式来考察大家。大家习惯在力扣刷题(核心代码模式),核心代码模式对图的存储和输出都隐藏了。
而图论题目的输出输出相对其他类型题目来说是最难处理的。ACM模式是最考察候选人对代码细节把控程度的, 图的构成,图的输出,这些只有ACM输入输出模式才能体现出来。
图论的输入难在图的存储结构,如果没有练习过邻接表和邻接矩阵 ,很多录友是写不出来的,不知道图应该怎么存,也不知道自己存的图如何去遍历。
图论理论基础
图的种类
整体上一般分为有向图和无向图。
加权有向图,就是图中边是有权值的。加权无向图也是同理。
度
无向图中有几条边连接该节点,该节点就有几度。
在有向图中,每个节点有出度和入度。
出度:从该节点出发的边的个数。
入度:指向该节点边的个数。
连通性
在图中表示节点的连通情况,我们称之为连通性。
在无向图中,任何两个节点都是可以到达的,我们称之为连通图 。
如果有节点不能到达其他节点,则为非连通图 。
在有向图中,任何两个节点是可以相互到达 的,我们称之为强连通图 。强连通图是在有向图中任何两个节点是可以相互到达,下面这个有向图才是强连通图:

连通分量
在无向图中的极大连通子图 称之为该图的一个连通分量。

该无向图中节点1、节点2、节点5构成的子图就是该无向图中的一个连通分量,该子图所有节点都是相互可达到的。
同理,节点3、节点4、节点6构成的子图 也是该无向图中的一个连通分量。
那么无向图中节点3 、节点4构成的子图 是该无向图的联通分量吗?
不是!因为必须是极大联通子图才能是连通分量,所以必须是节点3、节点4、节点6构成的子图才是连通分量。
在图论中,连通分量是一个很重要的概念,例如岛屿问题(后面章节会有专门讲解)其实就是求连通分量。
强连通分量
在有向图中极大强连通子图称之为该图的强连通分量。
图的构造
如何用代码来表示一个图呢?
主要是朴素存储、邻接表和邻接矩阵。
关于朴素存储,这是我自创的名字,因为这种存储方式,就是将所有边存下来。
邻接矩阵
邻接矩阵使用二维数组来表示图结构。 邻接矩阵是从节点的角度来表示图,有多少节点就申请多大的二维数组 。
例如: grid[2][5] = 6,表示节点2连接节点5为有向图,节点2指向节点5,边的权值为6。
如果想表示无向图,即:grid[2][5] = 6,grid[5][2] = 6,表示节点2与节点5 相互连通,权值为6。
这种表达方式(邻接矩阵)在边少,节点多的情况下,会导致申请过大的二维数组,造成空间浪费。而且在寻找节点连接情况的时候,需要遍历整个矩阵,即 n * n的时间复杂度,同样造成时间浪费。
邻接矩阵的优点 :
1、表达方式简单,易于理解
2、检查任意两个顶点间是否存在边的操作非常快
3、适合稠密图,在边数接近顶点数平方的图中,邻接矩阵是一种空间效率较高的表示方法。
缺点:
遇到稀疏图,会导致申请过大的二维数组造成空间浪费,且遍历边的时候需要遍历整个n * n矩阵,造成时间浪费。
邻接表
邻接表使用数组 + 链表的方式来表示。邻接表是从边的数量来表示图,有多少边才会申请对应大小的链表。
邻接表的构造如图:

这里表达的图是:
节点1指向节点3和节点5
节点2指向节点4、节点3、节点5
节点3指向节点4
节点4指向节点1
有多少边,邻接表才会申请多少个对应的链表节点。从图中可以直观看出使用数组 + 链表来表达边的连接情况 。
邻接表的优点 :
1、对于稀疏图的存储,只需要存储边,空间利用率高
2、遍历节点连接情况相对容易
缺点:
1、需要检查任意两个节点间是否存在边,效率相对低,需要O(V)时间,V表示某节点连接其他节点的数量
2、实现相对复杂,不易理解
图的遍历方式
图的遍历方式基本是两大类:
深度优先搜索(dfs)
广度优先搜索(bfs)
在讲解二叉树章节的时候,其实就已经讲过这两种遍历方式。
二叉树的递归遍历,是dfs在二叉树上的遍历方式。
二叉树的层序遍历,是bfs在二叉树上的遍历方式。
dfs和bfs是搜索算法,可以在不同的数据结构上进行搜索,在二叉树章节里是在二叉树这样的数据结构上搜索。而在图论章节,则是在图(邻接表或邻接矩阵)上进行搜索。
深度优先搜索理论基础
dfs 与 bfs 区别
dfs 是可一个方向去搜,不到黄河不回头,直到遇到绝境了,搜不下去了,再换方向(换方向的过程就涉及到了回溯)。
bfs是先把本节点所连接的所有节点遍历一遍,走到下一个节点的时候,再把连接节点的所有节点遍历一遍,搜索方向更像是广度,四面八方的搜索过程。
dfs 搜索过程
关键就两点:
搜索方向,是认准一个方向搜,直到碰壁之后再换方向
换方向是撤销原路径,改为节点链接的下一个路径,回溯的过程
代码框架
正是因为dfs搜索可一个方向,并需要回溯,所以用递归的方式来实现是最方便的。
有递归的地方就有回溯,那么回溯在哪里呢?
就在递归函数的下面,例如如下代码:
cpp
void dfs(参数) {
处理节点
dfs(图,选择的节点); // 递归
回溯,撤销处理结果
}
在学二叉树时,二叉树的递归法其实就是dfs,而二叉树的迭代法,就是bfs(广度优先搜索)。所以dfs,bfs其实是基础搜索算法,也广泛应用于其他数据结构与算法中。
回顾一下回溯法的代码框架:
cpp
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
回溯算法,其实就是dfs的过程,这里给出dfs的代码框架:
cpp
void dfs(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本节点所连接的其他节点) {
处理节点;
dfs(图,选择的节点); // 递归
回溯,撤销处理结果
}
}
深搜三部曲
1、确认递归函数,参数
cpp
void dfs(参数)
通常我们递归的时候,我们递归搜索需要了解哪些参数,其实也可以在写递归函数的时候,发现需要什么参数,再去补充就可以。
一般情况,深搜需要二维数组数组结构保存所有路径,需要一维数组保存单一路径,这种保存结果的数组,我们可以定义一个全局变量,避免让我们的函数参数过多。
例如这样:
cpp
vector<vector<int>> result; // 保存符合条件的所有路径
vector<int> path; // 起点到终点的路径
void dfs (图,目前搜索的节点)
2、确认终止条件
终止条件很重要,很多同学写dfs的时候,之所以容易死循环,栈溢出等等这些问题,都是因为终止条件没有想清楚。
cpp
if (终止条件) {
存放结果;
return;
}
终止添加不仅是结束本层递归,同时也是我们收获结果的时候。
另外,其实很多dfs写法,没有写终止条件,其实终止条件写在了, 隐藏在下面dfs递归的逻辑里了,也就是不符合条件,直接不会向下递归。
3、处理目前搜索节点出发的路径
一般这里就是一个for循环的操作,去遍历目前搜索节点所能到的所有节点。
cpp
for (选择:本节点所连接的其他节点) {
处理节点;
dfs(图,选择的节点); // 递归
回溯,撤销处理结果
}
广度优先搜索理论基础
广搜(bfs)是一圈一圈的搜索过程,和深搜(dfs)是一条路跑到黑然后再回溯不同。
广搜的使用场景
广搜的搜索方式就适合于解决两个点之间的最短路径问题。
因为广搜是从起点出发,以起始点为中心一圈一圈进行搜索,一旦遇到终点,记录之前走过的节点就是一条最短路。
当然,也有一些问题是广搜和深搜都可以解决的,例如岛屿问题,这类问题的特征就是不涉及具体的遍历方式,只要能把相邻且相同属性的节点标记上就行。
广搜的过程
BFS是一圈一圈的搜索过程,但具体是怎么一圈一圈来搜呢。
我们用一个方格地图,假如每次搜索的方向为上下左右(不包含斜上方),那么给出一个start起始位置,那么BFS就是从四个方向走出第一步。
代码框架
大家应该好奇,这一圈一圈的搜索过程是怎么做到的,是放在什么容器里,才能这样去遍历。
其实,我们仅仅需要一个容器,能保存我们要遍历过的元素就可以,那么用队列,还是用栈,甚至用数组,都是可以的。
用队列的话,就是保证每一圈都是一个方向去转,例如统一顺时针或者逆时针。因为队列是先进先出,加入元素和弹出元素的顺序是没有改变的。
如果用栈的话,就是第一圈顺时针遍历,第二圈逆时针遍历,第三圈又顺时针遍历。因为栈是先进后出,加入元素和弹出元素的顺序改变了。
广搜不需要注意转圈搜索的顺序先后,所以用队列,还是用栈都是可以的,但大家都习惯用队列了,所以下面的讲解用我也用队列来讲,只不过要给大家说清楚,并不是非要用队列,用栈也可以。
下面给出广搜代码模板,该模板针对的是四方格地图:
cpp
int dir[4][2] = {0, 1, 1, 0, -1, 0, 0, -1}; // 表示四个方向
// grid是地图,也就是一个二维数组
// visited标记访问过的节点,不要重复访问
// x,y 表示开始搜索节点的下标
void bfs(vector<vector<char>>& grid, vector<vector<bool>>& visited, int x, int y) {
queue<pair<int, int>> que; // 定义队列
que.push({x, y}); // 起始节点加入队列
visited[x][y] = true; // 只要加入队列,立刻标记为访问过的节点
while(!que.empty()) { // 开始遍历队列里的元素
pair<int ,int> cur = que.front(); que.pop(); // 从队列取元素
int curx = cur.first;
int cury = cur.second; // 当前节点坐标
for (int i = 0; i < 4; i++) { // 开始向当前节点的四个方向左右上下去遍历
int nextx = curx + dir[i][0];
int nexty = cury + dir[i][1]; // 获取周边四个方向的坐标
if (nextx < 0 || nextx >= grid.size() || nexty < 0 || nexty >= grid[0].size()) continue; // 坐标越界了,直接跳过
if (!visited[nextx][nexty]) { // 如果节点没被访问过
que.push({nextx, nexty}); // 队列添加该节点为下一轮要遍历的节点
visited[nextx][nexty] = true; // 只要加入队列立刻标记,避免重复访问
}
}
}
}
200. 岛屿数量

思路与解法
注意题目中每座岛屿只能由水平方向/或竖直方向上相邻的陆地连接形成。
本题思路,是用遇到一个没有遍历过的节点陆地,计数器就加一,然后把该节点陆地所能遍历到的陆地都标记上。
在遇到标记过的陆地节点和海洋节点的时候直接跳过。 这样计数器就是最终岛屿的数量。
那么如何把节点陆地所能遍历到的陆地都标记上呢,就可以使用 DFS,BFS或者并查集。
以下代码使用dfs实现:
cpp
void dfs(vector<vector<char>>& grid, int x, int y){
dfs函数在本题实现的功能是将在二维网格中遍历与起点 (x, y) 相连通的所有陆地(格子值为1),并将它们标记为已访问(值为0)。 不是返回最终的岛屿数量,最终结果要经过处理得到。
cpp
class Solution {
public:
void dfs(vector<vector<char>>& grid, int x, int y){
//越界或访问到水或访问过的陆地就返回
if(x<0||x>=grid.size()||y<0||y>=grid[0].size()||grid[x][y]=='0') return;
grid[x][y]='0'; //标记为已访问
dfs(grid,x+1,y);
dfs(grid,x-1,y);
dfs(grid,x,y+1);
dfs(grid,x,y-1);
}
int numIslands(vector<vector<char>>& grid) {
int result =0;
for(int i=0;i<grid.size();i++){
for(int j=0;j<grid[0].size();j++){
if(grid[i][j]=='1'){
result++;
dfs(grid,i,j);
}
}
}
return result;
}
};
【注】
1、x>=grid.size() 别忘了等于
2、水本来就是0,所以可以把访问过的陆地也标记为0,这样就统一了。
3、本题不需要回溯!!因为这里的目的是遍历并标记所有属于同一个岛屿的陆地,而不是寻找路径或组合。
994. 腐烂的橘子

思路与解法
本题必须用 BFS(广度优先搜索)!
传播方式:腐烂过程是同时从所有初始腐烂橘子开始,像水波一样一层一层地向外扩散。每一分钟,所有当前的腐烂橘子都会同步感染其周围的邻居。这正好符合 BFS"层层递进" 的特性。
求最短时间:题目要求的是"最小分钟数",本质上就是求从所有腐烂橘子出发,感染到最后一个新鲜橘子所需的最短路径(层数)。BFS正是解决"最短路径"或"最小步数"问题的首选算法。
相比之下,DFS(深度优先搜索) 会一条道走到黑,无法模拟"同时、逐层"感染的传播过程,也就无法准确计算最短时间。
这道题和标准的单一起点的BFS有所不同,它是一个 "多源 BFS" 问题。因为最初腐烂的橘子可能不止一个,它们都是"感染源"。
本题逻辑比较完整,直接一个函数写完即可,不用单独写bfs函数。
1、遍历网格,统计新鲜橘子数量
2、把所有腐烂橘子入队(元素类型是pair<int,int>,用于记录x和y坐标)
3、BFS 按层扩散
4、返回分钟数或 -1
cpp
class Solution {
public:
int orangesRotting(vector<vector<int>>& grid) {
int m = grid.size();
int n = grid[0].size();
queue<pair<int,int>> que;
int freshCount = 0;
int minutes = 0;
//将初始腐烂的橘子入队,并统计初始新鲜橘子的数量
for(int i=0;i<m;i++){
for(int j=0;j<n;j++){
if(grid[i][j]==2){
que.push({i,j});
}
else if(grid[i][j]==1){
freshCount++;
}
}
}
//当前就没有新鲜橘子,直接return 0分钟
if(freshCount == 0) return 0;
//开始BFS
int dir[4][2]={-1,0,0,-1,1,0,0,1};
while(!que.empty()&&freshCount>0){
int size = que.size();
//处理当前这一层(即当前的这一时刻)
for(int i=0;i<size;i++){
pair<int,int> cur = que.front();
que.pop();
int x = cur.first;
int y = cur.second;
for(int j=0;j<4;j++){
int nextx = x + dir[j][0];
int nexty = y + dir[j][1];
// 判断若未越界且为新鲜橘子,则变腐烂并入队!!
if(nextx>=0&&nextx<m&&nexty>=0&&nexty<n&&grid[nextx][nexty]==1){
grid[nextx][nexty]=2;
que.push({nextx,nexty});
freshCount--;
}
}
}
// 这一分钟结束,时间+1
// 注意:如果这一分钟结束后新鲜橘子为0,我们也会+1,最后返回时需要处理
minutes++;
}
return freshCount == 0?minutes:-1;
}
};
【注】
1、while (!q.empty() && freshCount > 0) 中的 freshCount > 0 是必要的,提前终止,避免无效循环,防止 minutes 错误累加。
2、注意minutes++;的位置,在一层处理完后,即在for(int i=0;i<size;i++){后面。
3、最终的return:
当 BFS 的队列变空(que.empty())且仍有新鲜橘子剩余(freshCount > 0)时,说明这些新鲜橘子无法被任何腐烂橘子接触到(例如被空格子包围或完全隔离),因此永远无法腐烂。
207. 课程表

思路与解法
这次的数据结构从网格(Grid)变成了有向图 (Directed Graph)。
核心任务是:判断这个有向图里是否存在"环"。你可以把它想象成课程的依赖关系:如果要学课程A,必须先学课程B;如果要学课程B,又必须先学课程A,这就形成了一个死结(环),导致课程无法完成。
同样使用BFS,更好理解。
1、 构建邻接表(二维数组)并计算入度(一维数组,即有多少门课依赖它作为先修课,没有先修课的课程,入度为0)
2、将所有入度为0的节点入队(队列存int即可,只需要记录是哪一门课)
3、BFS
4、返回结果
BFS需要统计入度的原因
BFS 解法模拟的是"逐步消除依赖"的过程。它从那些没有任何前置依赖(入度为 0)的节点开始,一层层地移除节点并减少其后继节点的依赖计数。
入度的作用:入度表示一个节点有多少个直接前驱(即需要先修的课程)。只有当某个节点的入度变为 0 时,才意味着它的所有前置条件都已满足,可以加入队列进行下一轮处理。
环的检测:如果图中存在环,环内所有节点的入度永远无法减到 0,因此最终处理的节点数会少于总节点数。通过统计入度并监控其变化,就能判断是否存在环。
cpp
class Solution {
public:
bool canFinish(int numCourses, vector<vector<int>>& prerequisites) {
vector<vector<int>> graph(numCourses);
vector<int> indegree(numCourses,0);
for(auto p:prerequisites){
graph[p[1]].push_back(p[0]);
indegree[p[0]]++;
}
queue<int> que;
for(int i=0;i<numCourses;i++){
if(indegree[i]==0){
que.push(i);
}
}
int result = 0;
while(!que.empty()){
int cur = que.front();
que.pop();
result++;
for(int i:graph[cur]){
indegree[i]--;
if(indegree[i]==0){
que.push(i);
}
}
}
return result==numCourses?true:false;
}
};
【注】
1、vector<vector<int>> graph(numCourses);
对于图中的每一个节点(课程),我们都需要一个列表来存储它的所有后继节点(即依赖于它的课程)。这个结构自然是一个二维的:
第一维是节点的索引,从0到numCourses-1,用来定位到具体的某个节点。
第二维是每个节点对应的列表,其中存放着该节点的所有后继节点。
2、prerequisites = [[1,0]]表示想要学习课程 1 ,你需要先完成课程 0 。即1是0的后继节点,p[1]->p[0],因此也是p[0]的入度+1。
cpp
for(auto p:prerequisites){
graph[p[1]].push_back(p[0]);
indegree[p[0]]++;
}
3、队列的处理逻辑,当队列不为空时,将队首弹出,然后result++(可以完成的课程),然后将弹出元素的所有后继节点(graph中记录了)的入度-1,紧跟着判断是否该节点入度变为0了,若变为0则入队!
208. 实现Trie(前缀树)

这题为何放图论里?不懂
Trie(前缀树/字典树) 是一种用于高效存储和检索字符串集合的树形数据结构。每个节点代表一个字符,从根节点到某个节点的路径拼接起来就是一个字符串。Trie 的核心优势在于可以快速进行前缀匹配,时间复杂度与字符串长度成正比,与数据量无关。

节点定义
每个节点包含:
1、一个指向 26 个子节点的指针数组(假设只包含小写字母 'a' 到 'z')。
2、一个布尔标记 isEnd,表示从根到当前节点的路径是否构成一个完整的单词。
cpp
class TrieNode {
public:
TrieNode* children[26];
bool isEnd;
TrieNode() {
isEnd = false;
for (int i = 0; i < 26; ++i) {
children[i] = nullptr;
}
}
};
Trie类
cpp
class Trie {
public:
TrieNode* root;
Trie() {
root = new TrieNode();
}
void insert(string word) {
TrieNode* node = root;
for(char c:word){
int id = c-'a';
if(node->children[id]==nullptr){
node->children[id]=new TrieNode();
}
node = node->children[id];
}
node->isEnd=true; //遍历到字符串最后,则isEnd为true
}
bool search(string word) {
TrieNode* node = root;
for(char c:word){
int id = c-'a';
if(node->children[id]==nullptr){
return false;
}
node = node->children[id];
}
//遍历到最后一个字母,因为要判断字符串的结尾(前提是node不为空!)
return node!=nullptr&&(node->isEnd==true);
}
bool startsWith(string prefix) {
TrieNode* node = root;
for(char c :prefix){
int id = c-'a';
if(node->children[id]==nullptr){
return false;
}
node = node->children[id];
}
return true;
}
};
【注】
1、几个操作函数结构都差不多
注意search函数return node!=nullptr&&(node->isEnd==true);因为需要判断最后一个元素的isEnd(遍历到最后node就是指向最后一个元素,不是最后一个元素的后一个)。