👑 哈希表(HashMap):O(1)查找速度王者,面试必考!

43 阅读8分钟

"哈希表就像字典,知道拼音直接翻到那一页!" 📖


😊 什么是哈希表?图书馆的故事

📚 两种找书方式

方式1:线性查找(没有哈希表)

找《Java编程思想》这本书:
第1本 → 第2本 → 第3本 → ... → 第1000本 ✅
时间:O(n) 🐌 太慢了!

方式2:哈希查找(有哈希表)

找《Java编程思想》这本书:
1. 计算书名的哈希值 → "J" = 10号书架
2. 直接去10号书架找 ✅
时间:O(1) ⚡ 超快!

这就是哈希表!通过哈希函数把键(key)映射到一个位置,直接访问! 🎯


🏗️ 哈希表的原理

核心组成

哈希表 = 数组 + 哈希函数 + 冲突解决

┌────────────────────────────┐
│   哈希函数 h(key)          │
│   把key映射到数组索引      │
└────────────────────────────┘
             ↓
    ┌───┬───┬───┬───┬───┐
    │ 01234 │ ... 数组
    └───┴───┴───┴───┴───┘
      ↓   ↓   ↓
    链表 链表 链表  ← 解决冲突

哈希函数

作用:把任意的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使用

原理:数组的每个位置是一个链表(或红黑树)

数组:
  ┌───┬────────┬───┬───┐
  │ 0123 │
  └───┴───┬────┴───┴───┘
          ↓
      链表/红黑树
      ┌─────────┐
      │("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 7JDK 8+
结构数组+链表数组+链表+红黑树
链表长度无限制超过8转红黑树
扩容头插法尾插法

JDK 8+ HashMap结构

数组(桶):
  ┌────┬────┬────┬────┐
  │ 0123  │ ...
  └────┴──┬─┴────┴────┘
          ↓
   链表长度 ≤ 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):
  ┌───┬───┬───┬───┐
  │ 0123 │
  └─┬─┴─┬─┴─┬─┴─┬─┘
    A   B   C   D

扩容后(容量8):
  ┌───┬───┬───┬───┬───┬───┬───┬───┐
  │ 01234567 │
  └─┬─┴───┴─┬─┴───┴─┬─┴───┴─┬─┴───┘
    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=77%3=1 → 服务器1
key2 → hash=88%3=2 → 服务器2
key3 → hash=99%3=0 → 服务器0

增加1台服务器(变成4台):
key1 → hash=77%4=3 → 服务器3 ❌ 位置变了!
key2 → hash=88%4=0 → 服务器0 ❌ 位置变了!
key3 → hash=99%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,更快!)