一、项目背景详细介绍

在计算机科学与软件工程领域,哈希表(Hash Table)因其高效的查找、插入和删除性能(平均 O(1))而被广泛应用。Java 标准库提供的 HashMapHashSet 等基于链式法(Separate Chaining)或红黑树优化的哈希表实现,已很好地满足了大多数使用场景。但对于理解哈希冲突处理原理、底层性能权衡,以及在特定场景下进行自定义扩展和优化,手动实现各种开放寻址策略仍具有重要的学习意义。

开放寻址方法(Open Addressing)通过在发生哈希冲突时,将元素放置于表中其他空闲槽位来解决冲突。常见策略有线性探测(Linear Probing)、二次探测(Quadratic Probing)和双重哈希(Double Hashing)。其中,双重哈希使用两个不同的哈希函数生成两个探测步长,具有更好的分散性与更低的聚集风险,适用于对性能和负载因子更高要求的场景。

本项目聚焦于使用双重哈希作为冲突解决技术,实现一个通用的、可扩容、支持 putgetremove、遍历等操作的 Java 哈希表。通过本项目,读者可以深入理解双重哈希的原理、装载因子对性能的影响、扩容策略、删除处理等核心技术细节。


二、项目需求详细介绍

  1. 功能需求

    • 插入(put(K key, V value)):若键不存在,将键值对插入;若存在,则更新其值。

    • 查找(get(K key)):根据键返回对应值,若不存在返回 null

    • 删除(remove(K key)):移除给定键的键值对,并保证后续探测链完整性。

    • 动态扩容(resize):当装载因子超过阈值时,将容量扩展为原来的两倍。

    • 遍历(keys() / values()):提供迭代器接口,遍历所有存储的键和值。

  2. 性能需求

    • 平均复杂度:插入、查找、删除均为 O(1)(摊销)。

    • 负载因子阈值:默认为 0.5,可配置;高负载因子下性能可能下降。

  3. 设计需求

    • 模块化:实现一个通用类 DoubleHashingHashTable<K, V>

    • 安全性:校验输入合法性(如键非空)。

    • 健壮性:在各种边界情况下行为正确(如扩容、删除后再查找)。

    • 可配置性:允许自定义初始容量与装载因子。


三、相关技术详细介绍

  1. 哈希函数

    • Java 对象的默认 hashCode() 用于生成初级哈希值,保证分布均匀。

    • 为避免负值,取正值:h1 = (hashCode & 0x7fffffff) % capacity

    • 第二哈希函数 h2 应与 capacity 互质,常取:

h2 = R - (hashCode & 0x7fffffff) % R
    • 其中 R 是比 capacity 小的质数(如 capacity/2 取下一个质数),保证探测步长与表长互质,以遍历全表。

  • 双重哈希(Double Hashing)探测策略

    • 在第 i 次探测时,计算索引:

idx = (h1 + i * h2) % capacity
    • 它结合了两种哈希函数的优点:第一哈希决定簇的初始位置,第二哈希决定簇内步长。

    • 能够有效避免线性探测的初级聚集问题与二次探测的二级聚集。

  1. 装载因子(Load Factor)与扩容

    • 定义:α = N / M(N:已存元素数,M:表容量)。

    • 若装载因子过高,冲突增多,查找/插入性能下降;过低,则空间利用率低。

    • α > threshold 时,自动扩容至 2*M 并重新哈希所有元素。

  2. 删除处理

    • 直接将槽置空会导致后续探测链断裂,查找失败。

    • 解决方案:删除后将后续遇到的所有元素暂存并重新插入;或使用“已删除标记”(tombstone)并在必要时重建表。

    • 本实现采用“重插”策略,保证表中无 tombstone 的概念,简化实现。


四、实现思路详细介绍

  1. 类成员定义

private K[] keys;           // 键数组
private V[] values;         // 值数组
private int capacity;       // 表容量
private int size;           // 当前元素数
private final double loadFactor;  // 装载因子阈值
private final int R;        // 用于第二哈希函数的质数
  1. 构造函数

    • 默认构造capacity=16loadFactor=0.5R=13(小于 capacity 的最大质数)。

    • 自定义构造:允许传入初始容量和装载因子,并根据容量计算 R。

  2. 主流程

    • put(K key, V value)

      1. 校验 keynull

      2. (size + 1)/capacity > loadFactor,调用 resize(2*capacity)

      3. 计算 h1h2

      4. i = 0, 1, 2…

        • 计算 idx = (h1 + i * h2) % capacity

        • keys[idx] == null,插入新键值对并 size++;返回。

        • keys[idx].equals(key),更新 values[idx];返回。

    • get(K key)

      1. 校验 keynull

      2. 同上计算 h1h2,并线性遍历至空槽返回 null 或找到后返回值。

    • remove(K key)

      1. 查找目标元素索引 idx

      2. 若不存在,返回 null

      3. 否则置 keys[idx]=values[idx]=nullsize--
        然后 idx = (idx + h2) % capacity 一直遍历后续元素,暂存后清除并重新 put

    • resize(int newCap)

      1. 保存原数组,创建新数组和新 capacity

      2. 重新计算新的 R

      3. 遍历原数组,依次 put

  3. 遍历与工具方法

    • hash1(K key)hash2(K key):分别实现两个哈希函数。

    • keys() / values():返回跳过 null 的懒加载 Iterable

    • size() / isEmpty():基础信息接口。


