项目背景详细介绍
在许多场景中,我们需要以 键–值对 的形式高效地存储和检索数据。除了使用分离链表解决冲突的哈希表外,开放寻址法(Open Addressing) 也是常见的方案之一。与分离链表不同,开放寻址将所有元素存储在同一个数组中,当发生冲突时通过探测序列寻找下一个可用槽位。二次探测(Quadratic Probing) 是一种有效的探测策略,它在冲突时按平方序列 hash + 1², hash + 2², hash + 3², … 依次尝试,能在一定程度上减少“主聚集”现象,从而提升性能。


项目需求详细介绍

  1. 泛型支持

    • 键类型 K、值类型 V

  2. 基本操作

    • void put(K key, V value):插入或更新;

    • V get(K key):根据键查找值;

    • V remove(K key):删除键并返回其值;

    • boolean containsKey(K key)

    • int size() / boolean isEmpty()

    • void clear()

  3. 冲突解决

    • 采用开放寻址 + 二次探测;探测函数:

idx = (hash + i + i * i) % capacity;
  1. 动态扩容

    • 当负载因子 size/capacity 超过阈值(如 0.5)时,容量翻倍并重新哈希;

  2. 删除处理

    • 使用“墓碑”(tombstone)标记已删除槽位,以保证探测链的完整性;

  3. 健壮性

    • null 键抛出 IllegalArgumentException

    • 空表 get/remove 不命中返回 null 或抛 NoSuchElementException


相关技术详细介绍

  • 哈希函数

    • key.hashCode() & 0x7FFFFFFF 取正,然后对容量取模;

  • 开放寻址

    • 所有元素存于一个数组中,冲突时按探测序列找下一个可用槽位;

  • 二次探测

    • 探测偏移随尝试次数呈二次增长,可减轻线性探测的主聚集问题;

  • 墓碑标记

    • 删除时不直接清空槽位,而是打上“已删除”标记,后续探测可经过,但插入可复用;


实现思路详细介绍

  1. 内部节点

private static class Entry<K, V> {
  K key;
  V value;
  boolean deleted;  // 墓碑标记
  Entry(K k, V v) { key = k; value = v; deleted = false; }
}

核心字段

private Entry<K, V>[] table;  // 存储槽位
private int capacity;         // 当前容量
private int size;             // 有效元素数
private final float loadFactor; // 负载因子阈值
  • 构造器

    • 默认 capacity = 16loadFactor = 0.5f

    • 分配 table = new Entry[capacity]

  • 插入(put)

    • 校验 key != null

    • 如果 (size + deletedCount) / capacity > loadFactor,调用 resize()

    • 计算初始 hash = hash(key)

    • 依次按 i = 0,1,2… 计算 idx = (hash + i + i*i) % capacity

      • 若槽位 null,或 deleted==true,插入新 Entry,更新 size,返回;

      • key.equals(slot.key),更新 value 并返回;

  • 查询(get)

    • 同样按二次探测序列遍历,遇 null 停止;

    • !deleted && key.equals(slot.key),返回 value

    • 否则继续;未命中返回 null

  • 删除(remove)

    • 按探测序列定位,找到且 !deleted 时打标 deleted=truesize--,返回旧值;

    • 未命中返回 null

  • 动态扩容(resize)

    • 容量翻倍,新建 newTable,遍历旧 table 中所有 != null && !deleted 的槽位,重新 putnewTable

  • 其他方法

    • containsKey 调用 get!=null

    • size/isEmpty/clear 简单实现。

// 文件:OpenAddressingHashMap.java
import java.util.NoSuchElementException;

@SuppressWarnings("unchecked")
public class OpenAddressingHashMap<K, V> {
    private static class Entry<K, V> {
        K key; V value;
        boolean deleted;
        Entry(K k, V v) { key = k; value = v; deleted = false; }
    }

    private Entry<K, V>[] table;
    private int capacity;
    private int size;
    private int deletedCount;
    private final float loadFactor;

    public OpenAddressingHashMap() {
        this.capacity = 16;
        this.loadFactor = 0.5f;
        this.table = (Entry<K, V>[]) new Entry[capacity];
        this.size = 0;
        this.deletedCount = 0;
    }

    /** 计算非负哈希并对容量取模 */
    private int hash(Object key) {
        return (key.hashCode() & 0x7FFFFFFF) % capacity;
    }

    /** 插入或更新 */
    public void put(K key, V value) {
        if (key == null) throw new IllegalArgumentException("Key cannot be null");
        // 扩容判断:有效数 + 墓碑数 > loadFactor * capacity
        if ((size + deletedCount) >= capacity * loadFactor) {
            resize();
        }
        int h = hash(key);
        int firstDeleted = -1;
        for (int i = 0; i < capacity; i++) {
            int idx = (h + i + i * i) % capacity;
            Entry<K, V> e = table[idx];
            if (e == null) {
                // 优先填充第一次遇到的“墓碑”
                int target = firstDeleted >= 0 ? firstDeleted : idx;
                table[target] = new Entry<>(key, value);
                if (firstDeleted >= 0) deletedCount--;
                size++;
                return;
            }
            if (e.deleted) {
                if (firstDeleted < 0) firstDeleted = idx;
                continue;
            }
            if (e.key.equals(key)) {
                e.value = value;
                return;
            }
        }
        // 理论上不会到这里
        throw new IllegalStateException("HashMap full");
    }

