# 第七章 哈希

> 译者：[Abel-Huang](https://github.com/Abel-Huang)

有序数组和二叉搜索树能更快的解决集合中是否存在更大(或更小)的元素这样的问题；堆适用于查找集合中最大元素问题。然而有时候我们只对集合中是否存在某个特定元素感兴趣 —— 或者说，查找集合中是否存在与该元素相等的元素。

再次思考1.3.1节中的程序`isIn`-- 在一个有序数组中进行线性查找，查找某个元素是否存在于这个集合中。这个算法非常耗时，时间复杂度近似为N，这个N是存储在搜索数组中待搜索元素的数量。如果我们能够降低这个N，就可以加速这个算法。其中一种方式我们可以将被搜索的元素集合划分成M个不相交的子集，找到一种快速的方法定位到被查找元素所在的子集。通常情况下查找的元素不仅仅是一个整数，更常见的情况会是一个对象，查找一般会以对象的某个部分(一般为对象的某个属性)进行，这部分就是关键字(key)。通过将元素的集合或多或少的均匀划分为键的子集，我们可以有效的减少搜索所需的时间，平均值一般为N/M，在实际使用中，我们通常把这个子集称之为桶(bucket)，整个哈希表就是由一系列这样的桶组成。二分查找的递归实现(1.3.1节`isInB`程序)就是在M=2的场景。如果我们进一步考虑，对于任意规模下的N选择一个合适的M值，那么查找一个元素所花费的时间会近似于一个常数。

现在问题就在于找到一种尽可能快的方法定位键所在的桶。这种方法必须保证一致性，无论何时去调用这个方法必须得到最初选择的桶。也就是说，必须存在一种将所有将要被搜索的键集合映射到值为0到M-1的范围内桶的函数，这种函数称为哈希函数(hashing function)。

## 7.1 链地址法
一旦我们找到这样的哈希函数，我们必须还要用一种方式来表示一个桶的集合。或许最简单的表达形式就是使用链表，在哈希表相关的资料中这种方法被称为*链地址法(chaining)*。Java标准库中的`HashSet`的实现就是采用这种方法，参见图7.1的说明。更常见的情况是，哈希表以映射的方式出现，例如标准库中`java.util.Map`接口的实现。哈希表中桶不仅包含有这些关键字，并且应该通过索引这些关键字同样的表达方式支持更多的额外信息，通常称为值(value)，这也就是键值对的由来，。图7.2展示了JDK中类`java.util.HashMap`的部分实现，这个类是`Map`接口的一个具体实现。

图7.2中的`HashMap`类使用了JDK中`java.lang.Object`定义的`hashCode`方法来为任意一个关键字生成一个桶对应的序号，在Java中任何类都是`Object`的子类。如果这个哈希函数设计的非常好，那么桶的数量大致会和键的数量对应(在7.3节中查看更多讨论)。

我们可以预先定义一个每个集合中关键字平均数量的限制，当超过这个限制时进行表的扩容。这就是构造函数参数和类属性中`loaderFactor`的目的。我们很自然的想到是否可以使用"更快"的数据结构(比如二叉搜索树)来存储这个集合。然而，我们真的选择一个合理大小的树用于存储集合，那么只能容纳少量的元素，这将得不偿失。当集合数组的长度达到我们设定的限制时，我们可以使用4.1节中`ArrayList`类似的策略增长。为了达到很好的渐进时间性能，通常会在需要扩容时，扩展为原来的两倍。除此之外，我们还需要记住，大多数元素的数量都会发生改变，因此我们必须进行移动。

## 7.2 开放地址法  
*开放地址法(open-address)* 直接将元素存储在桶中。如果某个桶已经存放了元素，后续相同哈希值的元素需要放到系统规则定义好的未使用的桶中。图7.2中的`put`操作就像这样：
```java
    public Val put (Key key, Val value) {
        int h = hash (key);
        while (bins.get (h) != null && ! bins.get (h).key.equals (key))
            h = nextProbe (h);
        if (bins.get (h) == null) {
            bins.add (new entry);
            size += 1;
            if ((float) size/bins.size () > loadFactor)
                resize bins;
            return null;
        } else
            return bins.get (h).setValue (value);
    }
```
而且`get`操作也需要进行类似的修改。

如果出现了一个桶h已经被占用(这种情况称为碰撞)，使用`nextProbe`进行额外的寻址，这个方法提供了另外一个可用桶的索引值。
最简单的`nextProbe(L)`实现被称为线性探测，其直接返回`(h+1) % bins.size()`。通常情况下，我们使用线性探测的值加上一个正整数的常数值，这个常数值必须哈希和表长度`bins.size()`的互质\[为什么需要互质？\]。

![figure_7_1](https://github.com/Abel-Huang/cs61b-textbook-zh/blob/master/zh/img/figure_7_1.jpg)  
图7.1: 一个简单的链地址法实现的哈希表，使用变量`nums`表示。这个哈希表包含11个桶，每个桶都存储了一个指向链表的指针(如果对应的元素存在)。这个哈希表表示一个特定的集合：  
**{81, 22, 38, 26, 86, 82, 0, 23, 39, 65, 83, 40, 9, -3, 84, 63, 5},**   

这个哈希函数是简单的针对整数键，`h(x) = x mod 11`。(`a mod b`的数学操作定义为$a-b\lfloor\a\b\rfloor$ 当`b != 0`。因此永远为非负数)。这个哈希当前的负载因子(load factor)为$17\11\appro1.5$，最大最多为2.0(`loadFactor`属性)，如你所见，桶的大小从0到3。

![figure_7_2](https://github.com/Abel-Huang/cs61b-textbook-zh/blob/master/zh/img/figure_7_2_1.jpg)    
图7.2: `java.util.HashMap`类的部分代码，实现了`java.util.Map interface`接口  
![figure_7_2](https://github.com/Abel-Huang/cs61b-textbook-zh/blob/master/zh/img/figure_7_2_2.jpg)   
图7.2续: `HashMap`类的私有方法  
如果我们取出图7.1中的17个关键字:  
    {81, 22, 38, 26, 86, 82, 0, 23, 39, 65, 83, 40, 9, -3, 84, 63, 5},   
使用线性探测法，其中增长量为1并且`x mod 23`作为哈希函数，将所有的关键字按序存放到容量为23的数组中，这个数组中包含的值如下：  
  
| 0| 1| 2| 3| 4| 5| 6| 7| 8| 9|10|11|12|13|14|15|16|17|18|19|20|21|22|
|-----|----|----|----|----|----|----|----|----|----|----|-----|----|----|----|----|----|----|----|----|----|----|----|
| 0|23|63|26|  |5 |  |  |  | 9|  |  |81|82|83|38|39|86|40|65|-3|84|22|

正如你所见，有几个键并没有放在其本来的位置。例如，**84 mod 23 == 15**和**63 mod 23 == 17**。

线性探测存在一个聚类现象，参考链地址法很容易发现这个问题。如果在搜索某个键时的条目序列是$b_0$，$b_1$，...，$bn_$，如果任何其他键应该散列到其中一个$b_i$，那么在搜索它时检查的条目序列将是同一序列的一部分，$b_i$，$b_{i+1}$，...，$b_n$，甚至这两个键的哈希值不相同。
  实际上，链接地址法下的两个不同列表将被合并，在线性探测下，这些键的有效平均大小将会加倍。我们整数集的最长链（见图7.1）长度只有3。在上面的开放地址示例中，最长链是有9个元素(见63），即使只有一个与其他键(40)的散列值相同。
  
让`nextProbe`以不同的量递增值，增量取决于原始keys，我们通过这种被称为双重散列的技术，可以有效改善这种效果。
  
从开放地址哈希表删除一个元素并不简单。简单地将条目标记为“未占用”可以打破碰撞条目链，并从表中删除多条所需要的元素\[为什么?\]。如果需要进行删除操作，我们不得不谨慎处理。有兴趣的读者可以参考高德纳的**计算机程序设计的艺术**第三卷了解更多相关信息。

一般来说，开放地址法的问题在于链地址法下位于不同桶中的键可以相互竞争。在使用链地址法时，如果所有的桶都已经被占用，我们要确认搜索的关键字并不在表中，最多只需要找与被搜索值具有相同哈希值的元素数量的次数。使用开放地址法，可能需要搜索N次才能确定被搜索的值是否在表中。根据我的经验，链地址法所需要的额外空间成本相对并不重要，对于大多数场景下，我更建议使用链地址法而不是开放地址法。

## 7.3 哈希函数
此前遗留了一个问题：如何选择哈希函数用于存放键。为了使我们实现的映射或集合能够正常工作，首先我们的散列函数满足下面两个很重要的约束：
1. 在执行程序期间，对于任何键值K，`hash(K)`的值必须保持不变，而K在表中(或者如果hash(K)改变则必须重建哈希表)。
2. 如果两个键相等(根据`equals`方法，或者任何哈希表使用的相等测试)，则他们两个的哈希值必须相等。

如果违反了上述任何一条规则，关键字key就会从哈希表中有效移除。另一方面，一次程序执行到下一次程序执行，哈希值是否保持固定不变并不重要，重要的是不同的键拥有不同的哈希值(如果太多的键具有相同的哈希值，性能将明显受到影响)。

如果键是简单的非负整数，一个简单有效的哈希函数就是使用键X模上哈希表的大小：  
`hash(X) == X % bins.size ();`  
对于负整数而言，我们必须做出一些规定。例如：  
`hash(X) = (X & 0x7fffffff) % bins.size ();`  
先将任何一个负数加上2^32可以达到和正整数一样的效果\[为什么？\]。或者如果表长度为奇数，那么  
`hash(X) = X % ((bins.size ()+1) / 2) + bins.size ()/2;`  
也是可以的\[为什么？\]。

处理非数值类型的键值可能需要多做一些工作。我们可以使用所有Java对象都定义过的`hashCode`方法将**Objects**转为整数(这样我们就可以继续按照上面的方法进行处理)。**Objects**默认的`x.equals(y)`是`x==y`,即x和y是同一个对象的引用。相应地，Object提供的`x.hashCode()`的默认实现只返回一个整数值，该值是从x指向的对象的地址派生的，即对象的指针值x被视为一个整数（这就是幕后的真实情况）。这种默认的实现方式不适用于考虑两个不同对象是否相同的情况。比如，两个字符串的计算：  
`String s1 = "Hello, world!", s2 = "Hello," + " " + "world!";`  
即使具有`s1.equals(s2)`的属性，但`s1！= s2`（它们是碰巧包含相同字符串序列的两个不同的String对象）。因此，默认的`hashCode`操作不适合String，因此String类自己进行重新实现并且覆盖默认定义的`hashCode`方法。

为了获得键的索引，我们使用了取余操作。这显然会产生一个数字范围；这对我们选择哈希表的大小而言并不明显(不接近2的幂的素数)。可以说，选择其他的值往往会产生不理想的结果。例如，如果使用2的幂意味将会忽略`X.hashCode()`的高位。

如果键不是简单的整数(比如字符串)，一个可行的方法是将他们转为整数并且使用上面的取余算法。下面是一个代表性的来自P. J. Weinberger的C编译器中的字符串散列函数，在实践中取得了广泛的应用。他设定了8位数的字符和32位的整型。

```java
    static int hash(String S) {
        int h;
        h = 0;
        for (int p = 0; p < S.length (); p += 1) {
            h = (h << 4) + S.charAt(p);
            h = (h ^ ((h & 0xf0000000) >> 24)) & 0x0fffffff;
        }
        return h;
    }
```
Java字符串类型有不同的`hashCode`函数实现，使用模运算来计算得到一个$-2^31$到$2^31-1$范围内的结果。 
 
$$\sum_{i=0}^n\c_i\31^{n - i - 1}$$  

这里，$c_i$表示clsString中的第i个字符。
## 7.4 性能
假设密钥均匀分布，无论在哈希表中是否包含待查找的元素，都可以常数时间内完成检索。正如我们在4.1节中关于ArrayList`规模增长的分析，哈希表插入也具有常数的摊分时间复杂度(即所有插入的平均复杂度)。当然，如果键分布不均匀，我们可以看成$\Theta(N)$的时间复杂度。

如果一个哈希函数有时可能会出现错误的聚类问题，我们可以使用一种被称为通用散列的技术帮助解决。可以从一些精心挑选的集合中随机挑选一个哈希函数，在平均情况下，在程序的运行过程中哈希函数将会有良好的表现。

## 练习

**7.1** 通过7.1节中， 给出的`HashMap`表示的`iterator`方法的具体实现，并且`Iterator`类也是必要的。我们已经选择简单的链表实现，你必须谨慎使用正确的`remove`操作。
