了解实际开发中 Hashtable 的特性原理 .NET, JAVA, PHP (之二)
[文档最后修正时间: Aug 19, 2006, by Arturya]
上一篇谈到 .NET 的 Hashtable 属于比较传统的算法. 并籍此复习了哈希表这种数据结构的经典原理. 下面我们来看看 Java 和 PHP 中又是如何实现 Hashtable 的. 之所以把 Java 和 PHP 的场景结合一起, 是因为他们俩的处理方式非常相似. 论述将以 java.util.HashMap 为主, 该原理同样也适于 PHP. HashMap 是 java.util.Hashtable 的轻量级实现, 且允许 NULL 作为关键字.
通过前文, 我们已知由于 .NET Hashtable 哈希函数 f(K) 的取模运算特征决定了其容量大小必然是某个质数 (若不明白请回顾第一篇). 而 Java HashMap 则恰恰相对, 她们要求哈希表的容量是一个偶数, 且为 2 的 N 次幂. 即 Array.Length = 2, 4, 8, 16, 32, 64... (转化为二进制恰好为 1111 ... 110 形式). 这是由于 Java HashMap 的哈希算法核心为与 (&) 运算, f(K) = HashOf(K) & (Array.Length - 1), 运算时 HashOf(K) 的高位同 0 相与被丢弃, 低位同 1 相与被保留, 以次保证 0 <= f(K) < Array.Length.
HashMap 的冲突处理方式也区别于 .NET Hashtable 的开放定址法, 为 "链地址法". 当后插入的 K2, K3, K4 等与先插入的 K1 位置冲突时, 在 K1 位置建立一个链表, 把 K2, K3, K4... 另存至链表中, 即一个位置可以对应 "一串" 数据. 搜索 K2, K3, K4 的时候先查找 f(K1) 位置本身, 若不符则顺序遍历链表. 这样处理的好处是不会产生如同 .NET Hashtable 中开放定址法的二次聚集现象 (在探测空位时产生连续冲突).
HashMap 也存在一个 Load Factor, 其值为 0.75. 我们看到 HashMap 的冲突处理方式不会产生二次聚集, 因此装填因子可以适当放大一些. 其初始默认容量为 16, 当实际装入数据和容量之比超过 0.75 时开始自动扩容, 新的容量为原始容量的 2 倍 (32, 64, 128... 依此类推). 乘 2 运算通过左移直接实现, 没有 .NET 那般运算判断质数的困扰.
PHP 的 Hashtable 构造和 Java HashMap 基本相通, 只是 Load Factor 值为 1.
从理论上来说, .NET Hashtable 的平均搜索步长约为 -ln(1-x)/x, 而 Java HashMap 的平均搜索步长约为 1 + x/2, 这里 x 为实际装入数据个数和容量之比, 由此看出一个哈希表的平均查找长度为 x 的函数, 且 0 <= x < Load Factor, 而非插入数据个数 N 的函数, 因此她的查找复杂度为 O(1).
从实际运算来看, 性能评估是比较复杂的事情, 不仅仅取决于理论搜索步长, 还取决于实际 Load Factor 的取值, HashOf(K) 的效率, Resize() 的效率, 探测数组和拆链装链的效率, 甚至函数的压参传值这种微小开销累积起来也对总体性能有可观的影响. 在 C# 中仿照 Java HashMap 简单写了一个采用链地址法的哈希表, 初步测试显示其与 .NET 原始的 Hashtable 相比读取速度较快但写入较慢, 互有胜负. 考虑到 Hashtable 还有线程安全处理, 慢一点可以理解, 情况要比想象的好.
下面我们从 Java 回到 .NET, 介绍一下 2.0 新推出的 System.Collections.Generic.Dictionary<K, V> 类型 (以下简称 Dictionary).
Dictionary 的用法与 Hashtable 差不多, f(K) 也采用相同的取模算法, 但其余内部结构无论同 Hashtable 还是 HashMap 都有很大区别. 体现在:
[1] Dictionary 没有 Load Factor 变量, 或可理解为 Load Factor = 1, 插入数据可以最大限度填满容量空间.
[2] 大体上, Dictionary 内的元素有按照先后插入顺序排列的特性. 区别于 Hashtable 及 HashMap 的离散型.
Dictionary 内部拥有两列数组, 姑且成为 "状态串" 和 "数据串", 数据串按顺序接受插入的数据并加以封装 (封装了一个 next 指针, 方便今后装链), 状态串以 f(K) 为索引, 存放 f(K) 到 K 在数据串内的位置映射. 查找时先访问状态串找 f(K), 然后根据映射关系找到数据串中的实际数据. 当有多个 f(K) 重复时, 状态串里面保留第一个 f(K), 在数据串内对冲突的数据建立链关系.
由图我们可以看出, 数据串忠实按照 K1, K2, K3 ... 的插入顺序排列, 直至排满数据串, 这个过程中自然不会发生冲突, 冲突只出现在状态串中, 同时, 发生冲突时并不需探测空位和挪动数据本身, 只需将冲突的数据链在一起即可.
Dictionary 默认初始大小为 3, 扩容方式和 Hashtable 一样, 为不小于当前容量的 2 倍的一个质数. 在速度方面, Dictionary 读取快于 Hashtable, 但不如用 C# 自写的链地址法的哈希表, 写入要慢于 Hashtable, 但快于链地址法的哈希表. 考虑到 Dictionary 隐含的装填因子为 1, 可以最大限度利用空间, 是一种以速度换空间的做法, 这个结果是可以接受的.
由于 Hashtable 和 Dictionary 同时存在, 在使用场景上必然存在选择性, 并不任何时刻都能相互替代.
[1] 单线程程序中推荐使用 Dictionary, 有泛型优势, 且读取速度较快, 容量利用更充分.
[2] 多线程程序中推荐使用 Hashtable, 默认的 Hashtable 允许单线程写入, 多线程读取, 对 Hashtable 进一步调用 Synchronized() 方法可以获得完全线程安全的类型. 而 Dictionary 非线程安全, 必须人为使用 lock 语句进行保护, 效率大减.
[3] Dictionary 有按插入顺序排列数据的特性 (注: 但当调用 Remove() 删除过节点后顺序被打乱), 因此在需要体现顺序的情境中使用 Dictionary 能获得一定方便.
以上分别谈到了 Hashtable, HashMap 和 Dictionary 三种类型, 介绍告一段落, 下面增加一些不成熟的观点:
[1] 三种哈希表均允许任意 object 做关键字, 但实际使用中我们一般只用 string 做键值, 对 string 做 HashOf(string) 处理比较单纯, 速度较快, 而对 object 取 HashOf(object) 则情况复杂. 若想进一步提高速度, 可以考虑自定义一个只允许 string 作为关键字的哈希表.
[2] Java HashMap 由于 f(K) 取与运算的特性, 每次扩容必须是 2 倍, 没有价钱可讲. 但 .NET Hashtable 和 Dictionary 的容量理论上只要求是质数, 新容量不一定要达到旧容量的 2 倍以上, 因而想进一步提高内存利用率, 可以考虑重写 Resize() 方法, 使得每次扩容变成稍大一点的质数即可. 当然这样一来插入效率会降低, 自行取舍.
[3] 对 Hashtable 初始化时直接指定 capacity 是个好主意, 减少了 Resize() 的次数, 降低开销.
转自:http://blog.csdn.net/arturya/archive/2006/08/19/1096139.aspx (1)
http://blog.csdn.net/arturya/archive/2006/08/19/1096142.aspx (2)