符号表
符号表最主要目的就是将一个键和一个值联系起来。用例能将一个键值对插入符号表并之后能从符号表的所有键值对中按键直接找到对应的值。
符号表是一种存储键值对的数据结构,支持两种操作:插入(put),即将一组新的键值对存入表中;查找(get),即根据给定的键得到对应的值。
API
|
|
默认实现:
|
|
设计决策
- 泛型
- 不含重复的键
- 键不能为空(null)
- 值不能为空
- 删除操作:延时删除(put(key, null)是 delete(key) 的一种简单的延时型实现)或即时删除
- 迭代:在 API 第一行加上
implements Iterable<Key>
这句话,强制所有实现都必须包含iterator()
方法来返回一个实现了hasNext()
和next()
方法的迭代器。定义一个keys()
方法来返回一个Iterable<Key>
对象以便遍历所有的键。 - 键的等价性
有序符号表
|
|
默认实现:
|
|
- 键的等价性:Java 的一条最佳实践就是维护所有的 Comparable 类型中的 compareTo() 方法和 equals() 方法的一致性。即任何一种 Comparable 类型的两个值 a 和 b 都要保证(a.compareTo(b) == 0 和 a.equals(b) 的返回值相同)。
二叉查找树
二叉查找树(BST)是一棵二叉树,其中每个节点都含有一个 Comparable 的键(以及相关联的值)且每个节点的键都大于其左子树中任意节点的键而小于右子树的任意节点的键。
数据表示
和链表一样,嵌套定义一个私有类来表示二叉查找树的节点。每个节点包含键、值、左节点、右节点和以该节点为根的子树的节点总数。

递归:递归调用沿着树向下走,递归调用返回沿着树向上爬。对于put方法,意味重置搜索路径上每个父节点指向子节点的链接,并增加路径上每个节点中的计数器的值。
分析
假设键分布均匀(随机),二叉查找树和快排类似,查找命中平均所需比较次数为logN.
有序性相关的方法与删除操作
二叉查找树广泛应用的一个重要原因是它能够保持键的有序性,可以用来实现有序符号表。
- 向上取整和向下取整
若给定的键key小于二叉查找树根节点的键,则小于等于key的最大键(floor)一定在根节点的左子树中;
若给定键key大于二叉查找树的根节点,则只有当根节点右子树中存在小于等于key的节点,小于等于key的最大键才会出现在右子树中,否则根节点就是小于等于key的最大键。 - 删除最大/最小键
删除最小键deleteMin()
只要不断深入根节点左子树直到遇到一个空链接,然后将指向该节点的链接指向该节点的右子树(递归调用返回其右链接)。 - 删除操作
- 将指向即将被删除的节点链接保存为t
- 将x指向它的后继节点min(t.right)
- 将x右链接指向deleteMin(t.right)
- 将x的左链接(本为空)设为t.left
- 范围查找
返回给定范围内键的keys()的方法
|
|

性能分析
在一棵二叉查找树中,所有操作最坏情况下所需时间都和树的高度(树中任意节点最大深度)成正比。
基本的二叉查找树的实现常常是非递归的。
平衡查找树
无论怎样构造,运行时间都是对数级别的。。。。。。。。。。。。。。。。。。。。。。。。。。。。
2-3查找树
标准二叉树中的结点称为2-结点(含有一个键和两条链接),引入3-结点,含有两个键和三条链接,2-结点和3-结点的每条链接都对应着保存键所分割产生的一个区间。
- 定义:一棵2-3查找树或为一棵空树,或由以下结点组成:
- 2-结点,含有一个键(及其对应的值)和两条链接,左链接指向的2-3树的键都小于该节点,右链接指向的2-3树中的键都大于该节点。
- 3-节点,含有两个键(及其对应的值)和三条链接,左链接指向的2-3树的键都小于该节点,中链接指向的2-3树中的键都位于该节点两个键之间,右链接指向的2-3树中的键都大于该节点。
一棵完美平衡的2-3查找树中的所有空连接到根节点的距离都应该是相同的
查找

向2-结点插入新键
要在2-3树中插入结点,可以和二叉查找树一样先进行一次未命中查找,但新节点直接挂在树底部不能保持树的平衡,但使用2-3树目的就是为了保持平衡,当未命中查找结束于一个2-结点的解决办法是——把这个2结点替换为一个3-结点;若结束于3-结点则稍麻烦。
向只含一个3-结点树中插入新键
先临时将新键存入该节点,使之成为4-结点,再将其转换为一棵由3个2-结点组成的2-3树。插入前树的高度为0,插入后树的高度为1。
向父结点为2-结点的3-结点中插入新键
转换可看成将指向3-结点的的一条链接替换为父结点中的原中键左右两边的两条链接,并分别指向两个新的2-结点。
向父结点为3-结点的3-结点中插入新键

