Week 6
哈希表 Hash Table
哈希 Hashing
-
哈希,一种实现标记表的高效方式
-
让整个过程像访问数组一样简单:将项目存储在以键作为索引的表中
-
哈希函数:根据键计算数组索引值的函数,将整个复杂的键计算为一个整数用于在数组上的索引来访问对应的值
-
问题
- 计算哈希函数
- 质量检验:判断两个键相等的方法
- 碰撞解决方法:解决两个值哈希到同一个索引的问题
-
比较典型的时间-空间权衡
- 没有空间限制:使用一个平凡哈希函数,直接使用键作为索引(一个接近无穷大的数组)
- 没有时间限制:使用一个平凡碰撞策略,将所有内容哈希到一个位置上然后进行序列查找(可能会查很久)
-
使用哈希函数意味着我们不在考虑从键存在的内联有序性
-
哈希函数
- 理想目标:均匀地混杂键以产生表索引
- 高效可计算
- 每一个索引对每一个键都是等概率的
- 所有的Java类都有一个继承方法
hashcode()
,返回一个32位整数- 要求:如果
x.equals(y)
,那么x.hashcode() == y.hashcode()
- 极其希望:如果
!x.equals(y)
,那么x.hashcode() != y.hashcode()
- Java默认的哈希实现时使用对象实例的内存地址
- 对于基本类型,Java有默认实现,我们也可以自定义实现
- 要求:如果
- 自定义哈希函数的标准思路
- 使用 31 x + y 31x+y 31x+y的形式混合每一个域
- 如果一个域是基本类型,使用封装器的
hashcode()
- 如果为空,计 0 0 0
- 引用类型,使用
hashcode()
可能涉及到递归 - 对数组,将策略应用到每一个项
- 理想目标:均匀地混杂键以产生表索引
-
模块化哈希
- 哈希码:一个整型数,在 − 2 31 -2^{31} −231到 2 31 − 1 2^{31}-1 231−1之间
- 哈希函数:针对 M M M大小的函数,产生 1 1 1到 M − 1 M-1 M−1之间的整型数(用于对数组的索引)
- 正确的实现(避免负数取余以及符号位绝对值溢出)
private int hash(Key key) { return (key.hashCode() & 0x7fffffff) % M; }
-
均匀哈希假设
- 每一个键都等可能哈希到0与M-1之间的整型数上
- bins & balls问题:将大量的求均匀地随机扔到M个桶中
- 生日问题:第一次出现碰撞是在 ∼ π M / 2 \sim \sqrt{\pi M/2} ∼πM/2次投掷
- 优惠券收集家问题:所有桶均有球是在 ∼ M ln M \sim M \ln M ∼MlnM次投掷之后
- 负载均衡:在M次投掷之后,容纳最多的桶拥有 Θ ( log M / log log M ) \Theta(\log M / \log \log M) Θ(logM/loglogM)个球
分离链接法 Separate Chaining
- 一种使用元素链表的碰撞解决办法
- 碰撞
- 两个不同的键哈希到了同一个索引上
- 生日问题:需要至少平方级别的内存才能避免碰撞
- 优惠券收集家和负载均衡问题:碰撞将会均匀分布
- 我们需要一个简单高效的方法处理碰撞
- 分离链接法
- 为每一个表位置构建一个链表,因此哈希表要比所有键的个数要小
- 一个数组共 M < N M \lt N M<N个位置,每一个位置对应一个链表
- 哈希:将键哈希到 0 ∼ M − 1 0 \sim M-1 0∼M−1的整数 i i i上
- 插入:将对应元素放到对应第 i i i位置链表头
- 查找:只需要查找第 i i i位置的链表,大约需要查找 N / M N/M N/M个元素(碰撞均匀分布)
- 实现
public class SeparateChainingHashST<Key, Value>
{
private int M = 97; // number of chains
private Node[] st = new Node[M]; // array of chains
private static class Node
{
private Object key;
private Object val;
private Node next;
...
}
private int hash(Key key)
{ return (key.hashCode() & 0x7fffffff) % M; }
public Value get(Key key) {
int i = hash(key);
for (Node x = st[i]; x != null; x = x.next)
if (key.equals(x.key)) return (Value) x.val;
return null;
}
public void put(Key key, Value val) {
int i = hash(key);
for (Node x = st[i]; x != null; x = x.next)
if (key.equals(x.key)) { x.val = val; return; }
st[i] = new Node(key, val, st[i]);
}
}
- 性质:在均匀哈希假设下,每一个链表(对应一个哈希项)大约是常量分数 N / M N/M N/M,十分接近1(常数时间)
- 结论:插入和查找中探查(调用
equals()
和hashcode()
)的次数,正比于 N / M N/M N/M- 如果 M M M过大,会出现大量空的链
- 如果 M M M太小,链会过长
- 一般情况下会选择 M ∼ N / 5 M \sim N/5 M∼N/5,基本接近常数时间,如果更加高效,需要使用变化的数组之策略
线性探针 Linear Probing
- 开放寻址(Open Addressing):如果出现新键碰撞,寻找下一个空槽并放于此
- 哈希过程:将键映射到 0 ∼ M − 1 0 \sim M-1 0∼M−1的整数 i i i
- 插入过程:如果为空,放到表中的第
i
个位置,否则尝试i+1
,i+2
- 这个方法要求用于存储的数组的大小 M M M要远大于键值对数量(考虑加入大小可调的数组实现)
- 寻找过程基本相同(拿到哈希值后一直往后找直到出现空位),称存储结果中连续的键值为簇,我们应尽可能保持小簇
public class LinearProbingHashST<Key, Value>
{
private int M = 30001;
private Value[] vals = (Value[]) new Object[M];
private Key[] keys = (Key[]) new Object[M];
private int hash(Key key) { /* as before */ }
public void put(Key key, Value val)
{
int i;
for (i = hash(key); keys[i] != null; i = (i+1) % M)
if (keys[i].equals(key))
break;
// 往后找,发现没有并且到空位了,放下这个值
keys[i] = key;
vals[i] = val;
}
public Value get(Key key)
{
for (int i = hash(key); keys[i] != null; i = (i+1) % M)
if (key.equals(keys[i]))
return vals[i];
return null;
}
}
- 对于在插入过程中形成的簇,会出现新插入键会更有可能插入到大簇的情况。因此大簇会越来越大甚至是与其他大簇合并
- 性能分析:Knuth停车问题
- 半满,当停车场里有 M / 2 M/2 M/2辆车时,平均移位(寻找次数)为 ∼ 3 2 \sim \frac 32 ∼23
- 全满,当停车场里有 M M M辆车时,平均移位为 ∼ π M 8 \sim \sqrt {\pi \frac M8} ∼π8M
- 性质:基于均匀哈希假设,用于
N
=
α
M
N=\alpha M
N=αM个键(M的因数)大小为
M
M
M的哈希表的线性探针法的平均探针次数为
- 找到 ∼ 1 2 ( 1 + 1 1 − α ) \sim \frac12 (1+\frac{1}{1-\alpha}) ∼21(1+1−α1)
- 未找到或插入 ∼ 1 2 ( 1 + 1 ( 1 − α ) 2 ) \sim \frac 12 (1+\frac1{(1-\alpha)^2}) ∼21(1+(1−α)21)
- 可见,α越大,次数就越多,尤其是未找到的情况
- M太大:出现大量空位
- M太小:搜索时间爆炸
- 典型选择: α = N M ∼ 1 2 \alpha = \frac NM \sim \frac12 α=MN∼21(保持存储数组半满可以实现常数搜索时间,3-5次)
哈希表环境 Hash Table Context
- 确保逻辑设计所基于的假设是成立的
- 一些DOS攻击就是试图破坏这种假设以破坏性能
- 单项哈希:让人很难找到指定哈希值的对应键,安全性很好但是生成代价昂贵
- 独立链表:
- 删除的实现很容易
- 表现可能不太好
- 对于设计的不好的哈希函数,聚簇对其很不敏感
- 线性探针:
- 更少的浪费空间
- 更好的缓存机制
- 一些改进技术:
- 双向探测(Two-probe)(SC变体),两个哈希函数哈希到不同位置,哪个链短挂在哪里(减小最长链期望值到 log log N \log \log N loglogN)
- 双哈希(Double)(LP变体),每次跳格跳超过一格,避免了聚簇但是删除的实现更难了
- Cuckoo哈希(LP变体),一次哈希两个位置,插入其中一个,如果已经满了就插第二个,最坏情况仍为常数时间
- 哈希表:
- 编码简单
- 对于无序键来说是最有效的选择
- 对于简单的键,更快
- 更好的系统支持
- 平衡搜索树:
- 更强的性能保证(不需要任何假设)
- 有序ST操作的系统级支持
- 实现
compareTo
方法要比实现equals
和hashcode
要容易
标记表应用
- 集合SET,保持相互不同的元素,不考虑值的引用
- 白名单:存在于集合中的内容
- 黑名单:不存在于集合中的内容
- 字典Dictionary,一键一值
- 最佳实现:哈希表
- 文件索引,由其中一词找到包含该词的文件名
- 对文件中的每一个词作为键建立表项,值为文件名集合
- 书籍索引
- 用于索引,包含搜索词上下文的索引
- 稀疏向量
- 矩阵向量乘法,稀疏矩阵导致经典方法性能很差
- 一维编码,只保存有效位,记录有效位的索引和值(哈希表),将时间降到正比于非零值数量
- 二维编码,每行视为一个稀疏向量,线性时间