Java 实现使用 Cuckoo Hashing 的哈希表算法


一、项目背景详细介绍

在传统的哈希表实现中,最核心的问题就是如何解决 哈希冲突(Hash Collision)。当两个键映射到相同的位置时,如果没有良好的冲突解决策略,哈希表的性能会大大下降。常见的解决方案有:

  1. 拉链法(Separate Chaining)
    每个桶存放一个链表或者红黑树,冲突时在桶中继续查找。

  2. 开放寻址法(Open Addressing)
    冲突时,在数组中寻找其他空位置存放,常见方式有 线性探测二次探测双重哈希

然而,拉链法会增加额外的内存开销,开放寻址法可能造成 聚集效应(Clustering),查找效率降低。

为了解决这些问题,Cuckoo Hashing(布谷鸟哈希) 被提出。

Cuckoo Hashing 的核心思想

布谷鸟哈希借鉴了布谷鸟在鸟巢中的行为:

  • 每个元素最多有两个可能的位置(由两个不同的哈希函数决定);

  • 插入新元素时,如果某个位置已被占用,就会“赶走”原来的元素,并让它去它的另一个可能位置;

  • 如果被赶走的元素在新位置也遇到冲突,就会继续“踢走”下一个,直到找到空位或触发重哈希。

这样,Cuckoo Hashing 保证了:

  • 查找操作只需要检查两个位置,复杂度为 O(1)

  • 插入和删除在平均情况下也很高效。

缺点是:

  • 插入可能会陷入“循环踢来踢去”的情况,此时需要 rehash(扩容或重新计算哈希函数)。

  • 内存利用率稍低,因为需要为两个表/两个哈希函数预留位置。


二、项目需求详细介绍

我们要实现一个 基于 Cuckoo Hashing 的哈希表,其功能包括:

  1. put(K key, V value):插入或更新键值对

  2. get(K key):根据键查找值

  3. remove(K key):删除键值对

  4. containsKey(K key):判断键是否存在

  5. size():获取当前元素个数

  6. rehash():在陷入死循环或负载过高时触发扩容并重新哈希

具体需求:

  • 使用两个哈希函数,分别映射到两张表(或者同一张表的两个区域)。

  • 在插入时,如果冲突,就进行踢出操作。

  • 设置一个最大踢出次数,超过时触发重哈希。

  • 支持泛型键值对存储。


三、相关技术详细介绍

1. 哈希函数

需要至少两个不同的哈希函数 h1h2,它们对同一个键生成两个可能的位置。
例如:


h1 = key.hashCode() % capacity; h2 = (key.hashCode() / capacity) % capacity;

2. 插入(Cuckoo 过程)

  • 首先尝试放入位置 h1;

  • 如果已被占用,则踢出原有元素,把它放到它的另一个位置;

  • 如果被踢出的元素再次冲突,则继续踢;

  • 如果踢的次数超过阈值,说明可能进入循环,触发重哈希。

3. 删除

在两个可能位置查找,如果找到就删除即可。

4. Rehash

当:

  • 踢出次数超过上限;

  • 或者负载因子超过阈值;
    需要扩展表容量并重新插入所有元素。


四、实现思路详细介绍

  1. 定义一个内部类 Entry<K,V> 存放键值对。

  2. 使用两个数组(表) table1table2 存储元素。

  3. put() 时:

    • 先尝试放入 table1[h1];

    • 冲突时踢出并转移到 table2[h2];

    • 如果循环过多次,触发 rehash。

  4. get() 时:

    • 查找 table1[h1] 或 table2[h2];

  5. remove() 时:

    • 如果在两个位置之一找到,设为 null。

  6. rehash() 时:

    • 扩展容量为原来的两倍;

    • 将所有旧数据重新插入。


五、完整实现代码

// 文件:CuckooHashMap.java

import java.util.Arrays;

public class CuckooHashMap<K, V> {
    private static final int DEFAULT_CAPACITY = 8; // 初始容量
    private static final double LOAD_FACTOR = 0.5; // 负载因子(较低,避免冲突过多)
    private static final int MAX_KICKS = 16; // 最大踢出次数

    private Entry<K, V>[] table1;
    private Entry<K, V>[] table2;
    private int capacity;
    private int size;

    // 内部类 Entry
    private static class Entry<K, V> {
        K key;
        V value;

        Entry(K key, V value) {
            this.key = key;
            this.value = value;
        }
    }

    // 构造函数
    public CuckooHashMap() {
        this.capacity = DEFAULT_CAPACITY;
        this.table1 = new Entry[capacity];
        this.table2 = new Entry[capacity];
        this.size = 0;
    }

    // 哈希函数1
    private int hash1(Object key) {
        return (key.hashCode() & 0x7fffffff) % capacity;
    }

    // 哈希函数2
    private int hash2(Object key) {
        return ((key.hashCode() / capacity) & 0x7fffffff) % capacity;
    }

