一、图与之前所学数据结构的比较:
- 线性结构中,元素仅有线性关系,每个元素只有一个直接前驱和直接后继;
- 树形结构中,数据元素(结点)之间有着明显的层次关系,每层上的元素可能和下一层中多个元素相关,但只能和上一层中一个元素相关;
- 图是一种较线性表和树更加复杂的数据结构,在图形结构中,结点之间的关系可以是可以是多对多,也可以孤立一个点(因此相比较与树,还需要 [访问记录] 来协助完成遍历)。
二、图的定义(图的数学表达式):
线性表中的数据元素叫元素,树中的数据元素叫结点,图中的数据元素叫顶点。
1 两种图:
- 无向图:如果图中任意两个顶点之间的边都是无向边,则该图称为无向图。如下图 7-2-2。
- 有向图:如果图中任意两个顶点之间的边都是有向边,则该图称为无向图。如上图 7-2-3。
注意:无向边的写法为(Vi,Vj);有向边的写法为<Vi,Vj>,有向边也成为弧,Vi称为弧尾,Vj称为弧头;
2 其他图概念:
1、连通图
2、顶点的度
对于无向图,顶点的度:与顶点相连的边的数目,记为TD(Vi);图的总边数其实就是各顶点度数和的一半。
对于有向图:
3、顶点之间的路径
四、图的两种存储结构
如何用数据表示一张图?事实上,数据结构中的”图“是比较简单的,是一堆结点之间的连线关系。
-
邻接矩阵形式(算法题主要形式):
一个顶点一维数组 + 一个边二维n*n的(对称)矩阵
那么邻接矩阵的实现图的创建,很简单即为:
-
邻接链表形式:
(邻接表法是为了节省存储空间引入的,对于稀疏图,相对于邻接矩阵,无需耗费大量存储空间,否则会造成一张白纸上画一个点的情况。)
邻接表的数据结构大致相似,就是在一维数组和头结点的设置上稍微有些不同,有如下几种:
(1)一维数组仅仅起到下标作用,表头结点数据域存储顶点,则把相邻顶点依次存放于表头结点后所指向的单向链表中。
(2)大话数据结构:其实一维数组可以写成结构体数组形式,存储更多的信息。
另外:可以根据具体情况,适当改变结点结构体。
邻接链表的实现图的创建:
五、图的两种遍历代码实现
深度优先和广度优先遍历,这两个是非常重要的思想,应用最多的就是对树和图的相关遍历了。
DFS:
一句话描述算法思路:
拿到一张图,从入口顶点出发,开始访问。入口顶点有很多相邻点,我们自己指定一个顺序(比如从矩阵对应顶点列的下标1开始),先访问其一个邻接点,下一跳规则如前,直到发现某下一跳已经访问过。就开始回退检查每个访问过的顶点的临接点,如果没有访问过就按照DFS顺序访问,直到再次回退到这个顶点。再接着回退,直到回退到最开始的入口顶点。这样以入口顶点开始的一个连通图就已经访问和标记完全,紧接着继续遍历顶点数组,判断哪个顶点还没访问过,它就属于另一个连通图里。以它开始,进行一个新的连通图的访问。
所以,总的代码框架是:一个for循环遍历顶点数组,判断顶点是否被访问过,如果没有,就以这个顶点为入口,对其代表的连通图进行DFS(显然对于一个连通图,这个for循环中的DFS只进入一次);否则继续遍历顶点数组,直到确定所有顶点都被访问过,则整个图被访问完毕,返回。
代码框架中,最重要的一部分就是:从一个顶点开始,对一个连通图(n*n 矩阵)进行访问的代码怎么写。这个问题可以拆分为两步:1)确定当前顶点的下一跳(在顶点数组中的下标),for循环从下标0(因为不能确定当前顶点下标之前的下标都已经被访问过了)开始检查其连通点中哪个还没被访问过。2)从下一跳又开始DFS(停止条件:当前顶点的所有邻接点都被访问过,返回上一层DFS)
要非常熟练写出下面DFS代码遍历:
class Solution { public: //单个连通图进行搜索 void DFS(vector<vector<int>>& isConnected, int i, vector<int>& visited, int n) { //一个经典的错误,vector<int>& visited没引用,导致结果不对 //访问一个标记一个 visited[i] = 1; //搜索当前顶点的没访问过的临接点,确认下一跳 for(int j = 0; j < n; j++) { if (isConnected[i][j] == 1 && visited[j] == 0) { DFS(isConnected, j, visited, n); } } } int findCircleNum(vector<vector<int>>& isConnected) { int n = isConnected.size(); vector<int>visited(n, 0); for (int i = 0; i < n; i++) { if(visited[i] == 0) { DFS(isConnected, i, visited, n); } } } };
ps:因为不能确定当前顶点下标之前的下标都已经被访问过了 ,举例:
[[1,0,0,1],
[0,1,1,0],
[0,1,1,1],
[1,0,1,1]]
首先回顾一下二叉树的深度优先遍历:
再扩展到N叉树的深度优先遍历,
最后我们发现图的遍历和树的遍历一个重要区别是:
树的遍历是有某种顺序的,按照某种规则很明确的就可以把结点挨个遍历一遍而不重复或者回到原点。
而图各个顶点任意连接,无法仅仅按照某种规则就将所有点一次性遍历结束,很可能回到原点。这是图的遍历相对树的遍历最大的不同,也是代码上需要注意的地方。
/*********************邻接矩阵图的深度优先遍历**************************/
/*
邻接矩阵的深度优先递归算法:
算法基本思想:从图中的下标为0的结点开始遍历,沿着右手一路走到底(发现跟他相连都遍历过了),此时开始执行回退操作,退一个判断一下
当前结点还有没有没访问的邻接结点,有点话从该结点入手,没有的话继续推,最终退到原点,退出dfs主调用。
*/
void DFS(AdjMatr g, int i)
{ //回退是主过程,回退到一个结点时,对该访问过的结点的其他连接点清查,确保这个连通图没有遗漏;最终回退到原点,本连通图遍历完成
int j;
visited[i] = TRUE;//状态改变
cout<<g.vexinfo[i]<< "->"; //打印顶点,也可以其他操作
for (j = 0; j < g.vexNum; j++)//抉择出下一遍历点
{//值得注意的是,这里并没有明显体现出“右手通行原则”,或许j的按序判断以及整个邻接矩阵的设计就自然形成了“右手通行原则”吧
if (g.matrix[i][j] == 1 && !visited[j])
{
DFS(g, j); //对为访问的邻接顶点递归调用,这次以下标j为初始遍历点
}
}
}
//邻接矩阵的深度遍历操作
void DFSTraverse(AdjMatr g)
{
int i;
for (i = 0; i < g.vexNum; i++)//先将所有顶点状态都初始化为未访问过状态
{
visited[i] = FALSE;
}
for (i = 0; i < g.vexNum; i++)//这里定义i=0即DFS从下标为0的元素开始遍历
{
if (!visited[i])
{
DFS(g, i);//对未访问的顶点调用DFS,若是连通图,只会执行一次
}//一个连通图遍历完成后,回到这里;要检查是否总图中是否所有结点都被访问到了,
//还要接着执行上面的for循环,要真的都被访问到了,循环也只是表面执行。
}
}
邻接表的遍历思想一样,代码要结合具体数据结构来写。
/*********************邻接表的深度优先遍历**************************/
//边表结点
typedef struct EdgeNode {
int adjvex;//存储邻接结点在顶点数组的下标
int weight;
struct EdgeNode *next;//指向下一个边表节点
}EdgeNode;
//(顶点结点与)顶点数组 数据类型的定义
typedef struct VertexNode {
VertexType data;//数据域
EdgeNode *firstedge;//指针域(firstedge:名称也意味着指向第一条边) ;之所以把顶点表结点定义放在后面,是因为这里面的成员的定义用到了上面的边表结点
}VertexNode, AdjList[MAXVEX];
void DFS(GraphAdjList g, int i) //如果使用的是邻接表存储结构,其DFSTraverse函数的代码几乎是相同的;
//只是在递归函数中因为将数组换成了链表,DFS函数会有不同,但是细看下,思想还是一样的
{
EdgeNode *p;
visited[i] = TRUE;
cout<<g.adjlist[i].data; //打印顶点,也可以其他操作
p = g.adjlist[i].firstedge;
while (p)
{
if (!visited[p->adjvex])
{
DFS(g, p->adjvex); //对访问的邻接顶点递归调用
}
p = p->next;
}
}
//邻接表的深度遍历操作
void DFSTraverse(GraphAdjList g)
{
int i;
for (i = 0; i < g.numVertexes; i++)
{
visited[i] = FALSE;
}
for (i = 0; i < g.numVertexes; i++)
{
if (!visited[i])
{
DFS(g, i);
}
}
}
BFS:
思路和树的层序遍历思路完全一样,都是借助队列。
一句花BFS算法思路:
将入口入口顶点访问和出队并标记visited,然后将所有邻接节点入队列。while循环,直到队列中元素为0,表示一个连通图遍历结束,退出BFS。
遍历顶点一位数组,确保每一个连通图都被访问到,一次连通图进入一次BFS。
必须熟练写出BFS:队列迭代写法!!
//广度优先搜索:队列迭代法
class Solution {
public:
int findCircleNum(vector<vector<int>>& isConnected) {
int cities = isConnected.size();
vector<int> visited(cities);
int provinces = 0;
queue<int> Q;
//外部大循环遍历顶点数组,确保每一个连通图被访问到。
for (int i = 0; i < cities; i++) {
if (!visited[i]) {
//将没被访问过的顶点入队
Q.push(i);
//外部while循环访问队首元素并出队
while (!Q.empty()) {
//访问,出队
int j = Q.front(); Q.pop();
visited[j] = 1;
//内部小循环将刚访问的队首节点的邻接节点入队
for (int k = 0; k < cities; k++) {
if (isConnected[j][k] == 1 && !visited[k]) {
Q.push(k);
}
}
}
provinces++;
}
}
return provinces;
}
};
BFS与DFS的对比及优缺点:
深搜优缺点:
优点:
- 能找出到达一个节点的所有解决方案
- 优先搜索一棵子树,然后是另一棵,所以和广搜对比,有着内存需要相对较少的优点
缺点:
- 要多次遍历,搜索所有可能路径,标识做了之后还要取消。
- 在深度很大的情况下效率不高
广搜优缺点:
优点:
- 对于解决最短或最少问题特别有效,而且寻找深度小
- 每个结点只访问一遍,结点总是以最短路径被访问,所以第二次路径确定不会比第一次短
缺点:
- 内存耗费量大(需要开大量的数组单元用来存储状态)
使用场景:计算网络数据链路层的最短跳数,走迷宫的最短路径
六、leetcode 图的DFS/BFS算法题目
最初应用 DFS/BFS算法是在二叉树的三序遍历、层序遍历以及图的深度优先遍历和广度优先遍历中。
类型一:图/矩阵结构
547. 省份数量
思路:就是遍历一遍,数一下连通图的数量。因此实现算法见DFS或BFS遍历算法。
类型二:网格结构(DFS)
网格结构和矩阵图结构的区别个人理解:矩阵图结构接近于树,路径很多,但明确;网格结构是一块没被开发的沼泽。
200. 岛屿数量
岛屿类问题的通用解法、DFS 遍历框架 - 岛屿数量 - 力扣(LeetCode) (leetcode-cn.com)
DFS:
void dfs(char** grid, int gridSize, int gridColSize, int i, int j) { if( !((i >= 0 && i < gridSize) && (j >= 0 && j < gridColSize)) ) //下标越界 return; if(grid[i][j] != '1') //非陆地 return; grid[i][j] = '2'; //当前结点可以探索上下左右四个方向 dfs(grid, gridSize, gridColSize, i - 1, j); dfs(grid, gridSize, gridColSize, i + 1, j); dfs(grid, gridSize, gridColSize, i, j - 1); dfs(grid, gridSize, gridColSize, i, j + 1); } int numIslands(char** grid, int gridSize, int* gridColSize){ int res = 0; for(int i = 0; i < gridSize; i++) //时间复杂度:m*n { for(int j = 0; j < gridColSize[0]; j++) { if(grid[i][j] == '1') { res++; dfs(grid, gridSize, gridColSize[i], i, j); } } } return res; }
算法思路:
网格问题算法框架:外部二层大循环,保证能进入每个子连通图;算法的精髓在于网格的DFS遍历。对每个子连通图的遍历,理论上可以遍历任意矩形连通图。
对于网格的DFS遍历:
结束条件是:遇到矩形边界或者进入访问过的表格(因此要对访问过的表格做标记)。
递归思路是:上下左右都走一遍(当然,这个顺序可以任意调换的)。
复杂度:
时间复杂度:O(MN),其中 M 和 N 分别为行数和列数。
空间复杂度:O(MN),在最坏情况下,整个网格均为陆地,深度优先搜索的深度达到 MN。
显然,一个dfs可以一次性递归一个完整的子连通图。结束之后,返回主循环,开始下一个dfs子连通图。
695. 岛屿的最大面积
关键点在于:在遍历算法的基础上,清楚子岛屿的面积等于一个连通图中首次访问陆地网格的次数累计,然后维护一个最大值。
463. 岛屿的周长
关键点在于:在遍历算法的基础上,清楚岛屿的周长等于访问到水或者边界的总次数。
827. 最大人工岛
LCR 130. 衣橱整理