• 九. 图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. 9.1 树的查找9.2 树与树区别10. 哈希表10.1 拉链法解决哈希冲突10.2 常见的哈希函数除留余数法直接定址法数字分析法平方取中法10.3 开放定址法解决哈希冲突线性探测法平方探测法伪随机序列法10.4 再哈希法

    九. 图

    图是由顶点集V和边集E组成的,|V|表示顶点个数,|E|表示边个数。

    顶点集表示图的顶点个数组成的集合,也可以称为图的阶。边集表示的是连接顶点的边组成的集合,任何一条边两头必须连接一个顶点。

    图

    注意:线性表可以是空表,树可以是空树,但图不可以是空,即V一定是非空集,但边E可以是空的。如下:

    图的边为空集

    图数据结构应用:地图、社交软件好友关系等。

    图的应用

    1. 图的概念

    G1=(V1,E1)V1={A,B,C,D,E}E1={<A,B>,<A,C>,<A,D>,<A,E>,<B,A>,<B,C>,<B,E>,<C,D>}

    有向图子图和生成子图概念同上。

    2. 图的存储

    2.1 邻接矩阵法

    邻接矩阵存放普通图

    用二维数组存放各个顶点之间边的关系

    无向图存储结构:

    无向图存储结构

    有向图存储结构:

    有向图存储结构

    上图中无论是有向图还是无向图,右边二维数组中存放的是边的关系。如:顶点A与顶点B之间有边,故二维数组[A][B]=1

    代码存储结构如下:

    上面存放顶点信息的vex数组,可以存放更复杂的信息。同时,由于二维数组中只存放01,所以可以使用更小的bool变量。

    vex数组存放顶点信息时,数组下标要与edge数组中边的信息一致。即:

    结点数为n的图G=(V,E)的邻接矩阵An×n的。将G的顶点编号为v1,v2....vn

    A[i][j]{1,(vi,vj)<vi,vj>E(G)0(vi,vj)<vi,vj>E(G)

    求顶点的度、入度和出度方法:

    对于无向图来说:第i个结点的度=i行(或第i列)的非零元素个数。

    对于无相图:

    邻接矩阵法求顶点的度/出度/入度的时间复杂度为O(|V|)

    邻接矩阵法存放带权图

    同样用二维数组存放,但同时存放的不再是01;如果两个顶点有边则存放权值。如果两个顶点无边则存放

    邻接矩阵存储带权图

    存储结构代码如下:

    邻接矩阵性能分析与性质

    空间复杂度: O(|V|2),只和顶点数相关,和实际的边数无关。

    适合用于存储稠密图。无向图的邻接矩阵是对称矩阵,可以压缩存储(只存储上三角区/下三角区)。

    性质:

    设图G的邻接矩阵为G(矩阵元素为01),则Gn的元素Gn[i][j]等于由顶点i到顶点j的长度为n的路径数目。

    邻接矩阵法性质

    1: G2[1][4]=a11a14+a12a24+a13a34+a14a44=1

    上面除了a12a34=1,其它表达式等于0,这个a12a24=1代表存在一条路径是AB再从BD。最后结果等于1代表AD如果路径为2情况下只能找到一条路径。其中a12指的是ABa24指的是BD

    2:G2[2][2]=a21a12+a22a22+a23a32+a24a42=3

    这个结果表示从BB长度为2的路径有三条。a21a12表示BA再从AB这条路径存在。由于这是一个简单图并不存在自身连接的路径a22a22=0不存在BB,BB这条路径;a23a32表示BC,CB这条路径存在;a24a42表示BD,DB这条路径存在。

    总结G2代表对应两个结点之间长度为2的路径总共多少条。

    2.2 邻接表

    通过顺序+链式存储方式实现:

    邻接表法

    存储上面无向图可以用下面存储结构:

    邻接表法存储结构

    代码结构实现如下:

    AdjList[MaxVertexNum]存储各个顶点信息(data*first指向第一条弧/边);vertices顶点结点数组,代表一个图,其中的vexnum表示有多少条结点,arcnum表示有多少条边;每个边和弧也会有与之对应的结点ArcNode

    有向图邻接表法存储结构:

    有向图邻接表法存储结构

    由于无向图中结点边都是对应的,所以两个结点间的边需要保存两次,即边结点的数量是2|E|,整体空间复杂度为O(|V|+2|E|)

    有向图每条弧都是对应一个结点,所以边结点的数量是|E|,整体空间复杂度为(O(|V|+|E|)

    2.3 邻接表与邻接矩阵对比

    邻接表与邻接矩阵:

    邻接表与邻接矩阵

     邻接表邻接矩阵
    空间复杂度无向图O(|V|+2|E|)
    有向图O(|V|+|E|)
    O(|V|2)
    适合用于存储稀疏图存储稠密图
    表示方式不唯一唯一
    计算度/出度/入度计算有向图的度、入度不方便,其余方便必须遍历对应行或列
    找相邻的边找有向图的入边不方便,其余很方便必须遍历对应行或列

    2.4 十字链表

    只能用于存储有向图。

    邻接矩阵主要问题是:空间复杂度高。而邻接表问题在于找有向图的入边不方便,其余很方便。

    十字链表结构体结构:

    十字链表存储有向图

    存储以下有向图:

    十字链表存储有向图2

    则十字链表数据存储结构如下:

    十字链表数据存储结构

    以上结点ABCD会分别存储在数组种0123位置。

    A结点绿色指针代表弧尾指向的是下标1所在的结点即B,表示从ABB的绿色指针弧尾又指向2下标所在C结点的橙色弧头位置,表示从AC

    A结点的橙色指针指针2下标所在的C结点,表明CAC结点橙色指针接着往下找指向的是下标为3D结点,代表DA

    总结:通过绿色指针往后找可以找到所有从当前结点发射的所有弧(该节点指向了谁)。橙色指针往后找则可以找到所有指向当前结点的弧。

    十字链表的空间复杂度:O(|V|+|E|)。V代表顶点个数,E代表边的个数。

    即解决邻接矩阵空间复杂度高的问题。又解决邻接表找有向图的入边不方便的问题

    2.5 邻接多重表

    只能用于存储无向图。

    用邻接矩阵存储无向图会导致空间复杂度高O(|V|2)。邻接表的每条边对应两份冗余信息导致删除顶点、删除边等操作时间复杂度高。

    邻接多重表结构体存储结构:

    邻接多重表结构体存储结构

    存储以下有向图:

    邻接多重表存储无向图

    则邻接多重表数据存储结构如下:

    邻接多重表数据存储结构

    结点存放ABCDE是一个数组,firstedge指针指向当前顶点相连的第一条边。

    如:A结点firstedge指向01相连的边,即A——B。顺着B结点橙色指针指向的边,可以找到另一个与A结点相连的结点D

    再如:B结点firstedge指向01相连的边,其中B结点在整个结点中是绿色1结点,其绿色指针指向的就是B结点下一个边结点,即2C结点,同样下一个绿色1结点是B结点,再顺着绿色指针指向E结点。此时绿色指针域为NULL

    总结:边结点存放的该结点下标是什么颜色就找这个颜色指针指向的边界点。直到指针指向NULL。另一个颜色就是该结点相连的结点下标。

    总之用邻接多重表存放无向图很方便,想要找到和某个顶点相连的边是很方便的,同时每一条只会对应一个边界点,所以不用再像邻接表那也同时维护两份冗余的数据,删除顶点、删除边等操作效率高很多。

    如删除AB之间连接的边,我们只需要顺着Afirstedge指向的边结点0|1,顺着对应颜色橙色找到下一个与A相连的边结点0|3,将firstedge指针指向这个0|3边结点即可。如下图:

    邻接多重表数据删除

    如果要删除E整个结点,只需要将E边指向的边结点4|12|4删除,再将指向这两个边结点的2|12|3边结点对应指针域指向NULL即可。

    邻接多重表数据删除结点

    采用邻接多重表的空间复杂度是:O(|V|+|E|)

    2.6 总结

     邻接矩阵邻接表十字链表邻接多重表
    空间复杂度O(|V|2)无向图O(|V|+2|E|)
    有向图O(|V|+|E|)
    O(|V|)+|E|O(|V|+|E|)
    找相邻边遍历对应行或列
    时间复杂度为O(|V|)
    找有向图的入边必须遍历整个邻接表很方便很方便
    删除边与顶点删除边很方便,删除顶点需要大量移动数据无向图中删除边或顶点都不方便很方便很方便
    适用于稠密图稀疏图和其他只能存储有向图只能存储无向图
    表示方式唯一不唯一不唯一不唯一

    3. 图的基本操作

    考研常考邻接矩阵和邻接表这两中结构。所以这里主要讲这两种存储结构的基本操作。

    此外,还有图的遍历算法,包括深度优先遍历和广度优先遍历。

    4. 图的广度优先搜索

    树的广度优先搜索是层次遍历。图的广度优先搜索和树的类似。如下无向图:

    无向图的广度优先搜索

    从顶点2出发,可以遍历到顶点1和顶点6,接着再根据顶点16找到其他顶点,即5,3,7三个顶点。接着再从这三个顶点出发找到更下一层的4,8两个顶点。这个过程和树很类似。两个结构对比如下:

    树和图BFS区别

    树搜索不存在"回路",搜索相邻的结点时,不可能搜到已经访问过的结点。树BFS算法步骤如下: ①若树非空,则根节点入队 ②若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队 ③重复②直到队列为空

    图在搜索相邻的顶点时,有可能搜到已经访问过的顶点。如上无向图,通过6顶点可以访问到2,3,7三个顶点,但此时2结点已经访问过了,仍会再次访问。解决方法也很简单,只需要给各个结点一个标记,来标记结点有没有被访问即可。

    4.1 图的BFS实现

    图的BFS算法如下:

    1. 找到与一个顶点相邻的所有顶点
    2. 标记哪些顶点被访问过
    3. 需要一个辅助队列

    实现代码如下:

    图存储结构:

    图BFS代码原理

    visited数组:

     12345678
    visitedfalsefalsefalsefalsefalsefalsefalsefalse

    假如先从2顶点开始遍历:

    1. 先访问2号顶点,再将2号顶点对应的visited数组设为true

       12345678
      visitedfalsetruefalsefalsefalsefalsefalsefalse
    2. 之后将2号顶点入队,进入while循环,判断队列是否为NULL,不为NULL将队中2顶点出队,进入for循环

    3. for循环中先获取与2顶点相邻顶点1,访问1号顶点后将对应visited数组设为true,再入队。NextNeighbor(G,v,w)再获取邻接点下一个顶点即6,同样,访问后数组设置为true后再入队。此时6顶点已经为2顶点最后一个邻接点,结束循环,进入外层while循环。

       12345678
      visitedtruetruefalsefalsefalsetruefalsefalse
    4. 此时队列为:1,6

    5. 循环1,2,3步,得到该无向图的BFS序列为:2,1,6,5,3,7,4,8

    同样,从顶点1出发得到的广度优先序列为:1,2,5,6,3,7,4,8

    对于BFS算法,如果图的存储结构不一样,得到的遍历序列也可能不一样。但对于邻接矩阵来说,它的存储方式是唯一的,所以其BFS遍历顶点其他邻接点时,一定是从小到大遍历。

    4.2 图BFS遍历非连通图

    有以下非连通图:

    BFS非连通图

    上面的代码不能访问9,10,11顶点。这个问题可以通过visited数组解决。解决代码如下:

    以上代码在BFSTraverse函数中会先从1号顶点开始遍历,对每个连通分量检查是否访问,没有访问则调用BFS。

    假如从1号结点开始访问,BFSTraverse函数第一次调用BFS会遍历1~8顶点,此时visited数组如下:

    非连通图遍历

    接着BFSTraverse函数会一直遍历直到发现没有访问的顶点9,接着从9号顶点开始继续调用BFS,之后BFS函数会访问10,11顶点。此时所有顶点访问完毕,结束循环。

    结论:对于无向图,调用BFS函数的次数=连通分量数。

    4.3 图的BFS算法复杂度分析

    BFS算法复杂度分析

    这里显然不能分析BFS函数最深层的for循环来确定程序的复杂度,因为假设一个图所有顶点之间都没有边相连,则此时BFS深层的for循环执行0次。事实上BFS函数调用此时为O(|V|)次。

    4.4 广度优先生成树与森林

    广度优先生成树

    广度优先生成树

    上面BFS遍历序列中,红色的边表示顶点第一次被访问的时候是从哪个边过去的。如上面4号顶点第一次访问时候是从3号顶点的边过去的。用这种方式,n个顶点的图要标红n1条边。

    如果把上面没有标红边去掉,则得到以下图:

    广度优先生成树2

    这个图实际上变为了树,因为里面已经没有回路出现了。这个树就是广度优先生成树。

    转换成生成树如下:

    广度优先生成树3

    同样的,通过邻接表存储的图生成树不唯一。

    广度优先生成森林

    广度优先生成森林

    同样对非连通图的广度优先遍历,可得到广度优先生成森林:

    广度优先生成森林2

    5. 图的深度优先搜索

    图的DFS与树一致,树的DFS是用先根遍历实现的:

    树的先根遍历

    以上树的先根遍历序列是:1,2,5,6,3,4,7,8

    5.1 图的DFS实现

    由于树的特性树的遍历新找到的结点肯定是没被访问过的结点。但图遍历的新结点有可能是已经被访问的。所以同广度优先搜索一样,同样要设置一个visited数组标记顶点有没有访问过。

    图的DFS

    DFS遍历上面无向图,需要先初始化visited数组为false

     12345678
    visitedfalsefalsefalsefalsefalsefalsefalsefalse

    代码实现如下:

    代码执行步骤如下:

    1. 假设从2号顶点出发,先访问2号,再将其对应visited数组设置为true;接着进入for循环内部,先获取2号顶点下一个邻接点11号邻接点显然没有被访问,进入递归函数DFS,递归遍历1号顶点。

      visited数组:

      图的DFS步骤1

      函数调用栈:

      图的DFS步骤2

    2. 重复执行第一步操作,在进入for循环后获取1号邻接点22顶点已经访问,所以不会进入递归,再和1号邻接的点为55号顶点进入递归

      visited数组:

      图的DFS步骤3

      函数调用栈:

      图的DFS步骤4

    3. 重复第一步操作,5号顶点访问完毕后,由于没有邻接点,本层递归执行结束,依次出栈:由于1号顶点5顶点已经是最后一个邻接点,所以1号顶点出栈;2号顶点已经处理了第一个邻接点,接着进入第二次循环处理邻接点66顶点没有访问进入递归。

      visited数组:

      图的DFS步骤5

      函数调用栈:

      图的DFS步骤6

    4. 访问6号顶点并设置visited数组后,进入for循环。第一个邻接点是2,已经访问过,接着访问下一个邻接点3,顶点3没有被访问过,同样进入递归。

      visited数组:

      图的DFS步骤7

      函数调用栈:

      图的DFS步骤8

    5. 访问3号顶点设置visited数组后,进入for循环。其第一个邻接点是4号顶点,没有被访问进入递归。

      visited数组:

      图的DFS步骤9

      函数调用栈:

      图的DFS步骤10

    6. 访问完4号顶点后,由于其第一个邻接点3已经被访问过,所以访问下一个邻接点7,进入递归。

      visited数组:

      图的DFS步骤11

      函数调用栈:

      图的DFS步骤12

    7. 访问完7号顶点后,与7相邻的顶点只有8没有被访问,进入递归

      visited数组:

      图的DFS步骤13

      函数调用栈:

      图的DFS步骤14

    8. 在访问完8号顶点后,由于与之相邻的邻接点都已经被访问过,所以循环结束,本次递归结束,依次出栈:7号顶点与之相邻的顶点都被访问完,同样结束出栈;其他层递归过程类似,全部出栈。

    所以从2号顶点出发得到的DFS序列为:2,1,5,6,3,4,7,8

    5.2 图DFS遍历非连通图

    有以下非连通图:

    BFS非连通图

    上面的代码并不能访问9,10,11顶点。这个问题可以通过visited数组解决。解决代码如下:

    以上代码在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

    5.3 图的DFS算法复杂度分析

    5.4 深度优先生成树与森林

    5.5 图的遍历与图的连通性

    6. 图的应用

    6.1 最小生成树

    前面介绍过生成树的概念。生成树的概念是:包含图中全部顶点的一个极小连通子图。边要尽可能的少,但要保持连通。

    定义是:对于一个带权连通无向图G=(V,E),生成树不同,每棵树的权(即树中所有边.上的权值之和)也可能不同。设RG的所有生成树的集合,若TR中边的权值之和最小的生成树,则T称为G的最小生成树(Minimum-Spanning-Tree, MST)。

    有以下无向图:

    最小代价树

    P城要进行道路规划。道路规划要求:所有地方都连通,且成本尽可能的低。

    图中边上的数字是修一条路所需成本。所以最小生成树就是一颗边上权之和最小的树。

    最小生成树特点:

    1. 最小生成树可以有多个。但边的权值之和总是唯一且最小的。
    2. 最小生成树的边数=顶点数1。砍掉一条则不连通,增加一条边则会出现回路
    3. 如果一个连通图本身就是一棵树,则其最小生成树就是它本身
    4. 只有连通图才有生成树,非连通图只有生成森林

    普利姆(Prim)算法

    算法核心:

    从某一个顶点开始构建生成树;

    每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止。

    上图最小生成树生成方式如下:

    1. 先从P城开始,与之连通的边权值最小的为1,即P

      最小代价树思路步骤1

    2. 接着能和P,相连的最小边为4,即矿场渔村,两个选一个P

      最小代价树思路步骤2

    3. 接着找能和P相连的最小边,为2,即

      最小代价树思路步骤3

    4. 找能和P相连,最小边为5,即P

      最小代价树思路步骤4

    5. 最后能与P相连的最小边为3,即

      最小代价树思路步骤5

    如果在第2步时选P,最后的最小生成树权值和仍为15

    最小代价树思路步骤6

    代码实现思路如下:

    最小生成树的Prim算法实现思想

    初始从V0开始创建两个数组:

    最小生成树的Prim算法实现思想1

    由于初始从V0开始,所以刚开始isJoinV0对应标记为true(√)。lowCost数组中由于V4V5没有直接与V0相连的边,故权值设置为

    1. 第1轮:循环遍历所有个结点,找到顶点对应lowCast值最低的,且还没加入树的顶点。显然V3lowCoast值最低,将其与V0连接,isJoinV3标记位true

      最小生成树的Prim算法实现思想2

      最小生成树的Prim算法实现思想3

      同时再次循环遍历,更新还没加入的各个顶点的lowCast值。还没有连接顶点是V1V2V4V5。连接V0V3后,剩下未连接顶点最小权值发生变化。具体做法是检查未连接顶点与新连接的顶点V3之间有没有边,有的话对比是否比V0边上的权值更下。V1V3权值变为5V2V4变为4V4V5变为64

      变化后的数组如下:

      最小生成树的Prim算法实现思想4

    2. 第二轮循环同第一轮,先找到顶点对应lowCast值最低的,且还没加入树的顶点。V2号顶点最低,权值为4,将其加入到V0V3树中。

      最小生成树的Prim算法实现思想5

      最小生成树的Prim算法实现思想6

      再次循环遍历,更新还没加入的各个顶点的lowCast值。新加入的V2只有V5与其有边,且V5V2边权为2V5V3边的权4更小,所以更新lowCastV5对应的值改为2

      最小生成树的Prim算法实现思想7

    3. 第三轮循环继续之前操作。

    克鲁斯卡尔(Kruskal)算法

    算法核心:

    每次选择一条权值最小的边,使这条边的两头连通(原本已经连通的就不选),直到所有结点都连通。

    用Kruskal得到上图最小生成树方式如下:

    1. 先找到权值最小的边,为1,这条边两边顶点是学校P城市且没有连通,连通这两个顶点

      最小代价树思路步骤7

    2. 接着在剩下边中找权值最小的,即2,两边顶点是矿场渔场且没有连通,连接这两个点

      最小代价树思路步骤8

    3. 剩下边中最小的是3,两边顶点是农场电站,且没有连通

      最小代价树思路步骤9

    4. 接着权值最小的是4,两个为4的边任意选一条即可

      最小代价树思路步骤10

    5. 剩余权值最小的边是4,但由于矿场已经连通P城,所以跳过。接着选剩下边中最小的5农场P城连接

      最小代价树思路步骤11

    代码实现思路如下:

    Kruskal最下生成树算法思路1

    首先初始化将各条边按权值排序

    Kruskal最下生成树算法思路

    1. 第1轮:检查第1条边的两个顶点是否连通(是否属于同一个集合,可以参考之间的并查集)

      第一个边的权值是1,连接的是V0V3两个顶点,通过并查集查询两个顶点不是在同一集合中,则将其连接。

      Kruskal最下生成树算法思路2

      连接后将V0V3合并为一个集合中

    2. 第二个边的权值是2,连接的是V2V5两个顶点,通过并查集查询两个顶点不是在同一集合中,则将其连接连,接后将V2V5合并为一个集合中。

      Kruskal最下生成树算法思路3

    3. 第三个边的权值是3,连接的是V1V4两个顶点,通过并查集查询两个顶点不是在同一集合中,则将其连接连,接后将V1V4合并为一个集合中。

      Kruskal最下生成树算法思路4

    4. 第四个边的权值是4,连接的是V2V3两个顶点,通过并查集查询两个顶点不是在同一集合中,则将其连接连,接后将V2V3合并为一个集合中。

      Kruskal最下生成树算法思路5

    5. 第五个边的权值是4,连接的是V3V5两个顶点,通过并查集查询两个顶点已经在同一集合中,则跳过

    6. 后面同上。

    两个算法比较

    6.2 最短路径问题

    有以下无向图:

    最短路径问题

    第一个问题是:

    G港是个物流集散中心,经常需要往各个城市运东西,求运送距离最近路径。这种问题可以归类为单源(从一个顶点出发)最短路径问题。

    解决方法用:BFS算法(无权图)、Dijkstra算法(带权图、无权图)解决。

    第二个问题是:

    各个城市之间也需要互相往来,相互之间怎么走距离最近?这类问题归类为:每对顶点间的最短路径。

    解决方法是:Floyd算法(带权图、无权图)。

    BFS算法解决最短路径

    有以下无权的无向图:

    BFS解决最短路径问题

    注:无权图可以视为一种特殊的带权图,只是每条边的权值都为1.

    假设从顶点2出发BFS算法遍历过程参考图的BFS实现。通过BFS遍历便可以得到2顶点到各个顶点的距离。

    修改BFS算法的visit(w)方法:

    第12行和12行修改后的代码。其中d[]数组是存放顶点w到遍历初始顶点u之间的长度;path[]数组作用存放当前遍历顶点的前一个顶点位置。两个数组初始化如下:

    BFS解决最短路径问题1

    执行步骤如下:

    1. 假设从顶点u=2开始遍历,会先将顶点2path对应的值设置为0,接着visited设置为true,并将2号顶点入队。

    2. 遍历2顶点的邻接点为16,访问这两个顶点时,由于d[u]=0,所以这两个顶点的d[w]=d[u]+1=1,其前一个顶点2,故path[w]=u

    3. 同样操作同上,执行完所有遍历操作后得到的d[]path[]数组如下:

      BFS解决最短路径问题2

    求得d[]path[]数组后使用:假设要找2顶点到8号顶点的路径:

    28的最短路径长度=d[8]=3 通过path数组可知,28的最短路径为: 8762

    同时通过BFS得到的广度优先生成树,其树每个结点在第几层也直接反应了,其到初始顶点的距离。

    BFS解决最短路径问题3

    BFS缺点:只能用于不带权的图,或所有边的权值都相同的图。

    Dijkstra算法解决最短路径

    可以解决带权图单源问题(从一点到另外几个点的最短路径)。其实现方法和Prim算法十分相似。

    求下图从V0点到其他顶点的最短路径。

    dijkstra算法

    其实核心思路是:起始顶点初始化后,找剩余顶点中dist值最小的顶点ViVi将其final值设置为true,接着遍历其所有邻接点中final=false的顶点。之后判断从当前顶点Vi到各个邻接点权值是否小于邻接点原本的dist值,小于则替换该值,并将path值设置为Vi的位置即path[]=i

    初始化:从V0开始,初始化三个数组信息如下:

    1. 首先从V0开始,由于V0前面没有顶点,所以path[0]=-1V0邻边是V1V4,所以将初始化dist[1]=10dist[4]=5,前驱path[1]=0path[4]=0。而另外两个点V2V3没有与V0连接的边所以dist[2]dist[3]path[2]path[3]-1

      final[5]:标记各个顶点是否已经找到最短路径。

      V0V1V2V3V4
      truefalsefalsefalsefalse

      dist[5]:最短路径的长度。

      V0V1V2V3V4
      0105

      path[5]:路径上的前驱

      V0V1V2V3V4
      10110

    循环执行以下步骤:

    1. 1轮:循环遍历所有结点,找到还没确定最短路径,且dist最小的顶点Vi,令final[i]=ture。即:

      首先找到final数组不为true,并且dist数组值最小的点Vi=V4

    2. 找到V4后先将其final[4]=true,表明这个顶点已经找到最短路径。其最短路径是dist[4]=5,其直接前驱是path[4]=0V0​。检查所有邻接自Vi的顶点,若其final值为false,则更新distpath信息。即:

      检查和V4相连的顶点V1V2V3这几个顶点中finalfalse的点从V4过来有没有可能比之前的dist值更小。V1原本dist[1]=10,但从V4dist[4]=5到到V1的值为8小于当前dist[1]=10,所以修改V1dist[1]=8path[1]=4。同样V2V3V4点经过路径长度为147,小于之前的,所以dist[2]=14dist[3]=7path[2]=4path[3]=4。处理完后数组信息如下:

      final[5]:标记各个顶点是否已经找到最短路径。

      V0V1V2V3V4
      truefalsefalsefalsetrue

      dist[5]:最短路径的长度。

      V0V1V2V3V4
      081475

      path[5]:路径上的前驱

      V0V1V2V3V4
      14440
    3. 第二轮循环同上。剩下final数组不为true,并且dist数组值最小的点Vi=V3;先将其final[4]=true,表明这个顶点已经找到最短路径。其最短路径是dist[3]=7,其直接前驱是path[3]=4V4。接着与V3邻接点是V0V2,但V0final值为true,所以跳过直接修改V2,若从V3点到V2,所需的权值为13,小于dsit[2]=14,所以将V2​的dist[2]=13path[2]=3。处理完后数组信息如下:

      final[5]:标记各个顶点是否已经找到最短路径。

      V0V1V2V3V4
      truefalsefalsetruetrue

      dist[5]:最短路径的长度。

      V0V1V2V3V4
      081375

      path[5]:路径上的前驱

      V0V1V2V3V4
      14340
    4. 第三轮,找剩下final=flase,且dist最小的顶点,为V1。将其fianl值设置为true,并遍历其邻接点中finalfalse的邻接顶点,即V2,从V1V2的权值为8+1=9,小于V2dist[2]=13,故修改V2​的dist[2]=9path[2]=1。处理完后数组信息如下:

      final[5]:标记各个顶点是否已经找到最短路径。

      V0V1V2V3V4
      truetruefalsetruetrue

      dist[5]:最短路径的长度。

      V0V1V2V3V4
      08975

      path[5]:路径上的前驱

      V0V1V2V3V4
      14140
    5. 最后一轮处理只剩V2​,将其final值设置为true即可。处理完后数组信息如下:

      final[5]:标记各个顶点是否已经找到最短路径。

      V0V1V2V3V4
      truetruetruetruetrue

      dist[5]:最短路径的长度。

      V0V1V2V3V4
      08975

      path[5]:路径上的前驱

      V0V1V2V3V4
      14140

    得到上面的数组使用方法如下:

    V0V2的最短(带权)路径长度为:dist[2]=9,通过path[]数组可知,路径为:V0V4V1V2

    代码实现思路如下:

    若从V0开始,令final[0]=turedist[0]=0path[0]=-1。其余顶点final[k]=false;从起始顶点V0k顶点的长度,如果不存在连接的边,则设置为或者为其它值:dist[k]=arcs[0][k]。没有和V0相连的顶点设置为1 path[k]=(arcs[0][k]==)? -1:0

    n1轮处理:循环比例所有顶点,找到还没有确定最短路径,且dist值最小的顶点Vi,令final[i]=ture。并检查所有邻接自Vi的顶点,对于邻接自Vi的顶点Vj,若final[j]=falsedist[i]+arcs[i][j]<dist[j],则令dist[j]=dist[i]+arcs[i][j]path[j]=i

    注意:arcs[i][j]表示ViVj的弧的权值。

    时间复杂度:循环遍历所有顶点,找到还没有确定最短路径,且dist最小的值,需要O(n)。而总共需要n1轮处理,所以整个时间复杂度为:O(n2)也即O(|V|2)

    如果带权图中有带负权值的图:

    dijkstra算法2

    用Dijkstra算法得到的数组如下:

    final[3]:标记各个顶点是否已经找到最短路径。

    V0V1V2
    truetruetrue

    dist[3]:最短路径的长度。

    V0V1V2
    0107

    path[3]:路径上的前驱

    V0V1V2
    100

    V0V2最短路径为dist[2]=7,但实际上如果从V0V1V2,其路径长度为10-(-5)=5。故Dijkstra算法不适用于有负权值的带权图

    Floyd算法解决最短路径

    可以求出每一对顶点之间的最短路径。其核心思想是使用动态规划思想,将问题的求解分为多个阶段。如:

    对于n个顶点的图G,求任意一对顶点ViVj之间的最短路径可分为如下几个阶段:

    1. 初始:不允许在其他顶点中转,最短路径是?
    2. 若允许在V0中转,最短路径是?
    3. 若允许在V0V1 中转,最短路径是?
    4. 若允许在V0V1V2中转,最短路径是?
    5. 若允许在V0V1V2Vn1中转,最短路径是?

    其执行代码如下:

    求以下带权图每对顶点之间的最短路径:

    floyd算法求最短路径

    floyd算法求最短路径2

    1. 若以V0为中转点,即k=0,此时没有一个点指向V0所以没有一个点的路径需要更新。

    2. 若允许在V0,V1处中转,以V1为中转点,即k=1A[2][3]>A[2][1]+A[1][3],即顶点V2V1,再从V1V3的路径比V2V3路径更短。此时修改A[2][3]=2path[2][3]=1

      同时A[2][4]>A[2][1]+A[1][4],即顶点V2V1,再从V1V4的路径比V2V4路径更短。此时修改A[2][4]=6path[2][4]=1修改后的数组如下:

      floyd算法求最短路径3

    3. 若允许在V0,V1,V2处中转,且以V2为中转点,即k=2A[0][1]>A[0][2]+A[2][1],此时修改A[0][1]=2path[0][1]=2

      同时,A[0][3]>A[0][2]+A[2][3],此时修改A[0][3]=3path[0][3]=2。注意此时V2V3之间是没有路径的,但由于之前将V2V3值最短路径做了修改,即V2V1V3。所以这里默认在这个基础上V2V3的是有路径的。

      接着A[0][4]>A[0][2]+A[2][4],此时修改A[0][4]=7path[0][4]=2

      floyd算法求最短路径4

    4. 若允许在V0,V1,V2,V3处中转,且以V3为中转点,即k=3A[0][4]>A[0][3]+A[3][4],此时修改A[0][4]=4path[0][4]=3

      同时,A[1][4]>A[1][3]+A[3][4],此时修改A[1][4]=2path[1][4]=3

      接着A[2][4]>A[2][3]+A[3][4],此时修改A[2][4]=3path[2][4]=3

      floyd算法求最短路径5

    5. 当以V4为中转点时,没有要修改的值。

    使用上面两个数组方法:找V0V4两个顶点的最短路径:A[0][4]=4path数组找路径如下:

    这个过程可以通过遍历path[]数组递归实现,代码实现如下:

    Floyd算法的事件复杂度是O(n3),所以一般情况下三阶矩阵情况多。并且Floyd算法可以解决带负权值的带权图。但Floyd算法不能解决带有负权回路的图。因为这种图可能没有最短路径。如下:

    floyd算法求最短路径6

    从图上可知有两条边的权值为7<-9,这样的图又形成了回路,走的越多权值越小。所以没有最短路径。

    6.3 总结

    最短路径问题总结

    7. 有向无环图(DAG图)

    有向无环图:若一个有向图中不存在环, 则称为有向无环图,简称DAG图(Directed Acyclic Graph)

    有向无环图

    7.1 DAG应用——有向无环图表达式

    有向无环图表达式:有表达式((a+b)(b(c+d))+(c+d)e)((c+d)e).其树形存储结构如下:

    有向无环图1

    这些树结点存在有重复的部分,我们可以将其合并:

    有向无环图2

    合并后的图是一个有向无环图。

    具体步骤如下:

    1. 把各个操作数不重复地排成一排
    2. 标出各个运算符的生效顺序(先后顺序有点出入无所谓)
    3. 按顺序加入运算符,注意分层
    4. 自底向上逐层检查同层的运算符是否可以合体

    有表达式(符号运算优先级已经标记,可不唯一):

    有向无环图表达式

    如果运算顺序合理更改后,得到的DAG结点数同样和上面结点数一样都是12

    7.2 DAG应用——拓扑排序

    AOV网(Activity On Vertex NetWork,用顶点表示活动的网):用DAG图(有向无环图)表示一个工程。顶点表示活动,有向边<Vi,Vj>表示活动Vi必须先于活动Vj进行。注意AOV网不能有环路。

    AOV网

    这是一个表示"番茄炒蛋"工程的AOV网。上面切番茄有个入边,表示其执行这个步骤前要先执行入边对应的顶点,即切番茄前要洗番茄

    拓扑排序本质是:找到做事的先后顺序。拓扑排序定义:在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:①每个顶点出现且只出现一次。②若顶点A在序列中排在顶点B的前面,则在图中不存在从顶点B到顶点A的路径。

    上面项目可以先准备厨具也可以先买菜

    拓扑排序

    接着买菜

    拓扑排序1

    此时洗番茄或者打鸡蛋,这里选洗番茄

    拓扑排序2

    之后切番茄也可以打鸡蛋

    拓扑排序3

    之后的顺序就是:打鸡蛋下锅炒

    所以这个工程的拓扑排序是:准备厨具、买菜、洗番茄、切番茄、打鸡蛋、下锅炒、吃

    拓扑排序代码实现

    通过上面这个例子可以归纳处拓扑排序实现方式:

    ①从AOV网中选择一个没有前驱(入度为0)的顶点并输出。 ②从网中删除该顶点和所有以它为起点的有向边。 ③重复①和②直到当前的AOV网为空或当前网中不存在无前驱的顶点为止。

    同时每个AOV网都有一个或多个拓扑排序序列。拓扑排序代码实现如下:

    上面代码执行方式如下:

    有一AOV网:

    拓扑排序4

    其邻接表存储结构如下:

    拓扑排序8

    初始化数组indegree[](保存当前顶点的入度信息)print[](记录拓扑序列)和栈/队列s(保存度为0的顶点)如下:

    拓扑排序5

    最后拓扑排序序列为:2,0,1,3,4

    若采用邻接表时间复杂度是:O(|V|+|E|)。但使用邻接矩阵时间复杂度是:O(|V|2)

    逆拓扑排序

    对一个AOV网,如果采用下列步骤进行排序,则称之为逆拓扑排序: ①从AOV网中选择一个没有后继(出度为0)的顶点并输出。 ②从网中删除该顶点和所有以它为终点的有向边。 ③重复①和②直到当前的AOV网为空。

    AOV网

    上面图的逆拓扑排序为:吃、下锅炒、切番茄、洗番茄、打鸡蛋、准备厨具、买菜。

    注意逆拓扑排序当删除一个顶点后,需要同时删除指向这个顶点的边。如果采用邻接表存储,找一个顶点指向的边较为复杂需要遍历整个邻接表,显然十分低效。而如果采用邻接矩阵当删除一个顶点后想要找到这个顶点对应的边,只需要遍历这一列即可。

    同样这里可以使用逆邻接表方式存储图,即邻接表保存的是入边信息,而逆邻接表存放的是出边的信息。逆邻接表存储如下:

    拓扑排序9

    如上面3顶点,其邻接表存放的入边顶点,即1顶点和2顶点同时指向了3顶点。这样也十分方便。

    DFS实现逆拓扑排序

    DFS实现逆拓扑排序:在顶点退栈前输出。

    实现方式如下:

    上面代码相对于正常DFS算法新加一行print(v);。执行大致步骤如下:

    1. 先通过0号顶点,进入递归,一直递归到4号顶点,此时4号顶点没有邻接点,所以打印第一个顶点是4。此时栈如下:

      DFS实现逆拓扑排序

    2. 4号顶点打印完毕后,出栈,栈顶3号顶点唯一邻接点4已经访问过,所以打印出栈。

      对于栈中1号顶点和0号顶点同样依次出栈打印。

    3. 接着DFSTraverse()函数中for循环会对没有进行访问2号顶点调用DFS()函数,由于2号顶点所有邻接点均已访问,所以直接打印结束。

    所以用DFS逆拓扑排序序列为:4,3,1,0,2

    如果有回路情况下可以加一个形参flag来记录当前递归调用次数,如果次数大于顶点数vexnum则表示有环路存在,退出循环。也可以通过if(NextNeighor(G,v,W)&&visited[w]),即下一个邻接点存在,并且还被访问过,就代表图有回路存在。

    8. 关键路径

    AOE网:在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如完成活动所需的时间), 称之为用边表示活动的网络,简称AOE网(Activity On Edge NetWork)

    AOE网

    上面图是一个AOE网,上面图入边指向的顶点表示,前一个出边的顶点需要在入边顶点之前完成。也就是说上图V_1顶点开始之后,才能进行打鸡蛋洗番茄的操作。只有V3之前边上的事件和顶点都执行完毕,才能进行V3顶点的事件。所以AOE网有以下性质:

    1. 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;
    2. 只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生。另外,有些活动是可以并行进行的。
    3. 在AOE网中仅有一个入度为0的顶点,称为开始顶点(源点),它表示整个工程的开始;也仅有一个出度为0的顶点,称为结束顶点(汇点),它表示整个工程的结束。

    关键路径:从源点到汇点的有向路径可能有多条,所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动。完成整个工程的最短时间就是关键路径的长度,若关键活动不能按时完成,则整个工程的完成时间就会延长。

    上面图有两条路径:V1V3V4V1V2V3V4

    显然第二个路径V1V2V3V4路径长度最大,所以是关键路径。

    求关键求法:

    求关键路径步骤:

    求关键路径步骤5

    1. 求所有事件的最早发生时间ve()
    2. 求所有事件的最迟发生时间vl()
    3. 求所有活动的最早发生时间e()
    4. 求所有活动的最迟发生时间l()
    5. 求所有活动的时间余量d()d(i)=0的活动就是关键活动,由关键活动可得关键路径。

    所有事件的最早发生时间ve()和所有活动的最早发生时间等价e()。所有事件的最迟发生时间vl()需要从最后一个顶点逆向推导每个事件的最迟发生时间vl()。在有了所有事件的最迟发生时间vl()后可以推出所有活动的最迟发生时间l()。最后由所有活动的最早发生时间e()和所有活动的最迟发生时间l()可以算出所有活动的时间余量d()

    算法实现步骤:

    求关键路径步骤5

    求上图的关键路径:

    关键活动、关键路径特性:

    1. 若关键活动耗时增加,则整个工程的工期将增长
    2. 缩短关键活动的时间,可以缩短整个工程的工期
    3. 当缩短到一定程度时,关键活动可能会变成非关键活动
    4. 可能有多条关键路径,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的。

    十. 查找

    查找:在数据集合中寻找满足某种条件的数据元素的过程称为查找

    查找表(查找结构):用于查找的数据集合称为查找表,它由同一类型的数据元素(或记录)组成

    关键字:数据元素中唯一标识该元素的某个数据项的值,使用基于关键字的查找,查找结果应该是唯一的。

    例子:

    查找概念

     

    查找表:学生成绩信息(线性结构、可顺序可链式存储) 数据元素(记录):每个学生的信息 关键字:学号

    查找表的常见操作:①查找符合条件的数据元素。②插入、删除某个数据元素

    如果只进行①操作就是静态查找表,那仅仅关注查找速度即可。

    静态查找表

    如果查找同时也要进行②的操作,就是动态查找表。除了查找速度,也要关注插/删操作是否方便实现。

    动态查找表

    1. 查找算法评价指标

    查找长度:在查找运算中, 需要对比关键字的次数称为查找长度

    平均查找长度(TASL , Average Search Length):所有查找过程中进行关键字的比较次数的平均值。ASL计算方法如下:

    查找算法平均查找长度

    通常认为查找任何元素概率相同。评价一个查找算法的效率时,通常考虑查找成功/查找失败两种情况的ASL。

    2. 顺序查找

    顺序查找, 又叫线性查找,通常用于线性表。

    算法思想:从头查到尾。

    2.1 代码实现

    实现代码1

    查找成功情况:

    顺序查找实现

    查找失败情况:

    顺序查找失败

    实现代码2

    查找成功:

    顺序查找成功2

    查找失败:

    顺序查找失败2

    这种算法优点是:无需判定是否越界,效率更高。

    2.2 查找效率分析

    以实现代码2为例:

    假设查找的概率都相等即pi=1n,则查找每一个成功概率是:1·1n+2·1n+,则

    ASL=1+2+3++nn=n+12

    查找失败情况是每个元素都要对比,对比n+1

    ASL=n+1

    2.3 顺序查找的优化(有序表)

    优化一:

    如果查找表中的元素有序存放(递增/递减)

    查找的优化

    那么我们只用判断查找的元素在哪个区间内即可。加入要查找21元素,当我们遍历到29号元素时,21<29所以后面的就没必要查找。

    查找的优化2

    此时共有n+1种情况,所以查找每个元素失败概率变为pi=1n+1,则

    ASL=1+2+3++n+nn+1=n2+nn+1

    优化二:

    如果每个元素被查概率不相等情况下,可以将被查概率大的放在靠前位置。

    被查概率

    被查元素:

    被查元素

    此时

    ASL=10.4+20.28+30.15+40.1+50.05+60.02=2.18

    但对于查找失败情况仍和之前一样。

    3. 二分查找(折半查找)

    二分查找:仅适用于有序的顺序表。这是因为顺序表具有随机访问的特性,而链表没有。

    3.1 查找步骤及实现

    如下顺序表查找:

    二分查找

    给定数组元素是{7,10,13,16,19,29,32,33,37,41,43},查找元素是33

    查找步骤是:

    1. 先用lowhigh两个指针分别指向数组的头部和尾部。而第一轮要检查元素是lowhigh中间的元素,用指针mid=(low+high)/2指向。即指向5位置上的29元素。而要查找的33元素大于29所以只有可能在mid指针右边。

      二分查找过程

    2. 所以将值改为low=mid+1,即32元素所在的位置。同样mid指针指向mid=(low+high)/2的位置,即位置8所在的37元素。

      而元素33小于37,所以只可能在mid指针的左边。

      二分查找过程2

    3. 将值改为high=mid+1,此时mid=[(low+high)/2]=6,所以mid指向6位置的32元素。

      二分查找过程3

    4. 33>32,所以只可能在mid指针的右边。即low=mid+1=7,同样mid=(low+high)/2=7,而位置7所在元素是33满足查找条件,查找成功。

      二分查找过程4

    看一个查找失败步骤:

    给定数组元素是{7,10,13,16,19,29,32,33,37,41,43},查找元素是12

    前面查找过程一致,最后一步mid指向位置1所在10元素。而此时查找元素12仍然大于10,所以查找元素会在mid指针的右边。此时仍然会执行low=mid+1这个操作,之后low>high本来在左边指针跑到右边,所以查找失败,即在数组种没有查找到12这个元素。

    二分查找失败过程1

    代码实现如下:

    3.2 查找的效率分析

    上面例子中的表:

    查找效率分析

    刚开始mid指针指向5所在位置29元素。此时有三种可能性:要么查找元素大于,要么小于,要么等于。如果小于就在mid左边查找,如果大于就在mid右边查找。

    查找效率分析1

    如果查找的元素小于29,那么此时mid指向13,如果大于29mid指向37元素。如果仍然不是这两个元素中的任意一个,则继续上面操作。第三轮查找情况如下:

    查找效率分析2

    如果仍没有找到元素,则进入第四轮循环:

    查找效率分析3

    最后一轮如果查找失败,只需要补上失败区间即可:

    查找效率分析4

    上面元素如果在紫色方框内,则表示查找失败。

    对于这个例子,ASL查找成功平均效率:有11个元素所以每个元素查找到的概率是pi=111,且第一层有一个元素且是第一次查找11,第二层有两个元素且是第二次查找22,第三层有四个元素且是第三次查找34,第四层有四个元素且是第四次查找44。故

    ASL=(11+22+34+44)/11=3

    而ASL查找失败平均效率:有12个查找失败的元素所以概率pi=112。第三层失败元素有四个,且要查找三次。第四次失败元素有八个,且要查找四次。故

    ASL=(34+48)/12=11/3

    3.3 二分查找判定树构造

    二分查找构造判定树

    如果当前lowhigh之间有奇数个元素,则mid分隔后,左右两部分元素个数相等。

    如果当前lowhigh之间有偶数个元素,则mid分隔后(这里是向下取整),左半部分比右半部分少一个元素。

    二分查找构造判定树偶数元素

    上图分割后构造树如下:

    二分查找偶数元素判定树

    二分查找构造树特点:

    二分查找对比顺序查找:

    二分查找判定树树高h=[log2(n+1)],查找成功的ASLh,查找失败的ASLh,故二分查找的时间复杂度为O(log2n)。而顺序查找时间复杂度为O(n)。显然二分查找可能优于顺序查找,但不一定完全优于顺序查找。如下:

    二分查找对比顺序查找

     

    使用顺序查找第一次就可以找到元素7,使用二分查找显然次数更多。

    4. 分块查找

    有以下数组:

    分块查找

    数组中各个元素可以根据元素位置不同分成一个个小区间。如下:

    分块查找数组划分区间

    划分不同区间后,数组变得有序,可以建立上级索引表。索引表当中保存每个分块的最大关键字和分块的存储区间。

    分块查找建立上级索引

    不难看出分块查找特点是:块内无序,块间有序。其中索引表元素是区间数组块上的最大值。

    4.1 分块查找实现

    其算法过程如下:

    1. 在索引表中确定待查记录所属的分块(可顺序、可折半)
    2. 在块区间内顺序查找

    假如查找元素是30,有两种方法:

    假如查找元素是19,二分查找步骤如下:

    折半查找进行分块查询

    1. 如上图第一次查找mid指向的是索引表中30元素,由于30>19,故查找元素可能在索引表左边,所以high=mid-1mid=(low+high)/2=0

      折半查找进行分块查询1

    2. 此时索引表mid指向的元素是10<19,所以low=mid+1mid=(low+high)/2=1

      折半查找进行分块查询2

    3. 索引表mid指向的元素是20>19,故查找元素可能在索引表左边,所以high=mid-1=0,此时low>high,所以二分查找结束。我们在low指向的分块中查找元素,当前low指向20元素区间是2,5,故在数组中找下标25之间的元素是否包含19

      折半查找进行分块查询3

      注意:若索引表中不包含目标关键字(要查找的元素),则折半查找索引表最终停在low>high的位置,此时要在low所指分块中查找。

      这样做原因在于,索引表保存的是区间块上的最大值,而我们要找的元素要比索引元素小才有可能在数组区间中找到。即要保证找第一个比查找元素大的索引元素

    再看一个二分查找失败例子,假如查找元素是54,二分查找最后一步如下:

    折半查找进行分块查询4

    查找元素54大于表中所有元素,所以二分查找low指针一直往右移,当最后一次移动时low超出索引表范围,所以查找失败。

    4.2 分块查找效率分析

    分块查找效率分析

    共有14个元素,各自被查概率为114

    若索引表采用顺序查找,则查元素7需要2次、查元素10需要3次、查元素13需要3

    若索引表采用折半查找,则查元素30需要4次、而查元素27需要次数并不是2次,因为我们要找的是第一个比查找元素27大的索引元素,虽然索引表第一个被查元素30>27,但有可能索引表之前的元素仍有比27大的元素,这样30就不是第一个比查找元素大的元素了。正确查找步骤是:

    1. low指向元素10high指向元素50mid指向30,由于30>27high=mid-1指向20
    2. 重新计算mid=(low+high)/2=0,指向元素10,而10<27,故low=mid+1指向20
    3. mid=(low+high)/2=1指向20,此时20<27,故low=mid+1指向30,由于low>high,所以二分查找结束,查找区间为low所在的数组区间。low指向30,且区间为[6,8]
    4. 根据区间[6,8]查找数组对应下标区间,发下第一个元素即27查找成功。

    所以查元素27需要次数是4次。

    而计算查找成功ASL值,我们需要把所有元素查找次数计算出来,并相加,再除元素总个数。对于顺序表分块查找相对来说可以计算,但对于二分查找显然不可能计算这么多元素的查找次数。所以考试只考顺序查找次数进行ASL计算。

    而计算查找失败的ASL值,由于分块查询具有块内无序的特点,所以查找失败情况更加复杂。故不做讨论。

    以上是分块不均匀情况,考试常考分块均匀情况下计算分块查找效率:

    分块均匀情况计算查找效率

    假设,长度为n的查找表被均匀地分为b块,每块s个元素。设索引查找和块内查找的平均查找长度分别为LILS, 则分块查找的平均查找长度为

    ASL=LI+LS

    5. 二叉排序树

    二叉排序树,又称叉查找树(BST Binary Search Tree)一棵二叉树或者是空二叉树,或者是具有如下性质的二叉树:

    1. 左子树上所有结点的关键字均小于根结点的关键字
    2. 右子树上所有结点的关键字均大于根结点的关键字
    3. 左子树和右子树又各是一棵二叉排序树

    即左子树结点值<根结点值<右子树结点值。根据这个特性进行中序遍历,可以得到一个递增的有序序列。如下:

    二叉排序树

    5.1 二叉排序树查找

    若树非空,目标值与根结点的值比较:

    若相等,则查找成功

    若小于根结点,则在左子树上查找,否则在右子树上查找。

    查找成功,返回结点指针,查找失败返回NULL

    例如要查找关键字为30得结点:

    二叉排序树查找

    查找步骤:

    查找实现代码如下:

    递归实现方法:

    采用非递归方式,最坏得空间复杂度O(1);而采用递归方式,最坏时间复杂度O(h)

    5.2 二叉排序树的插入

    若原二叉排序树为空,则直接插入结点;否则,若关键字k小于根结点值,则插入到左子树,若关键字k大于根结点值,则插入到右子树。

    二叉排序树查找

    代码实现如下:

    同样构造二叉排序树得的过程就是不断插入新结点的过程。构造代码如下:

    例1:按照序列str={50,66,60,26,21,30,70,68}建立BST

    结果如下:

    二叉排序树建立

    例2:按照序列str={50,26,21,30,66,60,70,68}建立BST

    结果同上:

    二叉排序树建立

    例3:按照序列str={26,21,30,50,60,66,68,70}建立BST

    结果如下:

    二叉排序树建立2

    由此可知不同的关键字序列可能得到同款二叉排序树。也可能得到不同款二叉排序树。

    5.3 二叉排序树的删除

    分三种情况:

    5.4 二叉排序树查找效率分析

    查找长度:在查找运算中,需要对比关键字的次数称为查找长度,反映了查找操作时间复杂度。

    二叉排序树查找效率分析

    对于上图查找成功的平均查找长度ASL(Average Search Length):ASL=(11+22+34+41)/8=2.625

    二叉排序树查找效率分析2

    对于上图查找成功的平均查找长度ASL(Average Search Length):ASL=(11+22+31+41+51+61+71)/8=3.75

    下面看查找失败平均查找长度:

    二叉排序树查找效率分析失败情况

    紫色方框区域为有可能查找失败区域,其中第三层失败结点有7个,第四层失败结点有2个。故ASL=(37+42)/9=3.22

    二叉排序树查找效率分析失败情况2

    上图查找失败平均查找长度:ASL=(23+3+4+5+6+72)/9=4.22

    虽然上面两个树结点都一样,但查找长度不同,所以二叉排序树查找效率很大程度上由这棵树的高度决定。

    对于二叉排序树最好情况:n个结点的二叉树最小高度为log2n+1。平均查找长度O(log2n)

    对于二叉排序树最坏情况:每个结点只有一个分支,树高h=n。平均查找长度O(n)

    所以在构建二叉排序树时,尽量保证树上任一结点的左子树和右子树的深度之差不超过1,即建立平衡二叉树。

    6. 平衡二叉树

    平衡二叉树( Balanced Binary Tree),简称平衡树(AVL树):树上任一结点的左子树和右子树的高度之差不超过1

    某结点平衡因子=左子树高右子树高

    平衡二叉树结点平衡因子

    上图根结点左子树高度为2,右子树高度为3,所以结点平衡因子=23=1。这个1就是这棵树根结点的平衡因子。

    平衡二叉树结点的平衡因子的值只可能是101。如果有任一结点的平衡因子绝对值大于1就不是平衡二叉树。

    二叉树平衡树的结构如下:

    上图平衡二叉树在按照二叉排序树方法插入新结点后,会变得不平衡

    平衡二叉树插入新结点后不平衡

    让这个平衡二叉树恢复平衡的办法是从插入点往回找到第一个不平衡结点,调整以该结点为根的子树。则其他祖先节点都会恢复平衡。

    6.1 调整最小不平衡子树

    调整最小不平衡子树A,分四种情况:

    情况具体原因
    LLA的左孩子的左子树中插入导致不平衡
    RRA的右孩子的右子树中插入导致不平衡
    LRA的左孩子的右子树插入导致不平衡
    RLA的右孩子的左子树中插入导致不平衡

    调整最小不平衡子树(LL)

    当根结点左子树的左孩子结点(LL)上插入一个新结点导致A为根结点的树不平衡情况。

    调整最小不平衡子树

    上图方框的结点抽象表示为子结点,其高度为H。目标是要恢复平衡并保持二叉排序树特性。

    根据前面的章节可知二叉排序树特性:左子树结点值<根结点值<右子树结点值。故BL<B<BR<A<AR。根据这一特性恢复平衡的具体做法是:由于在结点A的左孩子(L)的左子树(L)上插入了 新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要一次向右的旋转操作。

    1. 即将A的左孩子B向右上旋转代替A成为根结点。
    2. A结点向右下旋转成为B的右子树的根结点
    3. B的原右子树则作为A结点的左子树。

    调整最小不平衡子树结果

    代码实现核心:

    gA是A结点的父结点,A代表A结点,B代表B结点。

    RR代码结构

    调整最小不平衡子树(RR)

    当根结点右子树的右孩子结点(RR)上插入一个新结点导致A为根结点的树不平衡情况。

    调整最小不平衡子树RR

    同样,二叉排序树特性:左子树结点值<根结点值<右子树结点值。可知AL<A<BL<B<BR。根据这一特性恢复平衡的具体做法是:由于在结点A的右孩子(R)的右子树(R), 上插入了新结点,A的平衡因子由1减至2,导致以A为根的子树失去平衡,需要一次向左的旋转操作。

    1. A的右孩子B向左上旋转代替A成为根结点
    2. A结点向左下旋转成为B的左子树的根结点
    3. B的原左子树则作为A结点的右子树

    调整最小不平衡子树RR结果

    代码实现核心:

    gA是A结点的父结点,A代表A结点,B代表B结点。

    RR代码结构

    调整最小不平衡子树(LR)

    当根结点左子树的右孩子结点(LR)上插入一个新结点导致A为根结点的树不平衡情况。

    调整最小不平衡子树LR

    为了便于理解,这里将BR结点展开。假设BR结点是一个以C为根的树,其左子树是CL高度为H-1,右子树是CR高度为H-1

    调整最小不平衡子树LR分解

    这里将新插入的结点放在CR结点,也可以放在CL结点。那么恢复平衡的具体做法是先左后右双旋转

    由于在A的左孩子(L)的右子树(R)上插入新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要进行两次旋转操作先左旋转后右旋转:

    1. 先将A结点的左孩子B的右子树的根结点C向左上旋转提升到B结点的位置
    2. 然后再把该C结点向右上旋转提升到A结点的位置

    调整最小不平衡子树LR结果

    旋转之后亦然满足排序树特性:BL<B<CL<C<CR<A<AR

    调整最小不平衡子树(RL)

    当根结点右子树的左孩子结点(RL)上插入一个新结点导致A为根结点的树不平衡情况。

    调整最小不平衡子树RL

    同样,为了便于理解,这里将BR结点展开。假设BL结点是一个以C为根的树,其左子树是CL高度为H-1,右子树是CR高度为H-1。将新插入的结点放在CL结点,也可以放在CR结点。

    调整最小不平衡子树RL分解

    恢复平衡的具体做法是先右后左双旋转:由于在A的右孩子(R)的左子树(L)上插入新结点,A的平衡因子由1减至2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先右旋转后左旋转。

    1. 先将A结点的右孩子B的左子树的根结点C向右上旋转提升到B结点的位置
    2. 然后再把该C结点向左上旋转提升到A结点的位置

    调整最小不平衡子树RL结果

    旋转之后仍满足排序树特性:AL<A<CL<C<CR<B<BR

    平衡二叉树查找效率分析

    若树高为h,则最坏情况下,查找一个关键字最多需要对比h次,即查找操作的时间复杂度不可能超过O(h)。所以查找效率分析实际就是分析一颗平衡二叉树高度有多高。

    基于平衡二叉树特性:树上任一一结点的左子树和右子树的高度之差不超过1。可以假设以nh表示高度为h的平衡树中含有的最少结点数,则高度为h的平衡二叉树最少结点数为:

    nh=nh1+nh2+1

    即根结点+左子树高度(nh1)+右子树高度(nh2),注意保证左右子树结点最少。所以根据这个公式可以推出常见高度为h平衡二叉树最少结点数:

    n0=0,n1=1,n2=2n3=n2+n1+1=4n4=n3+n2+1=7n5=n4+n3+1=12

    故某平衡二叉树最少结点n=9,那么它的最大高度hmax=4(向下取高度)。即这样一颗平衡二叉树查找一个关键字最多需要对比4次。

    可以证明含有n个结点的平衡二叉树的最大深度为O(log2n)平衡二叉树平均查找长度为O(log2n)

    总结

    只有左孩子插入结点才进行右旋操作,只有右孩子插入结点才进行右旋操作。

    调整最小不平衡子树总结

    看个例子,有以下二叉排序树,由于再添加结点67变为非平衡二叉树

    调整最小不平衡子树例子

    恢复平衡的办法是从插入点往回找到第一个不平衡结点,即70。调整以该结点为根的子树:

    1. 首先观察这是一个LL类型的最小不平衡子树
    2. 采用右旋操作,将68结点成为根结点,70结点成为68结点的右子树。

    结果如下:

    调整最小不平衡子树例子结果

    插入操作导致最小不平衡子树的高度+1,经过调整后高度恢复,则祖先平衡因子也会恢复正常。

    2:有以下二叉排序树

    调整最小不平衡子树例2

    插入结点57

    调整最小不平衡子树例2插入

    此时平衡二叉树变为非平衡二叉树,其第一个不平衡结点为66。不平衡原因是在其左孩子的右子树中插入新结点,所以是LR类型。

    解决方法是:让66结点的左孩子的右孩子60结点先左旋,再右旋。熟练方法是左旋右旋之后60会成为根结点,而5066为根结点的左孩子和右孩子。之后让60原本的左子树(5557)与右子树(63),按照二叉排序树规则插入即可。

    调整最小不平衡子树例2结果

    6.2 平衡二叉树的删除

    同调整最小不平衡子树类似,平衡二叉树的删除操作在删除结点后,要保持二叉排序树的特性不变(左<<右)。若删除结点后导致结点不平衡,则需要调整平衡。

    平衡二叉树删除操作具体步骤:

    1. 删除结点(方法同二叉排序树)

      • 若删除的结点是叶子,直接删。
      • 若删除的结点只有一个子树,用子树顶替删除位置
      • 若删除的结点有两棵子树,用前驱(或后继)结点顶替,并转换为对前驱(或后继)结点的删除。
    2. 若删除后出现不平衡现象,则从被删除结点开始往上找到最小不平衡子树,找不到就完结撒花

    3. 如果找到最小不平衡子树,高度最高的儿子结点和孙子结点

    4. 根据孙子的位置,调整平衡(平衡调整四种情况:LL/RR/LR/RL)

      • 孙子结点在LL:儿子结点右单旋
      • 孙子结点在RR:儿子结点左单旋
      • 孙子结点在LR:孙子结点先左旋,再右旋
      • 孙子结点在RL:孙子结点先右旋,再左旋
    5. 如果调整完后,不平衡特性向上传导,则继续②

      对最小不平衡子树的旋转可能导致树变矮,从而导致上层祖先不平衡(不平衡向上传递)

    删除下面平衡二叉树的32结点:

    平衡二叉树删除结点

    整个平衡二叉树如下:

    平衡二叉树删除结7

    通过上图可以观察出,由于右侧树做了恢复调整,由于树高变矮,所以不平衡性向上传到。处理方法是从第二步开始:

    整棵树恢复平衡。

    平衡二叉树的删除操作时间复杂度是O(log2n)

    7. 红黑树

    红黑树和平衡二叉树在增查删三个基本操作上的时间复杂度一样,都是O(log2n)

    由于平衡二叉树的插入和删除两个操作很容易破坏平衡性,需要频繁调整树的形态。而红黑树在插入或删除时不会破坏红黑特性,无需频繁调整树的形态,即便需要调整一般都在常数级时间内完成。

    使用场景:

    红黑树是二叉排序树:左子树结点值根结点值右子树结点值。

    红黑树原则/特点:

    1. 每个结点或是红色,或是黑色
    2. 根节点是黑色
    3. 叶结点(外部结点、NULL结点、失败结点)均是黑色的
    4. 不存在两个相邻的红结点(即红结点的父节点和孩子结点均是黑色)
    5. 对每个结点,从该节点到任一叶子结点的简单路径上,所含黑结点的数目相同

    红黑树

    红黑树结构定义:

    黑结点的高度:从某一结点出发(不包含该结点)到达任意空叶结点的路径上黑结点总数。由于红黑树特点我们只用看一条路径就可以知道该结点的黑结点高度。

    若根节点黑高为h,内部结点数(关键字)最少有2h1个.

    红黑树黑高

    根据红黑树特点可以得到以下性质:

    1. 由上面4,5特点可以推出:从根节点到叶结点的最长路径不大于最短路径的2倍。即红黑树左右高度之差不会超过2倍。

    2. n个内部节点的红黑树高度h2log2(n+1)

      证明:若红黑树总高度=h,则根结点黑高h2,因此内部结点数n2h21,由此推出h2log2(n+1)

    红黑树的查找与BST、AVL 相同,从根出发,左小右大,若查找到一个空叶节点,则查找失败。

    7.1 红黑树的插入

    红黑树的插入和平衡二叉树有很多相似地方。

    插入步骤:

    如何调整:

    注意:新结点在插入的时候如果需要调整一般都是违背了"不存在两个相邻的红结点"这一原则。

    例子:从一棵空的红黑树开始,插入:20,10,5,30,40,57,3,2,4,35,25,18,22,23,24,19,18

    1. 根结点必须是黑色,所以20结点插入:

      红黑树插入

      上面蓝色框是新插入结点,下面两个叶子结点是NULL结点。

    2. 为了保证某节点到叶子结点任意一条简单路径上黑色结点数量都相同,这一特性我们要将插入的非根结点都设置为红色

      插入10结点:

      红黑树插入1

    3. 接着插入5结点:

      红黑树插入2

      此时发现破环了,"不存在两个相邻的红结点"这一原则,需要进行调整,使其能重新满足红黑树定义。

      新结点5的父结点的兄弟结点是NULL,即黑色。并且新结点4相对于爷结点20LL型,调整方法是:父结点右单旋和爷结点交换,之后对两个结点颜色取反。

      先右单旋:

      红黑树插入3

      再对两个结点颜色取反:

      红黑树插入4

    4. 接着插入30

      红黑树插入5

      新结点插入后破环了红黑树定义,违反了"不存在两个相邻的红结点"这一原则。

      新结点30的父结点的兄弟结点是5,即红色。调整方法是:则父结点,父结点的兄弟节点、爷结点颜色取反+爷结点变为新结点,再判断新结点能不能满足红黑树定义。如果不能再接着重复上面调整方法。

      颜色取反:

      红黑树插入6

      爷结点变为新结点,再判断红黑树是否满足定义。

      红黑树插入7

      发现违背"根节点是黑色"这一原则。将新结点改为黑色即可。

      红黑树插入8

    5. 接着插入40结点

      红黑树插入9

      同样是违背了"不存在两个相邻的红结点"这一原则。

      新结点40的父结点的兄弟结点是NULL,即黑色。并且新结点40相对于爷结点20RR型,调整方法是:父结点左单旋和爷结点交换,之后对两个结点颜色取反

      左单旋:

      红黑树插入10

      颜色取反:

      红黑树插入11

    6. 插入结点57,同样违背了"不存在两个相邻的红结点"这一原则。

      红黑树插入12

      新结点57的父结点的兄弟结点是20,即红色。调整方法是:则父结点,父结点的兄弟节点、爷结点颜色取反+爷结点变为新结点,再判断新结点能不能满足红黑树定义。如果不能再接着重复上面调整方法。

      颜色取反:

      红黑树插入13

      此时,30是新插入的结点,再次判断红黑树发现满足定义。

    7. 接着插入3结点,满足红黑树定义,不需要调整。

      红黑树插入14

    8. 插入结点2,违背了"不存在两个相邻的红结点"这一原则。

      红黑树插入15

      新结点2的父结点的兄弟结点是NULL,即黑色。并且新结点2相对于爷结点5LL型,调整方法是:父结点右单旋和爷结点交换,之后对两个结点颜色取反。

      右单旋:

      红黑树插入16

      颜色取反:

      红黑树插入17

    9. 接着插入结点4,同样违背了"不存在两个相邻的红结点"这一原则。

      红黑树插入18

      新结点4的父结点的兄弟结点是2,即红色。调整方法是:则父结点,父结点的兄弟节点、爷结点颜色取反+爷结点变为新结点,再判断新结点能不能满足红黑树定义。如果不能再接着重复上面调整方法。

      颜色取反:

      红黑树插入19

      此时,3是新插入的结点,再次判断红黑树发现满足定义。

    10. 接着插入的结点352518都没有违背红黑树定义。

      红黑树插入20

    11. 插入结点22,违背了"不存在两个相邻的红结点"这一原则。

      红黑树插入21

      新结点22的父结点的兄弟结点是18,即红色。调整方法是:则父结点,父结点的兄弟节点、爷结点颜色取反+爷结点变为新结点,再判断新结点能不能满足红黑树定义。如果不能再接着重复上面调整方法。

      颜色取反:

      红黑树插入22

      20变为新结点,再次检查发现同样违背了"不存在两个相邻的红结点"这一原则。其父结点的兄弟结点是3,即红色。调整方法是:则父结点,父结点的兄弟节点、爷结点颜色取反+爷结点变为新结点,再判断新结点能不能满足红黑树定义。如果不能再接着重复上面调整方法。

      颜色取反:

      红黑树插入23

      新结点变为10,再次检查发现违背了"根节点是黑色"这一原则,将根结点10颜色取反即可

      红黑树插入24

    12. 插入结点23,发现违背了"不存在两个相邻的红结点"这一原则。

      红黑树插入25

      新结点23的父结点的兄弟结点是NULL,即黑色。并且新结点23相对于爷结点25LR型,调整方法是:新结点先左旋再右旋,儿子结点和爷结点交换,之后对两个结点颜色取反

      先左旋:

      红黑树插入26

      再右旋:

      红黑树插入27

      颜色取反:

      红黑树插入28

    13. 接着插入结点24,违背了"不存在两个相邻的红结点"这一原则。

      红黑树插入29

      新结点24的父结点的兄弟结点是22,即红色。调整方法是:则父结点,父结点的兄弟节点、爷结点颜色取反+爷结点变为新结点,再判断新结点能不能满足红黑树定义。如果不能再接着重复上面调整方法。

      颜色取反:

      红黑树插入30

      此时新结点变为23,再次检查发现同样违背了"不存在两个相邻的红结点"这一原则。其父结点的兄弟结点40,即黑色。并且新结点23相对于爷结点25LR型,调整方法是:新结点先左旋再右旋,儿子结点和爷结点交换,之后对两个结点颜色取反

      先左旋:

      红黑树插入31

      再右旋:

      红黑树插入32

      颜色取反:

      红黑树插入33

    14. 插入19结点

      红黑树插入34

    15. 最后结点18可以插入红黑树内18结点左子树或者右子树

      红黑树插入35

      新结点18的父结点的兄弟结点是NULL,即黑色。并且新结点18相对于爷结点18RL型,调整方法是:新结点先右旋再左旋,儿子结点和爷结点交换,之后对两个结点颜色取反

      先右旋:

      红黑树插入36

      再左旋:

      红黑树插入37

      颜色取反:

      红黑树插入38

    将所有元素插入红黑树完毕。

    7.3 红黑树的删除

    重要考点:

    ①红黑树删除操作的时间复杂度=O(log2n)

    ②在红黑树中删除结点的处理方式和"二叉排序树的删除"一样

    ③按②删除结点后,可能破坏"红黑树特性",此时需要调整结点颜色、位置,使其再次满足"红黑树特性"。

    8. B树

    由前面的二叉排序树可以知道,二叉树中每个结点都可以将查找范围缩小到两个区间内。

    二叉排序树查找效率分析失败情况

    而B树就是拥有更多分支结点的树。具体定义如下:

    B树,又称多路平衡查找树B树中所有结点的孩子个数的最大值称为B树的阶,通常用m表示。一棵mB树或为空树,或为满足如下特性的m叉树:

    1. m叉查找树中,规定除了根节点外,任何结点至少有m/2个分叉,并且至少含有m/21个关键字,至多含有m1个关键字。

      这样构造出来的多叉树就是B树。这样规定是因为若每个结点内关键字太少,导致树变高,要查更多层结点,导致效率低。

    2. m叉查找树中,规定对于任何一个结点,其所有子树的高度都要相同。

    3. 若根结点不是终端结点,则至少有两棵子树。

    4. 树中每个结点至多有m棵子树,即至多含有m1个关键字。

    5. 所有的叶结点都出现在同一层次上,并且不带信息(可以视为外部结点或类似于折半查找判定树的查找失败结点,实际上这些结点不存在,指向这些结点的指针为空)。

    B树定义

    假设有一颗5叉树,则其每个结点至少有5/2=3个分支,至少含有5/21=2个关键字。

    B树

    上面是一个5叉查找树。其代码定义结构如下:

    查找步骤如下:查找9元素

    1. 从根结点出发,根结点关键字22>9,所以只可能在根结点左子树。
    2. 左子树有511两个关键字,遍历所有关键字11>9>5,所以可能在关键字511中间指针指向子树。
    3. 最下一层子树关键字是6,8,9遍历关键字成功找到元素。

    注:如果一个结点包含关键字有多个,可以用折半查找。

    再查找41元素:

    1. 根结点22<41,所以只可能再根结点右子树
    2. 右子树结点内关键字有36,45,遍历36<41<45。故可能在关键字3645中间指针指向子树。
    3. 最下一层子树关键字40,42,而40<41<42,所以可能在两个关键字中间指针指向子树
    4. 而中间指针最后指向NULL,是失败结点,返回查找失败,未找到。

    mB树的核心特性

    1. 根节点的子树数[2,m],关键字数[1,m1]。其他结点的子树数[m/2,m];关键字数[m/21,m1]
    2. 对任一结点,其所有子树高度都相同
    3. 关键字的值:子树0<关键字1<子树1<关键字2<子树2< (类比二叉查找树左<<右)。即每个结点指针和关键字之间存储结构。

    B树的高度(这里计算B树的高度不计算叶子结点(失败结点)):

    n个关键字的mB树,最小高度:让每个结点尽可能的满,有m1个关键字, m个分叉,则有

    n(m1)(1+m+m2+m3++mh1)=mh1hlogm(n+1)

    n个关键字的mB树,最大高度:让各层的分叉尽可能的少,即根节点只有2个分叉,其他结点只有m2个分叉。 各层结点至少有:第一层有1个结点、第二层有2个结点、第三层2m2h2(m2)h2 h+1层共有叶子结点(失败结点)2(m2)h1个。 n个关键字的B树必有n+1n个关键字将数据域切分为n+1个区间)个叶子结点,则n+12(2m)h1,即hlog2mn+12+1

    还可以从另一个角度分析:

    n个关键字的mB树,最大高度:即让每个结点包含的关键字、分叉尽可能的少。记k=m2

    B树高度

    h层的mB树至少包含关键字总数1+2(k1)(k0+k1+k2++kh2)=1+2(kh11)

    若关键字总数少于这个值,则高度一定小于h,因此n1+2(kh11)化简可得:

    hlogkn+12+1=logm2n+12+1

    总结:n个关键字的mB树,最大高度和最小高度:

    logm(n+1)hlogm2n+12+1

    8.1 B树的插入

    插入核心要求:

    1. 结点关键字个数m21nm1。即2n4
    2. 每个结点符合有序的特性

    插入步骤:

    在插入key后,若导致原结点关键字数超过上限,则从中间位置(m2)将其中的关键字分为两部分,左部分包含的关键字放在原结点中,右部分包含的关键字放到新结点中,中间位置(m2)的结点插入原结点的父结点。若此时导致其父结点的关键字个数也超过了上限,则继续进行这种分裂操作,直至这个过程传到根结点为止,进而导致B树高度会增加1

    下面演示5B树的插入:

    首先插入25,38,49,60三个关键字,插入后如下:

    B树插入

    接着再插入80,由于5阶B树每个结点关键字个数最多为4,所以80关键字导致原结点关键字数超过上限,则从中间位置(m2)将其中的关键字分为两部分,左部分包含的关键字放在原结点中,右部分包含的关键字放到新结点中,中间位置(m2)的结点插入原结点的父结点。

    B树插入1

    接着新元素一定是插入到最底层"终端节点",用"查找"来确定插入位置。

    关键字9988插入操作相同

    B树插入4

    88关键字插入后导致结点关键字超出5阶B树上限,所以和上面一样,结点中间的关键字88提到上一层结点上。60,80为其左子树;90,99为其右子树

    B树插入5

    继续插入关键字83,70,这两个关键字不影响B树平衡所以直接插入

    B树插入6

    接着插入关键字70

    B树插入7

    继续插入92,93,94三个关键字

    B树插入9

    插入关键字73,74,75

    B树插入11

    8.2 B树的删除

    删除关键字操作分情况:

    9. B+

    b+树和分块查找很相似,每个结点内部分块,都保存对应指针指向结点的最大值。

    一颗mB+需要满足以下条件:

    1. 非叶根结点至少有两棵子树,其他每个分支结点至少m2棵子树。和B树一样可以理解为:要追求"绝对平衡",即所有子树高度要相同。

    2. 每个分支结点最多有m棵子树(孩子结点)。

    3. 所有分支结点中仅包含它的各个子结点中关键字的最大值及指向其子结点的指针。

    4. 结点的子树个数与关键字个数相等。这是与B树最多区别。如:一个结点有两个关键字,则B+树有两个分支,B树有三个分支。

    5. 所有叶结点包含全部关键字及指向相应记录的指针,叶结点中将关键字按大小顺序排列,并且相邻叶结点按大小顺序相互链接起来。

      B+树叶子结点

    如下是一颗4阶的B+树:

    B+树

    9.1 B+树的查找

    如下是一颗4阶的B+树:

    B+树

    查找关键字9

    1. 从根节点开始9<15,所以只可能在15关键字指向的结点3,9,15内出现。

      B+树叶子查找1

    2. 接着遍历这一层结点,发现有关键字9,但B+树没有遍历到叶子结点内记录的信息不算查找成功,所以接着查找9指向的结点6,8,9

    3. 这一层是叶子结点,从左往右依次遍历,找到关键字9,读取其对应指针内的记录即可。而如果是B树,到这里就查找成功,不用再找下一层的叶子结点。

      B+树叶子查找2

      B树的记录:

      B的记录

    B+树中,无论查找成功与否,最终一定都要走到最下面一层结点。

    除了上面介绍的多路查找,还可以进行顺序查找。因为叶子结点有一个指针P,该指针连接所有叶子结点,所以可以直接从P结点往后查找每个结点中的关键字,直到找到对应的关键字即可。

    9.2 B+树与B树区别

    mBmB+
    结点中的n个关键字对应n+1棵子树结点中的n个关键字对应n棵子树
    每个结点关键字个数不能低于m21,不能大于m1,但根结点个数可以只有一个每个结点关键字个数不能低于m2,不能大于m,但根结点个数可以只有一个
    在B树中,各结点中包含的关键字是不重复的叶结点包含全部关键字,非叶结点中出现过的关键字也会出现在叶结点中
    B树的结点中都包含了关键字对应的记录的存储地址叶结点包含信息,所有非叶结点仅起索引作用,非叶结点中的每个索引项只含有对应子树的最大关键字和指向该子树的指针,不含有该关键字对应记录的存储地址。

    B+树比B树查询效率更高:在B+树中,非叶结点不含有该关键字对应记录的存储地址。可以使一个磁盘块可以包含更多个关键字,使得B+树的阶更大,树高更矮,读磁盘次数更少,查找更快。

    B+树总结

    10. 哈希表

    散列表(Hash Table),又称哈希表。是一种数据结构,特点是:数据元素的关键字与其存储地址直接相关。

    例:有一堆数据元素,关键字分别为{19,14,23,1,68,20,84,27,55,11,10,79},散列函数为H(key)=key%13,这个函数会将关键字范围限定在012范围内。

    具体步骤是让每个关键字对13取余,所得的值就是数组下标索引值,将关键字放入索引对应的数组位置即可。但取余操作会有以下两种情况:

    1. 若不同的关键字通过散列函数映射到同一个值,则称它们为"同义词"
    2. 通过散列函数确定的位置已经存放了其他元素,则称这种情况为"冲突"

    解决哈希冲突,是哈希表的关键。

    10.1 拉链法解决哈希冲突

    用拉链法(又称链接法、链地址法)处理冲突:把所有"同义词"存储在一个链表中。这也是实际开发中常用的方法。

    拉链发解决哈希冲突

    如上面,散列函数为H(key)=key%13,关键字分别为{19,14,23,1,68,20,84,27,55,11,10,79}。其中关键字14113取余,所得的值都为1,出现哈希冲突,用拉链法就是将关键字通过链表的形式放在同一个索引位置。

    存储所有关键字后哈希表结构如下:

    拉链发解决哈希冲突1

    拉链法的查找方法是,首先基于散列函数计算出被查找关键字的哈希值。根据哈希值找到对应数组索引位置,如果存放的是链表则遍历看是否有被查找关键字。

    如,上面哈希表查找关键字27,首先对27%13=1,接着遍历数组索引1内存放的链表

    拉链发查找

    这里要引入查找长度概念:在查找运算中, 需要对比关键字的次数称为查找长度。上面查找27关键字的查找长度为3。如果查找计算的哈希值对应数组索引位置内记录的链表为NULL,则表示查找失败,且此时查找失败的查找长度为0

    如上哈希表的平均查找长度ASL=1×6+2×4+3×1+4×12=1.75。分子代表的是所有关键字需要查找的次数总和,分母表示关键字个数。1×6表示第一行有6个元素,2×4表示第二行有4个元素。

    哈希表查找失败的平均查找长度ASL=0+4+0+2+0+0+2+1+0+0+2+1+013=0.92。分子代表每个索引处查找失败的对比次数,也就是哈希表所有关键字的总数;分母表示数组总长度,每个索引位置可能需要查找的概率为113

    上面这个ASL就是装填因子α=表中记录数/散列表长度。装填因子值越大代表散列表装的关键字越多。

    通过上面对查找长度和平均查找长度的计算,可以知道哈希冲突会导致查询效率变低。且查找长度越大,代表冲突越大,查找效率越低。

    最理想情况:只要哈希函数设计的足够好,哈希查找的时间复杂度可达到O(1)。即查找长度都为1

    如果拉链法中链表元素采用顺序存储方式,可以提高查找效率。

    10.2 常见的哈希函数

    哈希函数的设计可以直接决定一个哈希表的查找效率,所以哈希函数设计很重要,下面介绍几种常见的哈希函数。

    除留余数法

    H(key)=key%p,散列表的长度为m,取一个不大于m但最接近或等于m的质数p

    例如:散列表表长13,则散列函数H(key)=key%13

    散列表表长15,则散列函数H(key)=key%13。因为不大于15但接近于15的质数是13

    这样设计的原因是:质数分布更均匀,冲突更少。参见《数论》。大致原因是和数的公因子多,所以冲突概率更高。

    直接定址法

    H(key)=keyH(key)=akey+b

    其中,ab是常数。这种方法计算最简单,且不会产生冲突。它适合关键字的分布基本连续的情况,若关键字分布不连续,空位较多,则会造成存储空间浪费。

    例如:存储同一个班级的学生信息,班内学生学号为(11201121761120112205),即有三十个学生,学号后三位从176开始。此时的哈希函数为:H(key)=key1120112176。每个学号都和第一个人的学号相减的值即为数组索引位置。存储如下:

    直接定址法

    数字分析法

    选取数码分布较为均匀的若干位作为散列地址。

    设关键字是r进制数(如十进制数),而r个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀一些,每种数码出现的机会均等,而在某些位上分布不均匀,只有某几种数码经常出现,此时可选取数码分布较为均匀的若干位作为散列地址。这种方法适合于已知的关键字集合,若更换了关键字,则需要重新构造新的散列函数。

    例如:以"手机号码"作为关键字设计散列函数

    数字分析法

    平方取中法

    取关键字的平方值的中间几位作为散列地址。

    具体取多少位要视实际情况而定。这种方法得到的散列地址与关键字的每位都有关系,因此使得散列地址分布比较均匀,适用于关键字的每位取值都不够均匀或均小于散列地址所需的位数。

    例如:要存储整个学校的学生信息,以“身份证号”作为关键字设计散列函数

    平方取中法

    尽管这里表的长度十万,但还是有冲突的可能性。要完全解决哈希冲突,方法是设计的表要足够长,但这样空间复杂度也会很高,所以散列查找是一个典型的"空间换时间的"算法。

    10.3 开放定址法解决哈希冲突

    所谓开放定址法,是指可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放。其数学递推公式为:

    Hi=(H(key)+di)%m

    其中i=0,1,2,k(km1)m表示散列表表长。di为增量序列。i可以理解为第i次发生冲突。

    关于增量序列di计算有三种:

    开放地址法删除关键字:删除关键字不能只是简单的删除而是标记删除

    线性探测法

    di=0,1,2,3,,m1;即发生冲突时,每次往后探测相邻的下一个单元是否为空。

    例:有一堆数据元素,关键字分别为{19,14,23,1,68,20,84,27,55,11,10,79},哈希函数是H(key)=key%13

    冲突处理函数为:Hi=(H(key)+di)%16

    查找方法和插入方法一样,例如查找27关键字:

    27%13=1,数组索引为1的位置关键字是14查找失败。接着H1=(1+1)%16=2,索引2位置是关键字1,查找失败。H2=(1+2)%16=3,索引3处的关键字是68,查找失败。H3=(1+3)%16=4,索引为4位置是关键字27,查找成功。故27的查找长度是4

    注意如果Hi结果索引值指向位置关键字是空的,此时查找失败,但也要算作一次比较次数。所以在查找失败情况下,越早遇到空位置就可以越早确定查找失败,查找效率会提高。

    删除关键字:删除关键字不能只是简单的删除还要标记删除

    例如:删除关键字1。查找关键字27

    开放定址法5

    计算H(27)=27%13=11位置发生冲突;计算H1=(1+1)%13=22的位置是空,所以按照上面条件:索引值指向位置关键字是空的,此时查找失败。但明显可以看到27这个关键字真是存在,故采用"开放定址法"时,删除结点不能简单地将被删结点的空间置为空,否则将截断在它之后填入散列表的同义词结点的查找路径,可以做一个"删除标记",进行逻辑删除。

    这种方法也有弊端:假设将表的前8个关键字删除,再找关键字79

    开放定址法6

    此时发现,虽然前8个关键字都删除了,但由于是逻辑删除,仍会进行对比9次才能找到79这个关键字。所以这个哈希表看起来很满,但实际上很空。很多关键字在逻辑上被删除了。

    查找成功平均查找长度:先算出每个关键字能被查找到的次数,再将关键字查找次数相加除关键字长度即可。

    开放定址法查找成功平均查找长度

    ASL=1+1+1+2+4+1+1+3+3+1+3+912=2.5

    查找失败平均查找长度:如果刚开始是在索引0的位置,那么查找第一次就会失败。如果是1位置,我们要将1后面所有关键字都对比才能确定失败,所以是13,其他位置以此类推。最后相加除哈希映射区间长度。这里哈希函数区间长度13。故

    ASL=1+13+12+11+10+9+8++213=7

    可以看出线性探测法查找效率很低,因为线性探测法很容易造成同义词、非同义词的"聚集(堆积)"现象,严重影响查找效率。出现这一现象根本原因是冲突后再探测一定是放在某个连续的位置。

    平方探测法

    平方探测法可以有效解决聚集的问题。

    di=02,12,12,22,22,,k2,k2时,称为平方探测法,又称二次探测法其中km2

    例:哈希函数是H(key)=key%13,采用平方探测法处理冲突。冲突处理函数为:Hi=(H(key)+di)%16

    平方探测法

    上面6个关键字都哈希函数值都是6,产生冲突,采用平方探测法解决:

    查找方法同插入方法,都是取余后根据冲突次数计算Hi

    注意:当采用开放定址法时,哈希表的长度必须是一个可以用4i+3表示的质数。只有满足这个条件才能探测到所有位置。

    伪随机序列法

    di是一个伪随机序列,如di=0,5,24,11,

    这个随机序列就是计算冲突函数Hidi的值。

    10.4 再哈希法

    除了原始的哈希函数H(key)之外,多准备几个哈希函数,当哈希函数冲突时,用下一个哈希函数计算一个新地址,直到不冲突为止。

    Hi=RHi(key)i=1,2,3,,k

    1 |E|表示是图的边