昨天晚上写了树的一些基本概念和树的操作以及树的一些应用。
今天来说一说图!
基本术语:
- 图—— 一个图(G)定义为一个偶对 (V,E) ,记为 G=(V,E) 。其中:
V是顶点(Vertex) 的非空有限集合,记为V(G);
E是无序集V&V的一个子集,记为E(G) ,其元素是图的边(Arc)。 - 无向图——每条边都没有方向
- 有向图——每条边都有方向
- 完全图——任意两点都有一条边相连
- 稀疏图——有很少边的图
- 稠密图——有较多边弧的图
- 网——边带权的图
- 邻接——有 边相连的两个顶点的关系
- 关联(依附)——边与顶点之间的关系
- 顶点的度——与该顶点相关联的的边的数目
- 路径——连续的边构成的顶点序列
- 路径长度——路径上的边的权值之和
- 回路——第一个顶点和最后一个顶点相同的路径
- 简单路径——除路径起点和终点可以相同外,其余顶点均不相同的路径
- 简单回路——除路径起点和终点相同外,其余顶点均不相同的路径
- 连通图——在无向图 G=(V,E) 中,若对任何两个顶点 u、v 都存在从 v 到 u 的路径,则称 G 是连通图
- 强连通图——在有向图G=(V,E) 中,若对任何两个顶点 u、v 都存在从 v 到 u 的路径,则称 G 是强连通图
图的存储结构
顺序存储结构:数组表示法(邻接矩阵)
链式存储结构:多重链表(邻接表)
无向图的邻接矩阵表示法:
分析1:无向图的邻接矩阵都是对称的
分析2:顶点 i 的度是第 i 行(列)中 1 的个数
特别:完全图的邻接矩阵中,对角元素为0,其余为1
有向图的邻接矩阵表示法:
分析1:有向图的邻接矩阵可能是不对称的
分析2:顶点的出度等于第 i 行元素之和
分析3:顶点的入度等于第 i 列元素之和
网的邻接矩阵表示法:
邻接矩阵表示法的特点:
- 优点:容易实现图的操作,如:求某点的度、判断顶点之间是否有边、找顶点的邻接点等等
- 缺点:n 个点需要 n*n 个单元存储边;空间效率为O(n平方)
邻接矩阵存储表示的代码实现:
#define MaxInt 32767 //表示极大值,即∞
#define MVNum 100 //最大顶点数
typedef char VerTexType; //假设顶点的数据类型为字符型
typedef int ArcType; //假设边的权值类型为整型
typedef struct{
VerTexType vexs[MVNum]; //顶点表
ArcType arcs[MVNum][MVNum]; //邻接矩阵
int vexnum,arcnum; //图的当前点数和边数
}AMGraph;
int LocateVex(MGraph G,VertexType u)
{ /* 初始条件:图G存在,u和G中顶点有相同特征 */
/* 操作结果:若G中存在顶点u,则返回该顶点在图中位置;否则返回-1 */
int i;
for(i=0;i<G.vexnum;++i)
if(u==G.vexs[i])
return i;
return -1;
}
Status CreateUDN(AMGraph &G){
//采用邻接矩阵表示法,创建无向网G
cin>>G.vexnum>>G.arcnum; //输入总顶点数,总边数
for(i = 0; i<G.vexnum; ++i)
cin>>G.vexs[i]; //依次输入点的信息
for(i = 0; i<G.vexnum;++i) //初始化邻接矩阵,边的权值均置为极大值
for(j = 0; j<G.vexnum;++j)
G.arcs[i][j] = MaxInt;
for(k = 0; k<G.arcnum;++k) //构造邻接矩阵
{
cin>>v1>>v2>>w; //输入一条边依附的顶点及权值
i = LocateVex(G, v1);
j = LocateVex(G, v2); //确定v1和v2在G中的位置
G.arcs[i][j] = w; //边<v1, v2>的权值置为w
G.arcs[j][i] = G.arcs[i][j]; //置<v1, v2>的对称边<v2, v1>的权值为w
}//for
return OK;
}//CreateUDN
邻接链表表示法:
无向图的邻接表表示:
注:邻接表不唯一,因为各个边结点的链入顺序是任意的。
空间效率为 O(n+2e)
若是稀疏图(e << n平方),比邻接矩阵表示法节省空间
有向图的邻接表表示:
空间效率为 O(n + e)
邻接表表示法的特点:
- 优点:空间效率高,容易寻找顶点的邻接点。
- 缺点:判断两顶点是否有边,需搜索两结点对应的链表,没有邻接矩阵方便
总结
邻接矩阵多用于稠密图,邻接表多用于稀疏图。
邻接表表示的代码实现:
#define MVNum 100 //最大顶点数
typedef struct ArcNode{ //边结点
int adjvex; //该边所指向的顶点的位置
struct ArcNode * nextarc; //指向下一条边的指针
OtherInfo info; //和边相关的信息
}ArcNode;
typedef struct VNode{
VerTexType data; //顶点信息
ArcNode * firstarc; //指向第一条依附该顶点的边的指针
}VNode, AdjList[MVNum]; //AdjList表示邻接表类型
typedef struct{
AdjList vertices; //邻接表
int vexnum, arcnum; //图的当前顶点数和边数
}ALGraph;
Status CreateUDG(ALGraph &G){
//采用邻接表表示法,创建无向图G
cin>>G.vexnum>>G.arcnum; //输入总顶点数,总边数
for(i = 0; i<G.vexnum; ++i){ //输入各点,构造表头结点表
cin>> G.vertices[i].data; //输入顶点值
G.vertices[i].firstarc=NULL; //初始化表头结点的指针域为NULL
}//for
for(k = 0; k<G.arcnum;++k){ //输入各边,构造邻接表
cin>>v1>>v2; //输入一条边依附的两个顶点
i = LocateVex(G, v1);
j = LocateVex(G, v2);
p1=new ArcNode; //生成一个新的边结点*p1
p1->adjvex=j; //邻接点序号为j
p1->nextarc= G.vertices[i].firstarc;
G.vertices[i].firstarc=p1;
//将新结点*p1插入顶点vi的边表头部
p2=new ArcNode; //生成另一个对称的新的边结点*p2
p2->adjvex=i; //邻接点序号为i
p2->nextarc= G.vertices[j].firstarc;
G.vertices[j].firstarc=p2;
//将新结点*p2插入顶点vj的边表头部
}//for
return OK;
}//CreateUDG
图的遍历:
深度优先搜索(DFS)
广度优先搜索(BFS)
深度优先搜索步骤:(递归)
- 在访问图中某一起始顶点 v 后,由 v 出发,访问它的任一邻接顶点 w1;
- 再从 w1 出发,访问与 w1邻接但还未被访问过的顶点 w2;
- 然后再从 w2 出发,进行类似的访问,…
- 如此进行下去,直至到达所有的邻接顶点都被访问过的顶点 u 为止。
- 接着,退回一步,退到前一次刚访问过的顶点,看是否还有其它没有被访问的邻接顶点。
- 如果有,则访问此顶点,之后再从此顶点出发,进行与前述类似的访问;
- 如果没有,就再退回一步进行搜索。重复上述过程,直到连通图中所有顶点都被访问过为止。
计算机如何实现DFS ?————开辅助数组 visited[n]
根据辅助数组 visited[n]可得遍历顺序:v2→v1→v3→v5→v4→v6
代码实现:
//邻接矩阵 DFS 代码实现:
void DFS(AMGraph G, int v) //图G为邻接矩阵类型
{
cout<<v;
visited[v] = true; //访问第v个顶点
for(w = 0; w< G.vexnum; w++) //依次检查邻接矩阵v所在的行
if((G.arcs[v][w]!=0)&& (!visited[w]))
DFS(G, w);
//w是v的邻接点,如果w未访问,则递归调用DFS
}
//邻接表 DFS 代码实现:
void DFS(ALGraph G, int v) //图G为邻接表类型
{
cout<<v;
visited[v] = true; //访问第v个顶点
p= G.vertices[v].firstarc; //p指向v的边链表的第一个边结点
while(p!=NULL) //边结点非空
{
w=p->adjvex; //表示w是v的邻接点
if(!visited[w])
DFS(G, w); //如果w未访问,则递归调用DFS
p=p->nextarc; //p指向下一个边结点
}
}
DFS 算法效率分析:
- 用邻接矩阵来表示图,遍历图中每一个顶点都要从头扫描该顶点所在行,时间复杂度为O(n平方)。
- 用邻接表来表示图,虽然有 2e 个表结点,但只需扫描 e 个结点即可完成遍历,加上访问 n个头结点的时间,时间复杂度为O(n+e)。
结论:
稠密图适于在邻接矩阵上进行深度遍历;
稀疏图适于在邻接表上进行深度遍历。
广度优先搜索:
基本思想:—— 仿树的层次遍历过程
广度优先搜索的步骤:(非递归)
- 在访问了起始点 v 之后,依次访问 v 的邻接点;
- 然后再依次访问这些顶点中未被访问过的邻接点;
- 直到所有顶点都被访问过为止。
注意:广度优先搜索是一种分层的搜索过程,每向前走一步可能访问一批顶点,不像深度优先搜索那样有回退的情况。因此,广度优先搜索不是一个递归的过程,其算法也不是递归的。
计算机如果实现 BFS ?—— 除辅助数组 visited[n] 外还需要开一个辅助队列。
算法思想:
- 从图中某个顶点 v 出发,访问 v,并置 visited[v] 的值为 true,然后将 v 进队列
- 只要队列不空,则重复下述处理:
a.队头顶点 u 出列
b.依次检查 u 的所有邻接点 w,如果 visited[w] 的值为false,则访问 w
并置 visited[w] 的值为true,然后 w 进入队列
算法描述:
void BFS (Graph G, int v){
//按广度优先非递归遍历连通图G
cout<<v; visited[v] = true; //访问第v个顶点
InitQueue(Q); //辅助队列Q初始化,置空
EnQueue(Q, v); //v进队
while(!QueueEmpty(Q)){ //队列非空
DeQueue(Q, u); //队头元素出队并置为u
for(w = FirstAdjVex(G, u); w>=0; w = NextAdjVex(G, u, w))
if(!visited[w]){ //w为u的尚未访问的邻接顶点
cout<<w;
visited[w] = true;
EnQueue(Q, w); //w进队
}//if
}//while
}//BFS
BFS算法效率分析:
- 如果使用邻接矩阵,则BFS对于每一个被访问到的顶点,都要循环检测矩阵中的整整一行(n个元素),总的时间代价为O(n2)。
- 用邻接表来表示图,虽然有 2e 个表结点,但只需扫描 e 个结点即可完成遍历,加上访问 n个头结点的时间,时间复杂度为O(n+e)。
DFS 与 BRS算法效率比较:
- 空间复杂度相同,都是O(n)(借用了堆栈或队列);
- 时间复杂度只与存储结构(邻接矩阵或邻接表)有关,而与搜索路径无关。
图的应用:
最小生成树
最短路径
拓扑排序
最小生成树:
- 极小连通图子图:该子图是 G 的连通子图,在该子图中删除任何一条边,子图不再连通
- 生成树:包含图 G 所有顶点的极小连通子图所形成的树,若 G 有 n 个顶点,则其边有 n-1 条
求最小生成树:
首先明确:
a.使用不同的遍历图的方法,可以得到不同的生成树
b.从不同的顶点出发,也可能得到不同的生成树
c.按照生成树的定义,n 个顶点的连通网络的生成树有 n 个顶点 n-1 条边目标:
在网的多个生成树中,寻找一个各边权值之和最小的生成树
构造最小生成树的准则:
- 必须使用该网中的边来构造最小生成树;
- 必须使用且仅使用 n-1 条边来联络网络中的 n 个结点
- 不能使用产生回路的边
如何求最小生成树?
- Prim 算法
- Kruskal 算法
Prim 算法:归并顶点,与边数无关,适用于稠密网
Kruskal 算法:归并边,适用于稀疏网
Prim 算法的基本思想:归并顶点
- 设连通网络 N = { V, E }
- 从某顶点 u0 出发,选择与它关联的具有最小权值的边(u0, v),将其顶点加入到生成树的顶点集合U中
- 每一步从一个顶点在U中,而另一个顶点不在U中的各条边中选择权值最小的边(u, v),把它的顶点加入到U中
- 直到所有顶点都加入到生成树顶点集合U中为止
Prim 算法图解:
Kruskal 算法基本思想:归并边
- 设连通网络 N = { V, E }
- 构造一个只有 n 个顶点,没有边的非连通图 T = { V, }, 每个顶点自成一个连通分量( 表示空集)
- 在 E 中选最小权值的边,若该边的两个顶点落在不同的连通分量上,则加入 T 中;否则舍去,重新选择
- 重复下去,直到所有顶点在同一连通分量上为止
Kruskal 算法图解:
最短路径:
在带权有向图中源点到终点的多条路径中寻找一条各边权值之和最小的路径,即最短路径。
注意:最短路径与最小生成树不同,路径上不一定包含 n 个顶点
两种常见的最短路径问题:
- 单源最短路径——用 Dijkstra 算法(一顶点到其余各点)
- 所有顶点间的最短路径——用 Floyd 算法(任意两点之间)
单源最短路径问题:
目的:设一有向图G=(V, E),已知各边的权值,以某指定点v0为源点,求从v0到图的其余各点的最短路径。限定各边上的权值大于或等于0。
如何利用计算机求单源最短路径?——Dijkstra 算法
Dijkstra 算法思想:
设G=(V,E)是一个带权有向图,把图中顶点集合 V 分成两组
第一组为已求出最短路径的顶点集合(用S表示,初始时 S 中只有一个源点,以后每求得一条最短路径 , 就将其顶点加入到集合 S 中,直到全部顶点都加入到 S 中,算法就结束了)
第二组为其余未确定最短路径的顶点集合(用U表示)
按最短路径长度的递增次序依次把第二组的顶点加入 S 中。在加入的过程中,总保持从源点 v 到 S 中各顶点的最短路径长度不大于从源点 v 到 U 中任何顶点的最短路径长度。
此外,每个顶点对应一个距离,S 中的顶点的距离就是从 v 到此顶点的最短路径长度,U 中的顶点的距离,是从 v 到此顶点只包括 S 中的顶点为中间顶点的当前最短路径长度。
实例:
Dijkstra 算法描述:
void ShortestPath_DIJ(AMGraph G, int v0){
//用Dijkstra算法求有向网G的v0顶点到其余顶点的最短路径
n=G.vexnum; //n为G中顶点的个数
for(v = 0; v<n; ++v){ //n个顶点依次初始化
S[v] = false; //S初始为空集
D[v] = G.arcs[v0][v]; //将v0到各个终点的最短路径长度初始化
if(D[v]< MaxInt) Path [v]=v0; //v0和v之间有弧,将v的前驱置为v0
else Path [v]=-1; //如果v0和v之间无弧,则将v的前驱置为-1
}//for
S[v0]=true; //将v0加入S
D[v0]=0; //源点到源点的距离为0
/*开始主循环,每次求得v0到某个顶点v的最短路径,将v加到S集*/
for(i=1;i<n; ++i){ //对其余n−1个顶点,依次进行计算
min= MaxInt;
for(w=0;w<n; ++w)
if(!S[w]&&D[w]<min)
{v=w; min=D[w];} //选择一条当前的最短路径,终点为v
S[v]=true; //将v加入S
for(w=0;w<n; ++w) //更新从v0出发到集合V−S上所有顶点的最短路径长度
if(!S[w]&&(D[v]+G.arcs[v][w]<D[w])){
D[w]=D[v]+G.arcs[v][w]; //更新D[w]
Path [w]=v; //更改w的前驱为v
}//if
}//for
}//ShortestPath_DIJ
Dijkstra 算法的时间复杂度为O(n平方)
Floyd 算法这里就不作讨论了。
拓扑排序:
先了解
- AOV网(Activity On Vertices)——用顶点表示活动的网络
- AOE网(Activity On Edges)——用边表示活动的网络
应用:一个工程可以分为若干个子工程,只要完成了这些子工程(活动),就可以导致整个工程的完成。
例如:教学计划的制定,哪些课程是必须先修的,哪些课程是可以并行学习的。
拓扑排序算法的思想:
- 输入AOV网络。令 n 为顶点个数
- 在AOV网络中选一个没有直接前驱的顶点, 并输出之
- 从图中删去该顶点, 同时删去所有它发出的有向边
- 重复以上 2、3 步, 直到:
a.全部顶点均已输出,拓扑有序序列形成,拓扑排序完成;或:
b.图中还有未输出的顶点,但已跳出处理循环。这说明图中还剩下一些顶点,它们都有直接前驱,再也找不到没有前驱的顶点了。这时AOV网络中必定存在有向环。
拓扑排序了解一下就行了。