Dictionary Problem & Implementation
Dictionary Problem
dictionary, map, associated array 以及 symbol table 实际上都是指同一种抽象数据类型(ADT),它维护着一个键值对(这里记为 item)集合,支持以下方法:
方法
描述
将一个 item 加入到集合中
从集合中删除 item
如果 key 存在,则返回对应的 item
几乎所有语言都有 Dictionary ADT 的实现,以 Python 为例:
Python dictionary
Python set (dictionary where items are keys without values)
场景
几乎所有现代编程语言必备:Python, Perl, Ruby, JavaScript, Java, C++, C#, ...
database
compilers & interpreters: names -> variables
network routers: IP address -> wire
network server: port number -> socket/app
virtual memory: virtual address -> physical
substring search
string commonalities
file/directory synchronization
cryptography
Implementation
Associative List/Linked List
用 Linked List 将所有 item 串联起来,每次搜索最坏情况都需要遍历 Linked List,总结如下:
方法
复杂度(平均)
复杂度(最坏)
在 item 数量较少的时候,Linked List 的实现能展现出较高的性能,且它的常数系数很小。
Direct Access Table
将 items 存储在 array 中,用 key 做 array 的 index,但这会产生两个问题:
keys 必须是非负整数 解决方案:"pre-hash" keys to integers (注意理解这里的 pre-hash)
如果 key 的范围很大,将造成空间浪费 解决方案:"hashing"
Hashing
本质就是通过 hash function 将 key 所处的整个空间 (通常很大)压缩到有限的可接受大小的表 中,如下图所示:
由于 远大于 ,必然存在冲突(collision)的情况,即不同的 key 被映射到同一个值上,解决这个问题通常有两种做法:
chaining:hash function 指定 item 插入的位置,插入位置相同的 key 用链表串联起来
open addressing:hash function 指定 item 插入位置的顺序(多个位置,即所有位置的排列),发生冲突时,选择下一个位置
Hash Functions For Chaining
在 chaining 中,Hash Functions 指定 item 插入的位置
理想方案: Simple Uniform Hashing
什么样的 hash function 才是个好 hash function 呢?
每个 key 被 hash 到每个 slot 的概率相等
每个 key 被 hash 到某个 slot 的概率相互独立
设 为 key 的个数, 为 table 的大小,那么负载系数(load factor) ,search 的时间复杂度为 ,如果 ,即 时,search 的时间复杂度可进一步简化为 。如何实现呢? 实践方案
在实践中,通常很难满足 Simple Uniform Hashing 的假设,但我们仍然能够做到很好的结果。基本的想法就是:避免 hash 值与 keys 出现的模式(patterns)存在相关关系。
Division Method
,实践中 m 应取为质数且不接近 (提示:计算 hash 时考虑的信息)
Multiplication Method
分为三步:
确定一个 0 到 1 之间的常数 A
将 key k 与 A 相乘,取其小数部分
将小数部分与另一个常数 m 相乘,向下取整
以上步骤可以总结为:
这里的 m 取值则应该尽量接近 (思考:计算的方便性)
假设计算机的 word size 为 w,那么上述思想的一种实现就是:
即将 key k 与一个 w 位的常数 a 相乘,得到一个 2w 位的数,再取后者的后 w 位的前 r 位...如下图所示:
从图中可以看出,这种做法实际上就是将 key k 平移多次并加总,然后取图中红色区域的 bits 作为 hash 值,直觉可以观察出,红色区域的 bits 是随机程度最高的。此时 就应该接近 hash 表的大小。
Knuth 建议:
Universal Hashing
Universal Hashing 是一种随机算法,核心思想是从一组预定义好的 hash functions 中随机选取一个来执行 hash,从概率上可以证明它的预期效果与 Simple Uniform Hashing 一致。
将在 6.046 的笔记中详细介绍。
Hash Functions For Open Addressing
在 open addressing 中,hash functions 指定 item 尝试插入的位置先后顺序
各操作实现逻辑
Insert
Search
Delete
注意在删除元素的时候,我们不能直接找到该元素然后将其删除,这样之前依赖于这个位置的元素都将无法找到,可以使用特殊的标记,如 "DeleteMe",Insert 将它当作是 None,但是 Search 将它当作存在的元素。
理想方案:Uniform Hashing Assumption (与 Simple Hashing Assumption 不同)
对于 Open Addressing 来说,什么样的 hash function 才是个好 hash function 呢?
每个 key 的插入顺序是全排列中的任意一个的概率相同
通过推导可以得到:假设我们将 个元素插入到大小为 的表中,下一次插入尝试的次数期望为:
实践方案
Linear Probing
Linear Probing 就是在发生冲突的时候,找到最近的下一个空闲位置,将 item 插入。
一个形象的例子就是停车位,通常驾驶员会从马路边的某个车位开始向一个方向寻找空闲车位,知道找到为止。
这种策略的问题很明显,在 hash function 不够随机的时候,可能会出现聚簇(cluster),导致所有聚簇附近的操作的效率下降。经过概率分析,可以推导出:当 时,聚簇的大小为 ,因此从概率分析角度上看, insert, search, delete 的操作复杂度都将降格为 。
Double Hashing
顾名思义,就是使用两个 hash function 组合而成,其公式如下:
但我们仍然需要保证 是位置的一个排列(permutation),怎么做?
要保证 与 互质(relatively prime)
比如: ,且 是奇数
可以通过证明推导出,在一定条件下,Double Hashing 能达到 Uniform Hashing Assumption 的效果。
Table Resizing
目前为止,我们的讨论都是在 Hash Table 大小不变的前提下,但不论我们使用 chaining 还是 open addressing,当负载系数 逐渐增大时,Hash Table 的性能都将受到影响。
Hash Table 应该设为多大?
通常我们也不知道表应该设置为多大,所以简单地想法就是:从一个小的常数开始,按需放缩。
在扩容和缩容操作之后,通常我们还需要重新计算每个元素的 hash 值,这个过程称为 Rehashing
放缩的速度
m += 1:每次容量 +1,这样 次 操作的复杂度就是
m *= 2:每次容量 * 2,这样 次 操作的复杂度就是
什么时候放缩
放大:当 load factor 达到阈值时,或者 接近 时(有点晚了)
缩小:当 接近 时
参考
2011 Fall, MIT 6.006, lecture 8-10
Last updated