对数组中的数据进行快速访问必须要通过数组的下标,时间复杂度为 O(1)。如果只知道数据或者数据中的部分内容,想在数组中找到这个数据,还是需要遍历数组,时间复杂度为 O(N)。
事实上,知道部分数据查找完整数据的需求在软件开发中会经常用到,比如知道了商品ID,想要查找完整的商品信息;知道了词条名称,想要查找百科词条中的详细信息等。
这类场景就需要用到 Hash 表这种数据结构。Hash 表中数据以 Key、Value 的方式存储,上面例子中,商品 ID 和词条名称就是 Key,商品信息和词条详细信息就是 Value。存储的时候将 Key、Value 写入 Hash 表,读取的时候,只需要提供 Key,就可以快速查找到Value。
Hash 表的物理存储其实是一个数组,如果我们能够根据 Key 计算出数组下标,那么就可以快速在数组中查找到需要的 Key 和 Value。许多编程语言支持获得任意对象的HashCode,比如 Java 语言中 HashCode 方法包含在根对象 Object 中,其返回值是一个Int。我们可以利用这个 Int 类型的 HashCode 计算数组下标。最简单的方法就是余数法,使用 Hash 表的数组长度对 HashCode 求余, 余数即为 Hash 表数组的下标,使用这个下标就可以直接访问得到 Hash 表中存储的 Key、Value。

上图这个例子中,Key 是字符串 abc,Value 是字符串 hello。我们先计算 Key 的哈希值,得到 101 这样一个整型值。然后用 101 对 8 取模,这个 8 是哈希表数组的长度。101 对 8取模余 5,这个 5 就是数组的下标,这样就可以把 (“abc”,“hello”) 这样一个 Key、Value 值存储在下标为 5 的数组记录中。
当我们要读取数据的时候,只要给定 Key abc,还是用这样一个算法过程,先求取它的HashCode 101,然后再对 8 取模,因为数组的长度不变,对 8 取模以后依然是余 5,那么我们到数组下标中去找 5 的这个位置,就可以找到前面存储进去的 abc 对应的 Value值。
但是如果不同的 Key 计算出来的数组下标相同怎么办?HashCode101 对 8 取模余数是5,HashCode109 对 8 取模余数还是 5,也就是说,不同的 Key 有可能计算得到相同的数组下标,这就是所谓的 Hash 冲突,解决 Hash 冲突常用的方法是链表法。
事实上,(“abc”,“hello”) 这样的 Key、Value 数据并不会直接存储在 Hash 表的数组中,因为数组要求存储固定数据类型,主要目的是每个数组元素中要存放固定长度的数据。
所以,数组中存储的是 Key、Value 数据元素的地址指针。一旦发生 Hash 冲突,只需要将相同下标,不同 Key 的数据元素添加到这个链表就可以了。查找的时候再遍历这个链表,匹配正确的 Key。
如下图:

