词典之散列

1、词典

词条:entry = (key, value)

逻辑上的词典,是由一组数据构成的集合,其中各元素都是由关键码和数据项合成的词条(entry)。

映射(map)结构与词典结构一样,也是词条的集合。二者的差别仅仅在于,映射要求不同词条的关键码互异,而词典则允许多个词条拥有相同的关键码。

实际上,若你有 Java 等此类语言的学习经验,也许你已经对“词典”这一数据结构有了一定的了解,但是为了内容的完整性,仍然在下面做一定程度的讲解。

1.1、联合数组

在以往使用数组时,通常以整数数字作为下标,就像这样:num[0],num[1],num[2] ...

那联合数组与之前的普通数组有什么区别呢?

在联合数组中,下标不再局限于整数,甚至没有大小,次序!

这样做的好处是什么呢?实际上,这样的方式会让访问变得更加直观与便捷,就像这样:

实现根据数据元素的取值进行直接访问!

1.2、对比

与 BST 相比:词典的关键码之间未必可比较。

与 PQ 相比:词典的查找对象更广泛,不限于最大,最小词条。

1.3、词典的模板

首先给出词典的模板:

template <typename K, typename V> struct Dictionary {     virtual int size() = 0; // 查询当前词条总数     virtual bool put( K ) = 0; // 插入词条     virtual V* get( K ) = 0; // 查找以 key 为关键码的词条     virtual bool remove( K ) = 0; // 删除以 key 为关键码的词条 };

注意,尽管在众多语言中都支持词典结构,并各自实现。例如 Java 中的 TreeMap 等实现上虽然仍然要求支持比较器,但实际上词典中的词条只需要支持对比判等操作,而无需实现大小的比较。

以上词典的访问方式,我们称为:循值访问。这种方式更为自然,适用范围也更加广泛。

2、散列

散列以最基本的向量作为底层支撑结构,通过适当的散列函数在词条的关键码与向量单元的秩之间建立起映射关系。

只要散列表、散列函数以及冲突排解策略设计得当,散列技术可在期望的常数时间内实现词典的所有接口操作。也就是说,就平均时间复杂度的意义而言,可以使这些操作所需的运行时间与词典的规模基本无关。

注意:散列技术完全摒弃了“关键码有序”的先决条件,故就实现词典结构而言,散列所特有的通用性和灵活性是其它方式无法比拟的

2.1、完美散列

1、散列表

散列表(hashtable)是散列方法的底层基础,逻辑上由一系列可存放词条(或其引用)的单元组成,这些单元也称作桶(bucket)或桶单元。

各桶单元也按其逻辑次序在物理上连续排列,因此使用向量是最好的实现方式。当然,为简化和提高效率,也可以直接使用数组,此时散列表也被称作桶数组。

若桶数组的容量为 R,则其中合法秩的区间 [0, R) 也称作地址空间。

2、散列函数

一组词条在散列表内部的具体分布,取决于所谓的散列方案 --- 事先在词条与桶地址之间约定的某种映射关系,可描述为从关键码空间到桶数组地址空间的函数:

hash() : key -> hash(key)

这里的hash()称作散列函数。反过来,hash(key)也称作key的散列地址,亦即与关键码key相对应的 桶 在散列表中的秩。

3、实例

以学籍库为例。若某高校 2011 级共计 4000 名学生的学号为 2011-0000 至 2011-3999 ,则可直接使用一个长度为 4000 的散列表 A[0~3999],并取:hash(key) = key - 20110000

从而将学号为x的学生学籍词条存放于桶单元 A[hash(x)]

如此散列之后,根据任一合法学号,都可在 O(1) 时间内确定其散列地址,并完成一次查找、插入或删除。空间性能方面,每个桶恰好存放一个学生的学籍词条,既无空余亦无重复。这种在时间和空间性能方面均达到最优的散列,也称作完美散列。

然而遗憾的是,上面的实例都是在特定条件下才成立的,在实际日常中完美散列并不常见。

在更多的应用中,为了兼顾空间和时间效率,无论散列表或散列函数都需要经过精心的设计才行。

2.2、装填因子

所谓装填因子,即在散列表中非空桶的数目与桶单元总数的比值。

当装填因子过于的小时,会导致空间利用率过低。

既然空间浪费情况如此严重,那么为什么仍然想要使用散列表呢?这是因为散列方法的查找和更新速度实在是太诱人了。那么接下来的任务就是如何在保持优势的前提下,尽可能的优化其空间利用率。

2.3、散列函数

1、简介

hash() : key -> hash(key)

如果假定关键码均为 [0, R) 范围内的整数,将词典中的词条数记作 N,散列表长度数记作 M,于是有: N < M

即我们需要满足散列表长度 M 要尽可能的和词条数 N 在一个数量级,并且远小于整个关键码数量 R。

因此,对于散列函数的作用可以简单的表明:将关键码空间 [0, R) 中的任一 key 映射至散列地址空间 [0, M) ,以此提高装填因子,降低空间浪费率。

前面我们提到了装填因子:即 入 = N / M ( 存放的词条 / 桶数组 )

首先明确,入 不可能超过 100% ; 其次,是否只要 入

2、冲突

在设计散列函数时,有些情况下,即使 key 不同,但通过散列函数映射出来的结果却有可能一样,此种情况就称为冲突。

就像这样:

那么会有某些定址方式(散列函数实现)能保证不发生冲突吗?即是实现“单射”。答案是可以的,但是这些都是一些特别的情况,不具有一般性。

因此我们仍然需要寻找平衡时间效率和空间利用率的平衡点!

2.4、散列函数设计概览

词条空间(R) -> 可能的词条

地址空间(M) -> 散列表

在实际中,散列函数关于 R 与 M 之间的映射不可能为单射,因为通常情况 R 要远大于 M。

虽然完美散列中的单设难以实现,但是好消息是,近似的单设却是可行的!

为了实现近似的单设,我们需要做两件事:

1)精心设计散列表及散列函数,以尽可能的降低冲突的概率;

