数据结构-散列表

散列表

散列表也叫作哈希表(hash table),这种数据结构提供了键(Key)和值(Value)的映射关系。只要给出一个Key,就可以高效查找到它所匹配的Value,时间复杂度接近于O(1)。

存储原理

散列表的存储过程可以分为两步:

  1. 计算哈希值 :当要插入一个键值对 (key, value) 时,首先使用散列函数 f(key) 计算出 key 的哈希值。
  2. 确定存储位置 :将计算出的哈希值通过取模运算(%)映射到散列表数组的有效索引范围内,然后将 value 存储在该索引位置。

例如:如果散列表大小为 10,键 "apple" 的哈希值是 1452,那么它最终会被存储在索引 1452 % 10 = 2 的位置。

哈希冲突

冲突是散列表中不可避免的问题。它指的是两个不同的关键字 key1key2,经过散列函数计算后,得到了相同的哈希地址。

影响冲突的因素
  • 散列函数的设计:一个好的散列函数应尽可能地将关键字均匀地分布在地址空间中。
  • 装填因子 (Load Factor) :定义为 α = 表中填入的记录数 / 散列表的长度α 越大,表越满,发生冲突的概率就越高。
处理方法

链地址法 (Separate Chaining)

散列表的每个"桶"(即数组的每个位置)都维护一个链表(或其他数据结构)。所有哈希到同一位置的元素都会被添加到这个链表中。

  • 优点:实现简单,对高装填因子不敏感,删除操作方便。
  • 缺点:需要额外的空间存储指针,缓存局部性较差。

开放地址法 (Open Addressing)

当发生冲突时,按照某种探测序列(如线性探测、二次探测)在表中寻找下一个空的槽位来存放元素。

  • 优点:所有数据都存储在数组中,缓存局部性好,没有额外的指针开销。
  • 缺点:删除操作复杂,对装填因子敏感,当表快满时性能会急剧下降。

操作

插入 (Insert/Put)

  • 计算键的哈希值并定位到数组索引。
  • 如果该位置为空,直接插入。
  • 如果该位置不为空(发生冲突),则根据冲突处理方法(如遍历链表或线性探测)找到合适位置插入。如果键已存在,则更新其值。

查找 (Search/Get)

  • 计算键的哈希值并定位到数组索引。
  • 如果该位置为空,说明键不存在,返回空。
  • 如果该位置不为空,则在该位置(或对应的冲突链/探测序列)中查找目标键,找到则返回其值。

删除 (Remove/Delete)

  • 计算键的哈希值并定位到数组索引。
  • 在该位置(或对应的冲突链/探测序列)中找到目标键并执行删除。
  • 对于开放地址法,删除不能简单地将位置置空,通常会使用一个特殊的"已删除"标记,以保证后续查找的正确性。
时间复杂度
操作 平均情况 最坏情况
插入 O(1) O(n)
查找 O(1) O(n)
删除 O(1) O(n)

优缺点

优点
  • 高效的平均性能:插入、查找、删除的平均时间复杂度为 O(1),速度极快。
  • 灵活的键类型:支持多种键类型(整数、字符串、对象等),只要该类型是可哈希的。
  • 实用性强:在需要快速查找的工程场景中被广泛使用。
缺点
  • 最坏情况性能差:如果哈希函数设计不佳或装填因子过高,性能会退化为 O(n)。
  • 无序性:元素在表中是无序存储的,无法直接按顺序遍历。
  • 内存开销:链地址法需要存储指针,开放地址法可能会浪费一些桶空间。
  • 键必须不可变:键的哈希值在其生命周期内不能改变,否则无法再找到它。因此,像列表这样的可变对象通常不能作为键。

应用场景

HashMap

JDK1.7中HashMap使用一个table数组来存储数据,用key的hashcode取模来决定key会被放到数组里 的位置,如果hashcode相同,或者hashcode取模后的结果相同,那么这些key会被定位到Entry数组的 同一个格子里,这些key会形成一个链表,在极端情况下比如说所有key的hashcode都相同,将会导致 这个链表会很长,那么put/get操作需要遍历整个链表,那么最差情况下时间复杂度变为O(n)。 扩容死链 针对JDK1.7中的这个性能缺陷,JDK1.8中的table数组中可能存放的是链表结构,也可能存放的是红黑 树结构,如果链表中节点数量不超过8个则使用链表存储,超过8个会调用treeifyBin函数,将链表转换 为红黑树。那么即使所有key的hashcode完全相同,由于红黑树的特点,查找某个特定元素,也只需要 O(logn)的开销。

字典

Redis字典dict又称散列表(hash),是用来存储键值对的一种数据结构。Redis整个数据库是用字典来存储的。(K-V结构) 对Redis进行CURD操作其实就是对字典中的数据进行CURD操作。 Redis字典实现包括:字典(dict)、Hash表(dictht)、Hash表节点(dictEntry)。

布隆过滤器

布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机 hash映射函数。

布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法。

布隆过滤器的原理是:当一个元素被加入集合时,通过K个Hash函数将这个元素映射成一个数组中的K 个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如 果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的 基本思想。

代码示例

java 复制代码
import java.util.Objects;

public class MySimpleHashMap<K, V> {

    // 1. 基础配置
    private static final float LOAD_FACTOR = 0.75f; // 负载因子:默认0.75
    private static final int DEFAULT_CAPACITY = 16; // 初始容量:必须是2的幂次方

    // 2. 底层数据结构
    private Node<K, V>[] table; // 桶数组
    private int size;           // 实际元素个数
    private int threshold;      // 扩容阈值 = capacity * loadFactor

