零基础数据结构与算法——第三章:高级数据结构-哈希表

发布于:2025-07-04 ⋅ 阅读:(18) ⋅ 点赞:(0)

3.1 树(上)

3.1 树(下)

3.2 堆(Heap)

3.3 哈希表(Hash Table)

3.3.1 哈希表的基本概念

哈希表(Hash Table)是一种基于键值对(key-value)的数据结构,它通过哈希函数将键映射到数组的索引,从而实现快速的插入、查找和删除操作。哈希表的平均时间复杂度为O(1),这使它成为最高效的数据结构之一。

哈希表的生活例子

图书馆的索引系统:在图书馆中,每本书都有一个唯一的编号(ISBN),通过这个编号可以快速找到书的位置,而不需要逐一查找。

学生信息系统:学校使用学号作为键,可以快速查找到学生的所有信息,而不需要遍历所有学生记录。

字典:当你查找一个单词时,你不需要从头到尾翻阅整本字典,而是可以根据首字母快速定位到相应的页面。

哈希表的基本组成
  1. 键(Key):用于标识和查找数据的唯一标识符。
  2. 值(Value):与键关联的数据。
  3. 哈希函数(Hash Function):将键转换为数组索引的函数。
  4. 桶(Bucket):存储数据的数组单元。

3.3.2 哈希函数

哈希函数是哈希表的核心,它的作用是将键转换为数组索引。一个好的哈希函数应该具有以下特性:

  1. 计算简单:哈希函数的计算应该高效,不应该成为性能瓶颈。
  2. 分布均匀:哈希函数应该将键均匀地分布在整个数组中,避免聚集。
  3. 冲突少:不同的键被映射到相同索引的情况应该尽量少。
常见的哈希函数

整数哈希:对于整数键,可以使用简单的取模运算。

int hash(int key) {
    return key % tableSize;
}

字符串哈希:对于字符串键,可以使用字符的ASCII值进行计算。

int hash(String key) {
    int hash = 0;
    for (int i = 0; i < key.length(); i++) {
        hash = (hash * 31 + key.charAt(i)) % tableSize;
    }
    return hash;
}

Java中的hashCode方法:Java中的所有对象都有一个hashCode方法,可以用于计算哈希值。

3.3.3 冲突解决

当两个不同的键被哈希到同一个索引时,就会发生冲突(Collision)。冲突是不可避免的,因为键的数量通常远大于数组的大小。因此,我们需要有策略来解决冲突。

1. 链地址法(Chaining)

链地址法是最常用的冲突解决策略之一。它的基本思想是:

  • 每个桶存储一个链表
  • 当发生冲突时,将新元素添加到链表的末尾
  • 查找时,遍历链表直到找到目标键

优点

  • 实现简单
  • 不会浪费空间
  • 支持无限数量的元素

缺点

  • 链表可能变得很长,导致查找效率下降
  • 需要额外的空间来存储指针

生活例子:想象一个宿舍楼,每个房间都有一个编号。如果有多个学生被分配到同一个房间,那么这些学生会形成一个"室友链",每个人都知道下一个室友是谁。

2. 开放寻址法(Open Addressing)

开放寻址法的基本思想是:当发生冲突时,尝试其他位置,直到找到一个空位置。常见的开放寻址策略包括:

线性探测(Linear Probing)

  • 如果位置i被占用,尝试位置i+1, i+2, …
  • 简单但容易导致聚集问题

二次探测(Quadratic Probing)

  • 如果位置i被占用,尝试位置i+1², i+2², i+3², …
  • 减少聚集问题

双重哈希(Double Hashing)

  • 使用第二个哈希函数计算步长
  • 进一步减少聚集问题

优点

  • 不需要额外的空间来存储指针
  • 缓存局部性更好

缺点

  • 当表变满时,性能会急剧下降
  • 删除操作比较复杂

生活例子:想象你去停车场停车,发现你想停的位置已经被占用,于是你继续向前寻找下一个空位。

3.3.4 基于链地址法的哈希表实现

下面是一个基于链地址法的哈希表的Java实现:

// 哈希表节点类
class HashNode {
    String key;      // 键
    int value;       // 值
    HashNode next;   // 指向下一个节点的引用
    
    // 构造函数
    public HashNode(String key, int value) {
        this.key = key;
        this.value = value;
        this.next = null;
    }
}

// 哈希表类
class HashTable {
    private HashNode[] buckets;  // 桶数组
    private int numBuckets;      // 桶的数量
    private int size;            // 哈希表中的元素数量
    
    // 构造函数
    public HashTable(int capacity) {
        this.numBuckets = capacity;
        this.buckets = new HashNode[numBuckets];
        this.size = 0;
    }
    
    // 返回哈希表中的元素数量
    public int size() {
        return size;
    }
    
    // 检查哈希表是否为空
    public boolean isEmpty() {
        return size() == 0;
    }
    
    // 计算键的桶索引
    private int getBucketIndex(String key) {
        int hashCode = key.hashCode();
        // 确保索引为非负数,并取模以适应桶数组大小
        return (hashCode & 0x7fffffff) % numBuckets;
    }
    
