哈希表
在之前的章节中,我们讨论了数组和链表。数组可以通过索引在 时间内访问元素,但如果不知道索引,查找特定元素则需要 的时间。链表的插入和删除虽然灵活,但查找效率同样是 。
有没有一种数据结构,既能像数组一样快速访问,又能像链表一样灵活存储键值对呢?这就是本章的主角——哈希表(Hash Table)。
哈希表(也叫散列表)是一 种基于“键值对”(key-value pair)的高效数据结构。它通过将键映射到数组中的某个位置(索引),实现了近乎实时的快速数据存取。哈希表被广泛用于需要快速查找、插入或删除操作的场景,例如编程语言中的字典、数据库索引、缓存系统(如 Redis)以及集合等。
哈希函数
哈希表的核心在于如何将一个复杂的“键”(如字符串 "apple")转换成一个简单的数组“索引”(如整数 3)。完成这个转换工作的,就是哈希函数(Hash Function)。
哈希函数的主要职责是:
- 确定性:对于相同的输入,必须永远产生相同的输出。
- 高效性:计算过程要足够快。
- 均匀分布:接受一个键作为输入,输出一个整数(通常是索引)。好的哈希函数应该尽可能地将不同的键均匀地分布在数组的各个位置上,从而减少“冲突”。
如果我们将数组想象成一排抽屉,哈希函数就是那个告诉你“这个东西该放进第几个抽屉”的管理员。
哈希表的操作
在理想情况下(即哈希函数设计得当,且没有冲突),哈希表的三大基本操作都非常高效:
- 插入(Insert):
- 计算键的哈希值,找到对应索引,将键值对放入该位置。
- 时间复杂度 :
O(1)。
- 查找(Search):
- 给定一个键,通过哈希函数直接定位到存储位置,取出对应的值。
- 时间复杂度:
O(1)。
- 删除(Delete):
- 直接定位到键的位置并将其移除。
- 时间复杂度:
O(1)。
冲突与解决
虽然我们希望哈希函数能将每个键映射到唯一的索引,但实际上,数组的长度是有限的,而可能的键是无限的。根据“鸽巢原理”,必然会有两个不同的键被哈希函数映射到了同一个索引上。这种情况被称为哈希冲突(Collision)。
当冲突发生时,我们必须想办法解决,否则新数据就会覆盖旧数据。常见的解决方案主要有两种:
1. 链地址法(Separate Chaining)
这是最直观的解决方法。我们将哈希表的每个位置(Slot)看作一个“桶”(Bucket)。每个桶不再只存一个数据,而是存储一个链表。
- 原理:当多个键映射到同一个索引时,直接将新元素追加到该索引对应的链表中。
- 优点:实现简单,对哈希表的装载因子(Load Factor)不敏感,支持无限扩展(只要内存够)。
- 缺点:需要额外的指针存储空间。如果冲突严重,链表会变得很长,查找效率会从
O(1)退化为O(n)。在 Java 8 等高级实现中,当链表过长时会将其转化为红黑树,以保证查找效率为O(log n)。
2. 开放寻址法(Open Addressing)
这种方法不使用链表,所有的数据都直接存储在哈希表的主数组中。
-
原理:当计算出的索引
i已经被占用时,按照某种规则去探测(Probe)下一个空闲的位置。 -
探测方式:
-
线性探测(Linear Probing):如果位置
i被占了,就查i+1,如果还被占,就查i+2,依次类推。 -
二次探测(Quadratic Probing):探测间隔按平方递增()。
-
双重哈希(Double Hashing):使用第二个哈希函数来计算探测的步长。
-
优点:数据都在数组里,对 CPU 缓存友好,无需额外的指针空间。
-
缺点:删除操作比较麻烦(不能直接清空,通常需要标记为“已删除”),容易产生**堆积(Cluster)**现象,即数据扎堆存储,导致探测时间变长。