    // 插入/更新
    public void put(K key, V value) {
        if (containsKey(key)) {
            // 如果存在,更新
            if (table1[hash1(key)] != null && table1[hash1(key)].key.equals(key)) {
                table1[hash1(key)].value = value;
                return;
            }
            if (table2[hash2(key)] != null && table2[hash2(key)].key.equals(key)) {
                table2[hash2(key)].value = value;
                return;
            }
        }

        if ((double) size / (2 * capacity) > LOAD_FACTOR) {
            resize(capacity * 2);
        }

        Entry<K, V> newEntry = new Entry<>(key, value);

        for (int kick = 0; kick < MAX_KICKS; kick++) {
            int pos1 = hash1(newEntry.key);
            if (table1[pos1] == null) {
                table1[pos1] = newEntry;
                size++;
                return;
            } else {
                // 踢出
                Entry<K, V> temp = table1[pos1];
                table1[pos1] = newEntry;
                newEntry = temp;
            }

            int pos2 = hash2(newEntry.key);
            if (table2[pos2] == null) {
                table2[pos2] = newEntry;
                size++;
                return;
            } else {
                // 踢出
                Entry<K, V> temp = table2[pos2];
                table2[pos2] = newEntry;
                newEntry = temp;
            }
        }

        // 踢出过多次,触发rehash
        resize(capacity * 2);
        put(newEntry.key, newEntry.value);
    }

    // 查找
    public V get(K key) {
        int pos1 = hash1(key);
        if (table1[pos1] != null && table1[pos1].key.equals(key)) {
            return table1[pos1].value;
        }
        int pos2 = hash2(key);
        if (table2[pos2] != null && table2[pos2].key.equals(key)) {
            return table2[pos2].value;
        }
        return null;
    }

    // 删除
    public void remove(K key) {
        int pos1 = hash1(key);
        if (table1[pos1] != null && table1[pos1].key.equals(key)) {
            table1[pos1] = null;
            size--;
            return;
        }
        int pos2 = hash2(key);
        if (table2[pos2] != null && table2[pos2].key.equals(key)) {
            table2[pos2] = null;
            size--;
        }
    }

    // 是否包含
    public boolean containsKey(K key) {
        return get(key) != null;
    }

    // 当前大小
    public int size() {
        return size;
    }

    // 扩容并重新哈希
    private void resize(int newCapacity) {
        Entry<K, V>[] oldTable1 = table1;
        Entry<K, V>[] oldTable2 = table2;

        table1 = new Entry[newCapacity];
        table2 = new Entry[newCapacity];
        capacity = newCapacity;
        size = 0;

        for (Entry<K, V> e : oldTable1) {
            if (e != null) put(e.key, e.value);
        }
        for (Entry<K, V> e : oldTable2) {
            if (e != null) put(e.key, e.value);
        }
    }

    // 打印哈希表
    @Override
    public String toString() {
        return "Table1: " + Arrays.toString(table1) + "\nTable2: " + Arrays.toString(table2);
    }

    // 测试
    public static void main(String[] args) {
        CuckooHashMap<Integer, String> map = new CuckooHashMap<>();

        map.put(1, "A");
        map.put(9, "B");
        map.put(17, "C");
        map.put(25, "D");

        System.out.println("Map大小: " + map.size());
        System.out.println("取key=9: " + map.get(9));
        System.out.println("取key=17: " + map.get(17));

        map.remove(9);
        System.out.println("删除key=9后,containsKey(9): " + map.containsKey(9));

        // 扩容测试
        for (int i = 0; i < 50; i++) {
            map.put(i, "Val" + i);
        }
        System.out.println("扩容后大小: " + map.size());
    }
}

六、代码详细解读

  1. Entry<K,V>
    存放键值对。

  2. hash1 和 hash2
    分别计算两个位置,保证一个 key 有两个可能存放位置。

  3. put 方法

    • 优先尝试放到 table1;

    • 如果冲突,就踢出原来的元素,让它去 table2;

    • 如果 table2 也冲突,就再踢;

    • 如果超过最大踢出次数,触发 rehash。

  4. get 方法
    只需查两个位置,O(1)。

  5. remove 方法
    只要在两个位置之一找到,设为 null 即可。

  6. resize 方法
    容量翻倍,重新插入所有元素,避免死循环。


七、项目详细总结

通过实现 Cuckoo Hashing,我们得到了一种性能更稳定的哈希表:

  • 查找操作只需检查两个位置,时间复杂度 O(1);

  • 插入可能触发多次踢出,但平均性能依旧较好;

  • 删除操作也很高效;

  • 需要注意控制负载因子,避免过多冲突。

与传统 HashMap 相比,Cuckoo Hashing 的优点是 查找稳定且可预测,缺点是实现复杂度更高。


八、项目常见问题及解答

问题1:为什么要用两个哈希函数?
答:保证每个元素至少有两个可能位置,降低冲突概率。

问题2:为什么要设置最大踢出次数?
答:避免陷入死循环,当循环过多次说明需要 rehash。

问题3:负载因子为什么取 0.5?
答:因为 Cuckoo Hashing 在高负载下容易频繁触发踢出,为了性能稳定,需要保持较低的负载。

问题4:rehash 是否一定要扩容?
答:不一定,可以只更换哈希函数。但在实现上更常见的是扩容。


九、扩展方向与性能优化

  1. 多哈希函数 Cuckoo Hashing
    不止两个位置,可以使用 3 个或更多位置,进一步减少冲突概率。

  2. 随机化 rehash
    当 rehash 时,可以随机改变哈希函数,减少循环踢出的风险。

  3. 并发优化
    类似于 ConcurrentHashMap,可以分片锁实现线程安全的 Cuckoo Hashing。

  4. 内存优化
    将两个表合并为一个大表,分别管理不同区间,提高缓存命中率。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