    /** 根据键查找,未命中返回 null */
    public V get(K key) {
        if (key == null) throw new IllegalArgumentException("Key cannot be null");
        int h = hash(key);
        for (int i = 0; i < capacity; i++) {
            int idx = (h + i + i * i) % capacity;
            Entry<K, V> e = table[idx];
            if (e == null) break;
            if (!e.deleted && e.key.equals(key)) {
                return e.value;
            }
        }
        return null;
    }

    /** 删除键并返回旧值,未命中返回 null */
    public V remove(K key) {
        if (key == null) throw new IllegalArgumentException("Key cannot be null");
        int h = hash(key);
        for (int i = 0; i < capacity; i++) {
            int idx = (h + i + i * i) % capacity;
            Entry<K, V> e = table[idx];
            if (e == null) break;
            if (!e.deleted && e.key.equals(key)) {
                e.deleted = true;
                size--;
                deletedCount++;
                return e.value;
            }
        }
        return null;
    }

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

    /** 当前元素数量(不含墓碑) */
    public int size() {
        return size;
    }

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

    /** 清空所有数据 */
    public void clear() {
        this.table = (Entry<K, V>[]) new Entry[capacity];
        this.size = 0;
        this.deletedCount = 0;
    }

    /** 扩容并重哈希 */
    private void resize() {
        Entry<K, V>[] old = table;
        capacity <<= 1;
        table = (Entry<K, V>[]) new Entry[capacity];
        size = 0;
        deletedCount = 0;
        for (Entry<K, V> e : old) {
            if (e != null && !e.deleted) {
                put(e.key, e.value);
            }
        }
    }
}

// 文件:Main.java
public class Main {
    public static void main(String[] args) {
        OpenAddressingHashMap<String, Integer> map = new OpenAddressingHashMap<>();
        map.put("apple", 3);
        map.put("banana", 2);
        map.put("orange", 5);
        System.out.println("banana -> " + map.get("banana")); // 2
        map.put("banana", 7);
        System.out.println("banana -> " + map.get("banana")); // 7
        System.out.println("contains grape? " + map.containsKey("grape")); // false
        System.out.println("remove apple: " + map.remove("apple")); // 3
        System.out.println("size: " + map.size()); // 2
    }
}

代码详细解读

  1. 槽位与墓碑

    • Entry.deleted 标记“已删除”槽位;

    • 插入时优先复用首个遇到的墓碑槽位;

  2. 二次探测

    • idx = (h + i + i*i) % capacity,其中 i 从 0 递增;

    • 确保探测序列可覆盖整个表;

  3. 动态扩容

    • (size + deletedCount) ≥ loadFactor * capacity 时触发;

    • 容量翻倍后,对所有“真实”数据重新插入(跳过墓碑);

  4. 删除操作

    • 仅标记 deleted=true,不清空槽位,以保证后续探测链不被截断;

    • 墓碑数 deletedCount 增加,防止表过度填满;


项目详细总结
本实现通过开放寻址 + 二次探测,在一个连续数组中管理所有键值对,避免了链表节点的额外开销。利用墓碑标记实现删除操作,同时在负载因子超限时自动扩容并重哈希,保证了均摊 O(1) 的插入、查找与删除性能。


项目常见问题及解答

  1. 问:为什么要用墓碑?
    答:直接清空槽位会截断探测链,导致后续可能存在的元素无法被找到。墓碑既能标记已删除,又允许探测继续。

  2. 问:二次探测与线性探测有什么区别?
    答:线性探测按 hash + i 寻找,容易产生“主聚集”;二次探测按平方序列,有助于减少聚集。

  3. 问:扩容阈值为何用 (size+deletedCount)
    答:包括墓碑在内的已使用槽位会影响探测效率,需一并考虑。

  4. 问:如何改为无需墓碑的删除?
    答:可在删除后将后续探测链上的元素逐一重新插入,但实现复杂且效率较低。

  5. 问:如何选择 loadFactor
    答:一般 0.5~0.7 可在空间利用率与探测长度之间取得平衡。


扩展方向与性能优化

  1. 双哈希探测:使用第二个哈希函数生成跳跃长度,进一步减轻聚集;

  2. 并发优化:结合分段锁或 java.util.concurrent 包,实现线程安全版本;

  3. 定制哈希函数:针对特定键类型优化 hashCode 以减少冲突;

  4. 缓存友好:按块加载或内存对齐,提升缓存命中率;

  5. 统计分析:监控探测次数分布,动态调整扩容策略与负载因子。

Logo

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

更多推荐