|
| 1 | +# 第七章 哈希 |
| 2 | + |
| 3 | +> 译者:[Abel-Huang](https://github.com/Abel-Huang) |
| 4 | +
|
| 5 | +有序数组和二叉搜索树能更快的查找是否存在更大(或更小)的元素这样的问题。堆适用于查找最大元素问题。 然而有时候我们只对集合中是否存在某个特定元素感兴趣 —— 或者说,查找集合中是否存在与该元素相等的元素。 |
| 6 | + |
| 7 | +再次思考1.3.1节中的程序 `isIn` -- 在一个有序数组中进行线性查找。这个算法非常耗时,时间复杂度近似为N,每个元素的数量会被存储在搜索数组中。如果我们能够降低这个N,就可以加速算法。降低N的一种方式我们可以将被搜索的元素集合分成一些数字M,将他们划分成不相交的子集,并且找到方法快速的找到正确的子集。通过在键的子集之间进行或多或少的均匀划分,我们可以减小搜索所需的时间,平均值为N/M。二分查找的递归实现(1.3.1节`isInB`程序)就是M=2的场景。如果我们更进一步考虑,为任意情况下的N选择一个合适的M,那么查找一个元素所花费的时间会近似为一个常数。 |
| 8 | + |
| 9 | +困难就在于找到一种尽可能快的方法来搜索到我们需要的键集合的子集。这种方法必须保证一致性,因为无论何时去调用搜索时必须得到最初选择的子集。也就是说,必须存在一种被称为哈希函数(hashing function)的函数,将所有将要被搜索的键集合映射到值为0到M-1的范围内。 |
| 10 | + |
| 11 | +## 7.1 链地址法 |
| 12 | +一旦我们拥有一个哈希函数,我们必须还要用一种方式来表示一个子集合。或许最简单的表达形式就是使用链表,在哈希表文献中已知的做法就是链地址法(chaining)。Java标准库中的`HashSet`就是使用这种方法,参见图7.1的说明。更常见的是,哈希表显示为映射,例如标准库中`java.util.Map`接口的实现。子集中不仅包含有这些关键字,并且应该通过索引这些关键字以表达方式同样的支持更多的额外信息。图7.2展示了JDK中类`java.util.HashMap`的部分可能实现形式,这个类自身是`Map`接口的一个具体实现。 |
| 13 | + |
| 14 | +图7.2中的`HashMap`类使用了JDK中`java.lang.Object`定义的`hashCode`方法来为任意一个关键字key选择一个子集数字,在Java中任何类都是`Object`的子类。如果这个哈希函数设计的非常好,那么每个子集的数量大致会对应上元素的数量(在7.3节中查看更多讨论)。 |
| 15 | + |
| 16 | +我们可以定义一个每个集合中元素平均数量的先验限制,当超过这个限制时进行表的扩容。这就是构造函数参数和类属性中`loaderFactor`的目的。我们可以很自然的想到是否可以使用"更快"的数据结构(比如二叉搜索树)来存储这个集合。然而,我们真的为树的大小选择一个合理的值,因此每一个集合只能容纳少量的元素,这将得不偿失。当集合数组的长度达到我们设定的限制时,我们可以使用4.1节中`ArrayList`类似的策略增长。为了达到很好的渐进时间性能,通常会在需要扩容时,扩展为原来的两倍。除此之外,我们还需要记住,大多数元素的数量都会发生改变,因此我们必须进行移动。 |
| 17 | + |
| 18 | +## 7.2 开放地址法 |
| 19 | +开放地址法直接将元素存储在桶中。如果某个桶已经存放了元素,后续相同哈希值的元素需要放到根据系统规则定义好的未使用的桶中。图7.2中的`put`操作就像这样: |
| 20 | +```java |
| 21 | + public Val put (Key key, Val value) { |
| 22 | + int h = hash (key); |
| 23 | + while (bins.get (h) != null && ! bins.get (h).key.equals (key)) |
| 24 | + h = nextProbe (h); |
| 25 | + if (bins.get (h) == null) { |
| 26 | + bins.add (new entry); |
| 27 | + size += 1; |
| 28 | + if ((float) size/bins.size () > loadFactor) |
| 29 | + resize bins; |
| 30 | + return null; |
| 31 | + } else |
| 32 | + return bins.get (h).setValue (value); |
| 33 | + } |
| 34 | +``` |
| 35 | +而且`get`操作也需要进行类似的修改。 |
| 36 | + |
| 37 | +如果出现了一个位置h已经被占用(一种称为碰撞的情况),`nextProbe`方法提供了另外一个桶的索引的值。 |
| 38 | + |
| 39 | +最简单的`nextProbe(L)`实现被称为线性探测,其直接返回`(h+1) % bins.size()`。通常情况下,我们通常使用线性探测的值加上一个正整数的常数值,这个常数值必须哈希和表长度`bins.size()`的互质\[为什么需要互质?\]。如果我们取出图7.1中的17个关键字: |
| 40 | + {81, 22, 38, 26, 86, 82, 0, 23, 39, 65, 83, 40, 9, -3, 84, 63, 5}, |
| 41 | + |
| 42 | +使用线性探测法,其中增长量为1并且`x mod 23`作为哈希函数,将所有的关键字按顺序存放到容量为23的数组中,这个数组中包含的值如下: |
| 43 | + |
| 44 | +| 0| 1| 2| 3| 4| 5| 6| 7| 8| 9|10|11|12|13|14|15|16|17|18|19|20|21|22| |
| 45 | +|-----|----|----|----|----|----|----|----|----|----|----|-----|----|----|----|----|----|----|----|----|----|----|----| |
| 46 | +| 0|23|63|26| |5 | | | | 9| | |81|82|83|38|39|86|40|65|-3|84|22| |
| 47 | + |
| 48 | +正如你所见,有几个键并没有放在其本来的位置。例如,**84 mod 23 == 15**和**63 mod 23 == 17**。 |
| 49 | + |
| 50 | +线性探测存在一个聚类现象,参考链地址法很容易发现这个问题。如果在搜索某个键时检查的条目序列是b0,b1,...,bn,如果任何其他键应该散列到其中一个bi,那么在搜索它时检查的条目序列将是同一序列的一部分,bi,bi+1,...,bn,甚至这两个keys的哈希值不相同。 |
| 51 | + 实际上,链接地址法下的两个不同列表将被合并,在线性探测下,这些键的有效平均大小将会加倍。我们整数集的最长链(见图7.1)长度只有3。在上面的开放地址示例中,最长链是有9个元素(见63),即使只有一个与其他键(40)的散列值相同。 |
| 52 | + |
| 53 | +让`nextProbe`以不同的量递增值,增量取决于原始keys,我们通过这种被称为双重散列的技术,可以有效改善这种效果。 |
| 54 | + |
| 55 | +从开放地址哈希表删除一个元素并不简单。简单地将条目标记为“未占用”可以打破碰撞条目链,并从表中删除多条所需要的项目\[为什么?\]。如果需要进行删除操作,我们不得不谨慎处理。有兴趣的读者可以参考高德纳的**计算机程序设计的艺术**第三卷了解更多相关信息。 |
| 56 | + |
| 57 | +一般来说,开放地址法的问题在于链地址法下位于不同容器中的keys可以相互竞争。在使用链地址法时,如果所有的槽都已经被占用,我们要确认搜索的关键字key并不在表中,最多只需要找与被搜索值具有相同哈希值的元素数量的次数。使用开放地址法,可能需要搜索N次才能确定被搜索的值是否在表中。根据我的经验,链地址法所需要的额外存储空间成本相对并不重要,对于大多数场景下,我更建议使用链地址法而不是开放地址法。 |
| 58 | + |
| 59 | +```java |
| 60 | +package java.util; |
| 61 | + |
| 62 | +public class HashMap<Key,Val> extends AbstractMap<Key,Val> { |
| 63 | + /** A new, empty mapping using a hash table that initially has |
| 64 | + * INITIALBINS bins, and maintains a load factor <= LOADFACTOR. */ |
| 65 | + public HashMap (int initialBins, float loadFactor) { |
| 66 | + if (initialBuckets < 1 || loadFactor <= 0.0) |
| 67 | + throw new IllegalArgumentException (); |
| 68 | + bins = new ArrayList<Entry<Key,Val>>(initialBins); |
| 69 | + bins.addAll (Collections.ncopies (initialBins, null)); |
| 70 | + size = 0; |
| 71 | + this.loadFactor = loadFactor; |
| 72 | + } |
| 73 | + /** An empty map with INITIALBINS initial bins and load factor 0.75. */ |
| 74 | + public HashMap (int initialBins) { |
| 75 | + this (initialBins, 0.75); |
| 76 | + } |
| 77 | + /** An empty map with default initial bins and load factor 0.75. */ |
| 78 | + public HashMap () { |
| 79 | + this (127, 0.75); |
| 80 | + } |
| 81 | + /** A mapping that is a copy of M. */ |
| 82 | + public HashMap (Map<Key,Val> M) { |
| 83 | + this (M.size (), 0.75); putAll (M); |
| 84 | + } |
| 85 | + public T get (Object key) { |
| 86 | + Entry e = find (key, bins.get (hash (key))); |
| 87 | + return (e == null) ? null : e.value; |
| 88 | + } |
| 89 | + /** Cause get(KEY) == VALUE. Returns the previous get(KEY). */ |
| 90 | + public Val put (Key key, Val value) { |
| 91 | + int h = hash (key); |
| 92 | + Entry<Key,Val> e = find (key, bins.get (h)); |
| 93 | + if (e == null) { |
| 94 | + bins.set (h, new Entry<Key,Val> (key, value, bins.get (h))); |
| 95 | + size += 1; |
| 96 | + if (size > bins.size () * loadFactor) grow (); |
| 97 | + return null; |
| 98 | + } else |
| 99 | + return e.setValue (value); |
| 100 | + } |
| 101 | +... |
| 102 | +``` |
| 103 | + |
| 104 | +```java |
| 105 | + private static class Entry<K,V> implements Map.Entry<K,V> { |
| 106 | + K key; |
| 107 | + V value; |
| 108 | + Entry<K,V> next; |
| 109 | + |
| 110 | + Entry (K key, V value, Entry<K,V> next){ |
| 111 | + this.key = key; |
| 112 | + this.value = value; |
| 113 | + this.next = next; |
| 114 | + } |
| 115 | + public K getKey () { return key; } |
| 116 | + public V getValue () { return value; } |
| 117 | + public V setValue (V x){ |
| 118 | + V old = value; |
| 119 | + value = x; |
| 120 | + return old; |
| 121 | + } |
| 122 | + public int hashCode () { |
| 123 | + see Figure 2.14 |
| 124 | + } |
| 125 | + public boolean equals () { |
| 126 | + see Figure 2.14 |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + private ArrayList<Entry<Key,Val>> bins; |
| 131 | + private int size; /** Number of items currently stored */ |
| 132 | + private float loadFactor; |
| 133 | + |
| 134 | + /** Increase number of bins. */ |
| 135 | + private void grow () { |
| 136 | + HashMap<Key,Val> newMap |
| 137 | + = new HashMap (primeAbove (bins.size ()*2), loadFactor); |
| 138 | + newMap.putAll (this); copyFrom (newMap); |
| 139 | + } |
| 140 | + /** Return a value in the range 0 .. bins.size ()-1, based on |
| 141 | + * the hash code of KEY. */ |
| 142 | + private int hash (Object key) { |
| 143 | + return (key == null) ? 0 |
| 144 | + : (0x7fffffff & key.hashCode ()) % bins.size (); |
| 145 | + } |
| 146 | + |
| 147 | + /** Set THIS to the contents of S, destroying the previous |
| 148 | + * contents of THIS, and invalidating S. */ |
| 149 | + private void copyFrom (HashMap<Key,Val> S){ |
| 150 | + size = S.size; |
| 151 | + bins = S.bins; |
| 152 | + loadFactor = S.loadFactor; |
| 153 | + } |
| 154 | + |
| 155 | + /** The Entry in the list BIN whose key is KEY, or null if none. */ |
| 156 | + private Entry<Key,Val> find (Object key, Entry<Key,Val> bin) { |
| 157 | + for (Entry<Key,Val> e = bin; e != null; e = e.next) |
| 158 | + if (key == null && e.key == null || key.equals (e.key)) |
| 159 | + return e; |
| 160 | + return null; |
| 161 | + } |
| 162 | + |
| 163 | + private int primeAbove (int N) { |
| 164 | + return a prime number ≥ N; |
| 165 | + } |
| 166 | +} |
| 167 | +``` |
| 168 | + |
| 169 | +## 7.3 哈希函数 |
| 170 | +此前遗留了一个问题:如何选择哈希函数用于存放键的桶。为了使我们实现的映射或集合能够正常工作,首先我们的散列函数满足下面两个很重要的约束: |
| 171 | +1. 在执行程序期间,对于任何键值K,`hash(K)`的值必须保持不变,而K在表中(或者如果hash(K)改变则必须重建哈希表)。 |
| 172 | +2. 如果两个键相等(根据`equals`方法,或者任何哈希表使用的相等测试),则他们两个的哈希值必须相等。 |
| 173 | + |
| 174 | +如果违反了上述任何一条规则,关键字key就会从哈希表中有效移除。另一方面,一次程序执行到下一次程序执行,哈希值是否保持固定不变并不重要,重要的是不同的键拥有不同的哈希值(如果太多的键具有相同的哈希值,性能将明显受到影响)。 |
| 175 | + |
| 176 | +如果键是简单的非负整数,一个简单有效的哈希函数就是使用键X模上哈希表的大小: |
| 177 | +`hash(X) == X % bins.size ();` |
| 178 | +对于负整数而言,我们必须做出一些规定。例如: |
| 179 | +`hash(X) = (X & 0x7fffffff) % bins.size ();` |
| 180 | +先将任何一个负数加上2^32可以达到和正整数一样的效果\[为什么?\]。或者如果表长度为奇数,那么 |
| 181 | +`hash(X) = X % ((bins.size ()+1) / 2) + bins.size ()/2;` |
| 182 | +也是可以的\[为什么?\]。 |
| 183 | + |
| 184 | +处理非数值类型的键值可能需要多做一些工作。我们可以使用所有Java对象都定义过的`hashCode`方法将**Objects**转为整数(这样我们就可以继续按照上面的方法进行处理)。**Objects**默认的`x.equals(y)`是`x==y`,即x和y是同一个对象的引用。相应地,Object提供的`x.hashCode()`的默认实现只返回一个整数值,该值是从x指向的对象的地址派生的,即对象的指针值x被视为一个整数(这就是幕后的真实情况)。这种默认的实现方式不适用于考虑两个不同对象是否相同的情况。比如,两个字符串的计算: |
| 185 | +`String s1 = "Hello, world!", s2 = "Hello," + " " + "world!";` |
| 186 | +即使具有`s1.equals(s2)`的属性,但`s1!= s2`(它们是碰巧包含相同字符串序列的两个不同的String对象)。因此,默认的`hashCode`操作不适合String,因此String类自己进行重新实现并且覆盖默认定义的`hashCode`方法。 |
| 187 | + |
| 188 | +为了获得键的索引,我们使用了取余操作。这显然会产生一个数字范围;这对我们选择哈希表的大小而言并不明显(不接近2的幂的素数)。可以说,选择其他的值往往会产生不理想的结果。例如,如果使用2的幂意味将会忽略`X.hashCode()`的高位。 |
| 189 | + |
| 190 | +如果键不是简单的整数(比如字符串),一个可行的方法是将他们转为整数并且使用上面的取余算法。下面是一个代表性的来自P. J. Weinberger的C编译器中的字符串散列函数,在实践中取得了广泛的应用。他设定了8位数的字符和32位的整型。 |
| 191 | + |
| 192 | +```java |
| 193 | + static int hash(String S) { |
| 194 | + int h; |
| 195 | + h = 0; |
| 196 | + for (int p = 0; p < S.length (); p += 1) { |
| 197 | + h = (h << 4) + S.charAt(p); |
| 198 | + h = (h ^ ((h & 0xf0000000) >> 24)) & 0x0fffffff; |
| 199 | + } |
| 200 | + return h; |
| 201 | + } |
| 202 | +``` |
| 203 | +Java字符串类型有不同的`hashCode`函数实现,使用模运算来计算得到一个-231到231-1范围内的结果。 |
| 204 | +```java |
| 205 | +gongshi |
| 206 | +``` |
| 207 | +这里,ci表示clsString中的第i个字符。 |
| 208 | +## 7.4 性能 |
| 209 | +假设密钥均匀分布,无论在哈希表中是否包含待查找的元素,都可以常数时间内完成检索。正如我们在4.1节中关于ArrayList`规模增长的分析,哈希表插入也具有常数的摊分时间复杂度(即所有插入的平均复杂度)。当然,如果键分布不均匀,我们可以看成O(N)的时间复杂度。 |
| 210 | + |
| 211 | +如果一个哈希函数有时可能会出现错误的聚类问题,我们可以使用一种被称为通用散列的技术帮助解决。可以从一些精心挑选的集合中随机挑选一个哈希函数,在平均情况下,在程序的运行过程中哈希函数将会有良好的表现。 |
| 212 | + |
| 213 | +## 练习 |
| 214 | + |
| 215 | +**7.1** 通过7.1节中给出的`HashMap`表示的`iterator`方法的实现,并且`Iterator`类也是必要的。因为我们已经选择简单的链表实现,你必须谨慎使用正确的`remove`操作。 |
0 commit comments