五、完整实现代码

// 文件:DoubleHashingHashTable.java
package com.example.hashtable;

import java.util.Iterator;
import java.util.NoSuchElementException;

/**
 * 双重哈希(Double Hashing)开放寻址哈希表实现
 *
 * @param <K> 键类型
 * @param <V> 值类型
 */
public class DoubleHashingHashTable<K, V> {
    private static final int DEFAULT_CAPACITY = 16;    // 默认容量
    private static final double DEFAULT_LOAD_FACTOR = 0.5; // 默认装载因子阈值

    private K[] keys;           // 键数组
    private V[] values;         // 值数组
    private int capacity;       // 当前容量
    private int size;           // 元素个数
    private final double loadFactor; // 装载因子
    private int R;              // 第二哈希函数使用的质数

    @SuppressWarnings("unchecked")
    public DoubleHashingHashTable(int initialCapacity, double loadFactor) {
        this.capacity = initialCapacity;
        this.loadFactor = loadFactor;
        this.size = 0;
        // 选择小于 capacity 的最大质数 R
        this.R = getPreviousPrime(capacity);
        this.keys = (K[]) new Object[capacity];
        this.values = (V[]) new Object[capacity];
    }

    public DoubleHashingHashTable() {
        this(DEFAULT_CAPACITY, DEFAULT_LOAD_FACTOR);
    }

    /**
     * 插入或更新键值对
     */
    public void put(K key, V value) {
        if (key == null) throw new IllegalArgumentException("Key 不能为空");
        // 扩容检查
        if ((double) (size + 1) / capacity > loadFactor) {
            resize(2 * capacity);
        }
        int h1 = hash1(key);
        int h2 = hash2(key);
        int idx = h1;

        for (int i = 0; i < capacity; i++) {
            idx = (h1 + i * h2) % capacity;
            if (keys[idx] == null) {
                keys[idx] = key;
                values[idx] = value;
                size++;
                return;
            }
            if (keys[idx].equals(key)) {
                values[idx] = value;
                return;
            }
        }
        // 正常情况下不会到达此处
        throw new RuntimeException("表已满,无法插入");
    }

    /**
     * 查找值
     */
    public V get(K key) {
        if (key == null) return null;
        int h1 = hash1(key);
        int h2 = hash2(key);
        int idx;

        for (int i = 0; i < capacity; i++) {
            idx = (h1 + i * h2) % capacity;
            if (keys[idx] == null) return null;
            if (keys[idx].equals(key)) {
                return values[idx];
            }
        }
        return null;
    }

    /**
     * 删除键值对
     */
    public V remove(K key) {
        if (key == null) return null;
        int h1 = hash1(key);
        int h2 = hash2(key);
        int idx;

        for (int i = 0; i < capacity; i++) {
            idx = (h1 + i * h2) % capacity;
            if (keys[idx] == null) return null;
            if (keys[idx].equals(key)) {
                V oldValue = values[idx];
                keys[idx] = null;
                values[idx] = null;
                size--;
                // 重新插入后续探测链上的元素
                rehashFrom((idx + h2) % capacity, h2);
                return oldValue;
            }
        }
        return null;
    }

    /**
     * 从 startIdx 开始,按照步长 h2 重新插入所有后续元素
     */
    private void rehashFrom(int startIdx, int h2) {
        int idx = startIdx;
        while (keys[idx] != null) {
            K reKey = keys[idx];
            V reVal = values[idx];
            keys[idx] = null;
            values[idx] = null;
            size--;
            put(reKey, reVal);
            idx = (idx + h2) % capacity;
        }
    }

    /**
     * 哈希函数1:获取初始索引
     */
    private int hash1(K key) {
        return (key.hashCode() & 0x7FFFFFFF) % capacity;
    }

    /**
     * 哈希函数2:获取探测步长
     */
    private int hash2(K key) {
        return R - ((key.hashCode() & 0x7FFFFFFF) % R);
    }

    /**
     * 扩容至 newCapacity 并重新哈希
     */
    @SuppressWarnings("unchecked")
    private void resize(int newCapacity) {
        K[] oldKeys = keys;
        V[] oldValues = values;

        capacity = newCapacity;
        R = getPreviousPrime(capacity);
        keys = (K[]) new Object[capacity];
        values = (V[]) new Object[capacity];
        size = 0;

        for (int i = 0; i < oldKeys.length; i++) {
            if (oldKeys[i] != null) {
                put(oldKeys[i], oldValues[i]);
            }
        }
    }

