今天来探究哈希表,.net内置的哈希表容器是Hashtable类,而Dictionary<TKey,TValue>是对应的泛型哈希表.
哈希表-Hashtable的实例化
一般我们实例化ArrayList或List<T>的时候,如果不指定容量,则其内部是赋值为一个静态的空数组。当有添加操作时,会实例化为一个长度为4的数组,如果容量满了以后,再添加,就会自动扩充为两倍的容量。
哈希表也有一个类似的情况,new Hashtable()如果不指定长度,则会将内置bucket数组的长度默认指定为11。如果给定一个值如new Hashtable(20),也并不会将bucket数组长度设置为20,而是循环内部一个素数数组,设置为刚好大于20的素数(也叫质数),此处是23。可知,哈希表内部存取数组长度总是一个素数。相关代码:
public Hashtable(int capacity, float loadFactor)
{
......//默认值为11
int num2 = (num > 11.0) ? HashHelpers.GetPrime((int) num) : 11;
this.buckets = new bucket[num2];
......
}
[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
internal static int GetPrime(int min)
{
......
//循环素数数组找出刚好大于min的素数
for (int i = 0; i < primes.Length; i++)
{
int num2 = primes[i];
if (num2 >= min)
{
return num2;
}
}
......
}
//初始化素数数组
static HashHelpers()
{
primes = new int[] {
3, 7, 11, 0x11, 0x17, 0x1d, 0x25, 0x2f, 0x3b, 0x47, 0x59, 0x6b, 0x83, 0xa3, 0xc5, 0xef,
0x125, 0x161, 0x1af, 0x209, 0x277, 0x2f9, 0x397, 0x44f, 0x52f, 0x63d, 0x78b, 0x91d, 0xaf1, 0xd2b, 0xfd1, 0x12fd,
0x16cf, 0x1b65, 0x20e3, 0x2777, 0x2f6f, 0x38ff, 0x446f, 0x521f, 0x628d, 0x7655, 0x8e01, 0xaa6b, 0xcc89, 0xf583, 0x126a7, 0x1619b,
0x1a857, 0x1fd3b, 0x26315, 0x2dd67, 0x3701b, 0x42023, 0x4f361, 0x5f0ed, 0x72125, 0x88e31, 0xa443b, 0xc51eb, 0xec8c1, 0x11bdbf, 0x154a3f, 0x198c4f,
0x1ea867, 0x24ca19, 0x2c25c1, 0x34fa1b, 0x3f928f, 0x4c4987, 0x5b8b6f, 0x6dda89
};
}
从源码可以得出一个结论:Hashtable总是创建一个比我们预想中要大一些的数组。
哈希表-Hashtable的存取方式
Hashtable内部包含了一个bucket类型的数组变量,bucket是一个结构,用来保存Key,Value和哈希值
从上面代码可以看出Hashtable跟ArrayList,List<T>一样,内部都是用Array数组进行存储数据。既然同样是数组,Hashtable和ArrayList存取方式有什么不同?
当Hashtable添加数据时,(例如:hash.Add(key)) 首先调用当前键值key的GetHashCode()方法,得到哈希值(该值为Int32),取该哈希值的绝对值跟内部数组(上面代码的buckets)的总容量进行余运算(就是'%'操作),得到一个小于总容量的值,把该值当做数组序号,存在对应的位置,通过Key获取数组序号的过程叫映射。
当然,两个不同的key值有可能的得到相同的序号,这叫哈希冲突,在此不做分析。
下面是部分源码:
private void Insert(object key, object nvalue, bool add)
{ ......
uint num3 = this.InitHash(key, this.buckets.Length, out num, out num2);
int num4 = 0;
int index = -1; //num6就是通过余计算得到的数组序号。
int num6 = (int) (num % this.buckets.Length);
......
}
private uint InitHash(object key, int hashsize, out uint seed, out uint incr)
{
//获取key在CLR中的HashCode值,并取绝对值。
uint num = (uint) (this.GetHash(key) & 0x7fffffff);
seed = num; //此处针对哈希冲突
incr = 1 + ((uint) (((seed >> 5) + 1) % (hashsize - 1)));
return num;
}
既然Hashtable本身也是数组存储,查询速度又快,那还要ArrayList做什么?存储数据的时候都用Hashtable不就行了?有句话说,XX打开了一扇门,就必然要关闭很多窗,现在分析一下Hashtable关闭的那些窗。
首先,哈希表不能有重复键值。
其次,哈希表会占用更多内存空间。
第一点显而易见,现在分析第二点。
哈希表-Hashtable添加时的内部扩充方式
private struct bucket
{
public object key;
public object val;
public int hash_coll;
}
public class Hashtable : //相关接口
{
private IEqualityComparer _keycomparer;
private object _syncRoot;
private bucket[] buckets;
//....
}
我们知道ArrayList和List<T>在添加数值时,如果容量不足,会新建一个二倍容量的数组,然后把原数组数据复制过去,达到容量扩充的目的,所以使用ArrayList和List <T>的时候,建议在实例化方法里指定容量。
那么Hashtable在添加操作时碰到内部数组容量不足会怎么做?
其实处理方法和ArrayList基本相似,也是二倍扩充,但略有不同。不同点在于:
首先,扩充的大小比二倍略大。上面在实例化Hashtable那一部分中提到,由于哈希算法本身的特点,数组长度总是一个素数,所以当需要扩充时,新的数组长度是刚好大于原数组二倍长度的素数,也就是说略大于二倍。
其次,数组在未存满的情况下就要扩充。Hashtable有一个加载因子为0.72f。实际能存储的数据量等于内部Array长度* 0.72f。也就是说一个长度为11的数组,当存了7个数据以后,就不能再存储,需要要扩充容量。代码如下:
public Hashtable(int capacity, float loadFactor)
{
......
//初始状态loafFactor=1f
this.loadFactor = 0.72f * loadFactor;
......
int num2 = (num > 11.0) ? HashHelpers.GetPrime((int) num) : 11;
this.buckets = new bucket[num2];//实际可加载量
this.loadsize = (int) (this.loadFactor * num2);
......
}
//Hashtable的Add方法内部调用了Insert方法
private void Insert(object key, object nvalue, bool add)
{
......
if (this.count >= this.loadsize)
{ //内部容量扩充的方法
this.expand();
}
......
}
也就是说在Hashtable中,假如初始化了100个位置,只有72个可用,其他28个不能用,但仍然要占着地方。
由此我们可以知道,如果保存的数据量相同,Hashtable要比ArrayList占用更多的内存空间。
哈希表-泛型Dictionary<TKey,TValue>和Hashtable比较
.net 2.0之后出现了泛型,泛型避免了装箱拆箱造成的损失,在大多数情况下,使用泛型集合要比传统的集合有较高的性能。List<T>对应ArrayList,Dictionary<TKey,TValue>对应Hashtable。
Dictionary<TKey,TValue>除了泛型带来的优势以外,还在哈希值算法,哈希冲突检测上做了改进,也没有了100个位置28个不能用的空间浪费。所以当碰到哈希表使用时,优先考虑Dictionary<TKey,TValue>。
最后总结:哈希表在查询方面有明显优势,但会占用多一点的内存空间,当业务中的集合有大量的查找操作时,优先考虑哈希表,一旦决定使用哈希表,优先考虑泛型哈希表。