哈希表(Hash Table),又称散列表,是一种根据键(Key)直接访问存储位置的数据结构。它通过哈希函数将键映射到表中的一个位置来访问记录,从而实现平均 O(1) 时间复杂度的查找、插入和删除操作。哈希表是计算机科学中最重要的数据结构之一,广泛应用于数据库索引、缓存、符号表、唯一性检查等多种场景。

核心思想:哈希表通过哈希函数将任意大小的键映射到固定大小的数组索引,以实现快速的数据存取。


一、哈希表的基本概念

哈希表的核心思想是键值映射。它将用户提供的键(key)通过一个特定的函数(哈希函数)转换成一个整数,这个整数就是数据在底层数组中的索引(下标)。

  1. 键 (Key): 唯一的标识符,用于查找、插入和删除数据。
  2. 值 (Value): 与键关联的数据。
  3. 哈希函数 (Hash Function): 将键映射到数组索引的函数。
  4. 哈希值 (Hash Value 或 Hash Code): 哈希函数计算出的整数值。
  5. 桶/槽 (Bucket/Slot): 底层数组中的一个位置,用于存储键值对。

示意图:哈希表基本概念

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
                       +--------------------+
| 哈希表 (Hash Table) |
+--------------------+
|
| Key (键)
|
+-----------------+
| 哈希函数 (Hash) |
+-----------------+
|
| Hash Code (哈希值)
| -> (进一步处理,如取模)
| -> Array Index (数组索引)
V
+------------------------------------------------+
| 数组/桶 (Buckets/Slots) |
+------------------------------------------------+
索引 0 -----> | [Empty or Linked List/Entry 1] | (可能存储 Key: "grape", Value: "葡萄")
+------------------------------------------------+
索引 1 -----> | [Entry 2] | (可能存储 Key: "apple", Value: "苹果")
+------------------------------------------------+
索引 2 -----> | [Empty or Linked List/Entry 3] | (可能存储 Key: "orange", Value: "橘子")
+------------------------------------------------+
索引 3 -----> | [Entry 4] --> [Entry 5] | (这是链地址法解决冲突的例子)
| ↑ ↑ | Key: "banana", Value: "香蕉"
| | | | Key: "band", Value: "乐队" (哈希冲突,都映射到索引3)
+------------------------------------------------+
索引 4 -----> | [Entry 6] | (可能存储 Key: "cat", Value: "猫")
+------------------------------------------------+
索引 5 -----> | [Empty] |
+------------------------------------------------+
索引 ... -----> | ... |
+------------------------------------------------+

二、哈希函数 (Hash Function)

哈希函数是哈希表的“心脏”,它的质量直接决定了哈希表的性能。一个好的哈希函数应该满足以下条件:

  1. 确定性: 对于相同的输入键,哈希函数必须总是产生相同的哈希值。
  2. 快速计算: 哈希函数计算哈希值的速度要快,否则会抵消哈希表带来的性能优势。
  3. 均匀分布: 尽可能地将不同的键均匀地分布到哈希表的各个桶中,减少冲突。
  4. 一致性: 数据结构中的等价键应该有相同的哈希值。

常见的哈希函数构造方法

  • 直接定址法: H(key) = keyH(key) = a * key + b
    • 适用于键的范围不大且分布均匀的情况。
    • 例:学号为 1-100,则直接用学号作为索引。
  • 除留余数法 (Division Method): H(key) = key % m
    • 最常用的方法。m 是哈希表的桶数量 (通常选择一个质数可以减少冲突)。
    • 例:H(key) = key % 7。键 12 的哈希值是 12 % 7 = 5
  • 乘法哈希法 (Multiplication Method): H(key) = floor(m * (key * A mod 1))
    • A 是一个常数,通常选择 0 < A < 1
    • 特点是 m 的选择不那么严格,可以是 2 的幂次。
  • 折叠法 (Folding Method): 将键分成几部分,然后把这些部分相加或进行位运算,取结果的最后几位作为哈希值。
    • 适用于键很长的情况。
  • 数字分析法 (Digit Analysis Method): 分析键的分布情况,选取键中分布比较均匀的位作为哈希值。
  • 字符串哈希: 对于字符串键,常会用到各种变种的哈希,如 BKDR Hash、DJB Hash、AP Hash、SDBM Hash、RS Hash、JS Hash、ELF Hash 等。它们通过迭代地结合字符的 ASCII 值和乘法/位移运算来生成哈希值。
    • 例 (简单的字符串哈希): hash = 0; for char in key: hash = (hash * P + char_value) % M (其中 P 是一个质数,M 是桶的数量)

