"哈希表就像字典,知道拼音直接翻到那一页!" 📖
😊 什么是哈希表?图书馆的故事
📚 两种找书方式
方式1:线性查找(没有哈希表)
找《Java编程思想》这本书:
第1本 → 第2本 → 第3本 → ... → 第1000本 ✅
时间:O(n) 🐌 太慢了!
方式2:哈希查找(有哈希表)
找《Java编程思想》这本书:
1. 计算书名的哈希值 → "J" = 10号书架
2. 直接去10号书架找 ✅
时间:O(1) ⚡ 超快!
这就是哈希表!通过哈希函数把键(key)映射到一个位置,直接访问! 🎯
🏗️ 哈希表的原理
核心组成
哈希表 = 数组 + 哈希函数 + 冲突解决
┌────────────────────────────┐
│ 哈希函数 h(key) │
│ 把key映射到数组索引 │
└────────────────────────────┘
↓
┌───┬───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ ... 数组
└───┴───┴───┴───┴───┘
↓ ↓ ↓
链表 链表 链表 ← 解决冲突
哈希函数
作用:把任意的key转换成数组索引
// 简单的哈希函数
int hash(String key) {
int hash = 0;
for (char c : key.toCharArray()) {
hash = hash * 31 + c; // 31是质数,减少冲突
}
return hash % arrayLength; // 取模,映射到数组范围
}
// 例子
hash("Java") → 5
hash("Python") → 12
hash("C++") → 3
好的哈希函数特性:
- ✅ 分布均匀(减少冲突)
- ✅ 计算快速(O(1))
- ✅ 确定性(同样的key总是得到同样的结果)
💥 哈希冲突(Hash Collision)
什么是哈希冲突?
不同的key可能产生相同的哈希值!
hash("Java") → 5
hash("Ruby") → 5 ← 冲突了!两个key映射到同一个位置
生活比喻: 就像图书馆的10号书架,可能要放多本书名首字母是J的书!
解决方案
1️⃣ 拉链法(Chaining)- Java HashMap使用
原理:数组的每个位置是一个链表(或红黑树)
数组:
┌───┬────────┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │
└───┴───┬────┴───┴───┘
↓
链表/红黑树
┌─────────┐
│("Java", │
│ 100) │
└────┬────┘
↓
┌─────────┐
│("Ruby", │
│ 200) │
└────┬────┘
↓
null
优点:
- ✅ 简单易实现
- ✅ 不会因为冲突而无法插入
- ✅ 动态扩展
缺点:
- ❌ 需要额外的链表节点空间
- ❌ 链表过长时查找变慢
2️⃣ 开放寻址法(Open Addressing)
原理:如果位置被占用,就找下一个空位置
线性探测(Linear Probing)
插入"Java" (hash=5):
位置5被占用 → 看位置6 → 也被占用 → 看位置7 → 空的!放这里✅
0 1 2 3 4 5 6 7 8
┌───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ │ │ │ │ │XXX│XXX│Java│ │
└───┴───┴───┴───┴───┴───┴───┴───┴───┘
问题:容易产生"聚集"(clustering)
二次探测(Quadratic Probing)
探测序列:hash, hash+1², hash+2², hash+3²...
避免了线性聚集
双重散列(Double Hashing)
用第二个哈希函数计算步长
探测序列:hash, hash+h2, hash+2*h2, hash+3*h2...
💻 Java HashMap源码解析
JDK 7 vs JDK 8+
| 特性 | JDK 7 | JDK 8+ |
|---|---|---|
| 结构 | 数组+链表 | 数组+链表+红黑树 |
| 链表长度 | 无限制 | 超过8转红黑树 |
| 扩容 | 头插法 | 尾插法 |
JDK 8+ HashMap结构
数组(桶):
┌────┬────┬────┬────┐
│ 0 │ 1 │ 2 │ 3 │ ...
└────┴──┬─┴────┴────┘
↓
链表长度 ≤ 8:
Node → Node → Node → null
链表长度 > 8 且数组长度 ≥ 64:
转换为红黑树 🌲
TreeNode → TreeNode → ...
核心参数
static final int DEFAULT_INITIAL_CAPACITY = 16; // 初始容量
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 负载因子
static final int TREEIFY_THRESHOLD = 8; // 链表转树阈值
static final int UNTREEIFY_THRESHOLD = 6; // 树转链表阈值
static final int MIN_TREEIFY_CAPACITY = 64; // 最小树化容量
重要概念
1. 负载因子(Load Factor)= 0.75
负载因子 = 元素数量 / 数组容量
当 size > capacity × 0.75 时,触发扩容!
例子:
容量16,负载因子0.75
16 × 0.75 = 12
当插入第13个元素时,扩容到32
为什么是0.75?
- 太小(0.5):浪费空间,频繁扩容
- 太大(1.0):冲突严重,链表过长
- 0.75:时间和空间的平衡点 ⚖️
2. 扩容机制
// 扩容:容量翻倍
void resize() {
int newCapacity = oldCapacity << 1; // 乘以2
// 重新分配所有元素(rehash)
for (每个元素) {
int newIndex = hash(key) & (newCapacity - 1);
// 放到新位置
}
}
扩容过程:
原数组(容量4):
┌───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │
└─┬─┴─┬─┴─┬─┴─┬─┘
A B C D
扩容后(容量8):
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │
└─┬─┴───┴─┬─┴───┴─┬─┴───┴─┬─┴───┘
A B C D
元素重新分配(rehash)
3. 哈希函数(扰动函数)
JDK 8的hash()方法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
为什么要异或?
原始hashCode: 1111 1111 1111 1111 0000 1010 0000 1100
右移16位: 0000 0000 0000 0000 1111 1111 1111 1111
异或结果: 1111 1111 1111 1111 1111 0101 1111 0011
↑
高位也参与运算,减少冲突!
简化版HashMap实现
public class SimpleHashMap<K, V> {
// 节点
static class Node<K, V> {
final int hash;
final K key;
V value;
Node<K, V> next;
Node(int hash, K key, V value, Node<K, V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
private Node<K, V>[] table; // 哈希表
private int size; // 元素个数
private static final int DEFAULT_CAPACITY = 16;
private static final float LOAD_FACTOR = 0.75f;
@SuppressWarnings("unchecked")
public SimpleHashMap() {
table = (Node<K, V>[]) new Node[DEFAULT_CAPACITY];
}
// 哈希函数
private int hash(K key) {
int h = key.hashCode();
return (h ^ (h >>> 16)) & (table.length - 1);
}
// 插入/更新
public void put(K key, V value) {
int index = hash(key);
Node<K, V> node = table[index];
// 查找key是否已存在
while (node != null) {
if (node.key.equals(key)) {
node.value = value; // 更新值
return;
}
node = node.next;
}
// 新增节点(头插法)
Node<K, V> newNode = new Node<>(hash(key), key, value, table[index]);
table[index] = newNode;
size++;
// 检查是否需要扩容
if (size > table.length * LOAD_FACTOR) {
resize();
}
}
// 查找
public V get(K key) {
int index = hash(key);
Node<K, V> node = table[index];
while (node != null) {
if (node.key.equals(key)) {
return node.value;
}
node = node.next;
}
return null; // 未找到
}
// 删除
public V remove(K key) {
int index = hash(key);
Node<K, V> node = table[index];
Node<K, V> prev = null;
while (node != null) {
if (node.key.equals(key)) {
if (prev == null) {
table[index] = node.next; // 删除头节点
} else {
prev.next = node.next; // 删除中间节点
}
size--;
return node.value;
}
prev = node;
node = node.next;
}
return null;
}
// 扩容
@SuppressWarnings("unchecked")
private void resize() {
Node<K, V>[] oldTable = table;
table = (Node<K, V>[]) new Node[oldTable.length * 2];
size = 0;
// 重新插入所有元素
for (Node<K, V> node : oldTable) {
while (node != null) {
put(node.key, node.value);
node = node.next;
}
}
}
}
🌐 一致性哈希(Consistent Hashing)
为什么需要一致性哈希?
传统哈希的问题:
3台服务器的分布式缓存:
hash(key) % 3 → 服务器编号
key1 → hash=7 → 7%3=1 → 服务器1
key2 → hash=8 → 8%3=2 → 服务器2
key3 → hash=9 → 9%3=0 → 服务器0
增加1台服务器(变成4台):
key1 → hash=7 → 7%4=3 → 服务器3 ❌ 位置变了!
key2 → hash=8 → 8%4=0 → 服务器0 ❌ 位置变了!
key3 → hash=9 → 9%4=1 → 服务器1 ❌ 位置变了!
结果:所有数据都要重新分配!😱
一致性哈希的解决方案
把服务器和数据都映射到一个环上!
0
╱ ╲
服务器A
╱ ╲
数据1 数据2
╱ ╲
2^32-1 ──── 2^31 (环的中点)
╲ ╱
数据3 服务器B
╲ ╱
服务器C
╲ ╱
数据4
规则:
- 数据沿顺时针找到第一个服务器
- 数据1、数据2 → 服务器B
- 数据3、数据4 → 服务器C
增加服务器D:
只影响部分数据!
数据2 → 从服务器B迁移到服务器D
其他数据不受影响 ✅
虚拟节点(解决数据倾斜)
一个物理服务器对应多个虚拟节点:
服务器A → A1, A2, A3, ...
服务器B → B1, B2, B3, ...
虚拟节点均匀分布在环上,数据更均衡!
🎯 哈希表的应用场景
1. 数据库索引
用户表:
ID | 姓名 | 年龄
1 | 张三 | 25
2 | 李四 | 30
...
HashMap<Integer, User> userMap;
快速查找:userMap.get(1) → 张三
2. 缓存(LRU Cache)
class LRUCache {
private int capacity;
private Map<Integer, Node> map; // HashMap快速查找
private Node head, tail; // 双向链表维护顺序
// 结合HashMap + 双向链表
}
3. 去重
Set<String> set = new HashSet<>(); // 底层是HashMap
set.add("Java");
set.add("Python");
set.add("Java"); // 重复,不会添加
System.out.println(set.size()); // 2
4. 统计频率
Map<String, Integer> freq = new HashMap<>();
for (String word : words) {
freq.put(word, freq.getOrDefault(word, 0) + 1);
}
🏆 哈希表经典面试题
1. 两数之和(LeetCode 1)⭐⭐⭐
public int[] twoSum(int[] nums, int target) {
Map<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
int complement = target - nums[i];
if (map.containsKey(complement)) {
return new int[] { map.get(complement), i };
}
map.put(nums[i], i);
}
return new int[0];
}
// 时间:O(n),空间:O(n)
// 用空间换时间!
2. 有效的字母异位词(LeetCode 242)
public boolean isAnagram(String s, String t) {
if (s.length() != t.length()) return false;
Map<Character, Integer> map = new HashMap<>();
for (char c : s.toCharArray()) {
map.put(c, map.getOrDefault(c, 0) + 1);
}
for (char c : t.toCharArray()) {
map.put(c, map.getOrDefault(c, 0) - 1);
if (map.get(c) < 0) return false;
}
return true;
}
3. 最长连续序列(LeetCode 128)
public int longestConsecutive(int[] nums) {
Set<Integer> set = new HashSet<>();
for (int num : nums) {
set.add(num);
}
int longest = 0;
for (int num : set) {
// 只从序列起点开始查找
if (!set.contains(num - 1)) {
int current = num;
int length = 1;
while (set.contains(current + 1)) {
current++;
length++;
}
longest = Math.max(longest, length);
}
}
return longest;
}
📊 时间复杂度总结
| 操作 | 平均 | 最坏 |
|---|---|---|
| 查找 | O(1) ⚡ | O(n) |
| 插入 | O(1) ⚡ | O(n) |
| 删除 | O(1) ⚡ | O(n) |
最坏情况:所有key都冲突到一个链表(JDK8+会转红黑树,变成O(log n))
📝 总结
🎓 记忆口诀
哈希表像字典,
通过拼音找位置。
O(1)超级快,
冲突用链表。
负载因子0.75,
链表长了变红黑树。
一致性哈希解决,
分布式缓存问题。
面试必考HashMap,
扩容机制要牢记!
核心知识点
| 知识点 | 要点 | 符号 |
|---|---|---|
| 时间复杂度 | 平均O(1) | ⚡ |
| 冲突解决 | 拉链法(Java) | 🔗 |
| 扩容 | 容量×2,负载因子0.75 | 📈 |
| 优化 | 链表→红黑树(阈值8) | 🌲 |
| 应用 | 缓存、去重、索引 | 🎯 |
恭喜你!🎉 你已经掌握了面试中最重要的数据结构之一——哈希表!
记住:哈希表是用空间换时间的典范,O(1)的查找速度无人能敌! 👑
📌 小练习:手写一个简化版HashMap,实现put、get、remove方法!
🤔 思考题:为什么HashMap的容量总是2的幂次?
(答案:因为这样可以用位运算 hash & (n-1) 代替取模运算 hash % n,更快!)