代码改变世界

HashSet的实现(下)

2011-06-17 17:19 by Anders Cui, ... 阅读, ... 评论, 收藏, 编辑

HashSet的实现(上)中,简要介绍了散列法(hashing)的内容,并以二次探测法实现了一个简单的HashSet。在本文中,将进一步讨论散列法,尤其是GetHashCode方法的实现,最后给出完整的HashSet实现。

散列法再议

通过散列法实现的容器,不管是HashSet、Hashtable还是Dictionary,需要支持的基本操作是insert、remove和find,特别是insert和find,三个操作的时间复杂度期望是O(1)。

散列法的使用过程中,主要有两个问题:

  1. 散列函数的实现;
  2. 冲突的解决办法;

下面将在上篇的基础上进一步讨论这两个问题的解决方案。

散列函数的实现

一般情况下,要存储的元素(key/k)类型可能有多种,可能取值的集合为K,它的个数为,实际需要insert的个数为n。现在要把这些元素存放在大小为M的数组内,这样我们就需要一个函数h: K -> {0, 1, …, M-1},它可以将元素k映射到数组的一个有效下标,这个函数就是所谓的散列函数(hash function),可以记为h(k, M)。一个好的散列函数需要具备如下特性:

  1. 容易计算(主要是指散列函数的时间复杂度为常数时间)。
  2. 将元素均匀地映射到数组中,减少冲突的可能;

进一步,可以将h(k, M)视为如下两个函数f、g的复合函数:

f和g对h需要具备的两个特性,都会有所影响。

g将f返回的一个int值映射到{0, 1, …, M-1},常规方法是除法散列法(Division Method),即g(k) = k % M。在应用除法散列法时,需要主要M值的选取。比如,M不应是2的幂,如果M=,则 k % M的值就是k的二进制表示的p个最低位数字,要满足特性2,可能就会有问题了。可以选做M的值常常是与2的幂不太接近的素数。除了除法散列法,还可以选择乘法散列法(Multiplication Method): 。可分为两步,第一步,用k乘上常数A(0<A<1),取出kA的小数部分,即kA mod 1;第二步,用M乘以小数部分,取其下取整。

相比较而言,f的实现要复杂很多。首先,用作key的值的类型多种多样,包括各种自定义类型,另一方面要考虑h的两个特性。大体上,可以将f对应于object.GetHashCode()方法。默认情况下,每个类型从object(引用类型)或ValueType(值类型)继承了一个GetHashCode的实现。如果我们将h的两个特性与特定的.NET平台结合起来考虑,实现GetHashCode()的时候需要如下几个规则(rule):

  1. 计算简单;
  2. 产生的int值应当是均匀分布的;
  3. 如果两个对象相等(由==操作符确定),它们必须产生相同的hashCode;
  4. 对于任意一个对象o,o.GetHashCode()的返回值应当是一个实例不变量,即总是返回相同的值。

接下来就来详细地讨论如何实现GetHashCode()方法。

实现GetHashCode()方法

既然每个类型都从object(引用类型)或ValueType(值类型)继承了一个GetHashCode的实现,那还有必要自己实现吗?可以确定的是,没有一种完美的实现可以应用于所有类型,否则一个object.GetHashCode()方法就够了。因为object和ValueType对其派生类的可能信息一无所知,所以它们只能提供一个可用的实现,而未必是最佳的

对于引用类型来说,它继承了object.GetHashCode(),该方法返回在AppDomain内可以唯一标识该对象的值,在对象的整个生命周期内,该值是不变的,但经过垃圾收集之后,这个值可以复用到其它的对象。这个值也等于RuntimeHelpers.GetHashCode()方法的返回值,即使我们为某个类型提供了自己的GetHashCode()实现,RuntimeHelpers.GetHashCode()方法的返回值仍然是那个默认的值

