【数据结构与算法笔记】LeetCode 哈希表速成:吃透Hot100常见套路(Python 实现)
引言
本文是一篇面向 LeetCode 哈希表专题的速成笔记,适合需要在短时间内建立题感、掌握模板、快速覆盖 Hot100 高频题型的读者。
哈希表题的核心不在于 API,而在于一句话:
把“需要反复查询的信息”提前存起来,把原本重复的查找变成 O(1) 级别的访问。
学习这类题时,最重要的不是记住某一道题的答案,而是记住几个固定问题:
- 这题要查什么?
- 哈希表的
key存什么? - 哈希表的
value存什么? - 什么时候更新答案?
- 是否需要和滑动窗口、链表等结构配合?
文章会围绕 LeetCode Hot100 中几道典型题展开:
| 题号 | 题目 | 核心套路 |
|---|---|---|
| 1 | 两数之和 | 补数查找 |
| 49 | 字母异位词分组 | 分组 key |
| 128 | 最长连续序列 | set 判存在 |
| 3 | 无重复字符的最长子串 | 滑动窗口 |
| 76 | 最小覆盖子串 | 滑动窗口 + 计数 |
| 146 | LRU 缓存 | 哈希表 + 双向链表 |
本文使用 Python 作为示例语言。
一、哈希表的理论基础
1.1 什么是哈希表
在 Python 里,最常见的哈希表就是:
dictset
它们的共同特点是:
- 平均时间复杂度下,插入、删除、查询都接近
O(1) - 特别适合做“查重”“计数”“映射”“快速判断是否存在”
例如:
seen = set()
count = {}
seen.add(3)
3 in seen # True
count["a"] = count.get("a", 0) + 1
1.2 哈希表解决的本质问题
很多题如果暴力做,都会出现这样的问题:
- 反复查某个元素是否出现过
- 反复统计某个元素出现次数
- 反复找“某个条件对应的另一个值”
这类“反复查询”的动作,本来可能是 O(n),一旦放进哈希表,就能降到接近 O(1)。
所以你可以把哈希表理解成一句话:
用空间换时间。
1.3 先记住这几个代码模板
哈希表题速成时,不建议一开始就追求变化很多的写法。先把下面几个模板记熟,遇到题再判断应该套哪一种。
模板一:计数
count = {}
for x in nums:
count[x] = count.get(x, 0) + 1
关键词:
- 出现次数
- 字符频率
- 数量是否足够
- 两个对象是否由相同元素组成
模板二:判断是否存在
seen = set()
for x in nums:
if x in seen:
...
seen.add(x)
关键词:
- 是否出现过
- 去重
- 快速判断在不在
模板三:记录“值 -> 下标”
index_map = {}
for i, x in enumerate(nums):
index_map[x] = i
关键词:
- 找下标
- 找配对
- 找之前出现过的位置
模板四:记录“状态 -> 位置”
first_pos = {0: -1}
关键词:
- 前缀和
- 子数组
- 某种状态第一次出现的位置
模板五:窗口内计数
left = 0
window = {}
for right, ch in enumerate(s):
window[ch] = window.get(ch, 0) + 1
while 窗口不合法:
window[s[left]] -= 1
left += 1
关键词:
- 子串
- 最长
- 最短
- 覆盖
- 无重复
速成时可以先记一句话:
| 题目关键词 | 优先想到 |
|---|---|
| 找另一个数 | 补数查找 |
| 出现次数 | 计数表 |
| 分成几组 | 设计分组 key |
| 是否存在 | set |
| 子串 / 窗口 | 滑动窗口 + 哈希表 |
O(1) 设计 |
哈希表 + 其他结构 |
Python 里做 key 的两个注意点
哈希表的 key 必须是可哈希对象。
list 是可变对象,不能直接作为 dict 的 key:
count = [1, 0, 2]
groups[count] = ["eat"] # TypeError: unhashable type: 'list'
如果需要把一个列表状态作为 key,要先转成 tuple:
count = [1, 0, 2]
key = tuple(count)
groups[key] = ["eat"]
字符串排序时也要注意:
key = "".join(sorted(word))
这里用的是 sorted(word),它会返回一个新的列表。
不要写成:
key = word.sort()
原因有两个:
str没有.sort()方法.sort()是列表的原地排序方法,返回值是None
二、一眼识别哈希表的信号
很多人不会哈希表题,不是不会写 dict,而是不会判断什么时候该用它。
下面这些信号一出现,你就应该优先考虑哈希表:
2.1 题目在问“是否存在某个配对 / 某个补数”
典型题:
- 两数之和
关键词:
- 是否存在
- 找另一个数
- 满足和 / 差 / 条件
这类题通常是“边遍历边查表”。
2.2 题目在问“频次”
典型题:
- 字母异位词分组
- 有效的字母异位词
关键词:
- 出现次数
- 统计字符
- 统计元素
这类题的核心是“计数”。
2.3 题目在问“去重 / 是否出现过”
典型题:
- 最长连续序列
关键词:
- 是否存在
- 连续
- 去重
这类题通常用 set。
2.4 题目在问“子串 / 窗口内状态”
典型题:
- 无重复字符的最长子串
- 最小覆盖子串
关键词:
- 子串
- 窗口
- 覆盖
- 无重复
- 最长 / 最短
这类题经常是“滑动窗口 + 哈希表”。
2.5 题目要求你设计数据结构
典型题:
- LRU 缓存
关键词:
- 设计
get/put- 最近最少使用
- 要求
O(1)
这类题通常不是单纯哈希表,而是“哈希表 + 其他结构”组合。
三、六类高频套路与模板
3.1 套路一:补数查找
适用场景:
- 找两数和
- 找满足某种关系的配对
模板:
def template(nums, target):
pos = {}
for i, x in enumerate(nums):
need = target - x
if need in pos:
return [pos[need], i]
pos[x] = i
思路:
- 一边遍历当前值
x - 一边查“我需要的那个值
need在不在表里”
3.2 套路二:频次统计
适用场景:
- 统计字符出现次数
- 判断两个对象是否由相同元素构成
模板:
def build_count(items):
count = {}
for x in items:
count[x] = count.get(x, 0) + 1
return count
思路:
- 哈希表的键是元素
- 哈希表的值是频次
3.3 套路三:分组
适用场景:
- 按某种“标准化结果”分组
模板:
def group_by_key(items):
groups = {}
for item in items:
key = normalize(item)
groups.setdefault(key, []).append(item)
return list(groups.values())
关键点不在分组,而在于:
如何构造一个能代表这一组特征的 key。
3.4 套路四:集合判重 + 连续性判断
适用场景:
- 连续序列
- 去重后快速判断某个数是否存在
模板:
def longest_consecutive(nums):
s = set(nums)
ans = 0
for x in s:
if x - 1 not in s:
cur = x
length = 1
while cur + 1 in s:
cur += 1
length += 1
ans = max(ans, length)
return ans
关键技巧:
- 只从“连续段起点”开始扩展
- 否则会重复计算
3.5 套路五:滑动窗口 + 哈希表
适用场景:
- 最长无重复子串
- 最小覆盖子串
- 固定 / 可变窗口题
模板:
def sliding_window(s):
left = 0
count = {}
for right, ch in enumerate(s):
count[ch] = count.get(ch, 0) + 1
while window_invalid(count):
count[s[left]] -= 1
left += 1
update_answer(left, right)
核心思维:
right负责扩张窗口left负责收缩窗口- 哈希表负责维护“窗口当前状态”
3.6 套路六:哈希表 + 双向链表
适用场景:
- LRU 缓存
- LFU 之类设计题
模板思路:
- 哈希表:
key -> 节点 - 双向链表:维护使用顺序
为什么要组合?
- 只用哈希表能做到快速查找
- 但做不到快速调整“最近使用顺序”
- 双向链表刚好补这个缺口
四、Hot100 经典例题拆解
4.1 1. 两数之和
题目本质
对于每个数 x,我们都在问:
target - x 出现过吗?
这就是最典型的“补数查找”。
为什么用哈希表
如果每次都去后面找补数,时间复杂度是 O(n^2)。
如果把已经遍历过的数存进哈希表,就能在 O(1) 时间检查补数是否存在。
代码
from typing import List
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
pos = {}
for i, x in enumerate(nums):
need = target - x
if need in pos:
return [pos[need], i]
pos[x] = i
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(n)
这题记住什么
遇到“找另一个数”,优先想“补数能不能放进哈希表里查”。
4.2 49. 字母异位词分组
题目本质
如果两个字符串互为字母异位词,它们排序后一定相同。
例如:
"eat" -> "aet"
"tea" -> "aet"
所以这题的关键是:
为每个字符串构造一个统一的分组 key。
两种常见 key
写法一:排序后的字符串
key = "".join(sorted(s))
这里有一个 Python 小坑:
sorted(s)会把字符串拆成字符列表并返回排序结果"".join(...)再把字符列表拼回字符串- 字符串本身不能调用
.sort()
所以应该写:
key = "".join(sorted(s))
不要写:
key = s.sort()
写法二:26 个字母频次数组
适合你想更深入理解“计数型 key”的时候。
如果使用 26 个字母频次数组做 key,要记得把 list 转成 tuple:
count = [0] * 26
for ch in s:
count[ord(ch) - ord("a")] += 1
key = tuple(count)
因为 list 不能作为 dict 的 key,tuple 才可以。
代码
from typing import List
class Solution:
def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
groups = {}
for s in strs:
key = "".join(sorted(s))
groups.setdefault(key, []).append(s)
return list(groups.values())
复杂度
设单个字符串平均长度为 k:
- 时间复杂度:
O(n * k log k) - 空间复杂度:
O(n * k)
这题记住什么
分组题的核心不是“怎么存”,而是“怎么设计分组 key”。
4.3 128. 最长连续序列
题目本质
这题不是排序题,而是“快速判断某个数在不在”的题。
只要能快速判断:
x - 1在不在x + 1在不在
这题就能做出来。
为什么用 set
用 set(nums) 之后,判断一个数是否存在就是 O(1)。
真正的关键技巧是:
只从连续段的起点开始枚举。
如果 x - 1 存在,那说明 x 不是起点,就跳过。
代码
from typing import List
class Solution:
def longestConsecutive(self, nums: List[int]) -> int:
s = set(nums)
ans = 0
for x in s:
if x - 1 in s:
continue
cur = x
length = 1
while cur + 1 in s:
cur += 1
length += 1
ans = max(ans, length)
return ans
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(n)
这题记住什么
集合题常见关键不是“存所有元素”,而是“找到避免重复遍历的条件”。
4.4 3. 无重复字符的最长子串
题目本质
题目在求:
一个窗口里,所有字符都不能重复。
这类题最经典的做法就是“滑动窗口 + 哈希表计数”。
核心思路
right向右扩张,把字符加入窗口- 如果窗口里出现重复字符,就移动
left缩小窗口 - 在整个过程中维护最大长度
代码
class Solution:
def lengthOfLongestSubstring(self, s: str) -> int:
left = 0
count = {}
ans = 0
for right, ch in enumerate(s):
count[ch] = count.get(ch, 0) + 1
while count[ch] > 1:
count[s[left]] -= 1
left += 1
ans = max(ans, right - left + 1)
return ans
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(|字符集|)
这题记住什么
窗口题里,哈希表维护的是“窗口状态”,不是全局状态。
4.5 76. 最小覆盖子串
题目本质
这题和上一题一样,也是滑动窗口。
但难点在于:
- 不是“不能重复”
- 而是“窗口必须覆盖目标串的所有字符,并且数量也要够”
核心思路
先统计目标串 t 里每个字符需要多少个,再在窗口中动态统计当前已有多少个。
当窗口已经满足条件时,就尽量收缩左边界,寻找更短答案。
代码
class Solution:
def minWindow(self, s: str, t: str) -> str:
need = {}
for ch in t:
need[ch] = need.get(ch, 0) + 1
window = {}
need_kinds = len(need)
valid = 0
left = 0
start = 0
min_len = float("inf")
for right, ch in enumerate(s):
if ch in need:
window[ch] = window.get(ch, 0) + 1
if window[ch] == need[ch]:
valid += 1
while valid == need_kinds:
if right - left + 1 < min_len:
min_len = right - left + 1
start = left
left_ch = s[left]
if left_ch in need:
if window[left_ch] == need[left_ch]:
valid -= 1
window[left_ch] -= 1
left += 1
return "" if min_len == float("inf") else s[start:start + min_len]
复杂度
- 时间复杂度:
O(n) - 空间复杂度:
O(|字符集|)
这题记住什么
滑动窗口难题的关键不是代码长,而是先想清楚“窗口什么时候算合法,什么时候该收缩”。
4.6 146. LRU 缓存
题目本质
题目要求:
get是O(1)put是O(1)- 最近访问过的元素要更靠前
- 容量满了时,淘汰最久没使用的元素
这说明:
- 你需要快速按
key找到节点 - 你还需要快速调整节点在“使用顺序”中的位置
所以只用哈希表不够,必须配合双向链表。
结构设计
- 哈希表:
key -> node - 双向链表:
- 头部附近表示最近使用
- 尾部附近表示最久未使用
代码
class Node:
def __init__(self, key=0, value=0):
self.key = key
self.value = value
self.prev = None
self.next = None
class LRUCache:
def __init__(self, capacity: int):
self.capacity = capacity
self.cache = {}
self.head = Node()
self.tail = Node()
self.head.next = self.tail
self.tail.prev = self.head
def _add_to_front(self, node: Node) -> None:
node.prev = self.head
node.next = self.head.next
self.head.next.prev = node
self.head.next = node
def _remove_node(self, node: Node) -> None:
prev_node = node.prev
next_node = node.next
prev_node.next = next_node
next_node.prev = prev_node
def _move_to_front(self, node: Node) -> None:
self._remove_node(node)
self._add_to_front(node)
def _remove_lru(self) -> Node:
lru = self.tail.prev
self._remove_node(lru)
return lru
def get(self, key: int) -> int:
if key not in self.cache:
return -1
node = self.cache[key]
self._move_to_front(node)
return node.value
def put(self, key: int, value: int) -> None:
if key in self.cache:
node = self.cache[key]
node.value = value
self._move_to_front(node)
return
node = Node(key, value)
self.cache[key] = node
self._add_to_front(node)
if len(self.cache) > self.capacity:
lru = self._remove_lru()
del self.cache[lru.key]
复杂度
- 时间复杂度:
get和put都是O(1) - 空间复杂度:
O(capacity)
这题记住什么
设计题里,哈希表常常负责“定位”,其他结构负责“维护顺序 / 关系”。
五、刷题时的复盘清单
每做完一道哈希表题,建议你问自己五个问题:
5.1 我到底在查什么
是查:
- 某个值是否存在
- 某个值出现了几次
- 某个状态第一次出现在哪
- 某个 key 对应哪个对象
先把“查什么”说清楚,哈希表的键值设计才会自然。
5.2 哈希表的 key 应该是什么
常见 key:
- 单个数字
- 单个字符
- 排序后的字符串
- 频次数组转成元组
key
很多题的难点,不是哈希表本身,而是 key 设计。
5.3 哈希表的 value 应该是什么
常见 value:
- 下标
- 次数
- 列表
- 节点
- 最早位置
5.4 我是不是重复计算了
比如最长连续序列里,如果不是只从起点开始扩展,就会重复算很多次。
很多 O(n) 题和 O(n^2) 题的区别,往往就在这一点。
5.5 这题是不是哈希表和别的套路结合
哈希表很少单独考,它经常和这些思路组合出现:
- 哈希表 + 滑动窗口
- 哈希表 + 双指针
- 哈希表 + 链表
- 哈希表 + 前缀和
如果你已经想到“要查表”,下一步就要想“还需要哪个结构一起配合”。
六、文章总结
6.1 哈希表专题速查表
| 题型 | 判断关键词 | 哈希表里存什么 | 典型题 |
|---|---|---|---|
| 补数查找 | 两数之和、找另一个数、满足 target | 值 -> 下标 |
1. 两数之和 |
| 频次统计 | 出现次数、字符数量、是否足够 | 元素 -> 次数 |
49. 字母异位词分组 |
| 分组归类 | 分组、同一类、异位词 | 标准化 key -> 列表 |
49. 字母异位词分组 |
| 集合判存在 | 是否存在、连续、去重 | set(nums) |
128. 最长连续序列 |
| 滑动窗口 | 子串、最长、最短、覆盖、无重复 | 窗口内元素 -> 次数 |
3. 无重复字符的最长子串、76. 最小覆盖子串 |
| 结构设计 | get、put、O(1)、最近使用 |
key -> node |
146. LRU 缓存 |
6.2 Hot100 练习顺序
| 顺序 | 题目 | 建议掌握点 |
|---|---|---|
| 1 | 1. 两数之和 | 边遍历边查补数 |
| 2 | 49. 字母异位词分组 | 用排序字符串或频次数组做 key |
| 3 | 128. 最长连续序列 | 只从连续段起点开始扩展 |
| 4 | 3. 无重复字符的最长子串 | 用窗口计数维护无重复状态 |
| 5 | 76. 最小覆盖子串 | 用 need、window、valid 判断窗口是否合法 |
| 6 | 146. LRU 缓存 | 哈希表负责定位,双向链表负责顺序 |
6.3 考前记忆版
| 看到题目在问 | 先想什么 |
|---|---|
| 找两个数是否满足条件 | need = target - x |
| 判断是否出现过 | set |
| 统计字符或数字次数 | dict.get(x, 0) + 1 |
| 把字符串分成几组 | 设计一个统一的 key |
| 子串最长 / 最短 | 滑动窗口 |
要求 O(1) 查询和更新 |
哈希表配合链表或其他结构 |
最后按这三步检查一道哈希表题:
- 先判断题目是在查存在、查次数、查位置,还是查窗口状态。
- 再确定哈希表的
key和value。 - 最后看是否需要搭配滑动窗口、链表、前缀和等其他结构。
更多推荐

所有评论(0)