2)指定可行的预案,以便在发生冲突时,能够尽快的予以排解

接下来就要分两部分进行学习!

2.5、设计散列函数

什么样的散列函数足够优秀呢?

1)确定:同一关键码总是被映射到同一地址

2)快速:各个操作的时间复杂度在常数时间 O(1)

3)满射:尽可能充分的覆盖整个散列空间

4)均匀:关键码映射到散列表中各位置的概率尽可能接近,可有效避免聚集现象

2.5.1、除余法

hash(key) = key % M

若 M 取 2 ^ k ,则其效果相当于 key 与 ( M - 1 )做位运算:key % M = key & ( M - 1 )

在这种请款下,发生冲突的概率较大,那么 M 究竟应该取什么值呢?

实际上,我们说当 M 取到 素数 时,数据对散列表的覆盖最为充分,分布最均匀。

2.5.2、MAD 法

在除余法中,有两个很明显的缺点:

1)无论表长 M 取值如何,总有 hash(0) 得 0;

2)[0, R)的关键码可以平均分到 M 个桶,但是相邻关键码的散列地址也必然相邻。

此时可以考虑 MAD 法,即当 M 取素数时,a > 0, b > 0 且 a % M != 0,此时有:

hash(key) = ( a * key + b ) % M

这也被称为一阶均匀,此时临近的关键码,散列地址也不再临近。

除此之后,还有更高阶的方法,但是并非所有场合都需要高高阶的均匀性。

除了上面两种方法意外,还有其他的散列函数方法,例如数字分析法,平方取中法,折叠法,位异或法等等,总之对于散列函数的设计,越是随机,越是没有规律,就越好!

2.6、解决冲突

无论怎么精心设计的散列函数,总是有发生冲突的可能性。因此考虑冲突的解决方案也是必须的。

2.6.1、多槽位法

