从0手撸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行左右,非常简短。

另外,在 Ruby 中对于一个对象会先取得它的 C 指针值,也就是其实际的内存地址,本质上可以看作这个对象的唯一 ID,然后传入散列函数中进行打乱得到一个伪随机的整数。而对于字符串和数组,会遍历其中元素得到每个元素单独的散列值最后累加。实际效果可以调用对象的 hash 方法来感受。

日常中散列函数使用频率也非常高。比如要判断两个文件是否相等,一个简单的办法就是通过 MD5 或者 SHA-1 生成哈希值然后进行比较。其中 MD5 和 SHA-1 都是有名的散列函数。不幸的是,最近 Google 刚弄出第一例 SHA-1 碰撞,能够特意制造一个文件来生成相同的 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

函数式编程因为良好的可维护性并且天生支持并发,最近几年越来越火。比如我们公众号之前有篇文章,「在认识 Ecto 之前,我从未如此了解 ActiveRecord」其中提到的 Elixir 就是最近热门的函数式语言。而可持久化数据结构是其重要组成部分。

在函数式语言中,我们通过一系列的操作来修改某个状态,并希望这一串操作是原子的。持久化数据结构就可以解决这种问题,原来的状态保持不变,只有在整个操作完成后新状态产生时,才将新的状态替换回去。

我们第二种实现 Hash Map 的方式就通过常用作可持久化数据结构的 HAMT 来实现。

因为实现代码实在过长,不易于放在文章当中,更重要的是放了也没人看。所以推荐大家直接去阅读一些源码,比如可以看 Ruby 版本的实现: Rubinius 。

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(log32⁡N)。当然,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:

  1. 《Ruby原理剖析》: https://book.douban.com/subject/26920403

  2. Rubinius : https://github.com/rubinius/rubinius

  3. MurmurHash: https://en.wikipedia.org/wiki/MurmurHash

  4. Consistent hashing: https://en.wikipedia.org/wiki/Consistent_hashing

  5. SHA-1 碰撞: https://security.googleblog.com/2017/02/announcing-first-sha1-collision.html

  6. Tire: https://en.wikipedia.org/wiki/Tire

猜你喜欢

转载自blog.csdn.net/sinat_41832255/article/details/80048499
今日推荐