三、哈希冲突 (Hash Collision)

无论哈希函数设计得多么优秀,由于键空间通常远大于表空间,不同的键被映射到同一个哈希值是不可避免的,这就称为哈希冲突。哈希表设计中一个重要部分就是如何解决哈希冲突。

常见的冲突解决策略

1. 链地址法 (Separate Chaining)

  • 原理: 每个桶不再直接存储一个元素,而是存储一个链表(或红黑树、数组等数据结构)。当多个键被哈希到同一个桶时,这些键值对都会被存储到该桶对应的链表中。
  • 操作:
    • 插入: 计算哈希值得到桶索引,将新元素插入到该桶对应的链表中。
    • 查找/删除: 计算哈希值得到桶索引,然后遍历该桶对应的链表查找/删除目标元素。
  • 优点:
    • 实现简单。
    • 对负载因子不敏感,即使负载因子大于 1 也能很好工作。
    • 删除操作相对简单。
  • 缺点:
    • 需要额外的空间存储链表节点(指针)。
    • 当链表过长时,查找效率会下降(最坏 O(n))。
  • 示例: Java 的 HashMap 在冲突较少时使用链表,当链表长度超过一定阈值 (如 8) 时,会将链表转换为红黑树以提高查找效率 (最坏 O(log N))。

示意图:链地址法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
+-------------------------------------------------------------+
| 哈希表 (Hash Table) - 链地址法 |
+-------------------------------------------------------------+
| 索引 | 桶 (Bucket) |
+------+-------------------------------------------------------+
| 0 | NULL / 空链表 |
+------+-------------------------------------------------------+
| 1 | NULL / 空链表 |
+------+-------------------------------------------------------+
| 2 | NULL / 空链表 |
+------+-------------------------------------------------------+
| 3 | [ (3, "B") ] --> [ (10, "C") ] --> [ (24, "D") ] --> [ (17, "E") ] --> [ (31, "F") ] |
| | (这是一个链表,存储所有哈希到索引 3 的键值对) |
+------+-------------------------------------------------------+
| 4 | NULL / 空链表 |
+------+-------------------------------------------------------+
| 5 | NULL / 空链表 |
+------+-------------------------------------------------------+
| 6 | [ (20, "A") ] |
+------+-------------------------------------------------------+

2. 开放寻址法 (Open Addressing)

  • 原理: 当发生冲突时,不把元素放在另一个数据结构中,而是探测(Probe)哈希表的其他空闲位置来存储冲突的元素。所有元素都存储在哈希表的底层数组中。
  • 探测方法:
    • 线性探测 (Linear Probing): 发生冲突时,探查下一个连续的桶,H(key, i) = (H(key) + i) % m
      • 缺点: 容易形成聚集 (Clustering),即冲突的元素聚集在一起,导致后续查找时间增加。
    • 二次探测 (Quadratic Probing): 发生冲突时,以二次方的方式偏移,H(key, i) = (H(key) + c1*i + c2*i^2) % m
      • 可以缓解线性探测的聚集问题,但可能形成二次聚集。
    • 双重哈希 (Double Hashing): 使用两个哈希函数 H1(key)H2(key)。当 H1(key) 发生冲突时,使用 H2(key) 的结果作为步长进行探测:H(key, i) = (H1(key) + i * H2(key)) % m
      • 能有效消除聚集问题,减少冲突。
  • 操作:
    • 插入: 计算哈希值得到初始桶索引,如果该位置已被占用,根据探测方法找到下一个空闲位置。
    • 查找: 计算哈希值得到初始桶索引,如果该位置不是目标元素且不是空,根据探测方法继续查找,直到找到目标元素或遇到空桶(表示元素不存在)。
    • 删除: 比较复杂。简单地删除会破坏后续查找,通常采用惰性删除 (Lazy Deletion),即将被删除的位置标记为“已删除”,后续插入可以覆盖,查找时跳过。
  • 优点:
    • 不需要额外的指针空间。
    • 缓存友好(元素存储在连续内存区域)。
  • 缺点:
    • 对负载因子非常敏感,负载因子不能超过 1,且接近 1 时性能急剧下降。
    • 删除操作复杂。
    • 可能存在聚集问题。
  • 示例: Python 的 dict 实现了开放寻址法。

