一、定义
图(Graph)由顶点集(Vertex)和边集(Edge)组成。|V|表示图G中顶点个数,也称图G的阶。
|E|表示图G中边的条数。
注意:线性表可以是空表,树可以为空树,但是图不可为空,即V一定是非空集。
顶点可以没有边,但是边不能没有顶点。
二、一些基本概念
1.无向图、有向图
无向图:若E为无向边的有限集合。边是顶点的无序对,记为(v,w)或(w,v)。
有向图:若E为有向边(也称弧)的有限集合。弧是顶点的有序对,记为<v,w>,其中v,w是顶点,v称弧尾,w为弧头,<v,w>称为从顶点v到w的弧,也称v邻接到w或w邻接自v。【注意方向】
2.简单图、多重图
简单图:不存在重复边和绕回自己的边。【数据结构只探讨这个】
多重图:图G中某两个结点之间的边数多余一条,也允许顶点通过同一条边和自己关联。
3.顶点的度、入度、出度
无向图中,顶点v的度是指依附于该顶点的边的条数,记为TD(v)。
在具有n个顶点、e条边的无向图中,总度数一定为2e,因为每条边给左顶点一个度,右顶点一个度。即无向图的全部顶点的度之和为边数的两倍。
有向图中,入度是以顶点v为终点的有向边的数目,记为ID(v)。出度是以顶点v为起点的有向边的数目,记为OD(v)。
顶点v的度等于其入度和出度之和,即TD(v)=ID(v)+OD(v)。在具有n个顶点、e条边的有向图中,入度之和等同于出度之和等于e,也就是边的数量。【很好理解,一条有向边,肯定提供一个入度、一个出度】
4.顶点之间的关系描述
路径:顶点A到D的一条路径是指顶点序列ABCD。
回路:第一个顶点和最后一个顶点相同的路径成为回路/环。
简单路径:路径序列中,顶点不重复出现的路径。
简单回路:除了首尾两个顶点外,其余顶点不重复出现的回路。
路径长度:路径上的边的数目。
点到点的距离:从顶点u出发到顶点v的最短路径。若不存在该路径,则称距离为无穷大。
连通:无向图中,从A到B有路径。
强连通:有向图中,从A到B有路径,且从B到A也有路径。
5.连通图、强连通图
连通图:无向图中,任意两个顶点间都有路径。
考点:对于n个顶点的无向图G,若为连通图,则至少有n-1条边。
若G为非连通图,则最多有条边。把一个点排外,然后大家两两连接。
强连通图:有向图中,任意两个顶点都是强连通的。
考点:对于n个顶点的有向图G,若为强连通图,则最少有n条边(形成回路)
6.子图
在有向图/无向图中,取其中的一些顶点、边构成新的图。【注意满足图的条件】
取出全部顶点的子图,成为生成子图。
7.连通分量【重要!】
定义:无向图中的极大连通子图称为连通分量。【最大的可以连通的子图,不唯一】
子图必须连通,且包含尽可能多的顶点和边。
8.强连通分量
ABCDE互相强连通。而F不能进入ABCDE,是因为B到F可以,但是F没有到B的路径,无法和他们强连通,所以ABCDE已经是最大的连通的子图了。
9.生成树【同一个连通图中,撇去多余的边】
无向图中,连通图的生成树是包含图中全部顶点的一个极小连通子图。【村庄修路问题/答案不唯一】
若图中顶点数为n,则生成树一定含有n-1条边。如果少一条就无法连通,多一条就会形成回路。
10.生成森林【非连通图中,撇去多余的边】
在非连通图中,连通分量(内部的连通图)的生成树构成了非连通图的生成森林。
11.带权图/网
12.几种特殊形态的图
(1)无向完全图/有向完全图
(2)树是什么图
13.小结
三、图的存储
1.邻接矩阵法
(1)意义及代码实现:
如果元素为1,表示从X到Y有一步路径,如果为0则表示无法到达。
无向图:邻接矩阵一定是对称矩阵。【所以这里可以使用压缩存储】
有向图:邻接矩阵不一定是对称矩阵。
(2)如何通过邻接矩阵求图的度
无向图:所在的行或者列中,含有1的个数。
有向图:行表示出度,列表示入度。
时间复杂度O(n)=O(|v|)
(3)邻接矩阵法存储带权图(网)
到达自己:可以用无穷表示,也可以用0表示。
(4)邻接矩阵法的性能分析
空间复杂度较高,为O(n^2),只与顶点数有关,和实际的边数无关。
适用于存储稠密图【边越多,空间的使用率越高】
例如上图:
花了1步,从A走到C的路径一共有1条。
花了3步,从B走到B的路径一共有3条。
2.邻接表法【顺序+链式存储】
3.十字链表、邻接多重表
(1)十字链表法->有向图
缺点:邻接矩阵【边多,空间复杂度高】
邻接表【找顶点的入边/入度不方便,需要遍历】
为了克服这些困难,采用十字链表表法,既可以实现边的存储空间为O(|V|),又能很方便地找到入边。
(2)邻接多重表->无向图
缺点:邻接矩阵【空间复杂度高】
邻接表【每条边对应两条冗余信息,删除插入麻烦,时间复杂度高】
四、图的基本操作
五、图的遍历
以某种顺序访问所有结点,并且所有结点只会被访问一次。
1.BFS(Breadth First Search)
类似于树的层次遍历:需要一个辅助队列。
#define Max_Vertex_Num 100
bool visited[Max_Vertex_Num];
Graph G;
void BFSTraverse(Graph G)
{
for (int i = 0; i < G.vexnum; ++i)//从0号顶点开始遍历
visited[i] =false;//初始化所有顶点。
InitQueue(Q);
for (int i = 0; i < G.vexnum; ++i)
if (!visited[i])
BFS(G, i);//执行完第一次后,第一个连通分量的所有顶点均被遍历,开始第二次for循环
}
void BFS(Graph G, int v)//这里v表示顶点序号,往后进行广度优先遍历
{
visit(v);//访问初始顶点
visited[v] = true;//被访问后的顶点标记为true
Enqueue(Q, v);//被访问的顶点入队
while (!isEmpty(Q))
//非空->删除初始顶点->访问相邻所有顶点且入队->删除队列首顶点且遍历其所有(未被访问的)相邻顶点...
{
DeQueue(Q, v);//删除队列的第一个顶点,下面找他的所有邻居
for (w = FirstNeighbor(G, v); w >= 0; w = NextNeighbor(G, v, w))
//这里的0表示序号,只要有邻接点就能入,没有时,返回的是-1;
if (!visited[w])//判断邻接点是否被访问过
{
visit(w);
visited[w] =true;
EnQueue(Q, w);
}
}
}
(1)定义:从一个顶点出发,先访问它的下一层所有孩子,再从其中一个孩子出发访问其下一层的所有孩子,以此类推,直到访问完所有顶点。
(2)时间复杂度:访问顶点的时间+访问边的时间
空间复杂度:源于辅助队列。最坏情况:如果只有两层,一次就能带入所有顶点进来,那么队列需要很大。
(3)广度优先生成树
如果是邻接矩阵表示法:则得到的广度优先生成树唯一。
如果是邻接表的表示法:则得到的广度优先生成树不唯一。
2.DFS(Depth First Search)
#define Max_Vertex_Num 100
bool visited[Max_Vertex_Num];
Graph G;
void DFSTraverse(Graph G)
{
for (int i = 0; i < G.vexnum; ++i)
visited[i] =false;
for (int i = 0; i < G.vexnum; ++i)
if (!visited[i])
DFS(G, i);//执行完第一次后,第一个连通分量的所有顶点均被遍历,开始第二次for循环
}
void DFS(Graph G, int v)//这里v表示顶点序号,往后进行广度优先遍历
{
visit(v);
visited[v] = true;
for (w = FirstNeighbor(G, v); w >= 0; w = NextNeighbor(G, v, w))
{
if (!visited[w])
DFS(G, v);
}
}
(1)定义:一旦出现新结点,就先访问一个新结点。
(2)时间复杂度:访问顶点的时间+访问边的时间
空间复杂度:源于递归工作栈【DFS调用自己的次数】。最坏情况:如果树很深,要层层递归带入所有顶点进来,那么递归次数会增加很多。
(3)深度优先生成树:
同样地,如果是邻接矩阵表示法:则得到的深度优先生成树唯一。
如果是邻接表的表示法:则得到的深度优先生成树不唯一。
3.图的遍历与连通性
(1)无向图:进行BFS\DFS的次数与连通分量数有关
(2)有向图:具体问题具体分析。若是强连通图(起始顶点到其他顶点都有路径,则只需调用1次BFS\DFS),则肯定只需调用一次。
六、最小生成树及最短路径问题
1.最小生成树定义:
包含所有顶点的一个极小连通子图。【边尽可能少,但是保持连通】
2.Prim算法 vs Kruskal算法
(1)Prim算法实现思想
(2)Kruskal算法实现思想
3.最短路径问题
(1)单源最短路径【BFS及Dijkstra算法】
a.BFS算法【无权图】
#include <string>
#include <limits>
#define Max_Vertex_Num 100
Graph G;
void BFS_MIN_Distance(Graph G, int u)
{
for (i = 0; i < G.vexnum;i++)
{
d[i] = INT_MAX;//记录到起始顶点的距离
path[i] = -1;//记录前驱节点
}
//访问起始顶点
d[u] = 0;
visited[u] = true;
EnQueue(Q, u);
while (!isEmpty)
{
DeQueue(Q, u);
for (w = FirstNeighbor(G, u); w >= 0; w = NextNeighbor(G, u, w))
{
if (!visited[i])
d[w] = d[u] + 1;
path[w] = u;
visited[w] = true;
EnQueue(Q, u);
}//if
}//while
}
b.迪杰斯特拉算法
(贪心算法&并非最短路径)【带权图、无权图】
Dijkstra算法_dijkstra算法的优点_Hello.Reader的博客-优快云博客
过程:步步为营
1.每次需从尚未找到最短路径的点【final==false】出发,找distance最短的点纳入。2.此时标记该点的final为true。
3.然后再遍历下一层,更改distance
【不能处理带有负权边的图】
例(1):按照Dijkstra的思想,首先0点加入集合,然后1点加入集合,然后3点加入集合,最后2加入集合,此时0->2更新为99,但是此刻已经无法更新0->1和0->3了。
例(2):
【只能解决单源最短路径问题】
(2)各顶点间的最短路径【Floyd算法】
(动态规划)【带权图、无权图】
基本思想:
a.先不中转,都是直达,遍历的得到最短路径表。【path表的值均为-1】
b.在上表基础上,可以从V0中转,更新最短路径表A;【path表也更新,值为上一个来源点】。
c.在上表基础上,可以从V0、V1中转,更新最短路径表A...
d.直到遍历完去所有顶点,可以从所有点中转,得到最新的最短路径表A和路径表path。
Floyd算法可以求带负权值的边,但是无法解决带有“带负权值边的回路”,因为有可能值会越转越小。
总结:
BFS算法源于广度优先算法,其时间复杂度取决于用何种存储结构。(时间复杂度同深度优先)
【邻接矩阵:找顶点,遍历邻接顶点O(V^2)】
【邻接表:找顶点,找邻接边O(V+E)】
4.有向无环图描述表达式
(1)有向无环图定义:
(2)描述表达式:
3.练习
5.拓扑排序
(1)预备知识:
AOV网:Activity On Vertex Network,用顶点表示活动的网。
用DAG图(Directed Acyclic Graph)有向无环图表示一个工程。顶点表示活动,有向边<Vi,Vj>表示活动Vi先于Vj发生。
(2)拓扑排序定义
找到做事的先后顺序。
(3)如何实现:
a.从AOV网中选择一个没有前驱(入度为0)的顶点并输出。
b.从网中删除该顶点和所有以它为起点的有向边。
c.重复a和b操作直到当前AOV网为空或者当前网不存在无前驱的顶点为止【顶点都有前驱】。
注: 加粗文字表示有回路,此时无法进行拓扑排序。在代码最后也有相应的判断程序。
a.(正)拓扑排序
#include <iostream>
using namespace std;
#include <string>
#include <list>
#include <queue>
/****************类声明*********************/
class Graph
{
int V; //顶点个数
list<int>* adj; //邻接表,也可用数组代替
queue<int> q; //维护一个入度为0的顶点的集合【队列/栈】
int* indegree; //记录每个顶点的入度,数组实现
public:
Graph(int V); //构造函数
~Graph(); //析构函数
void addEdge(int v, int w); //添加边
bool topological_sort(); //拓扑排序
};
Graph::Graph(int V)
{
this->V = V;
adj = new list<int>[V];
indegree = new int[V];
for (int i = 0; i < V; i++)
indegree[i] = 0;
}
Graph::~Graph()
{
delete[] adj;
delete[] indegree;
}
void Graph::addEdge(int v, int w)
{
adj[v].push_back(w);
++indegree[w];
}
bool Graph::topological_sort()
{
for (int i = 0; i < V; i++)
if (indegree[i] == 0)
q.push(i); //入度为0的顶点入队
int count = 0; //计数,记录当时【已经从队列输出】的顶点数目
while (!q.empty())
{
int v = q.front(); //从队列取出一个顶点
q.pop();
cout << v << "\t"; //【出栈即输出】,也就是得到这个入度为0的点
++count;
list<int>::iterator beg = adj[v].begin(); //iterator beg是迭代器,从头开始
for (; beg != adj[v].end(); ++beg) //让与该结点相连的下一个结点入度-1,如何实现?
if (!(--indegree[*beg]))
q.push(*beg);
}
if (count < V) //未输出全部顶点:有向图中包含回路,无法实现拓扑排序
return false;
else
return true;
}
b.逆拓扑排序
1.定义:让出度为0的先出【事情的最后一件事先出】,从而得到逆拓扑排序。
2.存储方式:这里删除出度为0,还要删除指向它的边。如果采用邻接表的话,则需要遍历所有顶点;而邻接矩阵只需遍历这一列,比较方便。
此外,还可以采用逆邻接表的方式,这样就只需遍历这一条边【也就是需要预处理一下】
3.DFS(深度优先)算法实现逆拓扑排序【将访问的功能消失,让最深处的顶点,退栈时输出】
6.关键路径
(1)定义:从源点到汇点的 有向路径可能有多条,所有路径中,具有最大路径长度的路径成为关键路径【决定整个工程的最短时间/距离】,而把关键路径上的活动称为关键活动【边】
【关键路径可能有多条】
(2)寻找关键路径
正着推:求最大
反着推:求最小
a.求事件(顶点)的最早发生时间
b.求事件(顶点)的最晚发生时间
c.根据顶点最早发生时间,标定所有活动(每条边)的最早发生时间【保持一致】。
d.根据顶点最晚发生时间减去该活动的运行时间,标定所有活动(每条边)的最晚发生时间。