从零开始手撸 HashMap
Posted 流利说技术团队
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从零开始手撸 HashMap相关的知识,希望对你有一定的参考价值。
大部分语言中 HashMap
都是内置的基础数据结构,使用也非常频繁。 本文会简单介绍两种常见的实现 HashMap
的数据结构,并分析不同实现方式的时间和空间效率。第一种 Hash Table
比较直白,相对而言后一种 HAMT
要更为复杂。
Hash Table
首先想到的实现方式就是最直接利用数组来存储所有的元素,然后把元素通过某种方式映射到数组中。为了方便说明,例子只考虑 int 类型。比如用一个长度为11的数组作为值域,可以对于任何一个输入直接取模,这样存和取都是 O(1)
的操作。但这样有个问题在于数组长度有限的情况下,如果有两个 key 取模后要怎么办。
def set(key, value)
@hash[key.to_i % 11] = value
end
解决冲突
常见的处理冲突的方式如下图所示(来自 wikipedia),利用额外的链表来存储拥有同样值的元素。
简单来说就是同一个映射值后面对应了一串结果,每次查找都需要去遍历一次那个链表。考虑一个最坏的情况,我们每次插入的都是 11 的倍数,这样查找就退化到了 O(n)
。
当然,在实际情况下会有各种优化防止退化到这种比较坏的情况。比如当检查到最长的一个链超过某个长度后,可以扩大整个 buckets 的范围,然后 rehash 来重新处理一道。
class Node
attr_accessor :value, :next
def initialize(value, next = nil)
@value = value
@next = next
end
end
class Hash
HASH_NUM = 11
def initialize
@hash = Array.new
end
def set(key, value)
hash_key = key.to_i % HASH_NUM
@hash[hash_key] = Node.new([key, value], @hash[hash_key])
end
def get(key)
node = @hash[key.to_i % HASH_NUM]
value = nil
while next = node.try(:next) do
if next.value[0] == key
value = next.value[1]
break
end
node = next
end
end
end
话说,用 Ruby 实现一个链表真的很别扭。
散列函数
从上面的介绍中可以看出,直接取模这么粗暴的方法有一些局限性。比如说容易造成碰撞,另外在扩大或减小值域之后,需要对大量数据进行重新 hash,这样代价很高不利于拓展和容错。
为了解决上述问题,可以使用特殊的散列函数来解决。在很多语言中,都用到一个叫做 MurmurHash 的一致性散列函数,其代码只有20行左右,非常简短。
日常中散列函数使用频率也非常高。比如要判断两个文件是否相等,一个简单的办法就是通过 MD5 或者 SHA-1 生成哈希值然后进行比较。其中 MD5 和 SHA-1 都是有名的散列函数。不幸的是,最近 Google 刚弄出第一例 ,能够特意制造一个文件来生成相同的 SHA-1 值。虽然目前来看特意制造碰撞的代价不小,但是对于安全领域来说能够特意碰撞已经是不可逃避的问题。
时间复杂度和空间效率
时间上很大程度取决于不同的散列函数。比如取值范围是[1, 11]
,可以写个散列函数直接返回对应的数值达到 O(1) 的时间复杂度。但也可以写出永远返回一个常数这样奇怪的函数,来做到 O(n) 的复杂度。空间上在理想情况下,没有很多碰撞数据的话,可以维持在一个常数也就是 map 的值域。
在 Ruby 中使用 Hash 有个很有意思的点。考虑下面这段代码,最终的 benchmark 结果有点出乎意料。
Benchmark.ips do |x|
x.report(5) do
{ a: 0, b: 1, c: 2, d: 3, e: 4 }
end
x.report(6) do
{ a: 0, b: 1, c: 2, d: 3, e: 4, f: 5 }
end
x.report(7) do
{ a: 0, b: 1, c: 2, d: 3, e: 4, f: 5, g: 6 }
end
x.report(8) do
{ a: 0, b: 1, c: 2, d: 3, e: 4, f: 5, g: 6, h: 7 }
end
x.compare!
end
Calculating -----------
5 65.986k i/100ms
6 63.966k i/100ms
7 30.713k i/100ms
8 28.991k i/100ms
----------------------------------
5 1.243M (± 4.3%) i/s - 6.203M
6 1.202M (± 5.3%) i/s - 6.013M
7 373.366k (±13.7%) i/s - 1.843M
8 351.945k (± 8.8%) i/s - 1.768M
Comparison:
5: 1243005.5 i/s
6: 1202032.4 i/s - 1.03x slower
7: 373366.5 i/s - 3.33x slower
8: 351945.1 i/s - 3.53x slower
可以发现 hash 中元素个数到达 7后性能上有明显的下降。这是因为从 Ruby 2.0 开始引入了新的优化。对于包含6个或者更少元素的散列,不去计算其散列值,而只是简单在数组中保存散列数据。因此每次查询是通过直接枚举每个元素的值来进行判断的。
Hash Array Mapped Tree
在函数式语言中,我们通过一系列的操作来修改某个状态,并希望这一串操作是原子的。持久化数据结构就可以解决这种问题,原来的状态保持不变,只有在整个操作完成后新状态产生时,才将新的状态替换回去。
我们第二种实现 Hash Map
的方式就通过常用作可持久化数据结构的 HAMT
来实现。
因为实现代码实在过长,不易于放在文章当中,更重要的是放了也没人看。所以推荐大家直接去阅读一些源码,比如可以看 Ruby 版本的实现: 。
Vector Trie
在介绍 HAMT
之前需要先提到另外一种可持久化数据结构,Vector Trie
,在一定程度上类似于链表的数据结构。
在这种数据结构当中,所有的数据都保存在树的叶子节点,树最下层叶子节点储存了实际的数据类似于一个串。 区别在于,不同于串使用末尾的指针指向下一个数据单元,Vector trie 使用 Trie 树结构作为每个数据节点的索引。在 Vector trie 当中,每次检索都从根开始,依次经过多个中间节点到达叶子节点并获得数据。Trie 的样子如下图所示(图片来自 wikipedia):
比如要查询一个 index 为4的元素的值。我们需要把4也就是 int32 表示为一连串符号。最简单的方式当然就是转换为2进制表示,这样树中由上到下存储的是一个整型转换为二进制表示后每一位的值。比如4这个数字的二进制表示是100,在 Trie 中也就可以按照 1 -> 0 -> 0 的顺序查到对应的叶子节点也就是最终值。
在实际实现中,Vector trie 一般使用有 32 个分支的内部节点,整个树的结构更加扁平化, 操作的时间效率也更高,一般来说是 O(log32N)
。当然,O(log32N)≠O(1)
,但是很多 Vector trie 的实现为了宣传的目的, 都自诩为常数时间的时间复杂度。
HAMT
介绍了 Vector Trie 实现的持久化 List,如果将这种 List 作为基础,直接套用传统 Hash Table 的实现方法, 其实就可以实现持久化的 Hash Map 了。但是这种解决方案在时间效率、空间效率上都比较差。Hash Table 和 List 的一个区别在于 Hash Table 当中保存的元素是散列和稀疏的,不像 List 那样从下标 0 一直排列到 n。 然而只要把 List 当中下标必须连续的限制条件去掉,Vector Trie 本身就变成了一种相对传统 Array 更好的容器。
在引入 Trie 树作为数组的稀疏表示之后,已经大幅地提高空间效率并得到了不错的时间效率。尽管查询 Trie 树比直接访问数组更慢一些,但是由于表示的 Hash 空间足够宽广,在实际应用中遇见碰撞的概率极低, 因此在时间效率上还是很有竞争力的。
持久化 HAMT
这个是使用 HAMT 来作为 Hash Map 数据结构的原因之一。 因为使用树状结构,在进行增删改的时候,可以方便地只对一棵子树来进行修改或者删减,能够极大减小因为需要持久化而造成的空间和效率的损失。简单来说在需要修改节点时,不对原来的节点进行直接的修改,而是生成新节点并复制节点到根的路径。 这样对于原来的整棵树依然保持不变,直到新的子树操作完成,在把新的节点指过去。
时间复杂度和空间效率
考虑到持久化数据结构本身的不变性,每次修改都需要生成新的对象,这明显会比普通的数据结构更加消耗空间。而且也因为使用了更复杂的数据结构,在查询操作上也会更慢一点。总而言之,因为其更强大的特性,所以有着更高的复杂性,也就因此在时间和空间上有所妥协。
但 HAMT
相较而言已经是完全可以接受的程度(废话)。其时间复杂度是常数级别,取决于 Trie 节点宽度等。空间上,虽然用来表示 Hash 的空间足够大,但是因为可以省略树上实际的节点不去申请内存,所以是个非常优美的稀疏数组的表现方式,空间效率很高。而且除了前面讲到的常规实现,实际操作中会有很多优化可做,比如压缩树的高度、节点内部的压缩等。
总结
两种 Hash Map
的实现方式各有优劣,主要还是看应用场景来决定。比如 Ruby MRI 就是使用的第一种较为粗暴的方式,但也简单直接。而在很多函数式语言中就会用到 HAMT
来实现可持久化数据。
希望大家在读完本文后(希望有人能通读完),能对 Hash Map
的实现有基本的了解。
References:
《Ruby原理剖析》: https://book.douban.com/subject/26920403
Rubinius : https://github.com/rubinius/rubinius
MurmurHash: https://en.wikipedia.org/wiki/MurmurHash
Consistent hashing: https://en.wikipedia.org/wiki/Consistent_hashing
SHA-1 碰撞: https://security.googleblog.com/2017/02/announcing-first-sha1-collision.html
Tire: https://en.wikipedia.org/wiki/Tire
以上是关于从零开始手撸 HashMap的主要内容,如果未能解决你的问题,请参考以下文章