    /**
     * 返回小于 n 的最大质数
     */
    private int getPreviousPrime(int n) {
        for (int i = n - 1; i >= 2; i--) {
            if (isPrime(i)) return i;
        }
        return 3;
    }

    private boolean isPrime(int num) {
        if (num < 2) return false;
        for (int i = 2; i * i <= num; i++) {
            if (num % i == 0) return false;
        }
        return true;
    }

    /**
     * 返回所有键的迭代器
     */
    public Iterable<K> keys() {
        return () -> new Iterator<K>() {
            private int idx = 0;
            @Override
            public boolean hasNext() {
                while (idx < capacity && keys[idx] == null) idx++;
                return idx < capacity;
            }
            @Override
            public K next() {
                if (!hasNext()) throw new NoSuchElementException();
                return keys[idx++];
            }
        };
    }

    /**
     * 返回所有值的迭代器
     */
    public Iterable<V> values() {
        return () -> new Iterator<V>() {
            private int idx = 0;
            @Override
            public boolean hasNext() {
                while (idx < capacity && values[idx] == null) idx++;
                return idx < capacity;
            }
            @Override
            public V next() {
                if (!hasNext()) throw new NoSuchElementException();
                return values[idx++];
            }
        };
    }

    /**
     * 当前元素个数
     */
    public int size() {
        return size;
    }

    /**
     * 是否为空
     */
    public boolean isEmpty() {
        return size == 0;
    }
}

六、代码详细解读

  • 构造函数:初始化容量、装载因子,计算第二哈希质数 R,并分配数组。

  • put 方法

    1. 校验键非空并检查装载因子,必要时扩容;

    2. 计算 h1(主哈希)、h2(探测步长);

    3. 按公式 (h1 + i*h2)%capacity 线性探测:遇空槽则插入,遇相同键则更新。

  • get 方法:同样计算 h1h2,沿探测序列查找并返回值,否则为 null

  • remove 方法:找到目标后置空并 size--,再从删除位置沿步长 h2 重插后续所有元素,保证表完整性。

  • resize 方法:扩容为两倍,重新计算 R 并对旧表中所有元素逐一 put

  • hash1/hash2:分别实现主哈希和双哈希探测步长,通过取模、质数保证遍历全表。

  • getPreviousPrime/isPrime:用于计算小于容量的最大质数 R,保证 h2capacity 互质。

  • keys/values 迭代器:跳过 null,懒加载方式返回所有键或值。


七、项目详细总结

本项目通过 Java 从零实现了双重哈希的开放寻址哈希表,重点掌握了两种哈希函数设计、探测序列计算、装载因子与扩容、删除后重插策略等核心技术。与线性探测相比,双重哈希能够显著降低聚集现象,提高高负载场景下的性能稳定性。经多组测试,插入、查找、删除操作均能保持 O(1) 摊销复杂度,扩容后元素分布均匀、查找效率恢复良好。


八、项目常见问题及解答

  1. Q:为什么第二哈希函数要与容量互质?
    A:保证探测步长与表容量互质,可遍历整个表而无死角。

  2. Q:双重哈希相比线性探测的优势是什么?
    A:避免了线性探测的初级聚集和二次探测的二级聚集,大幅提升高负载情况下的性能。

  3. Q:删除后为何需要重插后续元素?
    A:直接置 null 会断裂探测链,造成后续元素无法被查找;重插可修复链路。

  4. Q:R 如何选取?
    A:R 为小于 capacity 的最大质数,可通过简单素性测试获得。

  5. Q:是否有 tombstone 策略?
    A:本实现采用直接重插,无 tombstone 标记;更简单但删除成本稍高。


九、扩展方向与性能优化

  1. 探测策略变种

    • 二次探测:结合双重哈希与二次探测,可进一步提高分散性。

    • 三重哈希:增加第三哈希函数,适用于超高性能场景。

  2. 并发支持

    • 使用分段锁(Segment Lock)或读写锁(ReadWriteLock)实现并发访问。

    • 参考 ConcurrentHashMap 的分段并发设计。

  3. 空间优化

    • tombstone 标记 + 延迟重建,减少删除时的重插成本。

    • 紧凑哈希(Compact Hash)结合位图,降低内存开销。

  4. 持久化与缓存

    • 将哈希表持久化到文件系统或嵌入式数据库。

    • 与 LRU、LFU 等缓存淘汰策略结合,构建高性能缓存。

  5. 动态调整装载因子

    • 根据运行时负载动态调节扩容阈值,实现性能与空间的自动平衡。

  6. 基准测试与调优

    • 使用 JMH(Java Microbenchmark Harness)对比不同策略在各种负载下的性能。

    • 分析 GC 行为,调整 JVM 参数优化内存管理。

Logo

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

更多推荐