分解根节点
若从插入结点到根节点的路径上都是3-结点,则将根节点变成一个临时4-结点,变换根节点,将其分解为3个2-结点,使得树高加1。
局部变换
将4-结点分解为一棵2-3树有6种可能:
2-3树插入算法根本在于这些变换都是局部的:除了相关结点和链接外不必修改或检查树其它部分。
全局性质
局部变换不会影响树的全局有序性和平衡性:任意空链接到根结点的路径的长度都相等。
- 标准二叉树是由上向下生长,2-3树是由下向上生长
- 在一棵大小为N的2-3树中,查找和插入操作访问结点不超过lgN个。
红黑二叉查找树
接下来利用红黑二叉查找树的简单数据结构来表达并实现上文2-3树。
替换3-结点
红黑二叉查找树基本思想是用标准二叉查找树(完全由2-结点构成)和一些额外信息(替换3-结点)来表示2-3树。
2-3查找树需要用到2-结点和3-结点,红黑树使用红链接来实现3-结点。指向一个节点的链接颜色如果为红色,那么这个节点和上层节点表示的是一个3-结点,而黑色则是普通链接。这样无须修改就可直接使用标准二叉查找树的get()方法。
红黑树等价定义
红黑树另一种定义是含有红黑链接且满足下列条件的二叉查找树:
- 红链接均为左链接
- 没有任何一个结点同时和两条红链接相连
- 该树是完美黑色平衡的,即任意空链接到根节点的路径上的黑链接数量相同
满足这样定义的红黑树和相应的2-3树一一对应。
一一对应
- 若将红黑树中红链接画平,那么所有空链接到根节点距离都相同
- 红黑树既是二叉查找树也是2-3树
若能在保持一一对应基础上实现2-3树的插入算法,就能将两个算法有点结合起来:
- 二叉查找树简洁高效的查找方法
- 2-3树中高效的平衡插入算法


颜色表示
每个结点都有一条指向自己的链接,若此链接为红,则令变量color
为true,黑色则为false。约定空链接为黑色。代码见实现。
旋转
合法的红链接都为左链接,如果出现右链接为红链接,那么就需要进行左旋转操作。实际只是将两个键中较小者作为根结点 变为 将较大者作为根结点。代码见实现。
旋转后重置父节点链接
旋转后可能会导致连续两条红色链接,进行右旋转就是为了转换两个连续的左红链接。代码见实现。
在插入新键时可以通过旋转保证2-3树和红黑树一一对应和红黑树的重要性质:有序性和完美平衡性。
向2-结点插入新键
左插和右插两种情况结果均为一棵和单个3-结点等价的红黑树,其中含有两个键,一条红链接,树的黑链接高度为1。

向树底部2-结点插入新键
向红黑树插入一个新键会在树底部新增一个结点,但总是用红链接将新结点和它的父节点相连。
向一棵双键树(即3-结点)插入新键
3种情况(通过0次,1次和2次旋转):
- 新键大于原树两个键
- 将两条连接较大和较小结点的红链接都变黑即可
- 新键小于原树两个键
- 产生两条连续左红链接,只需将上层红链接右旋转即可得到第一种情况
- 新键介于原树两键之间
- 会产生两条连续红链接(一条左红链接连接一条右红链接),此时只需将下层红链接右旋即可得到第二种情况
插入新键.png)
颜色变换
一个 4-节点在红黑树中表现为一个节点的左右子节点都是红色的。分裂 4- 节点除了需要将子节点的颜色由红变黑之外,同时需要将父节点的颜色由黑变红,从 2-3 树的角度看就是将中间节点移到上层节点。


根结点总是黑色
每次插入后都将根结点设为黑色,每当根结点由红变黑时树的黑链接高度加1。
将红链接在树中向上传递
红黑树中实现2-3树插入算法关键:在3-结点下插入新键,先创建一个临时4-结点,将其分解并将红链接由中间键传递给它的父结点,重复该过程直到遇到一个2-结点或根节点。
- 若右子结点是红色而左子结点是黑色,进行左旋转
- 若左子结点是红色且它的左子结点也是红色,进行右旋转
- 若左右子结点均红色,进行颜色变换
实现
|
|
插入操作和二叉查找树的插入操作一致,只是在最后加入了旋转和颜色变换操作即可。
根节点一定为黑色,因为根节点没有上层节点,也就没有上层节点的左链接指向根节点。flipColors() 有可能会使得根节点的颜色变为红色,每当根节点由红色变成黑色时树的黑链接高度加 1.
删除最小键
如果最小键在一个 2- 节点中,那么删除该键会留下一个空链接,就破坏了平衡性,因此要确保最小键不在 2-节点中。为保证不会删除一个2-结点,沿着左链接向下变换,确保当前结点不是2-结点(可能是3-结点,也可能是4-结点)。
将 2- 节点转换成 3- 节点或者 4- 节点有两种方法,一种是向上层节点拿一个 key,一种是向兄弟节点拿一个 key。如果上层节点是 2- 节点,那么就没办法从上层节点拿 key 了,因此要保证删除路径上的所有节点都不是 2- 节点。在向下删除的过程中,保证以下情况之一发生:
- 如果当前节点的左子节点不是 2- 节点,完成;
- 如果当前节点的左子节点是 2- 节点而它的兄弟节点不是 2- 节点,向兄弟节点拿一个 key 过来;
- 如果当前节点的左子节点和它的兄弟节点都是 2- 节点,将左子节点、父节点中的最小键和最近的兄弟节点合并为一个 4- 节点
最后得到一个含有最小键的 3-节点或者 4-节点,直接从中删除。然后再从头分解所有临时的 4-节点。

