一、项目背景详细介绍

在计算机科学中,哈希表(Hash Table) 是一种非常高效的数据结构。它通过哈希函数将键(Key)映射到存储位置,从而实现 平均时间复杂度为 O(1) 的插入、删除和查找操作。

但是,哈希表有一个非常核心的问题 —— 哈希冲突(Hash Collision)

哈希冲突是指:
当两个不同的键通过哈希函数计算后,映射到相同的存储位置时,就会发生冲突。

解决哈希冲突的常见方法有:

  1. 拉链法(Separate Chaining):每个桶存储一个链表/红黑树;

  2. 开放寻址法(Open Addressing):冲突时寻找下一个可用槽位存储元素。

线性探测(Linear Probing) 就是开放寻址法的一种具体实现。

线性探测的思想

当哈希表中的某个位置已经被占用时:

  • 就沿着数组顺序(即 index+1, index+2, …)继续寻找,直到找到一个空槽存放元素。

  • 查找时同样按照该规则查找。

例如:
哈希表大小 = 5
哈希函数:hash(key) = key % 5

插入 key=1, key=6 时:

  • 1 % 5 = 1 → 存在 index=1;

  • 6 % 5 = 1 → 冲突!那么尝试 index=2,如果空则存储。

这样就避免了覆盖问题。

在本文中,我们将基于 Java 实现一个简化版的 HashMap,并使用 线性探测法 来解决哈希冲突。


二、项目需求详细介绍

  1. 实现一个支持泛型的 HashMap

    • 键类型 K

    • 值类型 V

  2. 主要功能

    • put(K key, V value):插入/更新键值对

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

    • remove(K key):删除键值对

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

    • size():获取元素数量

  3. 冲突处理方式
    使用 线性探测(Linear Probing),即在哈希冲突时向后顺序查找空槽。

  4. 自动扩容
    当负载因子(Load Factor)超过阈值(如 0.75)时,扩展数组并重新哈希(rehash)。

  5. 测试用例

    • 插入多个键值对

    • 触发冲突并测试查找是否正确

    • 删除后是否还能继续插入

    • 自动扩容是否生效


三、相关技术详细介绍

1. 哈希函数

Java 的哈希表通常调用对象的 hashCode() 方法,再对数组长度取模:


index = key.hashCode() % capacity;

2. 开放寻址与线性探测

  • 开放寻址:元素直接存在数组中,不使用链表;

  • 线性探测:当冲突时,依次向后查找下一个空槽;

  • 查找和删除时,也必须按相同的探测路径进行。

3. 删除处理

删除元素时,不能简单设为 null,否则可能破坏查找链。
解决办法:引入一个特殊的 删除标记(TOMBSTONE),表示“曾经有数据,但现在被删除”。

4. 扩容

当负载因子超过阈值时,需要将数组大小扩展为原来的两倍,并重新插入所有元素。


四、实现思路详细介绍

  1. 定义 内部类 Entry<K,V> 表示一个键值对;

  2. 使用一个数组 table 存储 Entry;

  3. 插入操作:

    • 计算哈希位置;

    • 如果冲突,顺序查找下一个空位;

    • 插入新元素;

  4. 查找操作:

    • 按相同规则查找;

    • 如果遇到空位但没找到目标,说明不存在;

  5. 删除操作:

    • 查找到目标时,将该位置设为 TOMBSTONE

    • 保持查找链不断裂;

  6. 扩容:

    • 当元素数量超过 capacity * loadFactor 时,扩展容量并重新插入。


五、完整实现代码

// 文件:LinearProbingHashMap.java

import java.util.Arrays;

public class LinearProbingHashMap<K, V> {
    private static final int DEFAULT_CAPACITY = 8; // 初始容量
    private static final double LOAD_FACTOR = 0.75; // 负载因子

    private Entry<K, V>[] table; // 存储数据的数组
    private int size; // 元素个数
    private int capacity; // 当前容量

    // 删除标记
    private final Entry<K, V> TOMBSTONE = new Entry<>(null, null);

    // 内部类 Entry,表示键值对
    private static class Entry<K, V> {
        K key;
        V value;

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

