Hash
本章题海战术
| 典型例题 | leetcode题目 | leetcode链接 |
|---|---|---|
| 字母异位词 | leetcode 242 | https://leetcode.cn/problems/valid-anagram/description/ |
| 字母异位词分组 | leetcode 49 | https://leetcode.cn/problems/group-anagrams/description/ |
| 数组中出现次数 TOP K | leetcode 347 | https://leetcode.cn/problems/top-k-frequent-elements/description/ |
| 两数之和 | leetcode 1 | https://leetcode.cn/problems/two-sum/description/ |
| 数组中重复数字 | leetcode 217 | https://leetcode.cn/problems/contains-duplicate/description/ |
| 和为 K 的子数组 | leetcode 560 | https://leetcode.cn/problems/subarray-sum-equals-k/description/ |
| 最长无重复子串 | leetcode 3 | https://leetcode.cn/problems/longest-substring-without-repeating-characters/description/ |
| 设计哈希集合 | leetcode 705 | https://leetcode.cn/problems/design-hashset/description/ |
| LRU 缓存 | leetcode 146 | https://leetcode.cn/problems/lru-cache/description/ |
Hash
(语气平稳,条理清晰)面试官您好,关于 Hash 相关的算法核心知识点,我从本质原理、冲突解决方案、JDK 落地实现和高频算法场景四个维度来展开~
Hash 核心本质 🔑
哈希(散列)的核心思想是空间换时间,通过哈希函数把任意类型的输入映射成一个固定长度的整数(哈希值),再把哈希值映射到数组下标,实现O (1) 时间复杂度的查找、插入、删除操作。
通俗类比:就像快递驿站用手机号尾号给快递分配格口,报尾号直接定位格子,不用挨个翻找所有快递。
哈希映射流程:
核心三要素 & 哈希冲突解决方案 ⚙️
1. 合格哈希函数的标准
- 计算效率高,耗时稳定
- 分布均匀,尽可能减少冲突
- 输入微小变化会导致输出值巨大差异(雪崩效应)
2. 哈希冲突
不同输入算出相同哈希值,对应同一个数组下标,就是哈希冲突,这是哈希表的核心问题,行业内主流有 3 种解决方案:
| 解决方案 | 核心思路 | 典型落地场景 | 优缺点 |
|---|---|---|---|
| 链地址法(拉链法) | 冲突元素用链表 / 红黑树挂载在同一数组下标 | JDK HashMap、ConcurrentHashMap | 增删简单,无元素堆积;链表过长会导致查询性能退化 |
| 开放寻址法 | 冲突后按规则找下一个空位(线性 / 二次探测) | ThreadLocalMap | 数组利用率高,无额外指针;容易产生元素堆积,删除逻辑复杂 |
| 再哈希法 | 冲突后切换第二个哈希函数重新计算 | 极少单独使用 | 冲突概率低;多次哈希计算成本高 |
拉链法结构示意图:
Java 源码中的 Hash 设计 💡
Java 岗位面试中,哈希的考点基本都绑定源码实现,核心有 3 个:
1.HashMap(JDK1.8)
- 底层结构:数组 + 链表 + 红黑树,链表长度 > 8 且数组长度≥64 时转红黑树,把查询复杂度从 O (n) 优化到 O (logn)
- 哈希扰动:
hash = hashCode ^ (hashCode >>> 16),让高位参与下标计算,减少小容量下的哈希冲突 - 下标计算:用
hash & (n-1)替代取模运算,效率更高,因此要求数组长度必须是 2 的幂
2.ThreadLocalMap
- 采用线性探测的开放寻址法,key 为弱引用,避免 ThreadLocal 内存泄漏
3.ConcurrentHashMap
哈希逻辑与 HashMap 基本一致,通过 CAS + synchronized 保证线程安全,替代旧版分段锁,粒度更细
Hash 高频算法题场景 📝
算法题中,想到用哈希的核心信号:需要频次统计、存在性判断、去重,或者要把时间复杂度从 O (n²) 降到 O (n),高频题型有 3 类:
1.频次统计类:字母异位词(leetcode 242)、数组中出现次数 TOP K(leetcode 347)
思路:用 HashMap 存 <元素, 出现次数>,一次遍历完成统计
2.存在性判断类:两数之和(leetcode 1)、数组中重复数字(leetcode 217)
思路:用 HashSet 存已遍历元素,遍历过程中 O (1) 判断目标值是否存在
3.前缀和 + 哈希优化类:和为 K 的子数组(leetcode 560)、最长无重复子串(leetcode 3)
思路:用哈希表存前缀和 / 状态对应的下标,把双重循环优化成单次遍历
面试高频追问预判 ❓
Q:为什么 HashMap 数组长度必须是 2 的幂?
A:一是保证 hash & (n-1) 等价于取模,位运算远快于取模;二是扩容后元素要么在原下标,要么在「原下标 + 旧容量」,不用重新计算哈希,扩容效率高。
Q:哈希冲突严重会有什么影响?
A:查询效率从 O (1) 退化成 O (n)(链表)或 O (logn)(红黑树),极端情况下哈希表会退化成链表。
(收尾)以上就是我对 Hash 相关算法原理、工程落地和算法应用的理解~
核心代码与技术亮点 ✍️
我选取 3 道面试最高频的哈希算法题,每段代码都标注核心优化亮点,面试手写可直接复用。
1. 两数之和(LeetCode 1)
题目:给定整数数组 nums 和目标值 target,返回数组中和为 target 的两个整数的下标。
核心思路:一次遍历 + 哈希表存已遍历元素,将暴力解法的 O (n²) 时间复杂度直接降到 O (n)。
public int[] twoSum(int[] nums, int target) {
// 技术亮点1:预指定初始容量,规避HashMap自动扩容的性能损耗
Map<Integer, Integer> hashMap = new HashMap<>(nums.length);
for (int i = 0; i < nums.length; i++) {
// 技术亮点2:先查询再插入,避免同一元素重复配对(如target=6时,元素3不能和自身匹配)
if (hashMap.containsKey(target - nums[i])) {
return new int[]{hashMap.get(target - nums[i]), i};
}
hashMap.put(nums[i], i);
}
return new int[0];
}亮点总结:空间换时间的经典落地;预分配容量是面试加分细节;先查后存完美覆盖边界场景。
2. 有效的字母异位词(LeetCode 242)
题目:判断两个字符串是否为字母异位词(字母种类、出现次数完全一致)。
核心思路:小写字母范围固定 26 个,用数组模拟哈希表,比 HashMap 更轻量,无哈希冲突、无装箱拆箱开销。
public boolean isAnagram(String s, String t) {
if (s.length() != t.length()) return false;
// 技术亮点1:有限范围key用数组替代HashMap,下标做哈希映射,性能提升一个量级
int[] count = new int[26];
for (char c : s.toCharArray()) count[c - 'a']++;
// 技术亮点2:减法过程中实时判断负数,提前终止循环,优化平均耗时
for (char c : t.toCharArray()) {
if (--count[c - 'a'] < 0) return false;
}
return true;
}亮点总结:限定范围的哈希优先用数组实现,是面试中体现性能敏感度的加分项。
3. 和为 K 的子数组(LeetCode 560)
题目:统计整数数组中和为 k 的连续子数组的个数。
核心思路:前缀和 + 哈希表统计前缀和出现频次,将 O (n²) 的暴力前缀和优化为 O (n)。
public int subarraySum(int[] nums, int k) {
// key:前缀和值;value:该前缀和出现的次数
Map<Integer, Integer> preSumMap = new HashMap<>();
// 技术亮点1:初始化前缀和0出现1次,处理「子数组从下标0开始」的边界情况
preSumMap.put(0, 1);
int preSum = 0, count = 0;
for (int num : nums) {
preSum += num;
// 若存在前缀和 = preSum - k,说明中间这段子数组和恰好为k
if (preSumMap.containsKey(preSum - k)) {
count += preSumMap.get(preSum - k);
}
// 技术亮点2:累加更新前缀和频次,支持多组相同前缀和的场景
preSumMap.put(preSum, preSumMap.getOrDefault(preSum, 0) + 1);
}
return count;
}亮点总结:哈希表承接中间状态,将子数组求和转化为差值查找,是哈希降维的经典范式。
技术难点与解决方案 🎯
整理哈希算法在面试问答、工程落地中的核心难点与标准解法,是面试深挖环节的高频考点。
| 技术难点 | 问题本质 | 解决方案 | 典型落地场景 |
|---|---|---|---|
| 哈希冲突导致性能退化 | 不同 key 映射到同一数组下标,链表过长后查询从 O (1) 退化为 O (n) | 1. 优化哈希函数,提升分布均匀性; 2. 拉链法中链表长度超阈值转红黑树,保底 O (logn) 查询; 3. 扩容重哈希,降低负载因子 | JDK 1.8 HashMap、ConcurrentHashMap |
| 哈希函数分布不均 | 数组容量较小时,哈希值高位未参与下标计算,冲突集中 | 哈希扰动函数:高 16 位与低 16 位异或运算,让高位信息参与下标映射 | JDK HashMap hash() 扰动函数 |
| 扩容重哈希开销大 | 容量翻倍后所有元素需重新计算下标,大数据量下会造成阻塞 | 1. 初始化时预估数据量,指定合理初始容量,减少扩容次数; 2. 渐进式扩容,分批次迁移元素,避免单次阻塞 | 大数据量哈希表初始化、Redis 字典 |
| 开放寻址法删除困难 | 物理删除元素会打断探测链,导致后续冲突元素查询失败 | 1. 标记删除法(墓碑标记),仅标记失效不物理删除; 2. 扩容重哈希时清理无效标记 | ThreadLocalMap |
| 哈希碰撞攻击 | 恶意构造大量哈希冲突的 key,拖垮服务查询性能 | 1. 引入随机哈希种子,每次启动生成不同哈希规则; 2. 链表转红黑树,保底查询性能不退化到 O (n) | Web 容器参数解析、网关防攻击 |
| 大 key 内存冗余过高 | 哈希表容量过大,数组 + 链表结构内存浪费严重 | 1. 合理设置负载因子,平衡时间与空间开销; 2. 分片哈希,将大哈希表拆分为多个小表 | 海量数据缓存、分布式哈希分片 |
真实面试模拟
好的,我们直接切换到一对一现场面试的氛围中 🎤。
我是今天的面试官,你现在就是来应聘的候选人。咱们轻松点,但技术要点必须讲透。
下面开始模拟:
真实面试模拟
面试官 👨💼:
同学,先来个热身。说说你对 Java HashMap 底层结构的理解,重点讲讲它怎么解决哈希冲突?
候选人 🧑💻:
好的面试官。HashMap 底层是数组 + 链表 + 红黑树。
用哈希函数算出 key 在数组中的桶下标,如果多个 key 落在同一个桶,就用拉链法,也就是用链表串起来。
当链表长度达到 8 且数组长度 ≥ 64 时,链表会树化成红黑树,防止链表太长导致查询退化到 O(n)。
面试官 👨💼:
不错。那我追问一下,为什么容量一定要是 2 的幂?加载因子默认 0.75 又是为什么?
候选人 🧑💻:
容量是 2 的幂主要为了两点:
- 快速取模:
(n - 1) & hash等价于hash % n,位运算效率极高。 - 均匀分布:配合扰动函数
(h = key.hashCode()) ^ (h >>> 16),让高位也参与运算,减少碰撞。
加载因子 0.75 是时间与空间的折中。太小了空间浪费,太大了冲突严重。而且根据泊松分布,在 0.75 时链表长度达到 8 的概率低于百万分之一,非常适合树化阈值设计。
面试官 👨💼:
理论过关。现在来道经典算法题——两数之和(leetcode 1)。
给定 nums = [2,7,11,15],target = 9,请你口述思路并用 Java 写一下。
候选人 🧑💻:
思路是用 HashMap 存储 值 → 索引。遍历时,对于当前值 x,算 complement = target - x,如果 map 里已经有这个补数,就直接返回两个下标。
时间 O(n),空间 O(n)。
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];
}过程动画大概是这样的:
面试官 👨💼:
如果数组里有重复元素,或者要你返回所有和为 target 的下标对,你打算怎么改?
候选人 🧑💻:
重复元素不影响,因为 map 的 put 会覆盖旧索引,题目只要一组解的话没问题。
如果要所有解,可以把 map 的值改成 List<Integer>,存所有出现过的索引,找到补数时遍历列表输出。
面试官 👨💼:
继续,字母异位词分组(leetcode 49),["eat","tea","tan","ate","nat","bat"],把字母相同的字符串分到一组,你怎么做?
候选人 🧑💻:
核心是设计一个唯一的哈希键来代表同一组异位词。最直接的办法是排序:把每个字符串按字符排序后作为键,比如 "aet" 代表 "eat", "tea", "ate"。
public List<List<String>> groupAnagrams(String[] strs) {
Map<String, List<String>> map = new HashMap<>();
for (String s : strs) {
char[] chars = s.toCharArray();
Arrays.sort(chars);
String key = new String(chars);
map.computeIfAbsent(key, k -> new ArrayList<>()).add(s);
}
return new ArrayList<>(map.values());
}如果字符集小(如全是小写字母),可以用计数编码替换排序,比如 "aab" → "2#1#0#...",这样时间就能降到 O(N·K)。
面试官 👨💼:
好,现在上点硬菜。你来手写一个简易 HashMap,能支持 put、get、remove,还要能自动扩容。
候选人 🧑💻:
我需要一个内部类 Node 存键值对和 next 指针,用数组存桶。扩容时机是 size / capacity >= 0.75。
核心代码:
class MyHashMap {
static class Node {
int key, value;
Node next;
Node(int k, int v) { key = k; value = v; }
}
Node[] buckets = new Node[16];
int size = 0;
int hash(int key) { return key & (buckets.length - 1); } // 容量2的幂时可用
void rehash() {
Node[] old = buckets;
buckets = new Node[old.length * 2];
size = 0;
for (Node head : old) {
for (Node n = head; n != null; n = n.next) {
put(n.key, n.value);
}
}
}
public void put(int key, int value) {
if ((float) size / buckets.length >= 0.75) rehash();
int idx = hash(key);
Node head = buckets[idx];
for (Node n = head; n != null; n = n.next) {
if (n.key == key) { n.value = value; return; }
}
Node newNode = new Node(key, value);
newNode.next = head; // 头插法
buckets[idx] = newNode;
size++;
}
// get、remove 类似...
}扩容时所有节点必须重新哈希,因为数组长度变了。头插法简单,但 JDK8 已经改成尾插防止多线程死链问题。
面试官 👨💼:
懂了。压轴题——LRU 缓存(leetcode 146),get 和 put 都要 O(1) 时间,你能不能现场设计?
候选人 🧑💻:
必须靠 HashMap + 双向链表。
- HashMap 负责 O(1) 查找节点。
- 双向链表维护访问顺序,最新访问的放到头部,淘汰时删尾部。
- 用两个哨兵节点 head 和 tail 可以简化边界判断。
结构图:
核心操作:
class LRUCache {
class DNode { int k, v; DNode prev, next; }
Map<Integer, DNode> map = new HashMap<>();
DNode head = new DNode(), tail = new DNode(); // 哨兵
int cap;
void addToHead(DNode node) {
node.next = head.next; node.prev = head;
head.next.prev = node; head.next = node;
}
void removeNode(DNode node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
void moveToHead(DNode node) { removeNode(node); addToHead(node); }
public int get(int key) {
DNode node = map.get(key);
if (node == null) return -1;
moveToHead(node);
return node.v;
}
public void put(int key, int value) {
DNode node = map.get(key);
if (node != null) { node.v = value; moveToHead(node); }
else {
DNode newNode = new DNode(); newNode.k = key; newNode.v = value;
map.put(key, newNode);
addToHead(newNode);
if (map.size() > cap) {
DNode last = tail.prev;
removeNode(last);
map.remove(last.k);
}
}
}
}当然,JDK 的 LinkedHashMap 设置 accessOrder=true 再重写 removeEldestEntry 几行就能搞定,但实际考察还是手写双向链表见真章。
面试官 👨💼:
最后,分布式场景下,缓存扩缩容怎么减少数据迁移量?听过一致性哈希吗?
候选人 🧑💻:
听过。把哈希空间围成一个环,节点和数据都映射到环上,数据顺时针存入最近的节点。增减节点只影响相邻节点,迁移量大幅缩小。
通常还会加虚拟节点来解决节点少时数据倾斜的问题。Java 中可以用 TreeMap 的 ceilingKey 来找顺时针节点。
(环状简化示意,实际 TreeMap 查找 tailMap 即可)
面试官 👨💼:
很好 👍,今天 Hash 这块问得比较全,从原理、算法到手写和分布式思想都涉及了。总结一下:
| 考察模块 | 必会要点 | 代表题 |
|---|---|---|
| 哈希基础 | 拉链法、红黑树化、2次幂、扰动函数 | HashMap 源码追问 |
| 算法思想 | 空间换时间,键的设计 | Two Sum(leetcode 1),Group Anagrams(leetcode 49) |
| 手写结构 | 数组+链表扩容,双向链表+HashMap | Design HashMap(leetcode 705),LRU Cache(leetcode 146) |
| Java 特别提醒 ⚠️ | equals 与 hashCode 契约,key 不可变 | — |
面试官 👨💼:
刚才我们聊了很多,从底层原理到算法实战,你的回答整体不错。
现在我来帮你做个精华提炼——把今天 Hash 场景下的 核心代码 和 技术亮点 单独贴出来,再整理下 技术难点与解决方案,这绝对是面试官最爱听的加分项。✍️
1️⃣ 核心代码 & 技术亮点 ⭐
① Two Sum(leetcode 1) — 一行 computeIfAbsent 的优雅写法
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];
}🔥 技术亮点:
- 利用 HashMap O(1) 查询 将暴力 O(n²) 降为 O(n)
- 一次遍历边查边存,不需要预处理
- 若改用
map.computeIfAbsent(nums[i], k -> new ArrayList<>()).add(i)可轻松扩展为 返回所有下标对
② 字母异位词分组(leetcode 49) — 排序 vs 计数编码
// 标准版:排序键
public List<List<String>> groupAnagrams(String[] strs) {
Map<String, List<String>> map = new HashMap<>();
for (String s : strs) {
char[] chars = s.toCharArray();
Arrays.sort(chars);
String key = new String(chars);
map.computeIfAbsent(key, k -> new ArrayList<>()).add(s);
}
return new ArrayList<>(map.values());
}🔥 技术亮点:
computeIfAbsent实现一行代码完成“不存在则创建空List”的语义,避免冗长的 if-else- 键的设计体现了 “同质异位词映射到相同哈希键” 的核心思想
- 进阶优化:针对小写字母,用
int[26]计数编码 作为键,可将排序 O(KlogK) 优化为 O(K)
③ 手写简易 HashMap(leetcode 705) — 拉链法 + 扩容
class MyHashMap {
static class Node {
int key, value;
Node next;
Node(int k, int v) { key = k; value = v; }
}
Node[] buckets = new Node[16];
int size = 0;
int hash(int key) { return key & (buckets.length - 1); } // 2的幂取模
void rehash() {
Node[] old = buckets;
buckets = new Node[old.length * 2];
size = 0;
for (Node head : old)
for (Node n = head; n != null; n = n.next)
put(n.key, n.value);
}
public void put(int key, int value) {
if ((float) size / buckets.length >= 0.75f) rehash();
int idx = hash(key);
for (Node n = buckets[idx]; n != null; n = n.next) {
if (n.key == key) { n.value = value; return; }
}
Node node = new Node(key, value);
node.next = buckets[idx]; // 头插法
buckets[idx] = node;
size++;
}
// get / remove 类似...
}🔥 技术亮点:
- 位运算取模
hash & (len-1)仅当容量为 2 的幂时有效,性能极高 - 扩容时机用
size / capacity >= 0.75精确控制 - 头插法简单高效,但这里也埋下了 多线程死链 的伏笔(见难点表)
rehash()时所有节点重新映射,深刻理解扩容代价
④ LRU Cache(leetcode 146) — HashMap + 双向链表(必手写)
class LRUCache {
class DNode { int k, v; DNode prev, next; }
Map<Integer, DNode> map = new HashMap<>();
DNode head = new DNode(), tail = new DNode(); // 哨兵节点
int cap;
void addToHead(DNode node) {
node.next = head.next; node.prev = head;
head.next.prev = node; head.next = node;
}
void removeNode(DNode node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
void moveToHead(DNode node) { removeNode(node); addToHead(node); }
public int get(int key) {
DNode node = map.get(key);
if (node == null) return -1;
moveToHead(node);
return node.v;
}
public void put(int key, int value) {
DNode node = map.get(key);
if (node != null) {
node.v = value; moveToHead(node);
} else {
DNode newNode = new DNode(); newNode.k = key; newNode.v = value;
map.put(key, newNode);
addToHead(newNode);
if (map.size() > cap) {
DNode last = tail.prev;
removeNode(last);
map.remove(last.k);
}
}
}
}🔥 技术亮点:
- 哨兵节点(head/tail) 消除空链表判空,所有插入删除操作统一处理
- HashMap 存 key→Node 实现 O(1) 定位,双向链表保证 O(1) 调整顺序
- 淘汰时既删链表末尾,也从 HashMap 中移除,保证数据一致性
- 可对比 JDK
LinkedHashMap的accessOrder=true实现,但手写版更显功底
⑤ 一致性哈希查找 — TreeMap 顺时针寻址
// 简化示例,不含虚拟节点
TreeMap<Integer, String> ring = new TreeMap<>();
// 添加物理节点
ring.put(hash("Node-A"), "Node-A");
ring.put(hash("Node-B"), "Node-B");
// 数据寻址
int dataHash = hash("data-key");
Map.Entry<Integer, String> entry = ring.ceilingEntry(dataHash);
if (entry == null) entry = ring.firstEntry(); // 环状回绕
String targetNode = entry.getValue();🔥 技术亮点:
TreeMap的 红黑树 保证了ceilingEntry的 O(logN) 查找- 环状回绕通过
firstEntry()实现 - 实际生产必须引入 虚拟节点 解决数据倾斜,只需将每个物理节点映射为多个哈希值即可
2️⃣ 技术难点 & 解决方案 🧠
| 难点场景 | 问题描述 | 解决方案 | 代码体现 / 原理 |
|---|---|---|---|
| 哈希冲突严重 | 大量 key 落同一桶,链表退化为 O(n) 查询 | ① 扰动函数 h ^ (h>>>16) 增加随机性② 链表长度 ≥8 时树化成红黑树 | HashMap 源码 treeifyBin(),容量 ≥64 才树化 |
| 扩容带来的性能抖动 | 扩容时需要重新哈希所有元素,可能卡顿 | ① 容量恒为 2 的幂,简化重哈希计算 ② JDK8 引入 高低位拆分 避免全量重算 | 扩容时根据 (hash & oldCap) == 0 将链表分成两段,迁移开销减半 |
| LRU 缓存 O(1) 淘汰 | 双向链表快速删除尾部,HashMap 快速查找 | HashMap + 双向链表 组合,哨兵节点简化操作 | 手写 LRU 中 map.remove(last.k) 保证同步 |
| 多线程下 HashMap 死链 | JDK7 扩容时头插法导致链表成环,CPU 100% | ① 升级 JDK8(尾插法) ② 多线程下用 ConcurrentHashMap | JDK8 的 transfer 中使用尾插保持顺序,无环 |
| 一致性哈希数据倾斜 | 节点少时数据集中在少数几个节点,负载不均 | 虚拟节点:每个物理节点映射多个哈希值均匀分布在环上 | ring.put(hash("Node-A#v1"), "Node-A") 循环添加 |
| 对象作为 HashMap Key 的陷阱 | 可变对象修改后 hashCode 变化,导致数据“丢失” | ① 使用不可变对象(String、Integer) ② 若用可变对象,重写 equals 和 hashCode 且字段不可修改 | User 类作为 Key 时,必须 final 关键字段,或程序保证不修改 |
| 哈希碰撞攻击 | 恶意构造大量相同哈希值的 key,让 HashMap 退化 | ① JDK8 树化缓解 ② Tomcat 等对 POST 参数数量限制 | TREEIFY_THRESHOLD = 8,攻击成本大幅提高 |
总结成一张速查图 🗺️
这些就是 Hash 场景下 最值钱的代码片段 和 最常踩的坑 💰。