因为有 Hash 冲突的存在,所以“Hash 表的时间复杂度为什么是 O(1)?”这句话并不严谨,极端情况下,如果所有 Key 的数组下标都冲突,那么 Hash 表就退化为一条链表,查询的时间复杂度是 O(N)。但是作为一个面试题,“Hash 表的时间复杂度为什么是O(1)”是没有问题的。
下面是一个简单的Java实现的哈希表(HashMap),它包括了上述内容中的关键概念。为了简化代码,这里我们不会使用JDK自带的HashMap,而是创建一个非常基础的版本来演示Hash表的工作原理。这个版本将只处理字符串键和值,并且仅用于教育目的。
import java.util.LinkedList;
public class SimpleHashMap {
// 定义哈希表数组的大小
private static final int SIZE = 8;
// 创建一个LinkedList数组,每个位置都是一条链表
private LinkedList<Entry>[] buckets = new LinkedList[SIZE];
// Entry是哈希表中存储的键值对元素
private static class Entry {
String key;
String value;
public Entry(String key, String value) {
this.key = key;
this.value = value;
}
}
// 构造函数初始化哈希表
public SimpleHashMap() {
for (int i = 0; i < SIZE; i++) {
buckets[i] = new LinkedList<>();
}
}
// 计算给定key的hashCode并转换为数组索引
private int getBucketIndex(String key) {
// 使用String类自带的hashCode方法计算哈希码
int hashCode = key.hashCode();
// 对hashCode取模得到数组下标
return Math.abs(hashCode) % SIZE;
}
// 插入或更新键值对
public void put(String key, String value) {
int index = getBucketIndex(key);
LinkedList<Entry> bucket = buckets[index];
for (Entry entry : bucket) {
if (entry.key.equals(key)) {
// 如果key已存在则更新value
entry.value = value;
return;
}
}
// 如果key不存在,则添加新的键值对
bucket.add(new Entry(key, value));
}
// 根据key查找value
public String get(String key) {
int index = getBucketIndex(key);
LinkedList<Entry> bucket = buckets[index];
for (Entry entry : bucket) {
if (entry.key.equals(key)) {
return entry.value;
}
}
// 如果没有找到对应的key,返回null
return null;
}
// 打印哈希表的内容,用于调试
public void printMap() {
for (int i = 0; i < SIZE; i++) {
System.out.println("Bucket " + i + ": " + buckets[i]);
}
}
// 测试哈希表
public static void main(String[] args) {
SimpleHashMap map = new SimpleHashMap();
// 添加一些键值对
map.put("abc", "hello");
map.put("xyz", "world");
map.put("ghi", "java");
// 尝试访问一个存在的key
System.out.println("The value for key 'abc' is: " + map.get("abc"));
// 尝试访问一个不存在的key
System.out.println("The value for key 'unknown' is: " + map.get("unknown"));
// 打印整个哈希表
map.printMap();
}
}
这段代码实现了上述描述的功能,包含注释帮助理解。SimpleHashMap 类模拟了一个哈希表,其中 put 方法用于插入或更新键值对,而 get 方法用于根据键查找值。printMap 方法用于输出哈希表的内容以进行调试。main 方法展示了如何使用这个哈希表。请注意,在实际应用中,你应该使用JDK提供的 HashMap 或其他更复杂的实现,它们已经优化并且解决了许多潜在的问题。
总结:在不考虑hash冲突的情况下,由于是通过计算key的hash值作为数组下标,所以时间复杂度为O(1)。
问题:如果考虑hash冲突,时间复杂度还是O(1)吗?
当考虑哈希冲突时,哈希表的时间复杂度并不是严格的 O(1)。在最坏的情况下,如果所有的键都映射到同一个桶(bucket),那么查找、插入或删除操作就会退化为遍历一个链表,此时时间复杂度会变成 O(n),其中 n 是哈希表中元素的数量。
然而,在实际应用中,哈希表的性能通常接近于 O(1),这是因为良好的哈希函数和适当的哈希表大小能够将冲突降到最低。平均情况下,每个桶中的元素数量是有限的,因此即使有冲突,链表也不会太长,从而保持了高效的性能。
为了维持这样的性能,哈希表实现通常会监控负载因子(即哈希表中元素数量与桶的数量的比例)。当负载因子超过一定阈值时,哈希表会自动增加其容量,并重新计算所有元素的位置,以分散元素并减少冲突。这个过程称为再散列(rehashing)。
JDK 中的 HashMap 类就是一个很好的例子,它不仅使用链表来处理冲突,而且从 Java 8 开始,当某个桶中的元素数量达到一定阈值时,该桶的数据结构会从链表转换为红黑树,这进一步优化了在高冲突情况下的查找性能,使得在最坏情况下的时间复杂度变为 O(log n) 而不是 O(n)。
所以,理论上讲,哈希表的操作时间复杂度是 O(1),但在实践中,这一复杂度是在假设哈希函数均匀分布且哈希表适当调整大小的前提下得到的平均情况下的结果。
问题:链表是如何来处理冲突的?
在之前的讨论中,我提到了Java中的HashMap在处理哈希冲突时使用链表,并且从Java 8开始,在某些条件下会将链表转换为红黑树来优化性能。这实际上是为了处理哈希冲突的一种机制。
为了更清晰地说明,让我们回顾一下:
在HashMap中,当两个或多个键的哈希码(通过hashCode()方法计算)经过压缩映射到同一个桶(bucket)时,就会发生哈希冲突。为了应对这种情况,HashMap采用了一种称为“分离链接法”(separate chaining)的技术,即每个桶实际上是一个链表(或者在Java 8及以后版本中可能是红黑树)。如果两个键的哈希值相同,它们对应的键值对会被存储在这个链表的不同节点上。当访问一个键时,HashMap首先计算该键的哈希值以确定它所在的桶,然后遍历这个桶上的链表(或红黑树),直到找到匹配的键(使用equals()方法比较)。
在Java 8之前,HashMap仅使用链表来解决哈希冲突。然而,随着元素数量的增加,链表可能会变得很长,导致查找效率降低至O(n)。因此,在Java 8中引入了改进:当一个桶中的节点数量超过一定阈值(默认是8),并且HashMap的大小达到至少64个桶时,链表就会被转换成红黑树。这种数据结构保证了即使在最坏的情况下,操作的时间复杂度也可以保持在O(log n),而不是退化到O(n)。
所以,关于你提到的“前面没有说”的部分,确实是在描述HashMap如何处理哈希冲突时,不仅使用链表,而且在特定情况下还使用了红黑树来进一步提高性能。这一特性使得HashMap能够在大多数情况下维持接近常数时间的操作效率,即使面对大量哈希冲突的情况也是如此。

30万+

被折叠的 条评论
为什么被折叠?