红黑树性质
- 一颗大小为 N 的红黑树的高度不会超过 2logN。最坏的情况下是它所对应的 2-3 树中构成最左边的路径节点全部都是 3- 节点而其余都是 2- 节点。
- 红黑树大多数的操作所需要的时间都是对数级别的。
- 一棵大小为N的红黑树中,根结点到任意结点的平均路径长度为 logN

散列表
散列函数
对于一个大小为 M 的散列表,散列函数能够把任意键转换为 [0, M-1] 内的正整数,该正整数即为 hash 值。
散列表有冲突的存在,也就是两个不同的键可能有相同的 hash 值。
散列函数应该满足以下三个条件:
- 一致性:相等的键应当有相等的 hash 值,两个键相等表示调用 equals() 返回的值相等。
- 高效性:计算应当简便,有必要的话可以把 hash 值缓存起来,在调用 hash 函数时直接返回。
- 均匀性:所有键的 hash 值应当均匀地分布到 [0, M-1] 之间,这个条件至关重要,直接影响到散列表的性能。
除留余数法可以将整数散列到 [0, M-1] 之间,例如一个正整数 k,计算 k%M 既可得到一个 [0, M-1] 之间的 hash 值。注意 M 必须是一个素数,否则无法利用键包含的所有信息。例如 M 为 10k,那么只能利用键的后 k 位。
对于其它数,可以将其转换成整数的形式,然后利用除留余数法。例如对于浮点数,可以将其表示成二进制形式,然后使用二进制形式的整数值进行除留余数法。
对于有多部分组合的键,每部分都需要计算 hash 值,并且最后合并时需要让每部分 hash 值都具有同等重要的地位。可以将该键看成 R 进制的整数,键中每部分都具有不同的权值。
例如,字符串的散列函数实现如下
|
|
再比如,拥有多个成员的自定义类的哈希函数如下,R 的值不是很重要,通常取 31。
|
|
hashCode() 和 equals()
每种数据类型都需要相应散列函数,Java令所有数据类型都继承了一个能返回32位整数的hashCode()方法。每种数据类型hashCode()方法都必须和equals()一致。
等价的(调用equals返回true)对象必须产生相同的散列码。不等价的对象,不要求产生的散列码不相同。
将hashCode()返回值转化为一个数组索引
我们需要的是数组索引而不是一个32位整数,Java 中的 hashCode() 实现了 hash 函数,但默认使用对象的内存地址值。在使用 hashCode() 函数时,应当结合除留余数法来使用。因为内存地址是 32 位整数,我们只需要 31 位的非负整数,因此应当屏蔽符号位之后再使用除留余数法。
|
|
自定义hashCode()方法
|
|
软缓存
散列值计算很耗时,可以将每个键的散列值缓存起来,即在每个键中使用一个hash变量来保存它的hashCode()返回值。String 对象的 hashCode() 方法就使用了该方法。
基于拉链法的散列表
拉链法使用链表来存储 hash 值相同的键,从而解决冲突。此时查找需要分两步,首先查找 Key 所在的链表,然后在链表中顺序查找。
|
|
基于线性探测法的散列表
用大小为M的数组保存N个键值对,其中M>N,依靠数组中的空位解决碰撞冲突,基于这种策略的所有方法统称为开放地址散列表。
线性探测法:当碰撞发生时,直接检查散列表下一个位置(将索引值加1)。
- 删除时直接置null会使此位置后的元素无法被查找,因此需要将被删除键后的所有键都重新插入。
- 开放地址类的散列表性能依赖于
a=N/M
,称a
为散列表使用率。
|
|
两种方法性能差别主要是因为拉链法为每个键值对都分配了一小块内存而线性探测则为整张表使用了两个很大的数组。
Q&A
- Java 的Integer/Double/Long类型的hashCode()如何实现?
Integer 类型会直接返回该整数32位值。对于Double和Long类型,Java会返回值的机器表示的前32位和后32位的异或结果。 当动态调整数组大小时,散列表大小总是2的幂,这样hash()方法就只用了hashCode()的低位。
为什么不将hash(x)实现为x.hashCode() % M?
散列值必须在0到M-1之间,而Java中取余(%)结果可能为负数。- 散列表查找比红黑树更快吗?
取决于键的类型,这决定了hashCode()计算成本是否大于compareTo()比较成本。对于常见键类型以及Java的默认实现,两者成本近似,因此散列表会比红黑树快很多。
应用

Java 的 java.util.TreeMap 和 java.util.HashMap 分别是基于红黑树和拉链法的散列表的符号表实现。
文件索引(倒排索引)
|
|
- 稀疏向量乘法
当向量为稀疏向量时,可以使用符号表来存储向量中的非 0 索引和值,使得乘法运算只需要对那些非 0 元素进行即可。


|
|
本篇介绍的算法的完整代码地址:
代码地址