C#中实现了哈希表数据结构的集合类有:
(1) System.Collections.Hashtable
(2) System.Collections.Generic.Dictionary
前者为一般类型的哈希表,后者是泛型版本的哈希表。Dictionary和Hashtable之间并非只是简单的泛型和非泛型的区别,两者使用了完全不同的哈希冲突解决办法。Dictionary我已经做了动态演示程序,使用的是Window应用程序。虽然Dictionary相对于Hashtable来说,更优美、漂亮,但总觉得如果不给Hashtable也配上动态演示程序,也是一种遗憾。这次使用了Silverlight来制作,原因很简单,它可以挂在网上让大家很方便地观看。
先来看看效果,这里需要注意,必须安装Silverlight 2.0 RTW 才能正常运行游戏,下载地址:http://www.microsoft.com/silverlight/resources/install.aspx?v=2.0
程序中的键编辑框中只接受整数,因为整数的哈希码就是整数本身,这可以让大家更直观地查看哈希表的变化。如果输入了非法字符,则会从0至999中随机抽取一个整数进行添加或删除操作。
哈希冲突解决方法
哈希函数的目标是尽量减少冲突,但实际应用中冲突是无法避免的,所以在冲突发生时,必须有相应的解决方案。而发生冲突的可能性又跟以下两个因素有关:
(1) 装填因子α:所谓装填因子是指合希表中已存入的记录数n与哈希地址空间大小m的比值,即 α=n / m ,α越小,冲突发生的可能性就越小;α越大(最大可取1),冲突发生的可能性就越大。这很容易理解,因为α越小,哈希表中空闲单元的比例就越大,所以待插入记录同已插入的记录发生冲突的可能性就越小;反之,α越大,哈希表中空闲单元的比例就越小,所以待插入记录同已插入记录冲突的可能性就越大;另一方面,α越小,存储窨的利用率就越低;反之,存储窨的利用率就越高。为了既兼顾减少冲突的发生,又兼顾提高存储空间的利用率,通常把α控制在0.6~0.9的范围之内,C#的HashTable类把α的最大值定为0.72。
(2) 与所采用的哈希函数有关。若哈希函数选择得当,就可使哈希地址尽可能均匀地分布在哈希地址空间上,从而减少冲突的发生;否则,就可能使哈希地址集中于某些区域,从而加大冲突发生的可能性。
冲突解决技术可分为两大类:开散列法(又称为链地址法)和闭散列法(又称为开放地址法)。哈希表是用数组实现的一片连续的地址空间,两种冲突解决技术的区别在于发生冲突的元素是存储在这片数组的空间之外还是空间之内:
(1) 开散列法发生冲突的元素存储于数组空间之外。可以把“开”字理解为需要另外“开辟”空间存储发生冲突的元素。
(2) 闭散列法发生冲突的元素存储于数组空间之内。可以把“闭”字理解为所有元素,不管是否有冲突,都“关闭”于数组之中。闭散列法又称开放地址法,意指数组空间对所有元素,不管是否冲突都是开放的。
闭散列法(开放地址法)
闭散列法是把所有的元素存储在哈希表数组中。当发生冲突时,在冲突位置的附近寻找可存放记录的空单元。寻找“下一个”空位的过程称为探测。上述方法可用如下公式表示:
hi=(h(key)+di)%m i=1,2,…,k (k≤m-1)
其中h(key)为哈希函数;m为哈希表长;di为增量的序列。根据di取值的不同,可以分成几种探测方法,下面只介绍Hashtable所使用到的双重散列法。
双重散列法
双重散列法又称二度哈希,是闭散列法中较好的一种方法,它是以关键字的另一个散列函数值作为增量。设两个哈希函数为:h1和h2,则得到的探测序列为:
(h1(key)+h2(key))%m,(h1(key)+2h2(key))%m,(h1(key)+3h2(key))%m,…
其中,m为哈希表长。由此可知,双重散列法探测下一个开放地址的公式为:
(h1(key) + i * h2(key)) % m (1≤i≤m-1)
定义h2的方法较多,但无采用什么方法都必须使h2(key)的值和m互素(又称互质,表示两数的最大公约数为1,或者说是两数没有共同的因子,1除外)才能使发生冲突的同义词地址均匀地分布在整个哈希表中,否则可能造成同义词地址的循环计算。若m为素数,则h2取1至m-1之间的任何数均与m互素,因此可以简单地将h2定义为:
h2(key) = key % (m - 2) + 1
剖析System.Collections.Hashtable
万物之母object类中定义了一个GetHashCode()方法,这个方法默认的实现是返回一个唯一的整数值以保证在object的生命期中不被修改。既然每种类型都是直接或间接从object派生的,因此所有对象都可以访问该方法。自然,字符串或其他类型都能以唯一的数字值来表示。也就是说,GetHashCode()方法使得所有对象的哈希函数构造方法都趋于统一。当然,由于GetHashCode()方法是一个虚方法,你也可以通过重写这个方法来构造自己的哈希函数。
Hashtable的实现原理
Hashtable使用了闭散列法来解决冲突,它通过一个结构体bucket来表示哈希表中的单个元素,这个结构体中有三个成员:
(1) key :表示键,即哈希表中的关键字。
(2) val :表示值,即跟关键字所对应值。
(3) hash_coll :它是一个int类型,用于表示键所对应的哈希码。
int类型占据32个位的存储空间,它的最高位是符号位,为“0”时,表示这是一个正整数;为“1”时表示负整数。hash_coll使用最高位表示当前位置是否发生冲突,为“0”时,也就是为正数时,表示未发生冲突;为“1”时,表示当前位置存在冲突。之所以专门使用一个位用于存放哈希码并标注是否发生冲突,主要是为了提高哈希表的运行效率。关于这一点,稍后会提到。
Hashtable解决冲突使用了双重散列法,但又跟前面所讲的双重散列法稍有不同。它探测地址的方法如下:
h(key, i) = h1(key) + i * h2(key)
其中哈希函数h1和h2的公式如下:
h1(key) = key.GetHashCode()
h2(key) = 1 + (((h1(key) >> 5) + 1) % (hashsize - 1))
由于使用了二度哈希,最终的h(key, i)的值有可能会大于hashsize,所以需要对h(key, i)进行模运算,最终计算的哈希地址为:
哈希地址 = h(key, i) % hashsize
【注意】:bucket结构体的hash_coll字段所存储的是h(key, i)的值而不是哈希地址。
哈希表的所有元素存放于一个名称为buckets(又称为数据桶) 的bucket数组之中,下面演示一个哈希表的数据的插入和删除过程,其中数据元素使用(键,值,哈希码)来表示。注意,本例假设Hashtable的长度为11,即hashsize = 11,这里只显示其中的前5个元素。
(1) 插入元素(k1,v1,1)和(k2,v2,2)。
由于插入的两个元素不存在冲突,所以直接使用h1(key) % hashsize的值做为其哈希码而忽略了h2(key)。其效果如图8.6所示。
(2) 插入元素(k3,v3,12)
新插入的元素的哈希码为12,由于哈希表长为11,12 % 11 = 1,所以新元素应该插入到索引1处,但由于索引1处已经被k1占据,所以需要使用h2(key)重新计算哈希码。
h2(key) = 1 + (((h1(key) >> 5) + 1) % (hashsize - 1))
h2(key) = 1 + ((12 >> 5) + 1) % (11 - 1)) = 2
新的哈希地址为 h1(key) + i * h2(key) = 1 + 1 * 2 = 3,所以k3插入到索引3处。而由于索引1处存在冲突,所以需要置其最高位为“1”。
(10000000000000000000000000000001)2 = (-2147483647)10
最终效果如图8.7所示。
(3) 插入元素(k4,v4,14)
k4的哈希码为14,14 % 11 = 3,而索引3处已被k3占据,所以使用二度哈希重新计算地址,得到新地址为14。索引3处存在冲突,所以需要置高位为“1”。
(12)10 = (00000000000000000000000000001100)2 高位置“1”后
(10000000000000000000000000001100)2 = (-2147483636)10
最终效果如图8.8所示。
(4) 删除元素k1和k2
Hashtable在删除一个存在冲突的元素时(hash_coll为负数),会把这个元素的key指向数组buckets,同时将该元素的hash_coll的低31位全部置“0”而保留最高位,由于原hash_coll为负数,所以最高位为“1”。
(10000000000000000000000000000000)2 = (-2147483648)10
单凭判断hash_coll的值是否为-2147483648无法判断某个索引处是否为空,因为当索引0处存在冲突时,它的hash_coll的值同样也为-2147483648,这也是为什么要把key指向buckets的原因。这里把key指向buckets并且hash_coll值为-2147483648的空位称为“有冲突空位”。如图8.8所示,当k1被删除后,索引1处的空位就是有冲突空位。
Hashtable在删除一个不存在冲突的元素时(hash_coll为正数),会把键和值都设为null,hash_coll的值设为0。这种没有冲突的空位称为“无冲突空位”,如图8.9所示,k2被删除后索引2处就属于无冲突空位,当一个Hashtable被初始化后,buckets数组中的所有位置都是无冲突空位。
哈希表通过关键字查找元素时,首先计算出键的哈希地址,然后通过这个哈希地址直接访问数组的相应位置并对比两个键值,如果相同,则查找成功并返回;如果不同,则根据hash_coll的值来决定下一步操作。当hash_coll为0或正数时,表明没有冲突,此时查找失败;如果hash_coll为负数时,表明存在冲突,此时需通过二度哈希继续计算哈希地址进行查找,如此反复直到找到相应的键值表明查找成功,如果在查找过程中遇到hash_coll为正数或计算二度哈希的次数等于哈希表长度则查找失败。由此可知,将hash_coll的高位设为冲突位主要是为了提高查找速度,避免无意义地多次计算二度哈希的情况。
Hashtable的代码实现
哈希表的实现较为复杂,为了简化代码,本例忽略了部分出错判断,在测试时请不要设key值为空
using System; 2 public class Hashtable 3 { 4 private struct bucket 5 { 6 public Object key; //键 7 public Object val; //值 8 public int hash_coll; //哈希码 9 } 10 private bucket[] buckets; //存储哈希表数据的数组(数据桶) 11 private int count; //元素个数 12 private int loadsize; //当前允许存储的元素个数 13 private float loadFactor; //填充因子 14 //默认构造方法 15 public Hashtable() : this(0, 1.0f) { } 16 //指定容量的构造方法 17 public Hashtable(int capacity, float loadFactor) 18 { 19 if (!(loadFactor >= 0.1f && loadFactor <= 1.0f)) 20 throw new ArgumentOutOfRangeException( 21 "填充因子必须在0.1~1之间"); 22 this.loadFactor = loadFactor > 0.72f ? 0.72f : loadFactor; 23 //根据容量计算表长 24 double rawsize = capacity / this.loadFactor; 25 int hashsize = (rawsize > 11) ? //表长为大于11的素数 26 HashHelpers.GetPrime((int)rawsize) : 11; 27 buckets = new bucket[hashsize]; //初始化容器 28 loadsize = (int)(this.loadFactor * hashsize); 29 } 30 public virtual void Add(Object key, Object value) //添加 31 { 32 Insert(key, value, true); 33 } 34 //哈希码初始化 35 private uint InitHash(Object key,int hashsize, 36 out uint seed,out uint incr) 37 { 38 uint hashcode = (uint)GetHash(key) & 0x7FFFFFFF; //取绝对值 39 seed = (uint)hashcode; //h1 40 incr = (uint)(1 + (((seed >> 5)+1) % ((uint)hashsize-1)));//h2 41 return hashcode; //返回哈希码 42 } 43 public virtual Object this[Object key] //索引器 44 { 45 get 46 { 47 uint seed; //h1 48 uint incr; //h2 49 uint hashcode = InitHash(key, buckets.Length, 50 out seed, out incr); 51 int ntry = 0; //用于表示h(key,i)中的i值 52 bucket b; 53 int bn = (int)(seed % (uint)buckets.Length); //h(key,0) 54 do 55 { 56 b = buckets[bn]; 57 if (b.key == null) //b为无冲突空位时 58 { //找不到相应的键,返回空 59 return null; 60 } 61 if (((b.hash_coll & 0x7FFFFFFF) == hashcode) && 62 KeyEquals(b.key, key)) 63 { //查找成功 64 return b.val; 65 } 66 bn = (int)(((long)bn + incr) % 67 (uint)buckets.Length); //h(key+i) 68 } while (b.hash_coll < 0 && ++ntry < buckets.Length); 69 return null; 70 } 71 set 72 { 73 Insert(key, value, false); 74 } 75 } 76 private void expand() //扩容 77 { //使新的容量为旧容量的近似两倍 78 int rawsize = HashHelpers.GetPrime(buckets.Length * 2); 79 rehash(rawsize); 80 } 81 private void rehash(int newsize) //按新容量扩容 82 { 83 bucket[] newBuckets = new bucket[newsize]; 84 for (int nb = 0; nb < buckets.Length; nb++) 85 { 86 bucket oldb = buckets[nb]; 87 if ((oldb.key != null) && (oldb.key != buckets)) 88 { 89 putEntry(newBuckets, oldb.key, oldb.val, 90 oldb.hash_coll & 0x7FFFFFFF); 91 } 92 } 93 buckets = newBuckets; 94 loadsize = (int)(loadFactor * newsize); 95 return; 96 } 97 //在新数组内添加旧数组的一个元素 98 private void putEntry(bucket[] newBuckets, Object key, 99 Object nvalue, int hashcode) 100 { 101 uint seed = (uint)hashcode; //h1 102 uint incr = (uint)(1 + (((seed >> 5) + 1) % 103 ((uint)newBuckets.Length - 1))); //h2 104 int bn = (int)(seed % (uint)newBuckets.Length);//哈希地址 105 do 106 { //当前位置为有冲突空位或无冲突空位时都可添加新元素 107 if ((newBuckets[bn].key == null) || 108 (newBuckets[bn].key == buckets)) 109 { //赋值 110 newBuckets[bn].val = nvalue; 111 newBuckets[bn].key = key; 112 newBuckets[bn].hash_coll |= hashcode; 113 return; 114 } 115 //当前位置已存在其他元素时 116 if (newBuckets[bn].hash_coll >= 0) 117 { //置hash_coll的高位为1 118 newBuckets[bn].hash_coll |= 119 unchecked((int)0x80000000); 120 } 121 //二度哈希h1(key)+h2(key) 122 bn = (int)(((long)bn + incr) % (uint)newBuckets.Length); 123 } while (true); 124 } 125 protected virtual int GetHash(Object key) 126 { //获取哈希码 127 return key.GetHashCode(); 128 } 129 protected virtual bool KeyEquals(Object item, Object key) 130 { //用于判断两key是否相等 131 return item == null ? false : item.Equals(key); 132 } 133 //当add为true时用作添加元素,当add为false时用作修改元素值 134 private void Insert(Object key, Object nvalue, bool add) 135 { //如果超过允许存放元素个数的上限则扩容 136 if (count >= loadsize) 137 { 138 expand(); 139 } 140 uint seed; //h1 141 uint incr; //h2 142 uint hashcode = InitHash(key, buckets.Length,out seed, out incr); 143 int ntry = 0; //用于表示h(key,i)中的i值 144 int emptySlotNumber = -1; //用于记录空位 145 int bn = (int)(seed % (uint)buckets.Length); //索引号 146 do 147 { //如果是有冲突空位,需继续向后查找以确定是否存在相同的键 148 if (emptySlotNumber == -1 && (buckets[bn].key == buckets) && 149 (buckets[bn].hash_coll < 0)) 150 { 151 emptySlotNumber = bn; 152 } 153 if (buckets[bn].key == null) //确定没有重复键才添加 154 { 155 if (emptySlotNumber != -1) //使用之前的空位 156 bn = emptySlotNumber; 157 buckets[bn].val = nvalue; 158 buckets[bn].key = key; 159 buckets[bn].hash_coll |= (int)hashcode; 160 count++; 161 return; 162 } 163 //找到重复键 164 if (((buckets[bn].hash_coll & 0x7FFFFFFF)==hashcode) && 165 KeyEquals(buckets[bn].key, key)) 166 { //如果处于添加元素状态,则由于出现重复键而报错 167 if (add) 168 { 169 throw new ArgumentException("添加了重复的键值!"); 170 } 171 buckets[bn].val = nvalue; //修改批定键的元素 172 return; 173 } 174 //存在冲突则置hash_coll的最高位为1 175 if (emptySlotNumber == -1) 176 { 177 if (buckets[bn].hash_coll >= 0) 178 { 179 buckets[bn].hash_coll |= unchecked((int)0x80000000); 180 } 181 } 182 bn = (int)(((long)bn + incr) % (uint)buckets.Length);//二度哈希 183 } while (++ntry < buckets.Length); 184 throw new InvalidOperationException("添加失败!"); 185 } 186 public virtual void Remove(Object key) //移除一个元素 187 { 188 uint seed; //h1 189 uint incr; //h2 190 uint hashcode = InitHash(key, buckets.Length,out seed, out incr); 191 int ntry = 0; //h(key,i)中的i 192 bucket b; 193 int bn = (int)(seed % (uint)buckets.Length); //哈希地址 194 do 195 { 196 b = buckets[bn]; 197 if (((b.hash_coll & 0x7FFFFFFF) == hashcode) && 198 KeyEquals(b.key, key)) //如果找到相应的键值 199 { //保留最高位,其余清0 200 buckets[bn].hash_coll &= unchecked((int)0x80000000); 201 if (buckets[bn].hash_coll != 0) //如果原来存在冲突 202 { //使key指向buckets 203 buckets[bn].key = buckets; 204 } 205 else //原来不存在冲突 206 { //置key为空 207 buckets[bn].key = null; 208 } 209 buckets[bn].val = null; //释放相应的“值”。 210 count--; 211 return; 212 } //二度哈希 213 bn = (int)(((long)bn + incr) % (uint)buckets.Length); 214 } while (b.hash_coll < 0 && ++ntry < buckets.Length); 215 } 216 public override string ToString() 217 { 218 string s = string.Empty; 219 for (int i = 0; i < buckets.Length; i++) 220 { 221 if (buckets[i].key != null && buckets[i].key != buckets) 222 { //不为空位时打印索引、键、值、hash_coll 223 s += string.Format("{0,-5}{1,-8}{2,-8}{3,-8}\r\n", 224 i.ToString(), buckets[i].key.ToString(), 225 buckets[i].val.ToString(), 226 buckets[i].hash_coll.ToString()); 227 } 228 else 229 { //是空位时则打印索引和hash_coll 230 s += string.Format("{0,-21}{1,-8}\r\n", i.ToString(), 231 buckets[i].hash_coll.ToString()); 232 } 233 } 234 return s; 235 } 236 public virtual int Count //属性 237 { //获取元素个数 238 get { return count; } 239 } 240 }
Hashtable和ArrayList的实现有似的地方,比如两者都是以数组为基础做进一步地抽象而来,两者都可以成倍地自动扩展容量。