Leetcode Hot 100 —— 图论

前置知识

关于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就是指向最后一个元素,不是最后一个元素的后一个)。

相关推荐
我怎么又饿了呀2 小时前
DataWhale—大模型的算法基础(环境的部署Anaconda)
人工智能·算法
ZZhYasuo2 小时前
冒泡排序1
java·算法·排序算法
重生之后端学习2 小时前
72. 编辑距离
数据结构·算法·leetcode·深度优先·图论
juleskk2 小时前
3.15 复试训练
算法
j_xxx404_2 小时前
力扣:525.连续数组和1314.矩阵区域和(二维前缀和)
算法·leetcode·矩阵
23.2 小时前
【Java】Arrays工具类——数组操作终极指南
java·算法·面试
Sunsets_Red2 小时前
模意义下及同余的公式整理
c语言·c++·数学·算法·c#·数论·信息学竞赛
计算机安禾2 小时前
【C语言程序设计】第27篇:递归函数原理与实例分析
c语言·开发语言·数据结构·c++·算法·蓝桥杯·visual studio
無限進步D2 小时前
C++ 万能头
开发语言·c++·算法·蓝桥杯·竞赛·万能头