HashTable

HashTable是一种能提供快速插入和查询的数据结构,无论其包含有多少Item,查询和插入操作的平均时间总是接近O(1)

hash function 的作用就是将这些范围很大的数(domain of keys )转换成我们需要的序号(domain of location)。

.net framework采用Division Methed作为其散列算法,使用取模(modulo)操作将Hash code值域转换到合适的范围。即:

arrayIndex = hashcode % arraySize;

其中arrayIndex代表单词在数组中的位置,ArraySize代表数组长度,

 

Collisions

我们希望每一个Hash Code都唯一对应一个Index,然而这个算法并不能保证这一点。比如你想将"melioration"插入到数组,你将这个单词通过上述过程转换成index,然而你发现那个位置已经被"demystify"所占据,这种情况叫做Collisions(冲突)。

.net framework使用open address 的方式解决冲突,例如当进行插入操作时,根据键值生成的index已经被别的item占据时,它将自动搜索index+incr位置,直到找到一个空的位置。其中的incr由以下算法产生。

       incr = (uint)(1 + (((hashcode >> 5) + 1) % ((uint)itemCount - 1)));

.net framework生成incr的这种算法,其结果与当前冲突位置无关,避免了好多问题。事实上它根据键值的hash code 进行了另一次散列,即所谓的Double Hash.


Expand

 

由于HashTable基于数组的,所以它的容量需要提前指定,并且最好在运行过程中不要改变。数组的大小是不能在运行时改变的,所以当HashTable太满时,就需要声明一个新的大数组。

我们记得Hash Function 根据数组的长度计算键值的序号的,所以不可以将旧数组的数据直接复制到新数组,必须对针对每一个键值重新计算其位置,非常的低效。

.net framework实现中HashTable最小的容量为11,HashTable过满时,会新建立一个容量为 int prime = HashHelpers.GetPrime(this.buckets.Length * 2);这里有取最大素数的的数组,然后将旧数组的值复制到新数组对应的位置(复制的过程中,会对每一个键值重新计算位置的)

”HashTable用开放定址法解决冲突,用双散列法进行探测。装填因子过高之后使用再散列法扩充 

Hashtable 中的实际数据都存储在一个内部 Array 中 (当然和普通数组一样, 有固定容量, 上下标, 以数字索引存取), 当用户希望取得 Hashtable[K] 值的时候, Hashtable 进行如下处理:

[1] 为了保证 f(K) 的取值范围在   0 <= f(K) < Array.Length, 函数 f 的关键步骤是取模运算, 算得实际数据存储位置为 f(K) = HashOf(K) % Array.Length, 至于这个 HashOf(K) 怎么算出来的, 简单举例来说她可以取关键字的 ASCII 码根据一定规则运算得到.

[2] 如果发生多个 K 值的哈希值重复, 即 f(K1) = f(K2), 而 f(K1) 位置已经有数据占用了, Hashtable 采用的是 "开放定址法" 处理冲突, 具体行为是把 HashOf(K2) % Array.Length 改为 (HashOf(K2) + d(K2)) % Array.Length , 得出另外一个位置来存储关键字 K2 所对应的数据, d 是一个增量函数. 如果仍然冲突, 则再次进行增量, 依此循环直到找到一个 Array 中的空位为止. 将来查找 K2 的时候先搜索 HashOf(K2) 一档, 发现不是 K2, 那么增量 d(K2) 继续搜索, 直到找到为止. 连续冲突次数越多, 搜索次数也越多, 效率越低.

http://p.blog.csdn.net/images/p_blog_csdn_net/arturya/conflict.gif

[3] 当插入数据量达到 Hashtable 容量上限时, 对内部 Array 进行扩容 (重新 new   一个更大的数组, 然后把数据 copy 过去), 不仅如此, 由于 Array.Length 发生了变化, 扩容后要对所有现存数据重新计算 f(K). 所以说扩容是个耗能比较惊人的内部操作. Hashtable 之所以写入效率仅为读取效率的 1/10 数量级, 频繁的扩容是一个因素.

f(K) 的取法是哈希表的关键所在, 从根本上决定了该哈希表的许多重要特征, 例如 .NET 中 System.Collections.Hashtable 的哈希函数 f 其算法决定了这样一些方面:

[1] 数组容量 Array.Length 越大, 冲突的机会越小. 由于 f(K) 的取值范围等于 Array.Length, 因此随着 Array.Length 的增长, f(K) 的值也更加多样性, 不容易重复.

[2] 数组容量 Array.Length 期望是一个 "比较大的质数", 这样 f(K) = HashOf(K) % Array.Length 取模运算之后得出的数冲突机会较小. 想象一个极端例子, 假设 Array.Length = 2, 则只要 HashOf(K) 是偶数, f(k) 都为 0. 所以说哈希表的实际容量一般都是有规律的, 和数组不一样, 不能任意设置.

[3] 随着插入的数据项逐渐增多, Hashtable 内部数组剩余的空位也越来越少, 下一次冲突的可能性也越来越多严重影响效率. 因此不能等到数组全部塞满后才进行扩容处理. .NET , 当插入数据个数和数组容量之比为 0.72 ,   就开始扩容. 这个 0.72 称为装填因子 - Load Factor. 这是一个要求苛刻的数字, 某些时刻将装填因子增减 0.01, 可能你的 Hashtable 存取效率就提高或降低了 50%, 其原因是装填因子决定 Array.Length, Array.Length 影响 f(K) 的冲突几率, 进而影响了性能. 0.72 Microsoft 经过长期实验得出的一个比较平衡的值. (取什么值合适和 f(K) 的算法也有关, 0.72 不一定适合其他结构的哈希表)

[4] Hashtable 的初始容量 Array.Length 至少为 11, 再次扩容的容量至少为 "不小于 2 倍于当前容量的一个质数". 这里举一个例子, 方便大家看看 Hashtable 是多么浪费空间.

假设以默认方式初始化一个 Hashtable, 依次插入 8 个值,   由于 8 / 0.72 > 11, 因此   Hashtable 自动扩容, 新的容量为不小于 11 * 2 的质数, 即 23. 所以, 实际仅有 8 个人吃饭, 却不得不安排一桌 23 个座儿的酒席, 十分奢侈. 避免如此铺张的途径是在初始化 Hashtable 时用带参构造方式直接指定 capacity 为 17, 但即便这样仍浪费了 9 个空间.

有心的读者经过计算, 可能会问为什么不是指定初始容量为 13, 13 是质数啊, 13 * 0.72 > 8 . 确实理想情况是这样, 但实际上由于动态计算并判断一个数是否质数需要大量时间, .NET Hashtable 中的 capacity 值是内部预设的一个数列, 只能为 3, 7, 11, 17, 23... 所以十分遗憾. (: 只有当 Array.Length > 0x6DDA89 时动态计算扩容容量, 正常情况下我们不会存如此多的数据进去)

.NET Hashtable 就是以这种方式来减少冲突, 以牺牲空间为代价换取读写速度. 假设你在实际开发中对内存空间要求很敏感, 譬如开发 ASP.NET 超大型 B/S 网站时, 就十分有必要检讨使用 Hashtable 的场景需求, 有的时候能否换个方式, 采取自定义 struct, 或者数组来高效实现呢?

posted @ 2009-06-12 14:06  留云  阅读(2414)  评论(0编辑  收藏  举报