顺序查找(无序链表)
符号表中使用的数据结构的一个简单选择是链表,每个结点存储一个键值对。
示意
实现
1 | public class SequentialSearch<Key, Value> { |
特点
在含有N对键值的基于(无序)链表的符号表中,未命名的查找和插入操作都需要N次比较。命中的查找在最坏情况下需要N次比较。特别地,向一个空表中插入N个不同的键需要~N^2/2次比较。
二分查找(有序数组)
符号表使用的数据结构是一对平行的数组,一个存储键一个存储值。
计算小于给定键的键的数量:首先将key和中间键比较,如果相等则返回其索引;如果小于中间键则在左半部分查找;大于则在右半部分查找。
示意
实现
1 | public class BinarySearch<Key extends Comparable<Key>, Value> { |
特点
在N个键的有序数组中进行二分查找最多需要(lgN+1)次比较(无论是否成功)。
向大小为N的有序数组中插入一个新的元素在最坏情况下需要访问~2N次数组,因此向一个空符号表中插入N个元素在最坏情况下需要访问~N^2次数组。
比较
二叉查找树
定义一个类表示二叉查找树上的一个结点。每个结点都含有一个键、一个值、一条左链接、一条右链接和一个结点计数器。左链接指向一棵由小于该结点的所有键组成的二叉查找树,右链接指向一棵由大于该结点的所有键组成的二叉查找树。变量N给出了以该结点为根的子树的结点总数。
示意
实现
1 | public class BinarySearchTree<Key extends Comparable<Key>, Value> { |
特点
在由N个随机键构造的二叉查找树中,查找命中平均所需的比较次数为2lnN(约为1.39lgN),插入操作和查找未命中平均所需的比较次数为2lnN(约为1.39lgN)。
在一棵二叉查找树中,所有操作在最坏情况下所需的时间都和树的高度成正比。
比较
红黑二叉查找树
只要谨慎地使用左旋转、右旋转和颜色转换这三种简单的操作,就能够保证插入操作后红黑树和2-3树的一一对应关系:
- 如果右子结点是红色的而左子结点是黑色的,进行左旋转
- 如果左子结点是红色的且它的左子结点也是红色的,进行右旋转
- 如果左右子结点均为红色,进行颜色转换
示意
实现
1 | public class RedBlackTree<Key extends Comparable<Key>, Value> { |
特点
一棵大小为N的红黑树的高度不会超过2lgN。
一棵大小为N的红黑树中,根结点到任意结点的平均路径长度为~1.00lgN。
在一棵红黑树中,以下操作在最坏情况下所需的时间是对数级别的:查找(get)、插入(put)、查找最小键、查找最大键、floor、ceiling、rank、select、删除最小键(deleteMin)、删除最大键(deleteMax)、删除(delete)和范围查询。
比较
散列
使用散列的查找算法分为两步。第一步是用散列函数将被查找的键转化为数组的一个索引(散列函数);第二步就是一个处理碰撞冲突的过程(拉链法和线性探测法)。
因为需要的是数组的索引而不是一个32位的整数,在实现中会将默认的hashCode()方法和除留余数法结合起来产生一个0到M-1的整数,方法如下:
1 | private int hash(Key x){ |
这段代码会将符号位屏蔽(将一个32位整数变为一个31位非负整数),然后用除留余数法计算它除以M的余数。在使用这样的代码一般会将数组的大小M取为素数以充分利用原散列值的所有位。
基于拉链法的散列表(链表数组)
将大小为M的数组中的每个元素指向一条链表,链表中的每个结点都存储了散列值为该元素的索引的键值对。
查找分两步:首先根据散列值找到对应的链表,然后沿着链表顺序查找相应的键。
示意
实现
1 | public class SeparateChainHash<Key, Value> { |
特点
在一张含有M条链表和N个键的散列表中,任意一条链表中的键的数量均在N/M的常熟因子范围内的概率无限趋于1,未命中查找和插入操作所需的比较次数为~N/M。
基于线性探测法的散列表(并行数组)
用大小为M的数组保存N个键值对,其中M>N,需要依靠数组中的空位解决碰撞冲突。基于这种策略的所有方法被统称为开放地址散列表。
最简单的方法叫做线性探测法:当碰撞发生时(当一个键的散列值已经被另一个不同的键占用),直接检查散列表中的下一个位置(将索引值加1)。这样的线性探测可能会产生三种结果:
- 命中,该位置的键和被查找的键相同
- 未命中,键为空(该位置没有键)
- 继续查找,该位置的键和被查找的键不同
示意
实现
1 | public class LinearProbingHash<Key, Value> { |
特点
在一张大小为M并含有N=aM个键的基于线性探测的散列表中,命中和未命中的查找所需的探测次数分别为:
假设一张散列表能够自己调整数组的大小,初始为空。执行任意数序的t次查找、插入和删除操作所需的时间和t成正比,所使用的内存量总是在表中的键的总数的常数因子范围内。
- 线性探测的平均成本取决于元素在插入数组后聚集成的一组连续的条目,也叫做键簇。短小的键簇才能保证较高的效率,因此需要动态调整数组的大小来保证使用率在1/8到1/2之间。
比较
将a=N/M称为散列表的使用率。对于基于拉链法的散列表,a是每条链表的长度,因此一般大于1;对于基于线性探测的散列表,a是表中已被占用的空间的比例,它是不可能大于1的。
特点
- 每种类型的键都需要一个优秀的散列函数
- 性能保证来自于散列函数的质量
- 散列函数的计算可能复杂而且昂贵
- 难以支持有序性相关的符号表操作