数据结构与算法
数据结构+ 算法 = 程序
0. 绪论
数据结构的内容:逻辑结构、存储结构、运算集合。
数据类型:一个值的集合和定义在此集合上的一组操作的总称。
抽象数据类型:abstract data type,用户经行软件设计时从问题的数学模型中抽象出来的逻辑数据结构和逻辑数据结构上的运算,而不考虑计算机具体存储结构和运算的具体实现算法。此模块包含定义、表示和实现。也就是数据对象的定义、数据关系的定义、基本操作的定义。
算法特征:有穷性、确定性、可行性、输入、输出
算法设计的要求:正确性、可读性、健壮性、高效率和低存储的要求。
时间复杂度,空间复杂度。给一个程序要会算,常考。
抽象数据类型
抽象数据类型(ADT): 带有一组操作的一些对象的集合;对象指数据;
集合ADT的操作:添加(add),删除(remove),包含(contain);
两种操作:并(union)、查找(find)
1. 线性表
线性表 是最常用且最简单的一种数据结构
定义:是n个数据元素的有限序列
特点:同一性、有穷性、有序性
线性表主要由顺序表示或链式表示
实现线性表的两种方式:
- 数组:顺序表示,又称顺序表
- 链表:链式表示
前驱:Ai-1前驱 Ai
后继: Ai 后继Ai-1
1.1 数组(顺序表)
- 数组是大小固定的数据结构,对线性表的所有操作都可以通过数组来实现。
- 用一组连续的存储单元依次存储线性表的数据元素。
- 虽然数组一旦创建,它的大小无法改变,但当数组不能再存储线性表中的新元素时,可以创建一个新的大的数组来替换当前数组,这样就可以使用数组实现动态的数据结构。
优点:可以通过下标(index)来访问或者修改元素,比较高效。
缺点:插入和删除花费的开销比较大。
数组的插入与删除操作:
- 最坏情况:在位置0的插入、删除,时间复杂度O(N)
- 平均情况:插入和删除都需要移动表的一半的元素
- 最优情况:在表的高端进行插入、删除,没有元素需要移动,时间复杂度O(1)
存在许多情形,表是通过在高端进行插入操作建成,其后只发生对数组的访问,此时数组是表的一种恰当表达方式
1.2. 链表
1.2.1 链表的定义及特点
- 链表是一种物理存储单元上非连续、非顺序的存储结构。
- 数据元素的逻辑顺序通过链表中的指针链接次序实现。
- 链表由一系列节点组成,节点不必在内存中相连。
- 节点:由数据部分Data(数据区)和链部分Next(链接区/指针区)组成;next链指向下一个节点;节点不必在内存中相连。
优点:添加或删除元素时,只需要改变相关节点的Next指向,效率很高。
- 单链表的结构
2.2 链表的实现
单链表:如上图
循环单链表:链表的最后一个节点指向第一个节点,整体构成一个链环;
双向链表:节点中包含两个指针部分,一个指向前驱元,一个指向后继元;
循环双向链表:节点中包含两个指针部分,一个指向前驱元,一个指向后继元,最后一个节点指向第一个节点。
头节点:在前端的节点
尾节点:在末端的节点
2. 栈与队列
栈与队列也是常见的数据结构,是特殊的线性表。
2.1. 栈
- 栈是特殊的线性表
限制插入和删除只能在一个位置进行的表,该位置叫作栈顶;
访问、插入和删除元素只能在栈顶进行。 - 栈的操作:
进栈:push,相当于插入
出栈:pop,相当于删除最后一个元素 - 栈又叫作LIFO(Last In First Out),后进先出。
- 栈的模型
- 栈的实现:栈是一个表,能实现表的方法都能实现栈。
数组:
链表:例如单链表实现
在表的顶端插入来实现push,在表的顶端删除实现pop,top操作只是考查表的顶端元素并返回它的值。
2.2 队列
- 队列是元素只能从队列尾(后端rear)插入,从队列头(前端front)访问和删除。
- 队列操作:
插入:enqueue入队,队尾(末端)
删除:dequeue出队,队头(前端),删除也在队头 - 队列又叫作FIFO(First In First Out),先进先出(普通队列)。
注:优先队列中,元素被赋予优先级,具有最高优先级的元素最先被删除。
- 队列模型
- 队列的实现:队列也是表,链表可以实现队列。
数组:只要front和back到达数组的尾端,他就又绕回开头
链表:
3. 树与二叉树
树类结构是非常重要的非线性数据结构。树与二叉树最常用。
3.1 树
-
树是由n(n>=1)个有限节点组成一个具有层次关系的集合。
-
特点
每个节点有零个或多个字节点
根结点: 没有父节点的节点
非根结点:每个非根节点有且只有一个父节点
子节点:每个子节点可以分为多个不相交的子树 -
结构
3.2 二叉树
-
定义:二叉树是每个节点最多有两棵子树的树结构。子树被称为“左子树”和“右子树”。常用于实现二叉查找树和二叉堆。
-
性质:
- 二叉树的每个节点至多只有两棵子树(不存在大于2的节点),二叉树的子树有左右之分,次序不能颠倒
- 二叉树第i层至多有2(i-1)个节点
- 深度为k的二叉树至多有2k-1个节点
- 满二叉树:深度为k,且有2k-1个节点的二叉树。
- 完全二叉树:深度为k,有n个节点的二叉树,当且仅当其每个节点都与深度为k的满二叉树中序号为1-n的节点对应时,成为完全二叉树。
- 遍历方法
二叉树的应用中,常要求在树中查找具有某种特征的节点,或对树中全部节点进行某种处理,此时涉及二叉树的遍历。
3个基本单元:根节点,左子树,右子树
先序遍历:先访问根节点,再先序遍历左子树,最后遍历右子树。(根-左-右)
中序遍历:先中序遍历左子树,再访问根节点,最后中序遍历右子树。(左-根-右)
后序遍历:先后序遍历左子树,在后续遍历右子树,最后访问根节点。(左-右-根)
层序遍历:所有深度为d的节点要在深度d+1的节点之前执行;用到队列、属于广度优先。
- 二叉树前三种遍历结果
- 树与二叉树的区别
二叉树每个节点最多有2个节点,树则无限制,二叉树的平均深度为O(sqrt(N));
二叉树中子树分为左子树和右子树,二叉树是有序的(即使某节点只有一棵子树也要指明是左/右);
树决不能为空,至少有一个节点,但二叉树可以为空。
3.3 二叉查找树/二叉排序树/二叉搜索树
定义
- 若左子树不空,则左子树上所有节点的值均小于它的根节点的值;
- 若右子树不空,则有子树上所有节点的值均大于它的根节点的值;
- 左右子树也分别为二叉排序树;
- 没有键值相等的节点。
性质:
二叉查找树的平均深度为O(log(N))
构建过程
性能分析
当给定值相同但顺序不同时,所构建的二叉查找树形态是不同的
不同形态平衡二叉树的ASL不同
- 含有n个节点的二叉查找树的平均查找长度(Average Search Length)和树的形态有关。
- 最坏的情况:当先后插入的关键字有序时,构成的二叉查找树蜕变为单支树,树的深度为n,其平均查找长度为(n+1)/2;
- 最好的情况:二叉查找树的形态和折半查找的判定树相同,其ASL和log2n成正比;
- 平均情况:二叉查找树的ASL和log n等数量级;
为获得更好的性能,二叉查找树构建过程需要“平衡化处理”,使查找树的高度为O(log(n))
二叉查找树删除节点分析
首先需要定位包含该元素的节点,以及它的父节点。
若无子节点,即叶子节点可以直接删除;
若有子节点,则考虑两种情况:
- 需要删除的节点下有一个子节点(左或右),那么只需要将parent节点和current节点的右孩子相连。
- 我们要删除的节点下,有2个子节点,先在需要删除的节点的右子树中,找到一个最小的值(因为右子树中的节点的值一定大于根节点)。然后用找到的最小的值与需要删除的节点的值替换,最后再将最小值的原节点进行删除。
3.4 平衡二叉树AVL
平衡二叉树又称AVL树,它或者是一棵空树,或者是具有下列性质的二叉树:它的左子树和右子树都是平衡二叉树,且左子树和右子树的深度之差的绝对值不超过1;
AVL树是最先发明的自平衡二叉查找树算法。在AVL中任何节点的两个儿子子树的高度最大差别为1,所以它也被称为高度平衡树
特点:
- n个结点的AVL树最大深度约1.44log2 n;
- 在高度为h的在高度为h的AVL树中,最少节点数S(h)由S(h-1)+S(h-2)+1给出
查找、插入和删除在平均和最坏情况下都是O(log n);
增加和删除可能需要通过一次或多次树旋转来重新平衡这个树。
3.5 红黑树
红黑树是平衡二叉树的一种,它保证在最坏情况下基本动态集合操作的事件复杂度为O(log n)
红黑树和平衡二叉树区别如下:
(1) 红黑树放弃了追求完全平衡,追求大致平衡,在与平衡二叉树的时间复杂度相差不大的情况下,保证每次插入最多只需要三次旋转就能达到平衡,实现起来也更为简单;
(2) 平衡二叉树追求绝对平衡,条件比较苛刻,实现起来比较麻烦,每次插入新节点之后需要旋转的次数不能预知。
4. 图
图是一种较线性表和树更为复杂的数据结构。
在线性表中,元素之间仅有线性关系;
在树形结构中,数据之间有明显的层次关系;
在图形结构中,节点之间的关系可以是任意的,图中任意两个数据元素之间都可能相关。
4.1 存储结构
三大类:邻接矩阵、邻接表、十字链表
4.1.1 邻接矩阵
图的邻接矩阵存储方式:使用两个数组Array来表示图
一维数组:存储图中顶点信息
二维数组:(邻接矩阵)存储图中的边或弧的信息
设图有n个顶点,则邻接矩阵是一个n * n的方阵,定义为:
实例:下图是一个无向图
实例:下图是一个无向图
无向图的边数组是一个对称矩阵。所谓对称矩阵就是n阶矩阵的元满足aij = aji。即从矩阵的左上角到右下角的主对角线为轴,右上角的元和左下角相对应的元全都是相等的。
从这个矩阵中,很容易知道图中的信息:
(1)可以判断任意两顶点是否有边无边;
(2)知道某个顶点的度,其实就是这个顶点vi在邻接矩阵中第i行或(第i列)的元素之和;
(3)求顶点vi的所有邻接点就是将矩阵中第i行元素扫描一遍,arc[i][j]为1就是邻接点;
有向图: 讲究入度和出度,顶点vi的入度为1,正好是第i列各数之和。顶点vi的出度为2,即第i行的各数之和。
实例:图G是网图,有n个顶点,则邻接矩阵是一个n*n的方阵,定义为:
wij表示(vi,vj)上的权值;
无穷大表示一个计算机允许的、大于所有边上权值的值,也就是一个不可能的极限值。
实例:带权重的有向图
实例:带权重的有向图
实例:带权重的无向图
4.1.2 邻接表
数组与链表相结合的存储方式
一维数组:图中的顶点
链表:图中每个顶点的所有邻接点构成的一个线性表。由于临界点的个数不定,所以用单链表存储,五香兔成为顶点vi的边表,有向图成为顶点vi作为弧尾的出边表。
时间复杂度:n个顶点e条边,O(n+e)
实例:无向图
实例:无向图
实例:有向图
顶点表:各个结点由data和firstedge两个域表示,data是数据域,存储顶点的信息,firstedge是指针域,指向边表的第一个结点,即此顶点的第一个邻接点。
边表:结点由adjvex和next两个域组成。adjvex是邻接点域,存储某顶点的邻接点在顶点表中的下标,next则存储指向边表中下一个结点的指针。
实例:带权值的网图
在边表结点定义中再增加一个weight的数据域,存储权值信息即可。
邻接矩阵与临界表比较
优点:
对于,稀疏图,邻接表比邻接矩阵更节约空间。
缺点:
不容易判断两个顶点是有关系(边),顶点的出度容易,但是求入度需要遍历整个邻接表。
4.1.3 十字链表
十字链表:把邻接表和逆邻接表结合起来,解决有向图用邻接表存储的缺陷。
容易找到以v为尾的弧,也容易找到以v为头的弧,因而比较容易求得顶点的出度和入度。
邻接表:对于有向图来说,是有缺陷的。
关心了出度问题,想了解入度就必须要遍历整个图才知道,反之,逆邻接表解决了入度却不了解出度情况。
-
重新定义顶点表节点结构:
firstin表示入边表头指针,指向该顶点的入边表中第一个结点
firstout表示出边表头指针,指向该顶点的出边表中的第一个结点 -
重新定义边表 结构
tailvex:弧起点在顶点表的下表
headvex:弧终点在顶点表的下标
headlink:入边表指针域,指向终点相同的下一条边
taillink:边表指针域,指向起点相同的下一条边
如果是网,还可以增加一个weight域来存储权值
实例:有向图
实例:有向图
重点:需要解释虚线箭头的含义。
它其实就是此图的逆邻接表的表示。
对于v0来说,它有两个顶点v1和v2的入边。因此的firstin指向顶点v1的边表结点中headvex为0的结点,如上图圆圈1。接着由入边结点的headlink指向下一个入边顶点v2,如上图圆圈2。对于顶点v1,它有一个入边顶点v2,所以它的firstin指向顶点v2的边表结点中headvex为1的结点,如上图圆圈3。
有向图应用中,十字链表是非常好的数据结构模型
4.1.4 邻接多重表
邻接多重表:适用于无向图的存储结构
邻接多重表是无向图的另一种链式存储结构。我们之前也说了使用邻接矩阵来存储图比价浪费空间,但是如果我们使用邻接表来存储图时,对于无向图又有一些不便的地方,例如我们需要对一条已经访问过的边进行删除或者标记等操作时,我们除了需要找到表示同一条边的两个结点。这会给我们的程序执行效率大打折扣,所以这个时候,邻接多重表就派上用场啦。
首先,邻接多重表同样是对邻接表的一个改进得到来的结构,它同样需要一个头结点保存每个顶点的信息和一个表结点,保存每条边的信息,他们的结构如下:
其中,头结点的结构和邻接表一样,而表结点中就改变比较大了,其中mark为标志域,例如标志是否已经访问过,ivex和jvex代表边的两个顶点在顶点表中的下标,ilink指向下一个依附在顶点ivex的边,jlink指向下一个依附在顶点jvex的边,weight在网图的时候使用,代表该边的权重。
实例:无向图
4.2 图的遍历
4.2.1 深度优先遍历
深度优先遍历,也有称为深度优先搜索,简称DFS。
类似树的先序遍历
它从图中某个结点v出发,访问此顶点,然后从v的未被访问的邻接点出发深度优先遍历图,直至图中所有和v有路径相通的顶点都被访问到。若图中尚有顶点未被访问,则另选图中一个未曾被访问的顶点作起始点,重复上述过程,直至图中的所有顶点都被访问到为止。
- 无向图:
对上面的图G1进行深度优先遍历,从顶点A开始:
第1步:访问A。
第2步:访问(A的邻接点)C。
在第1步访问A之后,接下来应该访问的是A的邻接点,即"C,D,F"中的一个。但在本文的实现中,顶点ABCDEFG是按照顺序存储,C在"D和F"的前面,因此,先访问C。
第3步:访问(C的邻接点)B。
在第2步访问C之后,接下来应该访问C的邻接点,即"B和D"中一个(A已经被访问过,就不算在内)。而由于B在D之前,先访问B。
第4步:访问(C的邻接点)D。
在第3步访问了C的邻接点B之后,B没有未被访问的邻接点;因此,返回到访问C的另一个邻接点D。
第5步:访问(A的邻接点)F。
前面已经访问了A,并且访问完了"A的邻接点B的所有邻接点(包括递归的邻接点在内)";因此,此时返回到访问A的另一个邻接点F。
第6步:访问(F的邻接点)G。
第7步:访问(G的邻接点)E。
因此访问顺序是:A -> C -> B -> D -> F -> G -> E
- 有向图
对上面的图G2进行深度优先遍历,从顶点A开始:
第1步:访问A。
第2步:访问B。
在访问了A之后,接下来应该访问的是A的出边的另一个顶点,即顶点B。
第3步:访问C。
在访问了B之后,接下来应该访问的是B的出边的另一个顶点,即顶点C,E,F。在本文实现的图中,顶点ABCDEFG按照顺序存储,因此先访问C。
第4步:访问E。
接下来访问C的出边的另一个顶点,即顶点E。
第5步:访问D。
接下来访问E的出边的另一个顶点,即顶点B,D。顶点B已经被访问过,因此访问顶点D。
第6步:访问F。
接下应该回溯"访问A的出边的另一个顶点F"。
第7步:访问G。
因此访问顺序是:A -> B -> C -> E -> D -> F -> G
时间复杂度
- 邻接矩阵:O(n2)
- 邻接表:O(n+e)
4.2.2 广度优先遍历
广度优先遍历,又称为广度优先搜索,简称BFS。
类似树的层序遍历。
- 无向图
第1步:访问A。
第2步:依次访问C,D,F。
在访问了A之后,接下来访问A的邻接点。前面已经说过,在本文实现中,顶点ABCDEFG按照顺序存储的,C在"D和F"的前面,因此,先访问C。再访问完C之后,再依次访问D,F。
第3步:依次访问B,G。
在第2步访问完C,D,F之后,再依次访问它们的邻接点。首先访问C的邻接点B,再访问F的邻接点G。
第4步:访问E。
在第3步访问完B,G之后,再依次访问它们的邻接点。只有G有邻接点E,因此访问G的邻接点E。
因此访问顺序是:A -> C -> D -> F -> B -> G -> E
- 有向图
第1步:访问A。
第2步:访问B。
第3步:依次访问C,E,F。
在访问了B之后,接下来访问B的出边的另一个顶点,即C,E,F。前面已经说过,在本文实现中,顶点ABCDEFG按照顺序存储的,因此会先访问C,再依次访问E,F。
第4步:依次访问D,G。
在访问完C,E,F之后,再依次访问它们的出边的另一个顶点。还是按照C,E,F的顺序访问,C的已经全部访问过了,那么就只剩下E,F;先访问E的邻接点D,再访问F的邻接点G。
因此访问顺序是:A -> B -> C -> E -> F -> D -> G
时间复杂度
- 邻接矩阵:O(n2)
- 邻接表:O(n+e)
5. 散列表(哈希表)
散列表ADT: 哈希表(hash table)
散列表的实现叫作散列(hashing)
5.1 一般想法
散列是一种用于以常数平均时间执行插入、删除、查找的技术。
思想:用数组支持按照下标随机访问数据的特性实现的一种数据结构,时间复杂度是O(1)。是数组的一种扩展。
散列表中使用散列函数把元素的键值映射为下标,将数据存储在数组中对应的下标中。
查询元素的时候用同样的散列函数,将键值转化为数组下标,从而读取到位置。
5.2 散列函数
散列函数:是用来把关键字Key进行散列的一个方法,称为hash function。
设计基本要求:
- 计算的散列值是一个非负整数
- key值相等,散列后的的值也相等
- key值不相等,散列后的值也不相等
注:著名的哈希函数算法 MD5,SHA,CRC算法也无法避免哈希冲突。所以第三点要求无法达到完美的匹配
5.3 散列冲突
哈希冲突:计算hash值,两个不同的key得到两个一样的hash值
常用的哈希冲突解决方案有两类:开放定址法与分离链接法
5.3.1 分离链接法(链表法)
在链接法中,把散列到同一槽中的所有元素都放在一个链表中,如下图所示,槽j中有一个指针,它指向存储所有散列到j的元素的链表的表头;如果不存在这样的元素,槽j中为NIL。
分析(查找一个关键字)
给定一个能存放n个元素的,具有m个槽位的散列表T,定义T的装载因子α为n/m,即一个链表的平均存储元素数量,α可以大于,等于或者小于1.
用链接法散列的最坏情况性能很差:所有的n个关键字都散列到同一个槽中,从而产生一个长度为n的链表,这时,最坏情况下查找时间为θ(n),再加上计算散列函数的时间,如果就和用一个链表来链接所有的元素差不多了。
散列方法的平均性能依赖于所选取的散列函数h,将所有关键字集合分布在m个槽位上的均匀程度。
在平均情况下,查找一个关键字有两个结果:查找成功和查找不成功。
在简单均匀散列的情况下,任何尚未被存储在表中的关键字k都等可能地被散列到m个槽中的任何一个,因此,当查找一个关键字k时,在不成功的情况下,查找的期望时间就是查找到链表T[h(k)]末尾的期望时间,这一时间的期望长度为α,于是一次不成功的查找平均要检查α个元素,并且所需要的总时间(包括计算h(k)的时间)为θ(1+α)
在查找成功的情况下,平均需要的时间也是θ(1+α)。
上述的分析意味着,如果散列表中槽数至少与表中的元素成正比(比如说,当要散列的元素的数量增加时,散列表T的槽数也要保持同样比例的增长),则有n = Ο(m),从而α= n/m = Ο(m) / m = Ο(1),所以查找操作平均时间需要常数时间。如果散列的元素的数量增加了,但是散列表的槽数没有增长,此时n = Ο(m) 就不成立,散列表的操作时间就和之前的不一样了。
5.3.2 开放定址法
在开放寻址法中,所有的元素都存放在散列表中,也就是说,每个表项或包含动态集合的一个元素,或包含NIL。当查找某个元素时,要系统地检查所有的表项,知道查找到所需要的元素,或者最终查明该元素不在表中。不像链接法,这里既没有链表,也没有元素存放在散列表外,因此在开放寻址法中,散列表可能会被填满,以至于不能插入任何新的元素,因此装载因子α = n / m绝对不会超过1,通常α < 0.5;也就是说,要散列的元素绝对不会多于槽的数量。
探测散列表
核心思想:出现散列冲突,重新探测一个空闲位置将其插入。
探查方式:
- 线性探测法
插入数据
查找数据
给定一个普通的散列函数 h ’ : U → { 0, 1, …, m - 1 }(称为辅助散列函数),线性探查方法采用的散列函数为:
h(k, i) = ( h’(k) + i) mod m , i = 0, 1, …, m - 1
给定一个关键字 k ,第一个探查的槽是 T[h’(k)],亦即,由辅助散列函数所给出的槽。接下来探查的是槽 T[h’(k) + 1], …,直到槽 T[m - 1],然后又绕到槽 T[0], T[1], …直到最后探查槽 T[h’(k) - 1]。在线性探查方法中,初始探查位置确定了整个序列,故只有 m 种不同的探查序列。
线性探查方法很容易实现,但它存在一个问题,称作一次聚集。随着时间的推移,连续被占用的槽不断增加,平均查找时间也随着不断增加。聚集现象很容易出现,这是因为当一个空槽前有 i 个满的槽时,该空槽作为下一个将被占用槽的概率是( i + 1 ) / m 。连续被占用槽的序列将会越来越长,因而平均查找时间也会随之增加。
- 平方探测法
消除线性探测法的一次聚集问题;
平方探测采用如下形式的散列函数(二次)
h(k, i) = (h’(k) + c1 i + c2i2) mod m
其中 h’是一个辅助散列函数, c1 和 c2 为辅助常数(不等于0), i = 0,1,…,m - 1。初始的探查位置为 T[h’(k)],后续的探查位置要在此基础上加上一个偏移量,该偏移量以二次的方式依赖于探查号 i 。这种探查方法的效果要比线性探查好很多,但是,如果两个关键字的初始探查位置相同,那么他们的探查序列也是相同的,这是因为 h (k1,0) = h (k2,0)蕴含着 h(k1,i) = h(k2,i)。这一性质可导致一种程度较轻的聚集现象,称为二次聚集。二次探查也只有m个不同的探查序列。
- 双散列
双重散列 是用于开放寻址法的最好方法之一,它采用如下形式的散列函数:
h(k,i) = (h1(k) + i·h2(k)) mod m
其中 h1 和 h2 为辅助散列函数。初始探查位置为 T[ h1(k)],后续的探查位置在此基础上加上偏移量 h2(k) 模 m 。
为能查找整个散列表,值 h2(k)要与表的大小m互质。确保这个条件成立的一种方法是取m为2的幂,并设计一个总产生奇数的 h2 。另一种方法是取m为质数,并设计一个总是产生较m小的正整数的函数 h2 。例如,可以取m为素数,m’略小于m,如下:
h1(k) = k mod m
h2(k) = 1 + (k mod m’)
分析
相对于链接法,开放寻址法的好处在于不需要用到指针,而是计算出槽的序列,于是,不用存储指针而节省的空间,使得可以用同样地空间来提供更多的槽,潜在地减少了冲突,提高了检索速度。
从开放寻址法的散列表中删除元素比较困难,当从槽i中删除关键字时,不能仅仅将NIL置于其中来标识它为空,如果这样做就会出现问题:在插入关键字k时,发现槽i被占用了,则k会插入到后面的位置上;此时将槽i中的关键字删除后,就无法检索到关键字k了,有一个解决方法就是,在槽i中置一个特定的值DELETED替代NIL来标记空槽。当使用特殊的值DELETED时,查找时间就不再依赖于装载因子了,为此,在必须删除关键字的应用中,更常见的方法是采用链接法来解决冲突。
6. 优先队列(堆)
优先队列是一种抽象数据类型,它是一种排序的机制
功能强大:自动排序
两个核心操作:
- 删除最大元素
- 插入元素
效果:在维护一个动态的队列;可以收集一些元素,并快速取出键值最大的元素,对其操作后移出队列,然后再收集更多的元素,再处理当前键值最大的元素,如此这般。
6.1 优先队列的初级实现
数组实现(无序)
►思想:
我们维护一个数组,因为不考虑数组顺序,所以我们的插入算法就很简单了。
对于查找最大值,我们利用了选择排序,在找到最大值后,将其与最后一个元素交换,并使长度-1.
数组实现(有序)
►思想:
由于我们维护一个有序数组,所以每次插入元素的时候都要给他找到一个合适位置,来保证数组有序性,删除操作就会很简单了。
6.2 堆
二叉堆(堆):实现优先队列,一棵完全二叉树,是按一种特定的组织结构排列
两个性质:
- 结构性:高为h的完全二叉树有2h到2h+1-1个节点,O(log N)
- 堆序性:让操作快速执行
大顶堆:在二叉堆中每一个节点的值都要保证大于等于另外子节点的值,即头重脚轻
小顶堆:自上而下依次升高,即每一个节点的值都小于等于其子节点的值
例:大顶堆,根节点一定是所有元素中最大的一个,即优先性最高的,当我们取走后,取代其位置的也应是下一个最大的元素
说明:
这是一个堆有序的二叉树。所谓堆有序就是一颗二叉树的每个节点都大于等于它的两个子节点。
6.3 二叉堆表示法
►我们来组织一种堆有序的二叉树,这种有序的结构便于我们实现了优先队列。
我们可以使用指针来表示,但是这并不是最方便的。通过观察二叉有序堆,我们会发现它是一种完全二叉树,并且完全二叉树可以用数组来表示。
用数组实现二叉有序堆
具体方法就是将二叉树的节点按照层序顺序放入数组中,根节点位置在1,它的子节点位置在2,3.依次类推。
►两条重要的性质:
-
在一个二叉堆中,位置为K的节点的父节点的位置为|K/2|,而它的两个子节点位置为2K和2K+1
-
一颗大小为N的完全二叉树的高度为|Log N|
6.4 用堆实现优先队列
6.4.1 由下至上的堆有序化
►说明:
如果堆的有序化因为某个节点X变得比它的父节点更大而打破,我们需要交换它和它的父节点来修复堆,但是可能交换后X还是很大,所以我们需要X一次次的它的祖先节点进行比较,直到找打它最合适的位置。
根据二叉堆的性质,我们不难发现只要记住位置为K的节点的父节点为 |K/2|,一切都很简单了。
►图示:
6.4.2 由上至下的堆有序化
►说明:
如果堆的有序话因为某个节点X变得比它的两个子节点或其一更小而打破,我们需要交换它和它的子节点中较大的节点来修复堆,但是可能交换后X还是很小,所以我们需要X一次次的它的子节点进行比较并交换,直到找打它最合适的位置。
►图示:
6.5 基于堆的优先序列
►思路:
我们每次插入元素到数组尾,这样可能会破坏堆的有序化,所以我们对其进行上浮操作。
当我们取出并删除最大元素的时候,第一个位置不能空着,我们的做法是将让最后一个元素移到第一个,然后进行下沉操作。