    // 构造函数
    public LinearProbingHashMap() {
        this.capacity = DEFAULT_CAPACITY;
        this.table = new Entry[capacity];
        this.size = 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");

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

        int index = hash(key);

        while (table[index] != null && table[index] != TOMBSTONE) {
            if (table[index].key.equals(key)) {
                table[index].value = value; // 更新
                return;
            }
            index = (index + 1) % capacity; // 线性探测
        }

        table[index] = new Entry<>(key, value);
        size++;
    }

    // 查找
    public V get(K key) {
        if (key == null) return null;

        int index = hash(key);

        while (table[index] != null) {
            if (table[index] != TOMBSTONE && table[index].key.equals(key)) {
                return table[index].value;
            }
            index = (index + 1) % capacity;
        }

        return null;
    }

    // 删除
    public void remove(K key) {
        if (key == null) return;

        int index = hash(key);

        while (table[index] != null) {
            if (table[index] != TOMBSTONE && table[index].key.equals(key)) {
                table[index] = TOMBSTONE; // 设置为删除标记
                size--;
                return;
            }
            index = (index + 1) % capacity;
        }
    }

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

    // 返回大小
    public int size() {
        return size;
    }

    // 扩容
    private void resize(int newCapacity) {
        Entry<K, V>[] oldTable = table;
        table = new Entry[newCapacity];
        capacity = newCapacity;
        size = 0;

        for (Entry<K, V> entry : oldTable) {
            if (entry != null && entry != TOMBSTONE) {
                put(entry.key, entry.value);
            }
        }
    }

    // 打印哈希表(调试用)
    @Override
    public String toString() {
        return Arrays.toString(table);
    }

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

        map.put(1, "A");
        map.put(9, "B"); // 冲突测试(假设容量8,1和9会冲突)
        map.put(17, "C");

        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));

        map.put(9, "BB"); // 再次插入
        System.out.println("重新插入key=9后,get(9): " + map.get(9));

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

六、代码详细解读

  1. Entry<K,V>
    用于存储键值对,类似于 HashMap.Entry

  2. 哈希函数
    hashCode()capacity 取模,确保索引在数组范围内。

  3. put 方法

    • 计算哈希值;

    • 如果冲突,使用 (index+1) % capacity 继续探测;

    • 如果找到已有键,则更新;否则插入。

  4. get 方法

    • 从哈希位置开始查找;

    • 如果遇到删除标记或不同键,继续探测;

    • 如果为空位,说明不存在。

  5. remove 方法

    • 不能直接置 null,而是用 TOMBSTONE 标记;

    • 否则会破坏查找链。

  6. resize 方法

    • 当超过负载因子时,容量翻倍;

    • 重新插入旧数据。


七、项目详细总结

本文实现了一个 基于线性探测的 HashMap,其特点是:

  • 使用数组存储元素,结构紧凑;

  • 查找和插入在平均情况下为 O(1);

  • 删除时需要使用特殊标记,避免破坏查找链;

  • 支持自动扩容,保证性能。

相较于 Java 原生的 HashMap(采用拉链法),线性探测法减少了指针和链表的开销,但在高负载因子时可能产生 聚集效应(Clustering),降低性能。


八、项目常见问题及解答

问题1:为什么删除要用 TOMBSTONE?
答:如果直接置空,那么查找时遇到空位会认为不存在,从而导致错误。

问题2:扩容时是否必须重新哈希?
答:必须,因为哈希值和数组大小相关,容量变化后哈希值必须重新计算。

问题3:负载因子为什么默认 0.75?
答:这是经验值,兼顾了空间利用率与性能。

问题4:如果连续冲突很多怎么办?
答:这就是聚集效应,可以改用 二次探测(Quadratic Probing)双重哈希(Double Hashing) 优化。


九、扩展方向与性能优化

  1. 二次探测:冲突时用平方序列 (i^2) 代替线性增加。

  2. 双重哈希:使用两个哈希函数,冲突时用第二个哈希函数控制步长。

  3. 动态负载因子:根据使用情况自动调整负载因子。

  4. 并发优化:改进为线程安全版本,类似于 ConcurrentHashMap

  5. 持久化存储:扩展为支持持久化的哈希表。

Logo

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

更多推荐