    // 3. 节点类 (模拟 JDK 的 Entry/Node)
    private static class Node<K, V> {
        int hash;       // 缓存 Key 的哈希值
        K key;          // 键
        V value;        // 值
        Node<K, V> next; // 下一个节点(解决冲突用)

        Node(int hash, K key, V value) {
            this.hash = hash;
            this.key = key;
            this.value = value;
        }
    }

    // 构造方法
    public MySimpleHashMap() {
        this.table = new Node[DEFAULT_CAPACITY];
        this.threshold = (int) (DEFAULT_CAPACITY * LOAD_FACTOR);
        this.size = 0;
    }

    // ==========================================
    // 核心方法:Put (插入/更新)
    // ==========================================
    public void put(K key, V value) {
        // 1. 计算哈希值 (处理 null 键)
        int hash = (key == null) ? 0 : hash(key.hashCode());
        
        // 2. 计算数组索引 (位运算优化取模)
        int index = indexFor(hash, table.length);

        // 3. 检查该位置是否已有节点 (处理冲突 & 更新)
        Node<K, V> current = table[index];
        while (current != null) {
            // 如果 Key 已存在,更新 Value 并直接返回
            if (current.hash == hash && Objects.equals(key, current.key)) {
                current.value = value;
                return;
            }
            current = current.next;
        }

        // 4. Key 不存在,执行插入 (头插法)
        Node<K, V> newNode = new Node<>(hash, key, value);
        newNode.next = table[index]; // 新节点指向旧的头节点
        table[index] = newNode;      // 新节点成为新的头节点
        size++;

        // 5. 检查是否需要扩容
        if (size >= threshold) {
            resize();
        }
    }

    // ==========================================
    // 核心方法:Get (查询)
    // ==========================================
    public V get(K key) {
        // 1. 计算哈希和索引
        int hash = (key == null) ? 0 : hash(key.hashCode());
        int index = indexFor(hash, table.length);

        // 2. 遍历链表查找
        Node<K, V> current = table[index];
        while (current != null) {
            // 先比对哈希值(快),再比对 equals(准)
            if (current.hash == hash && Objects.equals(key, current.key)) {
                return current.value;
            }
            current = current.next;
        }
        return null; // 没找到
    }

    // ==========================================
    // 辅助方法:哈希扰动 & 索引计算
    // ==========================================
    
    // 简单的哈希扰动函数:让高位也参与运算,减少碰撞
    private int hash(int h) {
        return h ^ (h >>> 16); 
    }

    // 计算索引:(n - 1) & hash 等价于 hash % n (前提是 n 是 2 的幂)
    private int indexFor(int h, int length) {
        return h & (length - 1);
    }

    // ==========================================
    // 核心方法:Resize (扩容)
    // ==========================================
    private void resize() {
        Node<K, V>[] oldTable = table;
        int oldCapacity = oldTable.length;
        
        // 1. 容量翻倍
        int newCapacity = oldCapacity << 1; // 左移一位 = 乘以 2
        @SuppressWarnings("unchecked")
        Node<K, V>[] newTable = new Node[newCapacity];
        
        // 2. 重新哈希 (Rehash)
        for (int i = 0; i < oldCapacity; i++) {
            Node<K, V> node = oldTable[i];
            while (node != null) {
                Node<K, V> next = node.next; // 暂存下一个节点
                
                // 3. 重新计算新位置
                // 利用位运算特性:扩容后,节点要么在原位置,要么在 原位置+旧容量 的位置
                int newIndex = (node.hash & (newCapacity - 1));
                
                // 4. 插入新数组 (头插法)
                node.next = newTable[newIndex];
                newTable[newIndex] = node;
                
                node = next;
            }
        }
        
        table = newTable;
        threshold = (int) (newCapacity * LOAD_FACTOR);
    }

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

        // 1. 插入数据
        map.put("Apple", 10);
        map.put("Banana", 20);
        map.put("Orange", 30);
        
        // 2. 查询数据
        System.out.println("Apple: " + map.get("Apple")); // 输出: 10
        
        // 3. 更新数据
        map.put("Apple", 100);
        System.out.println("Apple (Updated): " + map.get("Apple")); // 输出: 100

        // 4. 触发扩容测试 (插入大量数据)
        for (int i = 0; i < 20; i++) {
            map.put("Key" + i, i);
        }
        System.out.println("当前容量: " + map.table.length); // 输出: 32 (16->32)
        System.out.println("当前大小: " + map.size);         // 输出: 23
    }
}
相关推荐
星如雨グッ!(๑•̀ㅂ•́)و✧2 小时前
Reactor背压
java·服务器·前端
啥咕啦呛2 小时前
java打卡学习6:集合框架 Collection
java·windows·学习
曹牧2 小时前
Tomcat连接池异常排查
java·tomcat
cool32002 小时前
Kubernetes集群节点扩容实战-kubeasz
java·开发语言·kubernetes
稻草猫.2 小时前
Spring AOP
java·后端·spring·java-ee·idea
第二只羽毛2 小时前
C++ 高并发内存池4
java·大数据·linux·c++·算法
有一个好名字2 小时前
常用注册中心大全(主流 5 个)介绍
java
散峰而望2 小时前
【数据结构】并查集从入门到精通:基础实现、路径压缩、扩展域、带权,一网打尽
数据结构·c++·算法·github·剪枝·推荐算法
watersink2 小时前
第7章 软件架构设计
java·开发语言