示意图:开放寻址法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
+-------------------------------------------------------------+
| 哈希表 (Hash Table) - 开放寻址法 |
+-------------------------------------------------------------+
| 索引 | 桶 (Bucket) |
+------+-------------------------------------------------------+
| 0 | [ (17, "E") ] |
+------+-------------------------------------------------------+
| 1 | [ 空 ] |
+------+-------------------------------------------------------+
| 2 | [ 空 ] |
+------+-------------------------------------------------------+
| 3 | [ (3, "B") ] |
+------+-------------------------------------------------------+
| 4 | [ (10, "C") ] |
+------+-------------------------------------------------------+
| 5 | [ (24, "D") ] |
+------+-------------------------------------------------------+
| 6 | [ (20, "A") ] |
+------+-------------------------------------------------------+

四、负载因子 (Load Factor) 与扩容 (Resizing)

负载因子 (Load Factor) 是衡量哈希表满载程度的指标:

$$
\text{Load Factor} = \frac{\text{当前元素数量 (n)}}{\text{桶的总数量 (m)}}
$$

  • 负载因子过低: 内存浪费,但冲突少,性能好。
  • 负载因子过高: 内存利用率高,但冲突多,性能差。

当负载因子达到某个预设的阈值时,哈希表会进行扩容 (Resizing/Rehashing)

扩容过程:

  1. 创建一个新的、更大的底层数组(通常容量翻倍)。
  2. 遍历旧哈希表中的所有键值对。
  3. 对每个键值对重新计算哈希值(因为桶的数量 m 变了),将其插入到新数组的正确位置。
  4. 释放旧数组。

扩容开销: 扩容是一个 O(n) 的操作,但由于它的发生频率逐渐降低,平均每次插入的开销(摊销分析)仍然是 O(1)。

常见阈值: Java HashMap 的默认负载因子阈值是 0.75。对于开放寻址法,阈值通常更低,例如 0.50.67

五、哈希表的性能分析

  • 平均时间复杂度:
    • 查找、插入、删除: O(1)
      • 前提是哈希函数设计良好,哈希值分布均匀,且负载因子在合理范围内。
  • 最坏时间复杂度:
    • 查找、插入、删除: O(n)
      • 发生在所有键都哈希到同一个桶(哈希函数设计极差),导致哈希表退化为链表。

六、实际应用中的哈希表

  • Java: HashMap, HashTable, ConcurrentHashMap
    • HashMap 使用链地址法,并在链表过长时转换为红黑树。
    • ConcurrentHashMap 是线程安全的 HashMap 变体。
  • Python: dict
    • 使用开放寻址法。
  • C++: std::unordered_map, std::unordered_set
    • 通常使用链地址法。
  • Go: map
    • 内部实现是类似链地址法的结构,但每个桶不是简单的链表,而是一个存有多个键值对的小数组(bmap),当小数组满时,会溢出到链表。

七、总结

哈希表是一种非常强大的数据结构,通过哈希函数将键映射到内存地址,允许我们以接近常数时间复杂度进行数据操作。它的核心在于:

  1. 优秀的哈希函数: 尽可能均匀地分散键。
  2. 有效的冲突解决策略: 优雅地处理多个键映射到同一地址的情况(链地址法或开放寻址法)。
  3. 动态扩容机制: 在保证性能的同时,适应数据量的增长。

理解这些原理对于高效地使用哈希表和解决相关问题至关重要。