九. 图1. 图的概念2. 图的存储2.1 邻接矩阵法邻接矩阵存放普通图邻接矩阵法存放带权图邻接矩阵性能分析与性质2.2 邻接表2.3 邻接表与邻接矩阵对比2.4 十字链表2.5 邻接多重表2.6 总结3. 图的基本操作4. 图的广度优先搜索4.1 图的BFS实现4.2 图BFS遍历非连通图4.3 图的BFS算法复杂度分析4.4 广度优先生成树与森林广度优先生成树广度优先生成森林5. 图的深度优先搜索5.1 图的DFS实现5.2 图DFS遍历非连通图5.3 图的DFS算法复杂度分析5.4 深度优先生成树与森林5.5 图的遍历与图的连通性6. 图的应用6.1 最小生成树普利姆(Prim)算法克鲁斯卡尔(Kruskal)算法两个算法比较6.2 最短路径问题BFS算法解决最短路径Dijkstra算法解决最短路径Floyd算法解决最短路径6.3 总结7. 有向无环图(DAG图)7.1 DAG应用——有向无环图表达式7.2 DAG应用——拓扑排序拓扑排序代码实现逆拓扑排序DFS实现逆拓扑排序8. 关键路径十. 查找1. 查找算法评价指标2. 顺序查找2.1 代码实现2.2 查找效率分析2.3 顺序查找的优化(有序表)3. 二分查找(折半查找)3.1 查找步骤及实现3.2 查找的效率分析3.3 二分查找判定树构造4. 分块查找4.1 分块查找实现4.2 分块查找效率分析5. 二叉排序树5.1 二叉排序树查找5.2 二叉排序树的插入5.3 二叉排序树的删除5.4 二叉排序树查找效率分析6. 平衡二叉树6.1 调整最小不平衡子树调整最小不平衡子树(LL)调整最小不平衡子树(RR)调整最小不平衡子树(LR)调整最小不平衡子树(RL)平衡二叉树查找效率分析总结6.2 平衡二叉树的删除7. 红黑树7.1 红黑树的插入7.3 红黑树的删除8. B树8.1 B树的插入8.2 B树的删除9.
图是由顶点集
顶点集表示图的顶点个数组成的集合,也可以称为图的阶。边集表示的是连接顶点的边组成的集合,任何一条边两头必须连接一个顶点。
注意:线性表可以是空表,树可以是空树,但图不可以是空,即V一定是非空集,但边
图数据结构应用:地图、社交软件好友关系等。
无向图与有向图:
若G
为无向图。边是顶点的无序对,记为
若
简单图与多重图:
简单图:①不存在重复边;②不存在顶点到自身的边
可分为简单无向图和简单有向图:
多重图:图G中某两个结点之间的边数多于条,又允许顶点通过同一条边和自己关联,则G为多重图
可分为多重无向图和多重有向图:
数据结构课程只探讨简单图。
顶点的度:
对于无向图:
顶点TD(v)
。
在具有
对于有向图:
入度是以顶点ID(v)
,即该顶点有多少个箭头指向它。
出度是以顶点OD(v)
。即该顶点有多少个箭头指向别的顶点。
顶点
上面有向图
在具有
描述顶点和顶点关系的术语:
路径:顶点
回路:第一个顶点和最后一个顶点相同的路径称为回路或环。如上无向图顶点
简单路径:在路径序列中,顶点不重复出现的路径称为简单路径。
简单回路:除第一个顶点和最后一个顶点外,其余顶点不重复出现的回路称为简单回路。
路径长度:路径上边的数目。
点到点的距离:从顶点
连通:无向图中,若从顶点
如果图
常见考点:对于
若
强连通:有向图中,若从顶点
上图
若图中任何一对顶点都是强连通的,则称此图为强连通图:
常见考点:对于
子图:
设有两个图
上面子图可以是:
生成子图:如果原图中包含子图所有顶点,这个子图就可以称为原图的生成子图:
有向图子图和生成子图概念同上。
连通分量:
无向图中的极大连通子图称为连通分量。子图必须连通,且包含尽可能多的顶点和边。
上图三个连通分量如下:
强连通分量
有向图中的极大强连通子图称为有向图的强连通分量。子图必须强连通,同时保留尽可能多的边。
上图强连通分量是:
同样的剩下两个顶点F
和G
也是图的一部分,所以这两个顶点单独拿出来也是极大强连通子图,故这个图强连通分量有三个。
连通图的生成树是包含图中全部顶点的一个极小连通子图。边要尽可能的少,但要保持连通。
上图的生成树是:
同时也有别的生成路径,所以对于一个生成树来说生成树结果不唯一。
若图中顶点数为
生成森林
在非连通图中,连通分量的生成树构成了非连通图的生成森林。
上面生成森林可以拆分成几个连通分量:
再生成上面连通分量与之对应的生成树,这样就得到了森林非连通图得生成森林:
带权图
边的权:在一个图中,每条边都可以标上具有某种含义的数值,该数值称为该边的权值。
带权图/网:边上带有权值的图称为带权图,也称网。
带权路径长度:当图是带权图时,一条路径上所有边的权值之和,称为该路径的带权路径长度。
几种特殊的图
无向完全图:无向图中任意两个顶点之间都存在边
有向完全图:有向图中任意两个顶点之间都存在方向相反的两条弧
稀疏图:边数很少的图
稠密图:反之称为稠密图
树:不存在回路,且连通的无向图
常见考点:
有向树:一个顶点的入度为
有向树并不是强连通图
用二维数组存放各个顶点之间边的关系
上图中无论是有向图还是无向图,右边二维数组中存放的是边的关系。如:顶点A
与顶点B
之间有边,故二维数组[A][B]=1
代码存储结构如下:
xxxxxxxxxx
71
2//顶点数目的最大值
3typedef struct{
4 char Vex[MaxVertexNum]; //顶点表
5 int Edge[MaxVertexNum][MaxVertexNum]; //邻接矩阵,边表
6 int vexnum, arcnum; //图的当前顶点数和边数/弧数
7} MGraph;
上面存放顶点信息的vex
数组,可以存放更复杂的信息。同时,由于二维数组中只存放0
和1
,所以可以使用更小的bool
变量。
vex
数组存放顶点信息时,数组下标要与edge
数组中边的信息一致。即:
结点数为
求顶点的度、入度和出度方法:
对于无向图来说:第
对于无相图:
邻接矩阵法求顶点的度
同样用二维数组存放,但同时存放的不再是0
和1
;如果两个顶点有边则存放权值
。如果两个顶点无边则存放
存储结构代码如下:
x1//顶点数目的最大值
2//宏定,义常量"无穷"
3
4typedef char VertexType; //顶点的数据类型
5typedef int EdgeType; //带权图中边上权值的数据类型
6typedef struct{
7 VertexType Vex[MaxVertexNum];//顶点
8 EdgeType Edge[MaxVertexNum][MaxVertexNum];//边的权
9 int vexnum,arcnum;//图的当前顶点数和弧数
10}MGraph;
空间复杂度:
适合用于存储稠密图。无向图的邻接矩阵是对称矩阵,可以压缩存储(只存储上三角区
性质:
设图
上面除了
这个结果表示从
总结
通过顺序
存储上面无向图可以用下面存储结构:
代码结构实现如下:
xxxxxxxxxx
181//"边/弧"
2typedef struct ArcNode{
3 int adjvex; //边/弧指向哪个结点
4 struct ArcNode *next; //指向下一条弧的指针
5 //InfoType info; //边权值
6}ArcNode;
7
8//"顶点"
9typedef struct VNode{
10 VertexType data;//顶点信息
11 ArcNode *first;//第一条边/弧
12}VNode,AdjList[MaxVertexNum];
13
14//用邻接表存储的图
15typedef struct{
16 AdjList vertices;
17 int vexnum,arcnum;
18}ALGraph;
AdjList[MaxVertexNum]
存储各个顶点信息(data
和*first
指向第一条弧vertices
顶点结点数组,代表一个图,其中的vexnum
表示有多少条结点,arcnum
表示有多少条边;每个边和弧也会有与之对应的结点ArcNode
有向图邻接表法存储结构:
由于无向图中结点边都是对应的,所以两个结点间的边需要保存两次,即边结点的数量是
有向图每条弧都是对应一个结点,所以边结点的数量是
无向图找度和边:
找无向图顶点的度只需要遍历和这个顶点相关的边链表(*first
指针指向的链表)即可,有几个边结点,度就是多少。同时这个边链表就是这个顶点的所有边。
有向图的入度,出度和边
找一个结点出度,只需要遍历和这个结点相关的边链表即可,这个边链表也是指向其他结点的弧。
找入度和指向当前结点的弧较为复杂:依次遍历所有顶点边链表,找对应的边。这也是邻接表存储图一大缺点。
邻接表 | 邻接矩阵 | |
---|---|---|
空间复杂度 | 无向图 有向图 | |
适合用于 | 存储稀疏图 | 存储稠密图 |
表示方式 | 不唯一 | 唯一 |
计算度 | 计算有向图的度、入度不方便,其余方便 | 必须遍历对应行或列 |
找相邻的边 | 找有向图的入边不方便,其余很方便 | 必须遍历对应行或列 |
只能用于存储有向图。
邻接矩阵主要问题是:空间复杂度高。而邻接表问题在于找有向图的入边不方便,其余很方便。
存储以下有向图:
则十字链表数据存储结构如下:
以上结点
从
而
总结:通过绿色指针往后找可以找到所有从当前结点发射的所有弧(该节点指向了谁)。橙色指针往后找则可以找到所有指向当前结点的弧。
十字链表的空间复杂度:
即解决邻接矩阵空间复杂度高的问题。又解决邻接表找有向图的入边不方便的问题。
只能用于存储无向图。
用邻接矩阵存储无向图会导致空间复杂度高
存储以下有向图:
则邻接多重表数据存储结构如下:
结点存放ABCDE
是一个数组,firstedge
指针指向当前顶点相连的第一条边。
如:firstedge
指向0
和1
相连的边,即A——B
。顺着B
结点橙色指针指向的边,可以找到另一个与
再如:firstedge
指向0
和1
相连的边,其中B
结点在整个结点中是绿色1
结点,其绿色指针指向的就是B
结点下一个边结点,即2
1
结点是B
结点,再顺着绿色指针指向E
结点。此时绿色指针域为NULL
。
总结:边结点存放的该结点下标是什么颜色就找这个颜色指针指向的边界点。直到指针指向NULL。另一个颜色就是该结点相连的结点下标。
总之用邻接多重表存放无向图很方便,想要找到和某个顶点相连的边是很方便的,同时每一条只会对应一个边界点,所以不用再像邻接表那也同时维护两份冗余的数据,删除顶点、删除边等操作效率高很多。
如删除AB
之间连接的边,我们只需要顺着A
中firstedge
指向的边结点0|1
,顺着对应颜色橙色找到下一个与A
相连的边结点0|3
,将firstedge
指针指向这个0|3
边结点即可。如下图:
如果要删除E
整个结点,只需要将E
边指向的边结点4|1
和2|4
删除,再将指向这两个边结点的2|1
和2|3
边结点对应指针域指向NULL即可。
采用邻接多重表的空间复杂度是:
邻接矩阵 | 邻接表 | 十字链表 | 邻接多重表 | |
---|---|---|---|---|
空间复杂度 | 无向图 有向图 | |||
找相邻边 | 遍历对应行或列 时间复杂度为 | 找有向图的入边必须遍历整个邻接表 | 很方便 | 很方便 |
删除边与顶点 | 删除边很方便,删除顶点需要大量移动数据 | 无向图中删除边或顶点都不方便 | 很方便 | 很方便 |
适用于 | 稠密图 | 稀疏图和其他 | 只能存储有向图 | 只能存储无向图 |
表示方式 | 唯一 | 不唯一 | 不唯一 | 不唯一 |
考研常考邻接矩阵和邻接表这两中结构。所以这里主要讲这两种存储结构的基本操作。
对于邻接矩阵存储无向图:
判断很方便,如上图要找B
和D
之间是否有边,只需要B
行和D
列的交点是否为1
。时间复杂度只有
对于邻接表存储无向图:
要找B
和D
之间是否有边,我们要先遍历B
的边结点指针有没有D
结点,也就是后面链表data
域有没有3
。最好情况是要找的目标结点就是*first
指针指向的第一个结点,此时时间复杂度是B
的边结点也没有发现要找的结点,而和B
连接的边最多可能有n-1
条,所以最坏的时间复杂度是
邻接矩阵和邻接表存储有向图分析同上。
G
中与结点x
邻接的边。
对于邻接矩阵存储无向图:
只需要遍历该结点的行或列即可,以行为例,假设矩阵某一行的一个值为1
则表示该节点与该列的结点有邻边。时间复杂度为
对于邻接表存储无向图:
要找当前结点只需要遍历结点*first
指向的边链表即可,与该结点连接的边链表所有值即该结点的邻边。时间复杂度是
对于邻接矩阵存储有向图方法同上,而邻接表存储时候我们需要考虑入边和出边:
对于出边,同样只用遍历结点*first
指向的边链表即可,时间复杂度是
而对于入边,由于邻接表的缺点只能遍历所有data
的*first
指向的边结点链表,这样时间复杂度较高,是
所以列出图G
中与结点x
邻接的边这个操作用邻接矩阵存储有向图是更优秀的方案。但如果邻接表存储的是稀疏图,可能优于邻接矩阵。
G
中插入顶点x
。
对于邻接矩阵存储无向图
只需要在data
后面写入新的结点x
即可。由于矩阵已经完成了初始化,所以x
结点的行和列都是0
。所以时间复杂度为
对于邻接表存储无向图:
同样只需要在data
域中写入x
即可,*first
指针指向NULL
。同样时间复杂度为
邻接矩阵和邻接表存储有向图分析同上。
G
中删除顶点x
。
对于邻接矩阵存储无向图
一个简单方法是,在data
中删除这个结点后,再把矩阵跟这个结点相关的行和列都变为0
即可。对于如何在data
中删除这个结点,可以设置一个bool
变量,为0
这表示该结点已经删除。由于只需要删除一行一列的数据只需要在
对于邻接表存储无向图
我们在data
中删除该顶点后,除了要将该结点的*first
置空,同时要将其他顶点*first
边链表中包含该删除顶点下标的链表结点也要删除。如下删除C
顶点:
同样的最好情况是后面只有一个边,只需要
对于邻接矩阵存储有向图方法同上,而邻接表删除顶点时候我们需要考虑入边和出边
删除顶点的出边很方便只要删除*first
指向的边链表即可。而要删除入边,那就需要遍历整个邻接表找*first
指向的边链表包含被删除顶点下标的结点删除即可。
删除出边时间复杂度是
所以列出图G
中删除顶点x
这个操作用邻接矩阵存储有向图是更优秀的方案。但如果邻接表存储的是稀疏图,可能优于邻接矩阵。
(x, y)
或有向边<x, y>
不存在,则向图G
中添加该边。(x,y
之间没有变,则添加一条)
对于邻接矩阵存储无向图
直接将矩阵中x
行y
列元素设置为1
即可。时间复杂度为
对于邻接表存储无向图
假如添加C
和F
之间的一条边,需要在各自结点*first
指针指向的边链表中添加对方的下标即可。建议使用头插法插入效率更高。时间复杂度只有
邻接矩阵和邻接表存储有向图分析同上。
G
中顶点x
的第一个邻接点,若有则返回顶点号。若x
没有邻接点或图中不存在x
,则返回-1
对于邻接矩阵存储无向图
只需要扫描该顶点对应的行找到第一个1
即为当前结点第一个邻接点。最好情况下时间复杂度是
对于邻接表存储无向图
只需要找到当前顶点*first
指向的第一个边结点即可。只需要
对于邻接矩阵存储有向图
对于出边要扫描该顶点对应的行;入边要扫描列。
对于邻接表存储有向图
找出边方法只需要找到当前顶点*first
指向的第一个边结点即可。时间复杂度是
找入边比较麻烦有可能遍历完所有顶点的边链表都没有指向该结点的边,时间复杂度为
G
中顶点y
是顶点x
的一个邻接点,返回除y
之外顶点x
的下一个邻接点的顶点号,若y
是x
的最后一个邻接点,则返回-1
对于邻接矩阵存储无向图
找邻接点的下一个邻接点只需要扫描当前矩阵行的下一个为1
的顶点即可。时间复杂度是
对于邻接表存储无向图
只需要找到当前顶点*first
指向的第二个边结点即可。只需要
邻接矩阵和邻接表存储有向图分析同上。
G
中边(x, y)
或<x, y>
对应的权值。
G
中边(x, y)
或<x, y>
对应的权值为v
。
此外,还有图的遍历算法,包括深度优先遍历和广度优先遍历。
树的广度优先搜索是层次遍历。图的广度优先搜索和树的类似。如下无向图:
从顶点2
出发,可以遍历到顶点1
和顶点6
,接着再根据顶点1
和6
找到其他顶点,即5,3,7
三个顶点。接着再从这三个顶点出发找到更下一层的4,8
两个顶点。这个过程和树很类似。两个结构对比如下:
树搜索不存在"回路",搜索相邻的结点时,不可能搜到已经访问过的结点。树BFS算法步骤如下: ①若树非空,则根节点入队 ②若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队 ③重复②直到队列为空
图在搜索相邻的顶点时,有可能搜到已经访问过的顶点。如上无向图,通过6
顶点可以访问到2,3,7
三个顶点,但此时2
结点已经访问过了,仍会再次访问。解决方法也很简单,只需要给各个结点一个标记,来标记结点有没有被访问即可。
FirstNeighbor(G,x)
:求图G中顶点NextNeighbor(G,x,y)
:假设图G中顶点bool visited[MAX_VERTEX_NUM];
记录访问标记数组,初始值全为false
。实现代码如下:
xxxxxxxxxx
181bool visited[MAX_VERTEX_NUM]; // 访问标记数组.
2//广度优先遍历
3void BFS(Graph G,int v){ //从顶点v出发,广度优先遍历图G
4 visit(v); //访问初始顶点v
5 visited[v]=TRUE; //对v做已访问标记
6 Enqueue(Q,v); //顶点v入队列Q
7 while(!isEmpty(Q)){
8 v=FrontQueue();
9 DeQueue(Q,v); //顶点v出队列
10 for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))
11 //检测v所有邻接点
12 if(!visited[w] ){ //w为v的尚未访问的邻接顶点
13 visit(w); //访问顶点W
14 visited[w]=TRUE; //对w做已访问标记
15 EnQueue(Q,w); //顶点w入队列
16 }
17 }
18}
图存储结构:
visited
数组:
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | |
---|---|---|---|---|---|---|---|---|
visited | false | false | false | false | false | false | false | false |
假如先从2
顶点开始遍历:
先访问2
号顶点,再将2
号顶点对应的visited
数组设为true
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | |
---|---|---|---|---|---|---|---|---|
visited | false | true | false | false | false | false | false | false |
之后将2
号顶点入队,进入while
循环,判断队列是否为NULL
,不为NULL
将队中2
顶点出队,进入for
循环
for
循环中先获取与2
顶点相邻顶点1
,访问1
号顶点后将对应visited
数组设为true
,再入队。NextNeighbor(G,v,w)
再获取邻接点下一个顶点即6
,同样,访问后数组设置为true
后再入队。此时6
顶点已经为2
顶点最后一个邻接点,结束循环,进入外层while
循环。
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | |
---|---|---|---|---|---|---|---|---|
visited | true | true | false | false | false | true | false | false |
此时队列为:1,6
循环1,2,3步,得到该无向图的BFS序列为:2,1,6,5,3,7,4,8
同样,从顶点1
出发得到的广度优先序列为:1,2,5,6,3,7,4,8
对于BFS算法,如果图的存储结构不一样,得到的遍历序列也可能不一样。但对于邻接矩阵来说,它的存储方式是唯一的,所以其BFS遍历顶点其他邻接点时,一定是从小到大遍历。
有以下非连通图:
上面的代码不能访问9,10,11
顶点。这个问题可以通过visited
数组解决。解决代码如下:
xxxxxxxxxx
261bool visited[MAX_VERTEX_NUM]; // 访问标记数组.
2void BFSTraverse(Graph G){ //对图G进行广度优先遍历
3 for(i=0; i<G.vexnum;++i)
4 visited[i]=FALSE; //访问标记数组初始化
5 InitQueue(Q); //初始化辅助队列Q
6 for(i=1;i<G.vexnum;++i) //从1号顶点开始遍历
7 if(!visited[i]) //对每个连通分量调用一次BFS
8 BFS(G,i); //vi未访问过,从vi开始BFS
9}
10
11//广度优先遍历
12void BFS(Graph G,int v){ //从顶点v出发,广度优先遍历图G
13 visit(v); //访问初始顶点v
14 visited[v]=TRUE; //对v做已访问标记
15 Enqueue(Q,v); //顶点v入队列Q
16 while(!isEmpty(Q)){
17 DeQueue(Q,v); //顶点v出队列
18 for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))
19 //检测v所有邻接点
20 if(!visited [w] ){ //w为v的尚未访问的邻接顶点
21 visit(w); //访问顶点W
22 visited[w]=TRUE; //对w做已访问标记
23 EnQueue(Q,w); //顶点w入队列
24 }
25 }
26}
以上代码在BFSTraverse
函数中会先从1
号顶点开始遍历,对每个连通分量检查是否访问,没有访问则调用BFS。
假如从1
号结点开始访问,BFSTraverse
函数第一次调用BFS会遍历1~8
顶点,此时visited
数组如下:
接着BFSTraverse
函数会一直遍历直到发现没有访问的顶点9
,接着从9
号顶点开始继续调用BFS,之后BFS函数会访问10,11
顶点。此时所有顶点访问完毕,结束循环。
结论:对于无向图,调用BFS函数的次数
空间复杂度:最坏情况,辅助队列大小为
时间复杂度:
邻接矩阵存储的图:
访问
邻接表存储的图:
访问
这里显然不能分析BFS函数最深层的for
循环来确定程序的复杂度,因为假设一个图所有顶点之间都没有边相连,则此时BFS深层的for
循环执行
上面BFS遍历序列中,红色的边表示顶点第一次被访问的时候是从哪个边过去的。如上面4
号顶点第一次访问时候是从3
号顶点的边过去的。用这种方式,
如果把上面没有标红边去掉,则得到以下图:
这个图实际上变为了树,因为里面已经没有回路出现了。这个树就是广度优先生成树。
转换成生成树如下:
同样的,通过邻接表存储的图生成树不唯一。
同样对非连通图的广度优先遍历,可得到广度优先生成森林:
图的DFS与树一致,树的DFS是用先根遍历实现的:
xxxxxxxxxx
81//树的先根遍历
2void Pre0rder(TreeNode *R) {
3 if (R!=NULL){
4 visit(R) //访问根节点
5 while(R还有下一个子树T)
6 Pre0rder(T); //先根遍历下一棵子树
7 }
8}
以上树的先根遍历序列是:1,2,5,6,3,4,7,8
由于树的特性树的遍历新找到的结点肯定是没被访问过的结点。但图遍历的新结点有可能是已经被访问的。所以同广度优先搜索一样,同样要设置一个visited
数组标记顶点有没有访问过。
DFS遍历上面无向图,需要先初始化visited
数组为false
:
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | |
---|---|---|---|---|---|---|---|---|
visited | false | false | false | false | false | false | false | false |
代码实现如下:
xxxxxxxxxx
91bool visited [MAX_VERTEX_NUM]; // 访问标记数组
2void DFS(Graph G,int v){ //从顶点v出发,深度优先遍历图G
3 visit(v); //访问顶点v
4 visited[v]=TRUE; //设已访问标记
5 for(w=FirstNeighbor(G,v);w>=0;w=nextNeighbor(G,v,w))
6 if(!visited[w]){ //w为u的尚未访问的邻接顶点
7 DFS(G,w);
8 }
9}
代码执行步骤如下:
假设从2
号顶点出发,先访问2
号,再将其对应visited
数组设置为true
;接着进入for
循环内部,先获取2
号顶点下一个邻接点1
,1
号邻接点显然没有被访问,进入递归函数DFS
,递归遍历1
号顶点。
visited
数组:
函数调用栈:
重复执行第一步操作,在进入for
循环后获取1
号邻接点2
,2
顶点已经访问,所以不会进入递归,再和1
号邻接的点为5
,5
号顶点进入递归
visited
数组:
函数调用栈:
重复第一步操作,5
号顶点访问完毕后,由于没有邻接点,本层递归执行结束,依次出栈:由于1
号顶点5
顶点已经是最后一个邻接点,所以1
号顶点出栈;2
号顶点已经处理了第一个邻接点,接着进入第二次循环处理邻接点6
,6
顶点没有访问进入递归。
visited
数组:
函数调用栈:
访问6
号顶点并设置visited
数组后,进入for
循环。第一个邻接点是2
,已经访问过,接着访问下一个邻接点3
,顶点3
没有被访问过,同样进入递归。
visited
数组:
函数调用栈:
访问3
号顶点设置visited
数组后,进入for
循环。其第一个邻接点是4
号顶点,没有被访问进入递归。
visited
数组:
函数调用栈:
访问完4
号顶点后,由于其第一个邻接点3
已经被访问过,所以访问下一个邻接点7
,进入递归。
visited
数组:
函数调用栈:
访问完7
号顶点后,与7
相邻的顶点只有8
没有被访问,进入递归
visited
数组:
函数调用栈:
在访问完8
号顶点后,由于与之相邻的邻接点都已经被访问过,所以循环结束,本次递归结束,依次出栈:7
号顶点与之相邻的顶点都被访问完,同样结束出栈;其他层递归过程类似,全部出栈。
所以从2
号顶点出发得到的DFS序列为:2,1,5,6,3,4,7,8
有以下非连通图:
上面的代码并不能访问9,10,11
顶点。这个问题可以通过visited
数组解决。解决代码如下:
xxxxxxxxxx
161bool visited[MAX_VERTEX_NUM]; //访问标记数组
2void DFSTraverse(Graph G){ //对图G进行深度优先遍历
3 for(v=0; v<G.vexnum; ++v)
4 visited[v]=FALSE; //初始化已访问标记数据
5 for(v=0;v<G.vexnum; ++v) //本代码中是从v=0开始遍历
6 if(!visited[v])
7 DFS(G,v);
8}
9void DFS(Graph G,int v){ //从顶点v出发,深度优先遍历图G
10 visit(v); //访问顶点v
11 visited [v]=TRUE; //设已访问标记
12 for(w=FirstNeighbor(G,v);W>=0;w=NextNeighor(G,v,w))
13 if(!visited[w]){ //w为u的尚未访问的邻接顶点
14 DFS(G,w);
15 }
16}
以上代码在DFSTraverse
函数中会先从1
号顶点开始遍历,对每个连通分量检查是否访问,没有访问则调用DFS。
假如从1
号结点开始访问,DFSTraverse
函数第一次调用DFS会遍历1~8
顶点,此时visited
数组如下:
接着DFSTraverse
函数会一直遍历直到发现没有访问的顶点9
,接着从9
号顶点开始继续调用DFS,之后DFS函数会访问10,11
顶点。此时所有顶点访问完毕,结束循环。
其他顶点出发得到的遍历序列(邻接表存储不唯一):
从2
出发的深度优先遍历序列:2,1,5,6,3,4,7,8
从3
出发的深度优先遍历序列:3,4,7,6,2,1,5,8
从1
出发的深度优先遍历序列:1,2,6,3,4,7,8,5
空间复杂度:来自函数调用栈,最坏情况,递归深度为
时间复杂度:无论是邻接矩阵还是邻接表其时间复杂度计算方法都可以归结为:访问各结点所需时间
邻接矩阵存储的图:
访问
邻接表存储的图:
访问
深度优先生成树
如果通过某一条边找到了一个还没有被访问的顶点,那么这条边标记为红色,上面的DFS遍历后标记如下:
去掉没有标记红色的边,得到的就是一个没有回路的无向图,此时可以转换为树,这个树就被称为深度优先生成树。
同一个图的邻接矩阵表示方式唯一,因此深度优先遍历序列唯一, 深度优先生成树也唯一
同一个图邻接表表示方式不唯一,因此深度优先遍历序列不唯一,深度优先生成树也不唯一
深度优先生成森林
同样对非连通图的深度优先遍历,可得到深度优先生成森林:
无向图:
对无向图进行BFS/DFS
遍历,调用BFS/DFS
函数的次数=连通分量数
而对于连通图,只需调用1次BFS/DFS
。
对有向图进行BFS/DFS遍历 调用BFS/DFS函数的次数要具体问题具体分析 若起始顶点到其他各顶点都有路径, 则只需调用1次 BFS/DFS函数
有向图:
对有向图进行BFS/DFS
遍历,调用BFS/DFS
函数的次数要具体问题具体分析:
①若起始顶点到其他各顶点都有路径,则只需调用BFS/DFS
函数
②对于强连通图,从任一结点出发都只需调用BFS/DFS
前面介绍过生成树的概念。生成树的概念是:包含图中全部顶点的一个极小连通子图。边要尽可能的少,但要保持连通。
定义是:对于一个带权连通无向图
有以下无向图:
P城要进行道路规划。道路规划要求:所有地方都连通,且成本尽可能的低。
图中边上的数字是修一条路所需成本。所以最小生成树就是一颗边上权之和最小的树。
最小生成树特点:
算法核心:
从某一个顶点开始构建生成树;
每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止。
上图最小生成树生成方式如下:
先从P城
开始,与之连通的边权值最小的为1
,即
接着能和4
,即矿场
和渔村
,两个选一个
接着找能和2
,即
找能和5
,即
最后能与3
,即
如果在第2步时选15
:
代码实现思路如下:
初始从
由于初始从isJoin
中true
(√)。lowCost
数组中由于
第1轮:循环遍历所有个结点,找到顶点对应lowCast
值最低的,且还没加入树的顶点。显然lowCoast
值最低,将其与isJoin
的true
。
同时再次循环遍历,更新还没加入的各个顶点的lowCast
值。还没有连接顶点是5
,4
,6
和4
。
变化后的数组如下:
第二轮循环同第一轮,先找到顶点对应lowCast
值最低的,且还没加入树的顶点。4
,将其加入到
再次循环遍历,更新还没加入的各个顶点的lowCast
值。新加入的2
比4
更小,所以更新lowCast
与2
:
第三轮循环继续之前操作。
算法核心:
每次选择一条权值最小的边,使这条边的两头连通(原本已经连通的就不选),直到所有结点都连通。
用Kruskal得到上图最小生成树方式如下:
先找到权值最小的边,为1
,这条边两边顶点是学校
,P城市
且没有连通,连通这两个顶点
接着在剩下边中找权值最小的,即2
,两边顶点是矿场
和渔场
且没有连通,连接这两个点
剩下边中最小的是3
,两边顶点是农场
和电站
,且没有连通
接着权值最小的是4
,两个为4
的边任意选一条即可
剩余权值最小的边是4
,但由于矿场
已经连通P城
,所以跳过。接着选剩下边中最小的5
将农场
和P城
连接
代码实现思路如下:
首先初始化将各条边按权值排序
第1轮:检查第
第一个边的权值是1
,连接的是
连接后将
第二个边的权值是2
,连接的是
第三个边的权值是3
,连接的是
第四个边的权值是4
,连接的是
第五个边的权值是4
,连接的是
后面同上。
普利姆(Prim)算法:
时间复杂度:
克鲁斯卡尔(Kruskal)算法:
时间复杂度:
有以下无向图:
第一个问题是:
G港
是个物流集散中心,经常需要往各个城市运东西,求运送距离最近路径。这种问题可以归类为单源(从一个顶点出发)最短路径问题。
解决方法用:BFS算法(无权图)、Dijkstra算法(带权图、无权图)解决。
第二个问题是:
各个城市之间也需要互相往来,相互之间怎么走距离最近?这类问题归类为:每对顶点间的最短路径。
解决方法是:Floyd算法(带权图、无权图)。
有以下无权的无向图:
注:无权图可以视为一种特殊的带权图,只是每条边的权值都为
假设从顶点2
出发BFS算法遍历过程参考图的BFS实现。通过BFS遍历便可以得到2
顶点到各个顶点的距离。
修改BFS算法的visit(w)
方法:
xxxxxxxxxx
211void BFS_MIN_Distance(Graph G,int u){
2 //d[i]表示从u到i结点的最短路径
3 for(int i=0;i<G.vexnum;++i){
4 d[i]=0x3f3f3f3f; //初始化路径长度为无穷大
5 path[i]=-1; //最短路径从哪个顶点过来
6 }
7 d[u]=0;
8 visited[u]=TRUE; //对v做已访问标记
9 Enqueue(Q,u); //顶点v入队列Q
10 while(!isEmpty(Q)){
11 u=DeQueue(Q,u); //顶点v出队列
12 for(w=FirstNeighbor(G,u);w>=0;w=NextNeighbor(G,u,w))
13 //检测v所有邻接点
14 if(!visited[w]){ //w为u的尚未访问的邻接顶点
15 d[w]=d[u]+1; //路径长度加1
16 path[w]=u; //最短路径应从u到w
17 visited[w]=TRUE; //对w做已访问标记
18 EnQueue(Q,w); //顶点w入队列
19 }
20 }
21}
第12行和12行修改后的代码。其中d[]
数组是存放顶点w
到遍历初始顶点u
之间的长度;path[]
数组作用存放当前遍历顶点的前一个顶点位置。两个数组初始化如下:
执行步骤如下:
假设从顶点u=2
开始遍历,会先将顶点2
的path
对应的值设置为0
,接着visited
设置为true
,并将2
号顶点入队。
遍历2
顶点的邻接点为1
和6
,访问这两个顶点时,由于d[u]=0
,所以这两个顶点的d[w]=d[u]+1=1
,其前一个顶点2
,故path[w]=u
同样操作同上,执行完所有遍历操作后得到的d[]
和path[]
数组如下:
求得d[]
和path[]
数组后使用:假设要找2
顶点到8
号顶点的路径:
2
到8
的最短路径长度path
数组可知,2
到8
的最短路径为:
同时通过BFS得到的广度优先生成树,其树每个结点在第几层也直接反应了,其到初始顶点的距离。
BFS缺点:只能用于不带权的图,或所有边的权值都相同的图。
可以解决带权图单源问题(从一点到另外几个点的最短路径)。其实现方法和Prim
算法十分相似。
求下图从
其实核心思路是:起始顶点初始化后,找剩余顶点中dist
值最小的顶点final
值设置为true
,接着遍历其所有邻接点中final=false
的顶点。之后判断从当前顶点dist
值,小于则替换该值,并将path
值设置为path[]=i
。
初始化:从
首先从path[0]=-1
。dist[1]=10
和dist[4]=5
,前驱path[1]=0
,path[4]=0
。而另外两个点dist[2]
和dist[3]
为path[2]
和path[3]
为-1
。
final[5]
:标记各个顶点是否已经找到最短路径。
true | false | false | false | false |
dist[5]
:最短路径的长度。
0 | 10 | 5 |
path[5]
:路径上的前驱
循环执行以下步骤:
第dist
最小的顶点final[i]=ture
。即:
首先找到final
数组不为true
,并且dist
数组值最小的点
找到final[4]=true
,表明这个顶点已经找到最短路径。其最短路径是dist[4]=5
,其直接前驱是path[4]=0
即final
值为false
,则更新dist
和path
信息。即:
检查和final
为false
的点从dist
值更小。dist[1]=10
,但从dist[4]=5
到到dist[1]=10
,所以修改dist[1]=8
,path[1]=4
。同样14
和7
,小于之前的dist[2]=14
,dist[3]=7
,path[2]=4
,path[3]=4
。处理完后数组信息如下:
final[5]
:标记各个顶点是否已经找到最短路径。
true | false | false | false | true |
dist[5]
:最短路径的长度。
0 | 8 | 14 | 7 | 5 |
path[5]
:路径上的前驱
4 | 4 | 4 |
第二轮循环同上。剩下final
数组不为true
,并且dist
数组值最小的点final[4]=true
,表明这个顶点已经找到最短路径。其最短路径是dist[3]=7
,其直接前驱是path[3]=4
即final
值为true
,所以跳过直接修改13
,小于dsit[2]=14
,所以将dist[2]=13
,path[2]=3
。处理完后数组信息如下:
final[5]
:标记各个顶点是否已经找到最短路径。
true | false | false | true | true |
dist[5]
:最短路径的长度。
0 | 8 | 13 | 7 | 5 |
path[5]
:路径上的前驱
4 | 3 | 4 |
第三轮,找剩下final=flase
,且dist
最小的顶点,为fianl
值设置为true
,并遍历其邻接点中final
为false
的邻接顶点,即8+1=9
,小于dist[2]=13
,故修改dist[2]=9
,path[2]=1
。处理完后数组信息如下:
final[5]
:标记各个顶点是否已经找到最短路径。
true | true | false | true | true |
dist[5]
:最短路径的长度。
0 | 8 | 9 | 7 | 5 |
path[5]
:路径上的前驱
4 | 1 | 4 |
最后一轮处理只剩final
值设置为true
即可。处理完后数组信息如下:
final[5]
:标记各个顶点是否已经找到最短路径。
true | true | true | true | true |
dist[5]
:最短路径的长度。
0 | 8 | 9 | 7 | 5 |
path[5]
:路径上的前驱
4 | 1 | 4 |
得到上面的数组使用方法如下:
从dist[2]=9
,通过path[]
数组可知,路径为:
代码实现思路如下:
若从final[0]=ture
;dist[0]=0
;path[0]=-1
。其余顶点final[k]=false
;从起始顶点dist[k]=arcs[0][k]
。没有和path[k]=(arcs[0][k]==
)? -1:0
dist
值最小的顶点final[i]=ture
。并检查所有邻接自final[j]=false
且dist[i]+arcs[i][j]<dist[j]
,则令dist[j]=dist[i]+arcs[i][j]
;path[j]=i
。
注意:arcs[i][j]
表示
时间复杂度:循环遍历所有顶点,找到还没有确定最短路径,且dist
最小的值,需要
如果带权图中有带负权值的图:
用Dijkstra算法得到的数组如下:
final[3]
:标记各个顶点是否已经找到最短路径。
true | true | true |
dist[3]
:最短路径的长度。
0 | 10 | 7 |
path[3]
:路径上的前驱
0 | 0 |
从dist[2]=7
,但实际上如果从10-(-5)=5
。故Dijkstra算法不适用于有负权值的带权图。
可以求出每一对顶点之间的最短路径。其核心思想是使用动态规划思想,将问题的求解分为多个阶段。如:
对于
其执行代码如下:
xxxxxxxxxx
101for(int k=0;k<n;k++){////考虑以k作为中转点
2 for(int i=0; i<n; i++) {//遍历整个矩阵, i为行号,j为列号
3 for (int j=0; j<n; j++){
4 if (A[i][j]>A[i][k]+A[k][j]){//以k为中转点的路径是否比原先的路径更短
5 A[i][j]=A[i][k]+A[k][j];//更新最短路径长度
6 path[i][j]=k;//中转点
7 }
8 }
9 }
10}
求以下带权图每对顶点之间的最短路径:
若以
若允许在path[2][3]=1
同时path[2][4]=1
修改后的数组如下:
若允许在path[0][1]=2
同时,path[0][3]=2
。注意此时
接着path[0][4]=2
。
若允许在path[0][4]=3
同时,path[1][4]=3
。
接着path[2][4]=3
。
当以
使用上面两个数组方法:找path
数组找路径如下:
这个过程可以通过遍历path[]
数组递归实现,代码实现如下:
xxxxxxxxxx
71void printPath(int i,int j){
2 if(path[i][j]==-1) cout<<i;
3 else{
4 printPath(i,path[i][j]);
5 printPath(path[i][j],j);
6 }
7}
Floyd算法的事件复杂度是
从图上可知有两条边的权值为7<-9
,这样的图又形成了回路,走的越多权值越小。所以没有最短路径。
有向无环图:若一个有向图中不存在环, 则称为有向无环图,简称DAG图(Directed Acyclic Graph)
有向无环图表达式:有表达式
这些树结点存在有重复的部分,我们可以将其合并:
合并后的图是一个有向无环图。
具体步骤如下:
有表达式(符号运算优先级已经标记,可不唯一):
先把表达式中操作数去重排列
运算符连接操作数,但注意层次。如操作符需要用到下一层两个操作数运算结果,则这个操作符应该在操作数符号运算的上一层。
倒数第二层右边三个加法都是
倒数第三层右边两个乘法都是对
如果运算顺序合理更改后,得到的DAG结点数同样和上面结点数一样都是
AOV
网(Activity On Vertex NetWork,用顶点表示活动的网):用DAG
图(有向无环图)表示一个工程。顶点表示活动,有向边AOV
网不能有环路。
这是一个表示"番茄炒蛋"工程的AOV
网。上面切番茄
有个入边,表示其执行这个步骤前要先执行入边对应的顶点,即切番茄
前要洗番茄
。
拓扑排序本质是:找到做事的先后顺序。拓扑排序定义:在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:①每个顶点出现且只出现一次。②若顶点A在序列中排在顶点B的前面,则在图中不存在从顶点
上面项目可以先准备厨具
也可以先买菜
:
接着买菜
:
此时洗番茄
或者打鸡蛋
,这里选洗番茄
:
之后切番茄
也可以打鸡蛋
:
之后的顺序就是:打鸡蛋
、下锅炒
、吃
所以这个工程的拓扑排序是:准备厨具、买菜、洗番茄、切番茄、打鸡蛋、下锅炒、吃
通过上面这个例子可以归纳处拓扑排序实现方式:
①从AOV
网中选择一个没有前驱(入度为AOV
网为空或当前网中不存在无前驱的顶点为止。
同时每个AOV
网都有一个或多个拓扑排序序列。拓扑排序代码实现如下:
xxxxxxxxxx
351//图中顶点数目的最大值
2typeder struct ArcNode{ //边表结点
3 int adjvex; //该弧所指向的顶点的位置
4 struct ArcNode * nextarc;//指向下一条弧的指针
5 //InfoType info; //网的边权值
6} ArcNode;
7typedef struct VNode{ //顶点表结点
8 VertexType data; //顶点信息
9 ArcNode *firstarc; //指向第一条依附该顶点的弧的指针
10}VNode,AdjList[MaxVertexNum];
11typedef struct{
12 AdjList vertices; //邻接表
13 int vexnum, arcnum; //图的顶点数和弧数
14} Graph; //Graph是以邻接表存储的图类型
15
16bool TopologicalSort(Graph G){
17 InitStack(S); //初始化栈, 存储入度为0的顶点
18 for(int i=0; i<G.vexnum;i++)
19 if(indegree[i]==0)
20 Push(S,i); //将所有入度为0的顶点进栈
21 int count=0; //计数,记录当前已经输出的顶点数
22 while(!IsEmpty(S)){ //栈不空,则存在入度为0的顶点
23 Pop(S,i); //栈顶元素出栈
24 print [count++]=i; //输出顶点i
25 for(p=G.vertices[i].firstarc;p;p=p->nextarc){//将所有i指向的顶点的入度减1,并且将入度减为0的顶点压入栈s
26 v=p->adjvex;
27 if(!(--indegree[v]))
28 Push(S,v); //入度为0, 则入栈
29 }
30 }//while
31 if (count<g.vexnum)
32 return false; //排序失败,有向图中有回路
33 else
34 return true; //拓扑排序成功
35}
上面代码执行方式如下:
有一AOV
网:
其邻接表存储结构如下:
初始化数组indegree[]
(保存当前顶点的入度信息)print[]
(记录拓扑序列)和栈s
(保存度为
代码for
循环会将所有入度为indegree[]
数组发现顶点入度为0
和1
。
在while
循环中会将栈顶元素出栈并保存到print[]
数组中。就是将入度为
while
中的for
循环将与2
号顶点相连的顶点的indegree
对应值-1
,即将3
和4
的indegree
值-1
。这在逻辑上相当于删除2
号结点的出边。之后判断3
和4
号顶点的indegree
值是否为
while
循环第二次循环弹出0
号顶点,重复以上操作。最后要判断count
的值是否等于结点数vexnum
的值。如果小于,证明图中有环路,排序失败。
最后拓扑排序序列为:2,0,1,3,4
若采用邻接表时间复杂度是:
对一个AOV
网,如果采用下列步骤进行排序,则称之为逆拓扑排序:
①从AOV
网中选择一个没有后继(出度为AOV
网为空。
上面图的逆拓扑排序为:吃、下锅炒、切番茄、洗番茄、打鸡蛋、准备厨具、买菜。
注意逆拓扑排序当删除一个顶点后,需要同时删除指向这个顶点的边。如果采用邻接表存储,找一个顶点指向的边较为复杂需要遍历整个邻接表,显然十分低效。而如果采用邻接矩阵当删除一个顶点后想要找到这个顶点对应的边,只需要遍历这一列即可。
同样这里可以使用逆邻接表方式存储图,即邻接表保存的是入边信息,而逆邻接表存放的是出边的信息。逆邻接表存储如下:
如上面3
顶点,其邻接表存放的入边顶点,即1
顶点和2
顶点同时指向了3
顶点。这样也十分方便。
DFS实现逆拓扑排序:在顶点退栈前输出。
实现方式如下:
xxxxxxxxxx
161void DFSTraverse(Graph G){ //对图G进行深度优先遍历
2 for(v=0;v<G. vexnum; ++v)
3 visited [v]=FALSE; //初始化已访问标记数据
4 for(v=0; v<G.vexnum; ++v) //本代码中是从v=0开始遍历
5 if( !visited[v])
6 DFS(G,v);
7}
8void DFS(Graph G,int v){ //从顶点v出发,深度优先遍历图G
9 visit(v); //访问顶点v
10 visited[v]=TRUE; //设已访问标记
11 for(w=FirstNeighbor(G,v);W>=0;w=NextNeighor(G,v,W))
12 if( !visited[w]){ //w为u的尚未访问的邻接顶点
13 DFS(G,W);
14 }
15 print(v);
16}
上面代码相对于正常DFS算法新加一行print(v);
。执行大致步骤如下:
先通过0
号顶点,进入递归,一直递归到4
号顶点,此时4
号顶点没有邻接点,所以打印第一个顶点是4
。此时栈如下:
4
号顶点打印完毕后,出栈,栈顶3
号顶点唯一邻接点4
已经访问过,所以打印出栈。
对于栈中1
号顶点和0
号顶点同样依次出栈打印。
接着DFSTraverse()
函数中for
循环会对没有进行访问2
号顶点调用DFS()
函数,由于2
号顶点所有邻接点均已访问,所以直接打印结束。
所以用DFS逆拓扑排序序列为:4,3,1,0,2
如果有回路情况下可以加一个形参flag
来记录当前递归调用次数,如果次数大于顶点数vexnum
则表示有环路存在,退出循环。也可以通过if(NextNeighor(G,v,W)&&visited[w])
,即下一个邻接点存在,并且还被访问过,就代表图有回路存在。
AOE网:在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如完成活动所需的时间), 称之为用边表示活动的网络,简称AOE网(Activity On Edge NetWork)
上面图是一个AOE网,上面图入边指向的顶点表示,前一个出边的顶点需要在入边顶点之前完成。也就是说上图V_1
顶点开始之后,才能进行打鸡蛋
和洗番茄
的操作。只有
关键路径:从源点到汇点的有向路径可能有多条,所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动。完成整个工程的最短时间就是关键路径的长度,若关键活动不能按时完成,则整个工程的完成时间就会延长。
上面图有两条路径:
显然第二个路径
求关键求法:
先求事件洗番茄
最早开工时间是1
分钟,切番茄
要洗番茄
完成后进行,所以切番茄
最早开工时间是3+1=4
,而打鸡蛋
这个事件可以在洗番茄
和切番茄
时同时进行,所以炒菜
需要两分钟,所以结束
最早时间是4+2=6
分钟。
活动
事件
活动
如:打鸡蛋这个活动,后面的事件4
这个时刻开始,而打鸡蛋又需要两分钟时间,所以打鸡蛋这个活动最晚可以2
这个时刻开始。而像切番茄这个活动,总共要消耗3
的时间,而4
这个时间发生,所以切番茄活动必须在
将活动最早开始时间和最晚开始时间放在一起(红色最早开始时间,绿色最晚开始时间):
上图只有打鸡蛋这个活动可以在2
分钟后开始,剩下的活动最早开始时间和最晚开始时间都是一样的,即这些活动时间都不能延后。
将活动
由关键活动组成的路径就是关键路径。
求关键路径步骤:
所有事件的最早发生时间
算法实现步骤:
求上图的关键路径:
求所有事件的最早发生时间
按拓扑排序序列,依次求各个顶点的
上图拓扑序列为:
而
求所有事件的最迟发生时间
按逆拓扑排序序列,依次求各个顶点的
上图逆拓扑序列:
求所有活动的最早发生时间
若边
求所有活动的最迟发生时间
若边
弧指向顶点的最晚发生时间
求所有活动的时间余量
由上表
关键活动、关键路径特性:
查找:在数据集合中寻找满足某种条件的数据元素的过程称为查找
查找表(查找结构):用于查找的数据集合称为查找表,它由同一类型的数据元素(或记录)组成
关键字:数据元素中唯一标识该元素的某个数据项的值,使用基于关键字的查找,查找结果应该是唯一的。
例子:
查找表:学生成绩信息(线性结构、可顺序可链式存储) 数据元素(记录):每个学生的信息 关键字:学号
查找表的常见操作:①查找符合条件的数据元素。②插入、删除某个数据元素
如果只进行①操作就是静态查找表,那仅仅关注查找速度即可。
如果查找同时也要进行②的操作,就是动态查找表。除了查找速度,也要关注插
查找长度:在查找运算中, 需要对比关键字的次数称为查找长度
平均查找长度(TASL , Average Search Length):所有查找过程中进行关键字的比较次数的平均值。ASL计算方法如下:
通常认为查找任何元素概率相同。评价一个查找算法的效率时,通常考虑查找成功
顺序查找, 又叫线性查找,通常用于线性表。
算法思想:从头查到尾。
实现代码
xxxxxxxxxx
111typedef struct{//查找表的数据结构(顺序表)
2 ElemType *elem;//动态数组基址
3 int TableLen;//表的长度
4}SSTable;
5//顺序查找
6int Search_Seq(SSTable ST, ElemType key){
7 int i;
8 for(i=0;i<ST.TableLen && ST.elem[i]!=key; ++i);
9 //查找成功,则返回元素下标;查找失败,则返回-1
10 return i==ST.TableLen? -1:i;
11}
查找成功情况:
查找失败情况:
实现代码
xxxxxxxxxx
111typedef struct{//查找表的数据结构(顺序表)
2 ElemType *elem;//动态数组基址
3 int TableLen;//表的长度
4}SSTable;
5//顺序查找
6int Search_Seq(SSTable ST, ElemType key){
7 ST.elem[0]=key; //"哨兵”,将查找元素key放入表头
8 int i;
9 for(i=ST.TableLen;ST.elem[i]!=key;--i); //从后往前找
10 return i; //查找成功, 则返回元素下标;查找失败,则返回0
11}
查找成功:
查找失败:
这种算法优点是:无需判定是否越界,效率更高。
以实现代码
假设查找的概率都相等即
查找失败情况是每个元素都要对比,对比
优化一:
如果查找表中的元素有序存放(递增
那么我们只用判断查找的元素在哪个区间内即可。加入要查找21
元素,当我们遍历到29
号元素时,21<29
所以后面的就没必要查找。
此时共有
优化二:
如果每个元素被查概率不相等情况下,可以将被查概率大的放在靠前位置。
被查元素:
此时
但对于查找失败情况仍和之前一样。
二分查找:仅适用于有序的顺序表。这是因为顺序表具有随机访问的特性,而链表没有。
如下顺序表查找:
给定数组元素是
查找步骤是:
先用low
和high
两个指针分别指向数组的头部和尾部。而第一轮要检查元素是low
和high
中间的元素,用指针mid=(low+high)/2
指向。即指向5
位置上的29
元素。而要查找的33
元素大于29
所以只有可能在mid
指针右边。
所以将值改为low=mid+1
,即32
元素所在的位置。同样mid
指针指向mid=(low+high)/2
的位置,即位置8
所在的37
元素。
而元素33
小于37
,所以只可能在mid
指针的左边。
将值改为high=mid+1
,此时mid=[(low+high)/2]=6
,所以mid
指向6
位置的32
元素。
而33>32
,所以只可能在mid
指针的右边。即low=mid+1=7
,同样mid=(low+high)/2=7
,而位置7
所在元素是33
满足查找条件,查找成功。
看一个查找失败步骤:
给定数组元素是
前面查找过程一致,最后一步mid
指向位置1
所在10
元素。而此时查找元素12
仍然大于10
,所以查找元素会在mid
指针的右边。此时仍然会执行low=mid+1
这个操作,之后low>high
本来在左边指针跑到右边,所以查找失败,即在数组种没有查找到12
这个元素。
代码实现如下:
xxxxxxxxxx
331//折半查找
2typedef struct{ //查找表的数据结构(顺序表)
3 ElemType *elem; //动态数组基址
4 int TableLen; //表的长度
5}SSTable;
6//方法一:
7int Binary_Search(SSTable L,ElemType key){
8 int low=0,high=L.TableLen-1,mid;
9 while(low<=high){
10 mid=(low+high)/2; //取中间位置
11 if(L.elem[mid]==key)
12 return mid; //查找成功则返回所在位置
13 else if(L.elem[mid]>key)
14 high=mid-1; //从前半部分继续查找
15 else
16 low=mid+1; //从后半部分继续查找
17 return -1; //查找失败,返回-1
18 }
19}
20//方法二:
21int Binary_Search(SSTable L,ElemType key){
22 int low=-1,high=TableLen;
23 while(low+1!=high){
24 mid=(low+high)/2;
25 if(L.elem[mid]==key)
26 return mid;
27 else if(L.elem[mid]>key)
28 high=mid;
29 else
30 low=mid;
31 return -1;
32 }
33}
上面例子中的表:
刚开始mid
指针指向5
所在位置29
元素。此时有三种可能性:要么查找元素大于,要么小于,要么等于。如果小于就在mid
左边查找,如果大于就在mid
右边查找。
如果查找的元素小于29
,那么此时mid
指向13
,如果大于29
,mid
指向37
元素。如果仍然不是这两个元素中的任意一个,则继续上面操作。第三轮查找情况如下:
如果仍没有找到元素,则进入第四轮循环:
最后一轮如果查找失败,只需要补上失败区间即可:
上面元素如果在紫色方框内,则表示查找失败。
对于这个例子,ASL查找成功平均效率:有11
个元素所以每个元素查找到的概率是
而ASL查找失败平均效率:有12
个查找失败的元素所以概率
如果当前low
和high
之间有奇数个元素,则mid
分隔后,左右两部分元素个数相等。
如果当前low
和high
之间有偶数个元素,则mid
分隔后(这里是向下取整),左半部分比右半部分少一个元素。
上图分割后构造树如下:
二分查找构造树特点:
二分查找对比顺序查找:
二分查找判定树树高
使用顺序查找第一次就可以找到元素7
,使用二分查找显然次数更多。
有以下数组:
数组中各个元素可以根据元素位置不同分成一个个小区间。如下:
划分不同区间后,数组变得有序,可以建立上级索引表。索引表当中保存每个分块的最大关键字和分块的存储区间。
不难看出分块查找特点是:块内无序,块间有序。其中索引表元素是区间数组块上的最大值。
其算法过程如下:
假如查找元素是30
,有两种方法:
30
的第一个索引元素。再根据30
区间在数组中对比6
到8
这三个元素对应值是否包含30
即可。mid=(low+high)/2
指针指向的是30
这个索引数组元素,而30=30
,故查找数组下标区间6
到8
这三个元素对应值是否包含30
即可。假如查找元素是19
,二分查找步骤如下:
如上图第一次查找mid
指向的是索引表中30
元素,由于30>19
,故查找元素可能在索引表左边,所以high=mid-1
,mid=(low+high)/2=0
。
此时索引表mid
指向的元素是10<19
,所以low=mid+1
,mid=(low+high)/2=1
索引表mid
指向的元素是20>19
,故查找元素可能在索引表左边,所以high=mid-1=0
,此时low>high
,所以二分查找结束。我们在low
指向的分块中查找元素,当前low
指向20
元素区间是2,5
,故在数组中找下标2
到5
之间的元素是否包含19
。
注意:若索引表中不包含目标关键字(要查找的元素),则折半查找索引表最终停在low>high
的位置,此时要在low所指分块中查找。
这样做原因在于,索引表保存的是区间块上的最大值,而我们要找的元素要比索引元素小才有可能在数组区间中找到。即要保证找第一个比查找元素大的索引元素。
再看一个二分查找失败例子,假如查找元素是54
,二分查找最后一步如下:
查找元素54
大于表中所有元素,所以二分查找low
指针一直往右移,当最后一次移动时low
超出索引表范围,所以查找失败。
共有
若索引表采用顺序查找,则查元素7
需要10
需要
若索引表采用折半查找,则查元素30
需要27
需要次数并不是27
大的索引元素,虽然索引表第一个被查元素30>27
,但有可能索引表之前的元素仍有比27
大的元素,这样30
就不是第一个比查找元素大的元素了。正确查找步骤是:
low
指向元素high
指向元素50
,mid
指向30
,由于30>27
,high=mid-1
指向20
mid=(low+high)/2=0
,指向元素10
,而10<27
,故low=mid+1
指向20
mid=(low+high)/2=1
指向20
,此时20<27
,故low=mid+1
指向30
,由于low>high
,所以二分查找结束,查找区间为low
所在的数组区间。low
指向30
,且区间为[6,8]
[6,8]
查找数组对应下标区间,发下第一个元素即27
查找成功。所以查元素27
需要次数是
而计算查找成功ASL值,我们需要把所有元素查找次数计算出来,并相加,再除元素总个数。对于顺序表分块查找相对来说可以计算,但对于二分查找显然不可能计算这么多元素的查找次数。所以考试只考顺序查找次数进行ASL计算。
而计算查找失败的ASL值,由于分块查询具有块内无序的特点,所以查找失败情况更加复杂。故不做讨论。
以上是分块不均匀情况,考试常考分块均匀情况下计算分块查找效率:
假设,长度为
若采用顺序查找的方式查找索引表,由于
由于每块有
故
其中由于长度为
那么此时在什么情况下可以使ASL的平均长度最少:
故分块查找平均查找长度当把
若
用二分查找查索引表,则
则
二叉排序树,又称叉查找树(BST Binary Search Tree)一棵二叉树或者是空二叉树,或者是具有如下性质的二叉树:
即左子树结点值
若树非空,目标值与根结点的值比较:
若相等,则查找成功
若小于根结点,则在左子树上查找,否则在右子树上查找。
查找成功,返回结点指针,查找失败返回
例如要查找关键字为
查找步骤:
根结点19<30
,所以结点可能在根结点右侧。
右侧结点50>30
,所以结点在该结点左侧。
查找实现代码如下:
xxxxxxxxxx
141//二叉排序树结点
2typedef struct BSTNode{
3 int key;
4 struct BSTNode *lchild, *rchild;
5}BSTNode,*BSTree;
6
7//在二叉排序树中查找值为key的结点
8BSTNode *BST_Search(BSTree T,int key){
9 while(T!=NULL&&key!=T->key){ //若树空或等于根结点值,则结束循环
10 if(key<T->key) T=T->lchild; //小于,则在左子树上查找
11 else T=T->rchild; //大于,则在右子树上查找
12 }
13 return T;
14}
递归实现方法:
xxxxxxxxxx
111//在二叉排序树中查找值为key 的结点(递归实现)
2BSTNode *BSTSearch(BSTree T,int key){
3 if (T==NULL)
4 return NULL; //查找失败
5 if (key==T->key)
6 return T; //查找成功
7 else if (key < T->key)
8 return BSTSearch(T->lchild,key);//在左子树中找
9 else
10 return BSTSearch(T->rchild,key);//在右子树中找
11}
采用非递归方式,最坏得空间复杂度
若原二叉排序树为空,则直接插入结点;否则,若关键字
代码实现如下:
xxxxxxxxxx
161//在二叉排序树插入关键字为k的新结点(递归实现)
2int BST_Insert(BSTree &T,int k){
3 if(T==NULL){ //原树为空,新插入的结点为根结点
4 T=(BSTree)malloc(sizeof(BSTNode));
5 T->key=k;
6 T->lchild=NULL;
7 T->rchild=NULL;
8 return 1; //返回1, 插入成功
9 }
10 else if(k==T->key) //树中存在相同关键字的结点,插入失败
11 return 0;
12 else if(k<T->key) //插入到T的左子树
13 return BST_Insert(T->lchild,k);
14 else //插入到T的右子树
15 return BST_Insert(T->rchild,k);
16}
同样构造二叉排序树得的过程就是不断插入新结点的过程。构造代码如下:
xxxxxxxxxx
91//按照str[] 中的关键字序列建立二叉排序树
2void Creat_BST(BSTree &T,int str[],int n){
3 T=NULL;//初始时T为空树
4 int i=0;
5 while(i<n){//依次将每个关键字插入到二叉排序树中
6 BST_Insert(T,str[i]);
7 i++;
8 }
9}
例1:按照序列
结果如下:
例2:按照序列
结果同上:
例3:按照序列
结果如下:
由此可知不同的关键字序列可能得到同款二叉排序树。也可能得到不同款二叉排序树。
若被删除结点
若结点
删除
若结点
具体来说就是:将该结点右子树当中,中序遍历被访问的第一个结点(即
将
或者可以用直接前驱替代
查找长度:在查找运算中,需要对比关键字的次数称为查找长度,反映了查找操作时间复杂度。
对于上图查找成功的平均查找长度ASL(Average Search Length):
对于上图查找成功的平均查找长度ASL(Average Search Length):
下面看查找失败平均查找长度:
紫色方框区域为有可能查找失败区域,其中第三层失败结点有
上图查找失败平均查找长度:
虽然上面两个树结点都一样,但查找长度不同,所以二叉排序树查找效率很大程度上由这棵树的高度决定。
对于二叉排序树最好情况:
对于二叉排序树最坏情况:每个结点只有一个分支,树高
所以在构建二叉排序树时,尽量保证树上任一结点的左子树和右子树的深度之差不超过1,即建立平衡二叉树。
平衡二叉树( Balanced Binary Tree),简称平衡树(AVL树):树上任一结点的左子树和右子树的高度之差不超过
某结点平衡因子
上图根结点左子树高度为
平衡二叉树结点的平衡因子的值只可能是
二叉树平衡树的结构如下:
xxxxxxxxxx
61//平衡二叉树结点
2typedef struct AVLNode{
3 int key;//数据域
4 int balance;//平衡因子
5 struct AVLNode *lchild,*rchild;
6}AVLNode,*AVLTree;
上图平衡二叉树在按照二叉排序树方法插入新结点后,会变得不平衡
让这个平衡二叉树恢复平衡的办法是从插入点往回找到第一个不平衡结点,调整以该结点为根的子树。则其他祖先节点都会恢复平衡。
调整最小不平衡子树
情况 | 具体原因 |
---|---|
LL | 在 |
RR | 在 |
LR | 在 |
RL | 在 |
当根结点左子树的左孩子结点(LL)上插入一个新结点导致
上图方框的结点抽象表示为子结点,其高度为
根据前面的章节可知二叉排序树特性:左子树结点值
代码实现核心:
xxxxxxxxxx
31A->lchild=B->rchild;
2B->rchild=A;
3gA->lchild||gA->rchild=B;
gA是A结点的父结点,A代表A结点,B代表B结点。
当根结点右子树的右孩子结点(RR)上插入一个新结点导致
同样,二叉排序树特性:左子树结点值
代码实现核心:
xxxxxxxxxx
31A->rchild = B->lchild;
2B->lchild=A;
3gA->lchild||gA->rchild=B;
gA是A结点的父结点,A代表A结点,B代表B结点。
当根结点左子树的右孩子结点(LR)上插入一个新结点导致
为了便于理解,这里将BR
结点展开。假设C
为根的树,其左子树是CL
高度为H-1
,右子树是CR
高度为H-1
。
这里将新插入的结点放在CR
结点,也可以放在CL
结点。那么恢复平衡的具体做法是先左后右双旋转:
由于在
旋转之后亦然满足排序树特性:
当根结点右子树的左孩子结点(RL)上插入一个新结点导致
同样,为了便于理解,这里将BR
结点展开。假设C
为根的树,其左子树是CL
高度为H-1
,右子树是CR
高度为H-1
。将新插入的结点放在CL
结点,也可以放在CR
结点。
恢复平衡的具体做法是先右后左双旋转:由于在
旋转之后仍满足排序树特性:
若树高为
基于平衡二叉树特性:树上任一一结点的左子树和右子树的高度之差不超过
即根结点
故某平衡二叉树最少结点
可以证明含有
只有左孩子插入结点才进行右旋操作,只有右孩子插入结点才进行右旋操作。
看个例子,有以下二叉排序树,由于再添加结点67
变为非平衡二叉树
恢复平衡的办法是从插入点往回找到第一个不平衡结点,即70
。调整以该结点为根的子树:
68
结点成为根结点,70
结点成为68
结点的右子树。结果如下:
插入操作导致最小不平衡子树的高度
例
插入结点57
此时平衡二叉树变为非平衡二叉树,其第一个不平衡结点为66
。不平衡原因是在其左孩子的右子树中插入新结点,所以是LR类型。
解决方法是:让66
结点的左孩子的右孩子60
结点先左旋,再右旋。熟练方法是左旋右旋之后60
会成为根结点,而50
和66
为根结点的左孩子和右孩子。之后让60
原本的左子树(55
和57
)与右子树(63),按照二叉排序树规则插入即可。
同调整最小不平衡子树类似,平衡二叉树的删除操作在删除结点后,要保持二叉排序树的特性不变(左
平衡二叉树删除操作具体步骤:
删除结点(方法同二叉排序树)
若删除后出现不平衡现象,则从被删除结点开始往上找到最小不平衡子树,找不到就完结撒花
如果找到最小不平衡子树,高度最高的儿子结点和孙子结点
根据孙子的位置,调整平衡(平衡调整四种情况:
如果调整完后,不平衡特性向上传导,则继续②
对最小不平衡子树的旋转可能导致树变矮,从而导致上层祖先不平衡(不平衡向上传递)
删除下面平衡二叉树的32
结点:
因为32
结点是叶子结点,所以直接删除,删除后出现二叉树出现不平衡现象。找最小不平衡子树为44
结点
找最小不平衡子树下面最高儿子结点78
和孙子结点50
接着根据孙子所在位置选择平衡方法。50
结点是
先右旋:
再左旋:
此时最小不平衡子树恢复平衡
整个平衡二叉树如下:
通过上图可以观察出,由于右侧树做了恢复调整,由于树高变矮,所以不平衡性向上传到。处理方法是从第二步开始:
从刚刚调整的树的根50
出发,往上找最小不平衡子树,是33
。对这棵最小不平衡树再做调整
找最小不平衡子树下面最高儿子结点10
和孙子结点20
接着根据孙子结点20
所在位置选择平衡方法。20
结点是
先左旋:
再右旋:
整棵树恢复平衡。
平衡二叉树的删除操作时间复杂度是
红黑树和平衡二叉树在增查删三个基本操作上的时间复杂度一样,都是
由于平衡二叉树的插入和删除两个操作很容易破坏平衡性,需要频繁调整树的形态。而红黑树在插入或删除时不会破坏红黑
特性,无需频繁调整树的形态,即便需要调整一般都在常数级时间内完成。
使用场景:
红黑树是二叉排序树:左子树结点值
红黑树原则
红黑树结构定义:
xxxxxxxxxx
71struct RBnode { //红黑树的结点定义
2 int key; //关键字的值
3 RBnode* parent; //父节点指针
4 RBnode* lChild; //左孩子指针
5 RBnode* rChild; //右孩子指针
6 int color; //结点颜色, 如:可用0/1表示黑/红, 也可使用枚举型enum表示颜色
7};
黑结点的高度:从某一结点出发(不包含该结点)到达任意空叶结点的路径上黑结点总数。由于红黑树特点我们只用看一条路径就可以知道该结点的黑结点高度。
若根节点黑高为
根据红黑树特点可以得到以下性质:
由上面4,5
特点可以推出:从根节点到叶结点的最长路径不大于最短路径的
有
证明:若红黑树总高度
红黑树的查找与BST、AVL 相同,从根出发,左小右大,若查找到一个空叶节点,则查找失败。
红黑树的插入和平衡二叉树有很多相似地方。
插入步骤:
如何调整:
找新插入结点的父结点的兄弟结点。
如果兄弟结点是黑色:旋转
插入结点是
插入结点是
插入结点是
插入结点是
如果兄弟结点是红色:则父结点,父结点的兄弟节点、爷结点颜色取反
注意:新结点在插入的时候如果需要调整一般都是违背了"不存在两个相邻的红结点"这一原则。
例子:从一棵空的红黑树开始,插入:20,10,5,30,40,57,3,2,4,35,25,18,22,23,24,19,18
根结点必须是黑色,所以
上面蓝色框是新插入结点,下面两个叶子结点是NULL
结点。
为了保证某节点到叶子结点任意一条简单路径上黑色结点数量都相同,这一特性我们要将插入的非根结点都设置为红色。
插入
接着插入
此时发现破环了,"不存在两个相邻的红结点"这一原则,需要进行调整,使其能重新满足红黑树定义。
新结点
先右单旋:
再对两个结点颜色取反:
接着插入
新结点插入后破环了红黑树定义,违反了"不存在两个相邻的红结点"这一原则。
新结点
颜色取反:
爷结点变为新结点,再判断红黑树是否满足定义。
发现违背"根节点是黑色"这一原则。将新结点改为黑色即可。
接着插入
同样是违背了"不存在两个相邻的红结点"这一原则。
新结点
左单旋:
颜色取反:
插入结点
新结点
颜色取反:
此时,
接着插入
插入结点
新结点
右单旋:
颜色取反:
接着插入结点
新结点
颜色取反:
此时,
接着插入的结点
插入结点
新结点
颜色取反:
颜色取反:
新结点变为
插入结点
新结点
先左旋:
再右旋:
颜色取反:
接着插入结点
新结点
颜色取反:
此时新结点变为
先左旋:
再右旋:
颜色取反:
插入
最后结点
新结点
先右旋:
再左旋:
颜色取反:
将所有元素插入红黑树完毕。
重要考点:
①红黑树删除操作的时间复杂度
②在红黑树中删除结点的处理方式和"二叉排序树的删除"一样
③按②删除结点后,可能破坏"红黑树特性",此时需要调整结点颜色、位置,使其再次满足"红黑树特性"。
由前面的二叉排序树可以知道,二叉树中每个结点都可以将查找范围缩小到两个区间内。
而B树就是拥有更多分支结点的树。具体定义如下:
在
这样构造出来的多叉树就是B树。这样规定是因为若每个结点内关键字太少,导致树变高,要查更多层结点,导致效率低。
若根结点不是终端结点,则至少有两棵子树。
树中每个结点至多有
所有的叶结点都出现在同一层次上,并且不带信息(可以视为外部结点或类似于折半查找判定树的查找失败结点,实际上这些结点不存在,指向这些结点的指针为空)。
假设有一颗
上面是一个
xxxxxxxxxx
61//5叉排序树的结点定义
2struct Node {
3 ElemType keys[4];//最多4个关键字
4 struct Node * child[5]; //最多5个孩子
5 int num;//结点中有几个关键字
6};
查找步骤如下:查找
注:如果一个结点包含关键字有多个,可以用折半查找。
再查找
B树的高度(这里计算B树的高度不计算叶子结点(失败结点)):
含
含
还可以从另一个角度分析:
含
若关键字总数少于这个值,则高度一定小于
总结:含
插入核心要求:
插入步骤:
在插入key后,若导致原结点关键字数超过上限,则从中间位置
将其中的关键字分为两部分,左部分包含的关键字放在原结点中,右部分包含的关键字放到新结点中,中间位置 的结点插入原结点的父结点。若此时导致其父结点的关键字个数也超过了上限,则继续进行这种分裂操作,直至这个过程传到根结点为止,进而导致B树高度会增加 。
下面演示
首先插入
接着再插入
此时中间位置关键字为
接着新元素一定是插入到最底层"终端节点",用"查找"来确定插入位置。
插入新关键字
关键字
当
继续插入关键字
接着插入关键字
当
继续插入
当
插入关键字
当
此时发现刚刚插入关键字
删除关键字操作分情况:
如果删除的关键字位于终端结点,且不会破坏B树关键字个数特性,可以直接删除。如上面
如果删除的关键字位于根结点,可以找该关键字的直接前驱或直接后继,顶替这个位置。直接前驱:当前关键字左侧指针所指子树中"最右下"的元素。直接后继:当前关键字右侧指针所指子树中"最左下"的元素。
如删除
所以,对非终端结点关键字的删除,必然可以转化为对终端结点的删除操作。
如果删除的关键字位于终端结点,但删除完后破坏B树关键字个数特性,则:
如果兄弟结点够借:若被删除关键字所在结点删除前的关键字个数低于下限,且与此结点右(或左) 兄弟结点的关键字个数还很宽裕,则需要调整该结点、右(或左)兄弟结点及其双亲结点(父子换位法)。再具体一点做法是(这里是中序遍历):
如:删除关键字
删除
如果删除的关键字位于终端结点,删除完后破坏B树关键字个数特性,且其左右兄弟结点关键字都不够替换时,则将删除关键字的结点与左(或右)兄弟结点及双亲结点中的关键字进行合并。在合并过程中,双亲结点中的关键字个数会减
如:删除
此时可以看到,父结点就剩一个关键字
由于父结点内没有关键字,可以删除该结点
一颗
非叶根结点至少有两棵子树,其他每个分支结点至少有
每个分支结点最多有
所有分支结点中仅包含它的各个子结点中关键字的最大值及指向其子结点的指针。
结点的子树个数与关键字个数相等。这是与
所有叶结点包含全部关键字及指向相应记录的指针,叶结点中将关键字按大小顺序排列,并且相邻叶结点按大小顺序相互链接起来。
如下是一颗
如下是一颗
查找关键字
从根节点开始
接着遍历这一层结点,发现有关键字
这一层是叶子结点,从左往右依次遍历,找到关键字
除了上面介绍的多路查找,还可以进行顺序查找。因为叶子结点有一个指针
结点中的 | 结点中的 |
每个结点关键字个数不能低于 | 每个结点关键字个数不能低于 |
在B树中,各结点中包含的关键字是不重复的 | 叶结点包含全部关键字,非叶结点中出现过的关键字也会出现在叶结点中 |
B树的结点中都包含了关键字对应的记录的存储地址 | 叶结点包含信息,所有非叶结点仅起索引作用,非叶结点中的每个索引项只含有对应子树的最大关键字和指向该子树的指针,不含有该关键字对应记录的存储地址。 |
散列表(Hash Table),又称哈希表。是一种数据结构,特点是:数据元素的关键字与其存储地址直接相关。
例:有一堆数据元素,关键字分别为
具体步骤是让每个关键字对
解决哈希冲突,是哈希表的关键。
用拉链法(又称链接法、链地址法)处理冲突:把所有"同义词"存储在一个链表中。这也是实际开发中常用的方法。
如上面,散列函数为
存储所有关键字后哈希表结构如下:
拉链法的查找方法是,首先基于散列函数计算出被查找关键字的哈希值。根据哈希值找到对应数组索引位置,如果存放的是链表则遍历看是否有被查找关键字。
如,上面哈希表查找关键字
这里要引入查找长度概念:在查找运算中, 需要对比关键字的次数称为查找长度。上面查找
如上哈希表的平均查找长度
哈希表查找失败的平均查找长度
上面这个
通过上面对查找长度和平均查找长度的计算,可以知道哈希冲突会导致查询效率变低。且查找长度越大,代表冲突越大,查找效率越低。
最理想情况:只要哈希函数设计的足够好,哈希查找的时间复杂度可达到
如果拉链法中链表元素采用顺序存储方式,可以提高查找效率。
哈希函数的设计可以直接决定一个哈希表的查找效率,所以哈希函数设计很重要,下面介绍几种常见的哈希函数。
例如:散列表表长
散列表表长
这样设计的原因是:质数分布更均匀,冲突更少。参见《数论》。大致原因是和数的公因子多,所以冲突概率更高。
其中,
例如:存储同一个班级的学生信息,班内学生学号为
选取数码分布较为均匀的若干位作为散列地址。
设关键字是r进制数(如十进制数),而r个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀一些,每种数码出现的机会均等,而在某些位上分布不均匀,只有某几种数码经常出现,此时可选取数码分布较为均匀的若干位作为散列地址。这种方法适合于已知的关键字集合,若更换了关键字,则需要重新构造新的散列函数。
例如:以"手机号码"作为关键字设计散列函数
取关键字的平方值的中间几位作为散列地址。
具体取多少位要视实际情况而定。这种方法得到的散列地址与关键字的每位都有关系,因此使得散列地址分布比较均匀,适用于关键字的每位取值都不够均匀或均小于散列地址所需的位数。
例如:要存储整个学校的学生信息,以“身份证号”作为关键字设计散列函数
尽管这里表的长度十万,但还是有冲突的可能性。要完全解决哈希冲突,方法是设计的表要足够长,但这样空间复杂度也会很高,所以散列查找是一个典型的"空间换时间的"算法。
所谓开放定址法,是指可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放。其数学递推公式为:
其中
关于增量序列
开放地址法删除关键字:删除关键字不能只是简单的删除而是标记删除
例:有一堆数据元素,关键字分别为
冲突处理函数为:
前三个关键字没有发生冲突直接放入
关键字
接着关键字
关键字
之后方法类似。但要注意的是本题中哈希函数值域是
查找方法和插入方法一样,例如查找
注意如果
删除关键字:删除关键字不能只是简单的删除还要标记删除
例如:删除关键字
计算索引值指向位置关键字是空的,此时查找失败
。但明显可以看到
这种方法也有弊端:假设将表的前
此时发现,虽然前
查找成功平均查找长度:先算出每个关键字能被查找到的次数,再将关键字查找次数相加除关键字长度即可。
查找失败平均查找长度:如果刚开始是在索引
可以看出线性探测法查找效率很低,因为线性探测法很容易造成同义词、非同义词的"聚集(堆积)"现象,严重影响查找效率。出现这一现象根本原因是冲突后再探测一定是放在某个连续的位置。
平方探测法可以有效解决聚集的问题。
例:哈希函数是
上面
关键字
关键字
后序元素方法类似。
查找方法同插入方法,都是取余后根据冲突次数计算
注意:当采用开放定址法时,哈希表的长度必须是一个可以用
这个随机序列就是计算冲突函数
除了原始的哈希函数