    // 插入键值对
    public void put(String key, int value) {
        // 计算桶索引
        int bucketIndex = getBucketIndex(key);
        HashNode head = buckets[bucketIndex];
        
        // 检查键是否已存在
        while (head != null) {
            if (head.key.equals(key)) {
                // 如果键已存在,更新值
                head.value = value;
                return;
            }
            head = head.next;
        }
        
        // 插入新节点到链表头部
        size++;
        head = buckets[bucketIndex];
        HashNode newNode = new HashNode(key, value);
        newNode.next = head;
        buckets[bucketIndex] = newNode;
        
        // 如果负载因子超过阈值,可以在这里调整哈希表大小
    }
    
    // 获取键对应的值
    public int get(String key) {
        // 计算桶索引
        int bucketIndex = getBucketIndex(key);
        HashNode head = buckets[bucketIndex];
        
        // 在链表中查找键
        while (head != null) {
            if (head.key.equals(key)) {
                return head.value;
            }
            head = head.next;
        }
        
        // 键不存在
        return -1;
    }
    
    // 删除键值对
    public int remove(String key) {
        // 计算桶索引
        int bucketIndex = getBucketIndex(key);
        HashNode head = buckets[bucketIndex];
        HashNode prev = null;
        
        // 在链表中查找键
        while (head != null) {
            if (head.key.equals(key)) {
                break;
            }
            prev = head;
            head = head.next;
        }
        
        // 键不存在
        if (head == null) {
            return -1;
        }
        
        // 减少元素数量
        size--;
        
        // 从链表中移除节点
        if (prev != null) {
            prev.next = head.next;
        } else {
            buckets[bucketIndex] = head.next;
        }
        
        return head.value;
    }
    
    // 打印哈希表内容(用于调试)
    public void display() {
        for (int i = 0; i < numBuckets; i++) {
            System.out.print("Bucket " + i + ": ");
            HashNode current = buckets[i];
            while (current != null) {
                System.out.print("(" + current.key + ", " + current.value + ") ");
                current = current.next;
            }
            System.out.println();
        }
    }
    
    // 主方法:测试哈希表
    public static void main(String[] args) {
        HashTable ht = new HashTable(10);
        
        // 插入键值对
        ht.put("apple", 10);
        ht.put("banana", 20);
        ht.put("cherry", 30);
        ht.put("date", 40);
        ht.put("elderberry", 50);
        
        // 显示哈希表内容
        System.out.println("哈希表内容:");
        ht.display();
        
        // 获取值
        System.out.println("\n获取值:");
        System.out.println("apple: " + ht.get("apple"));
        System.out.println("banana: " + ht.get("banana"));
        System.out.println("fig: " + ht.get("fig"));  // 不存在的键
        
        // 删除键值对
        System.out.println("\n删除 'banana' 后:");
        ht.remove("banana");
        ht.display();
    }
}

3.3.5 哈希表的性能分析

哈希表的性能主要取决于哈希函数的质量和冲突解决策略的效率。

操作 平均时间复杂度 最坏时间复杂度
插入 O(1) O(n)
查找 O(1) O(n)
删除 O(1) O(n)

最坏情况:当所有键都被哈希到同一个桶时,哈希表退化为链表,操作的时间复杂度变为O(n)。

负载因子:负载因子是哈希表中元素数量与桶数量的比值。当负载因子增大时,冲突的可能性也会增加。因此,许多哈希表实现会在负载因子超过某个阈值(通常是0.75)时,自动增加桶的数量并重新哈希所有元素。

3.3.6 哈希表的应用场景

哈希表因其高效的查找、插入和删除操作,在许多场景中都有广泛应用:

  1. 数据库索引:数据库使用哈希索引来加速查询。

  2. 缓存系统:如Redis、Memcached等使用哈希表来存储键值对。

  3. 符号表:编译器和解释器使用哈希表来存储变量名和其属性。

  4. 关联数组:许多编程语言(如JavaScript的对象、Python的字典)使用哈希表实现关联数组。

  5. 去重:哈希表可以用来检测重复元素。

  6. 计数器:统计元素出现的频率。

  7. 缓存:实现LRU(最近最少使用)缓存。

生活中的哈希表应用例子

电话簿:你可以通过人名快速查找电话号码,而不需要从头到尾翻阅整本电话簿。

超市收银系统:收银员扫描商品条形码,系统立即显示商品信息和价格。

社交网络:当你搜索好友时,系统可以快速找到匹配的用户,而不需要遍历所有用户。

3.3.7 哈希表与其他数据结构的比较

数据结构 查找 插入 删除 有序性 空间效率
哈希表 O(1) O(1) O(1)
数组 O(n) O(1)* O(n)
链表 O(n) O(1) O(n)
二叉搜索树 O(log n) O(log n) O(log n)
平衡BST O(log n) O(log n) O(log n)

*数组在末尾插入的时间复杂度为O(1),但在中间插入的时间复杂度为O(n)。

哈希表的优势

  • 查找、插入和删除操作的平均时间复杂度都是O(1)
  • 实现简单,易于理解

哈希表的劣势

  • 不保持元素的顺序
  • 需要一个好的哈希函数
  • 可能需要动态调整大小
  • 在最坏情况下性能可能下降到O(n)

网站公告

今日签到

点亮在社区的每一天
去签到