对于引用类型,默认的==会检查两个对象的引用(identity),如果相等,必然会返回相等的值;而一旦重写了==操作符,就需要同时重写GetHashCode,否则编译器会警告你的。现在看一下上面的f要满足的4个规则,object.GetHashCode()可以满足1、3、4,但规则2未必能满足,它产生的值倾向于使用int的较低位(这个结论来自《Effective C#》,未验证),所以说,object.GetHashCode()是可用的,但可能会在使用Hash容器时出现性能问题。当你创建了新的引用类型,并确定会用做Hash容器的key的话,最好重写GetHashCode方法。

对于值类型来说,它继承了ValueType.GetHashCode(),该方法用到了反射,所以比较慢。关于具体的实现,《CLR via C#》中说是对类型的某些实例字段做了XOR操作,《Effective C#》中说是返回了第一个字段的GetHashCode()。经过测试,应该说ValueType.GetHashCode()是由第一个字段的GetHashCode()决定的,但不相等。ValueType的问题要比object更严重。对于规则1,上面说过,由于反射会比较慢;规则2,说一个极端点儿的情况,如果第一个字段是bool类型的,那会出现怎样的悲剧?;规则3,还是一样,如果重写了==,必须要同时重写GetHashCode()。

对于规则4,就很可能有问题了。 对于一个值类型的实例,它的hashCode取决于第一个字段,如果第一个字段是可修改的,一旦修改,hashCode的值也就改变了。当我们把key加入到Hash容器时它的hashCode是h1,如果后来改成h2,就意味着原来的实例找不到了。。。

总结下来,object.GetHashCode()对引用类型来说是一个正确的实现,但不一定会产生均匀分布的hashCode,GetHashCode必须与==同时重写;ValueType.GetHashCode()较慢,而且仅在第一个字段只读、能提供均匀分布的hashCode时可用。如果在计算hashCode时用到了3个字段,这3个字段应该都要参与到==的实现里;参与到hashCode计算的字段或属性最好设置为只读的。

最好还是看看在.NET Framework中GetHashCode是怎么实现的。

FCL中GetHashCode的实现

1 // 32位的int
2  public override int GetHashCode()
3 {
4 return this;
5 }
6
7  // 16位的char
8  public override int GetHashCode()
9 {
10 return (int)(this | (int)this << 16);
11 }
12
13  // 64位的long
14  public override int GetHashCode()
15 {
16 return (int)this ^ (int)((int)this >> 32);
17 }

整数类型的实现是最简单的,需要在int内均匀分布,所以像char这样的,要补足32位(通过<<),同时要用到所有的位(像long这样的实现,会用到64位的每一位)。对于复合的值类型,可考虑如下方法:

1 // 值类型:System.Drawing.Point
2  public override int GetHashCode()
3 {
4 return this.x ^ this.y;
5 }

对若干字段进行^操作。

对于最常用作key的string类型,它的GetHashCode实现比较复杂,具体的分析可以看这里。唔,关于散列函数h的讨论就先到这里了。

冲突的解决办法

上篇曾介绍过两种方法:线性探测法二次探测法,它们和更好的双重散列法都属于开放寻址法的范畴,只是探测序列的分布有所不同。在开放寻址法中,所有的元素(key)都放在散列数组本身中,插入新元素时,通过探测序列找到它的合适位置。

而在分离链接法中,把散列到同一槽(slot)的元素都放在一个链表中,这样散列数组存放的只是相对应链表的指针而已,借助于链表,分离链接法实现起来其实更简单,它不需要花费很多心思在那个探测序列的选择上。

可以看到,要对散列法、散列函数有全面的认识,为类型提供良好的GetHashCode()实现都不是容易的事情。最好的办法还是借助于ILSpy这样的反编译工具去看一下FCL中HashSet<T>、Dictionary<K, V>和Hashtable的实现,园子里有些这方面的文章。关于Hash,就到这里,余下的部分将讨论ISet的实现。

ISet接口简介

ISet<T>继承了IEnumerable<T>和ICollection<T>,前面讨论过的insert、find、remove操作对应ICollection<T>的Add、Contains和Remove方法。ISet<T>本身的方法几乎都是(除了Add)关于两个集合间的操作的,比如求交集、并集、差集,判断是否是另一集合的子集等等,这些实现起来不难,差不多就是把集合操作的定义搬过来就行了,这里就不再赘述了。

根据《数据结构与问题求解Java语言描述》和FCL的HashSet<T>代码写了一个简单的HashSet实现,没有经过严格的测试,可以当作伪代码来参考一下:)

参考

数据结构与问题求解
算法导论
CLR via C#
Effective C#
Hash Function
Hash Table
string.GetHashCode()
ILSpy