所谓多槽位法,即将每个桶单元细分位若干槽位,用于存放在同一桶单元中冲突的元素。

就像图中这样,每一行都代表一个桶单元,每个桶单元中有 A,B,C,D 四个槽位用于存放冲突的元素。

只要每个桶单元中的槽位在一定数量内,就仍然可以保证 O(1) 的时间效率。

但是这里有一个难点,即每个桶单元的槽位应该预留多少,这是无法预测的!预留过多则会导致严重的空间浪费。另一个方面,无论你预留再多,在极端情况下都有可能出现槽位不够的情况。

2.6.2、独立链

也许你已经想到,上面出现的问题,不是和数组的预留空间类似吗。那么想要自由的增加数量,则考虑链表这种数据结构。

通过在桶中预留指针,只需要在冲突时使用指针实现列表即可。

这种解决冲突的方法有点在于无需预留槽位,并且无论有多少冲突元素都可以解决。

但是该方法的缺点也很明显,即不仅指针需要空间,如果有大量冲突,还需要进行动态的节点申请,更重要的是,使用链表导致他们的空间未必是连续分布的,这样会导致系统的缓存机制近乎失效。

2.6.3、公共溢出区法

在内存中单独开辟出一块连续空间,发生冲突的词条按顺序存入该空间。

该方法有点在于实现简单,但是缺点也很明显,即一旦发生冲突,则有可能导致处理冲突的时间正比于公共溢出区的规模。

2.6.4、开放定址

所谓开放定址,即在发生冲突时只允许在当前散列表内部为其寻找空桶,这样的条件下,各个桶就并非只能存放特定的某组词条。因为散列地址空间对所有词条开放,故这一新的策略被称作开放定址。

同时,因为可用的散列地址仅限于散列表所覆盖的范围内,所以也被称为闭散列。相对的,之前的则被称为封闭定址或开散列。

开放定址的优点是:自身的结构简洁,无需申请额外空间去解决冲突。

而缺点也很明显,即冲突之后可能会引发本可避免的新冲突。

实际上,开放地址策略包含了一系列的冲突解决方法,包括线性试探法,平方试探法以及再散列法等。

需要注意的是,由于不能使用附加空间,因此装填因子需要适当降低,通常都取 入 < 0.5 。

下面就让我们逐一介绍相关的冲突解决方案吧。

2.6.5、线性试探法

该方法的优点在于:无需附加的空间(如指针,链表),查找链具有局部性,可充分利用系统缓存,有效减少 I/O 。

但其操作的时间大于 O(1),且冲突的情况会增多(一个冲突可能导致若干的后续冲突)

2.6.6、懒惰删除

在开放定址中,先后插入相互冲突的词条,若需要删除某一词条,则可能导致查找链被切断,后续的词条明明存在却不能访问。

在这种情况下,懒惰删除是一个不错的选择,即给查找链上的桶做上删除标记,而此时带有删除标记的桶多扮演的角色就有两种:

1)查找词条时,被视作 “必不匹配的非空桶”,查找链在此得以延续

2)插入词条时,被视作 “必然匹配的空闲桶”,可以用来存放新的词条

2.6.7、平方试探

相比较线性试探,平方试探的优势在于一旦发生冲突,就可快速的跳离冲突区域。

但这样的大范围跳离,也可能导致 I/O 的激增。

同样,还有一个缺点不易被发现,即通过这样的方式,一定能找到空桶吗?

我们直接给出答案:若 M 是素数,且 入 < 0.5 ,则一定能够找出空桶;否则不一定能找出。

在满足上述条件的情况下,我们只利用到了一般的空间,那么剩下的一半空间能利用起来吗?

答案是可以的,即双向平方试探。

2.6.8、双向平方试探

不过有一点需要注意的是,使用双向平方试探,要保证 M = 4 * k + 3

至此,散列的学习即告一段落,本节的内容会比较生涩一些,希望多读多理解,一定能够完全明白散列和运用散列。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值