hash 结构的另一种形式 —— 开放地址法

7,999 阅读4分钟

本文我们来探讨一个数据结构的基础话题:hash 结构中的开放地址法(Open Addressing)

HashMap 无 Java 人不知无 Java 人不晓,它使用开链法处理 hash 碰撞,将碰撞的元素用链表串起来挂在第一维数组上。但是并不是所有语言的字典都使用开链法搞定的,比如 Python,它使用的是另一种形式 —— 开放地址法。相比 HashMap 是二维的结构,它只是一维的,只有一个数组。

开放地址法与开链法的不同之处在于如何处理 hash 冲突。当新来一个元素哈希到数组中的位置已经被其它元素占据了该怎么办?

开放地址法会根据当前的位置计算出下一个位置,将这个冲突的元素挪进来。如果这下一个位置也被占用了,那么就再计算下一个位置,直到找到一个空的位置。可以想像,将会有一条虚拟的链条将这些相关的位置串起来。这个虚拟的链条就好比开链法里面的第二维链表。只不过链表有显示的指针字段,而虚拟链条没有,它的这个链条完全是通过数学函数计算出来的。

root = hash(key) % m   // 第一个位置,m 为数组的长度
index_i = (root + p(key, i)) % m  // 链条中的第 i 个位置

index_1 = (root + p(key, 1)) % m
index_2 = (root + p(key, 2)) % m 
...

这个数学函数就是上面代码中的 p —— probe sequence (探测序列)。寻找空位置的过程就是一步一步的探测的过程。不同的 key 会生成不一样的探测序列。

在查找的时候,如果第一个位置上保存的 key 不是目标 key,那就沿着探测路径继续寻找,直到找到或者遇到一个空位置为止。

到这里你可能会担心又没有可能探测过程会出现死循环,探来探去又回到原点了,或者是回到路径的中间。这是很有可能的,所以这里的探测函数不能随意选择,它必须保证探测序列不会出现循环,经过 m-1 次探测生成的探测序列必须正好是 1..m-1的全排列。

这样的探测函数有很多,其中最常见的一种是线性探测函数。该探测序列和输入 key 无关。最终的探测路径只和初始位置相关。

// m = 2^n,c 必须是一个奇数
p(key, i) = c * i
index_i = root + c * i

这里我不去仔细证明这个函数为什么满足要求,我们可以写个简单的代码来验证一下。

public class HashTest {
    public static void main(String[] args) {
        int m = 1 << 16;
        int c = 111111;
        Set<Integer> nums = new HashSet<>();
        for (int i = 1; i < m; i++) {
            int p = c * i % m;
            if(nums.contains(p)) {
                System.out.println("duplicated");
                return;
            }
            nums.add(p);
        }
        System.out.println("no duplicate");
    }
}

------------
no duplicate

好,死循环的问题解决了。下面还有一个问题,删除该怎么办?开链法删除就很简单,直接从链表中摘走就是,但是开放地址法就不是那么好办,你不能随意地将探测路径中的某个元素删除,这样会导致探测路径中断。

为了不让探测路径中断,删除有两种实现方案

在删除的位置置一个特殊的删除标记,查找时可以直接跳过继续沿着探测路径往后寻找。需要注意的是这个删除的位置在后续的新元素插入时会得到回收利用。插入元素时,遍历探测路径,遇到了第一个删除标记的位置,这时不能立即插入。因为有可能这个元素存在于探测路径的后半部。所以需要遍历到底如果发现确实路径里不存在这个元素,这时候要回过头来插入到第一个发现删除标记的位置。如果删除的位置过多,会影响查找和插入性能。 将探测路径的后半部元素全部删除,然后重新插入。如果路径较长,可能会影响插入性能。 到这里似乎就结束了,其实还有一点我们没有注意到。那就是在采用 p(key, i) = c * i 探测函数的前提下,如果多个不同的 key 哈希的第一个位置相同,那么它们将会共享同一条探测路径。因为探测路径完全由第一个位置来决定的,和 输入 key 无关。那么这些相关的 key 就会在一条探测路径上聚集,这可能会导致数据分布的结果不那么均匀。

如果我们使用一个不同的探测函数,使得它和输入 key 相关,那么就可以消灭这个聚集问题。我们可以将探测函数中的常量 c 换成一个 hash 函数,只要这个函数总是返回奇数就可以了,这样的 hash 函数还是非常容易编写的。

p(key, i) = h2(key) * i

总算结束了,读者们,你们还有什么需要补充的么?