哈希表专题总结:从困惑到掌握容器选择
哈希表专题总结:从困惑到掌握容器选择

目录
学习记录
- 刷题周期: 4天分散学习(10.2、10.8-10.9、10.19)
- 完成题量: 8题全部AC
- 题目分布: 基础哈希5题 + 前缀和+哈希表3题
刷题记录
Day01(10.2):第一次见哈希表
题目: LeetCode 1 - 两数之和
当时在学双指针,这道题用暴力法做了,后来知道哈希表能O(n)解决。那时候还不太懂哈希表,只知道能快速查找。
状态: 暴力法AC,哈希表看懂了但没实践
Day07-08(10.8-10.9):前缀和+哈希表(3题)
这几天学前缀和的时候,遇到了3道"前缀和+哈希表"的题:
- LeetCode 560 - 和为K的子数组
- LeetCode 974 - 和可被K整除的子数组
- LeetCode 525 - 连续数组
状态: 看了讲解才做出来,hash里面存什么、查什么,当时很迷糊
当时的疑惑:
- 为什么要用
hash[sum-k]而不是直接算? hash[0] = 1是什么意思?- 余数为什么能判断整除?
Day19(10.19):哈希表专题突破(5题)
专门学哈希表:
- LeetCode 1 - 两数之和(重做)
- 面试题 01.02 - 判定是否互为字符重排
- LeetCode 217 - 存在重复元素
- LeetCode 219 - 存在重复元素 II
- LeetCode 49 - 字母异位词分组
完成情况:
- 第2题:3分12秒AC
- 第3题:1分38秒AC ⚡
- 第4题:5分钟AC(遇到括号bug)
- 第5题:经过详细引导后AC
状态: 终于搞懂了什么时候用set、什么时候用map、什么时候用数组模拟
我的学习过程
第一阶段:懵懵懂懂(Day01-Day08)
那时候只知道哈希表"快",但不知道为什么快,也不知道什么时候该用。
我的问题:
- unordered_map和unordered_set有什么区别?
- 什么时候用数组模拟哈希表?
- 前缀和+哈希表到底在干什么?
第二阶段:踩坑实践(Day19)
踩了很多坑:
- 单引号vs变量 -
's[i]'写成了多字符常量 - 数组类型 -
char hash[26]存计数会溢出 - 数组比较 - 数组不能直接用
==比较 - abs括号位置 -
abs(a-b<=k)运算顺序错了 - 嵌套数据结构 -
vector<vector<string>>理解不了
第三阶段:理解本质(Day19晚)
终于搞懂了:
- 哈希表的本质是用空间换时间
- 容器选择的关键是看你要存什么
- "边遍历边查找"是哈希表的核心技巧
哈希表的核心概念(我的理解)
什么是哈希表?
我的理解就是:一个能O(1)快速查找的容器,用来建立映射关系。
关键是:
- 要存什么信息?
- 查找的时候要什么?
- 怎么保证不重复统计?
哈希表的三种用途
通过这8道题,我发现哈希表主要有3种用法:
1. 判断存在性(去重、检测重复)
用什么容器: unordered_set<T>
特点: 只存元素,不存其他信息
适用: 判断是否重复、是否存在
模板:
unordered_set<int> hash;
for(auto x : nums) {
if(hash.count(x)) return true; // 重复了
hash.insert(x);
}
return false;
我做过的题:
- LeetCode 217 - 存在重复元素
2. 统计频次/建立映射
用什么容器: unordered_map<Key, Value>
特点: 存键值对,可以是元素→次数、元素→下标等
适用: 需要统计次数、需要记录位置
模板:
// 统计频次
unordered_map<int, int> hash; // <元素, 次数>
for(auto x : nums) {
hash[x]++;
}
// 元素与下标
unordered_map<int, int> hash; // <元素, 下标>
for(int i = 0; i < n; i++) {
hash[nums[i]] = i;
}
我做过的题:
- LeetCode 1 - 两数之和(元素→下标)
- LeetCode 219 - 存在重复元素II(元素→下标)
- LeetCode 560 - 和为K的子数组(前缀和→次数)
- LeetCode 974 - 和可被K整除的子数组(余数→次数)
- LeetCode 525 - 连续数组(前缀和→位置)
3. 分组(嵌套结构)
用什么容器: unordered_map<Key, vector<T>>
特点: 一个key对应一组数据
适用: 需要把相同特征的元素分到一组
模板:
unordered_map<string, vector<string>> hash;
for(auto& s : strs) {
string key = getKey(s); // 提取特征
hash[key].push_back(s); // 分组
}
// 提取结果
vector<vector<string>> ret;
for(auto& [k, v] : hash) {
ret.push_back(v);
}
我做过的题:
- LeetCode 49 - 字母异位词分组
典型题目分类
类型1:基础哈希表
LeetCode 1. 两数之和 ⭐⭐⭐
这是哈希表最经典的题目。
题目: 找两个数相加等于target,返回下标。
我的思路演变:
第一次(Day01): 暴力法,O(n²)
for(int i = 0; i < n; i++) {
for(int j = i+1; j < n; j++) {
if(nums[i] + nums[j] == target)
return {i, j};
}
}
第二次(Day19): 哈希表,O(n)
unordered_map<int, int> hash; // <元素值, 下标>
for(int i = 0; i < n; i++) {
int complement = target - nums[i];
if(hash.count(complement))
return {hash[complement], i};
hash[nums[i]] = i; // 先查找后插入
}
return {};
关键点:
- 先查找后插入 - 防止同一个元素被使用两次
- “边遍历边查找” - 不需要先全部插入再查找
- 用空间换时间 - 从O(n²)到O(n)
面试题 01.02. 判定是否互为字符重排
题目: 判断两个字符串能否通过重新排列得到。
我的思路: 统计每个字符出现的次数,看是否相等。
方法一:unordered_map
unordered_map<char, int> hash1, hash2;
for(auto ch : s1) hash1[ch]++;
for(auto ch : s2) hash2[ch]++;
return hash1 == hash2; // map可以直接比较
方法二:数组模拟(只有小写字母)
int hash1[26] = {0}, hash2[26] = {0};
for(auto ch : s1) hash1[ch - 'a']++;
for(auto ch : s2) hash2[ch - 'a']++;
// 数组不能直接比较,要逐个元素比
for(int i = 0; i < 26; i++) {
if(hash1[i] != hash2[i]) return false;
}
return true;
方法三:单数组优化 ⭐
if(s1.size() != s2.size()) return false;
int hash[26] = {0};
// s1加,s2减
for(int i = 0; i < s1.size(); i++) {
hash[s1[i] - 'a']++;
hash[s2[i] - 'a']--;
}
// 检查是否全为0
for(int i = 0; i < 26; i++) {
if(hash[i] != 0) return false;
}
return true;
我的收获:
- 数组模拟比map快,但只适用于字符范围确定的情况
- "加减抵消法"是一个很巧妙的技巧
- 数组不能直接用
==比较!
我踩的坑:
's[i]'写成了单引号 → 多字符常量char hash[26]→ 应该用int hash[26]hash1[s1[1]]→ 应该用循环变量i
LeetCode 217. 存在重复元素
最简单的一道题。
题目: 判断数组中是否有重复元素。
我的解法(1分38秒AC):
unordered_map<int, int> hash;
for(int i = 0; i < nums.size(); i++) {
hash[nums[i]]++;
if(hash[nums[i]] > 1) return true;
}
return false;
更简洁的写法:
unordered_set<int> hash;
for(auto x : nums) {
if(hash.count(x)) return true;
hash.insert(x);
}
return false;
关键理解:
- 只需要判断是否存在,不需要统计次数
- 用
unordered_set更合适 for(auto x : nums)比下标访问更简洁
LeetCode 219. 存在重复元素 II ⭐⭐
题目: 判断是否存在两个相同元素,且下标距离 ≤ k。
我的解法(5分钟,遇到bug):
unordered_map<int, int> hash; // <元素值, 下标>
for(int i = 0; i < nums.size(); i++) {
if(hash.count(nums[i]) && abs(hash[nums[i]] - i) <= k)
return true;
hash[nums[i]] = i; // 更新为最新下标
}
return false;
我踩的坑:
// ❌ 错误
abs(hash[nums[i]] - i <= k)
// 计算顺序:(- i <= k) → bool → abs(bool)
// ✅ 正确
abs(hash[nums[i]] - i) <= k
// 计算顺序:(- i) → abs(差值) → (<= k)
关键点:
- 必须每次都更新下标 - 保证比较的是最近的距离
- abs()的括号位置 - 运算符优先级问题
测试用例模拟:
输入:nums = [1,2,3,1,2,3], k = 2
i=0: hash[1]=0
i=1: hash[2]=1
i=2: hash[3]=2
i=3: 1在hash中,|0-3|=3 > 2,不满足,更新hash[1]=3
i=4: 2在hash中,|1-4|=3 > 2,不满足,更新hash[2]=4
i=5: 3在hash中,|2-5|=3 > 2,不满足,更新hash[3]=5
结果:false
LeetCode 49. 字母异位词分组 ⭐⭐
这题比较难,涉及到嵌套数据结构。
题目: 把字母异位词分到一组。
我的理解过程:
一开始不知道怎么写,不懂 vector<vector<string>> 是什么意思,也不知道怎么分组。
经过详细演示后,我理解了:
- 异位词的特点:排序后相同
- 用哈希表自动分组:相同key的自动归到一起
vector<vector<string>>就是二维数组
完整代码:
vector<vector<string>> groupAnagrams(vector<string>& strs) {
unordered_map<string, vector<string>> hash;
// 1. 分组
for(auto& s : strs) {
string key = s;
sort(key.begin(), key.end()); // 排序后作为key
hash[key].push_back(s); // 加入对应的组
}
// 2. 提取结果
vector<vector<string>> ret;
for(auto& [key, val] : hash) {
ret.push_back(val); // val就是一组异位词
}
return ret;
}
演示过程:
输入:["eat", "tea", "tan", "ate", "nat", "bat"]
处理"eat": key="aet" → hash["aet"] = ["eat"]
处理"tea": key="aet" → hash["aet"] = ["eat", "tea"]
处理"tan": key="ant" → hash["ant"] = ["tan"]
处理"ate": key="aet" → hash["aet"] = ["eat", "tea", "ate"]
处理"nat": key="ant" → hash["ant"] = ["tan", "nat"]
处理"bat": key="abt" → hash["abt"] = ["bat"]
最终:
hash = {
"aet": ["eat", "tea", "ate"],
"ant": ["tan", "nat"],
"abt": ["bat"]
}
提取结果:
[["eat","tea","ate"], ["tan","nat"], ["bat"]]
我的收获:
- 排序识别异位词 - 字母相同,排序后一样
- 哈希表自动分组 - 相同key的自动归到一起
- vector vs pair - vector只有1个类型参数,pair有2个
- 数据结构理解 -
vector<vector<T>>是二维数组
数据结构补充:
// ✅ 正确
vector<int> // int数组
vector<vector<int>> // 二维数组
pair<int, int> // 两个int
vector<pair<int, int>> // pair数组
// ❌ 错误
vector<int, int> // 不存在!
vector<vector<int, int>> // 不存在!
类型2:前缀和+哈希表
这是我最困惑的部分,搞了好几天才理解。
为什么要用哈希表?
一开始我的疑问:
- 前缀和不是用数组吗?为什么要用哈希表?
hash[sum-k]是什么意思?- 为什么能快速找到符合条件的子数组?
后来我理解了:
前缀和有两种应用场景:
1. 查询具体区间的和 → 用数组
vector<int> dp(n+1);
for(int i = 1; i <= n; i++) {
dp[i] = dp[i-1] + nums[i-1];
}
// 查询 [left, right]
int sum = dp[right+1] - dp[left];
2. 找符合条件的子数组个数 → 用哈希表
unordered_map<int, int> hash; // 前缀和 → 次数
hash[0] = 1;
int sum = 0, ret = 0;
for(int i = 0; i < n; i++) {
sum += nums[i];
if(hash.count(sum - k)) {
ret += hash[sum - k];
}
hash[sum]++;
}
return ret;
关键区别:
- 数组:知道下标,查询和
- 哈希表:知道和,查询有多少个
LeetCode 560. 和为K的子数组 ⭐⭐⭐
题目: 统计有多少个子数组的和等于k。
我的理解过程:
最开始: 完全不懂,为什么要查 hash[sum-k]?
后来理解:
如果 sum[i] - sum[j] = k
那么 sum[j] = sum[i] - k
所以在i位置,我要找之前有多少个前缀和是 sum[i] - k
这些前缀和对应的位置到i,和都是k
代码:
unordered_map<int, int> hash; // 前缀和 → 次数
hash[0] = 1; // 虚拟起点
int sum = 0, ret = 0;
for(auto x : nums) {
sum += x;
if(hash.count(sum - k)) {
ret += hash[sum - k];
}
hash[sum]++;
}
return ret;
手动模拟:
输入:nums = [1, 1, 1], k = 2
初始:hash = {0: 1}, sum = 0, ret = 0
i=0, x=1:
sum = 1
查找 hash[1-2=-1],不存在
hash[1]++
hash = {0:1, 1:1}, ret = 0
i=1, x=1:
sum = 2
查找 hash[2-2=0],存在!次数是1
ret += 1
hash[2]++
hash = {0:1, 1:1, 2:1}, ret = 1
i=2, x=1:
sum = 3
查找 hash[3-2=1],存在!次数是1
ret += 1
hash[3]++
hash = {0:1, 1:1, 2:1, 3:1}, ret = 2
结果:2
解释:[1,1] 和 [1,1] 两个子数组
关键点:
hash[0] = 1表示"前0个元素的和是0",处理从头开始的子数组- 先查找后插入,不会重复统计
- hash里存的是历史所有前缀和及其次数
LeetCode 974. 和可被K整除的子数组 ⭐⭐⭐
题目: 统计有多少个子数组的和能被k整除。
我的错误思路:
一开始我想用循环查找 sum - k, sum - 2k, sum - 3k…
// ❌ 错误代码
for(int i = 1; i*k < sum; i++) {
if(hash.count(sum - k*i)) ret += hash[sum - k*i];
}
问题:
- 循环条件
i*k < sum不对,sum可能是负数 - 会漏掉很多情况
- 时间复杂度变成O(n²)
正确思路:
如果 (sum[i] - sum[j]) % k == 0
那么 sum[i] % k == sum[j] % k
只要两个前缀和的余数相同,它们之间的子数组就能被k整除
代码:
unordered_map<int, int> hash; // 余数 → 次数
hash[0] = 1;
int sum = 0, ret = 0;
for(auto x : nums) {
sum += x;
int r = (sum % k + k) % k; // 处理负数
if(hash.count(r)) {
ret += hash[r];
}
hash[r]++;
}
return ret;
关键点:
- 存的是余数,不是前缀和本身
- 负数取模:
(sum % k + k) % k- C++中负数取模结果可能是负数
- 例如:
-1 % 5 = -1,应该是4 (-1 % 5 + 5) % 5 = 4
为什么这样能找到所有情况?
例如:sum = 14, k = 5
我之前的错误方法:查找 hash[9], hash[4]
漏掉了:hash[19], hash[24], ...
正确方法:查找 hash[14%5=4]
找到所有余数为4的前缀和,无论它们是4、9、14、19、24...
LeetCode 525. 连续数组 ⭐⭐⭐
题目: 找到含有相同数量0和1的最长子数组。
思维转换: 把0看作-1,问题变成:找和为0的最长子数组。
代码:
unordered_map<int, int> hash; // 前缀和 → 第一次出现的位置
hash[0] = -1; // 虚拟起点
int sum = 0, maxLen = 0;
for(int i = 0; i < n; i++) {
sum += (nums[i] == 1 ? 1 : -1);
if(hash.count(sum)) {
maxLen = max(maxLen, i - hash[sum]);
} else {
hash[sum] = i; // 只记录第一次出现的位置
}
}
return maxLen;
关键点:
- 只记录第一次出现的位置 - 为了让长度最大
hash[0] = -1- 处理从头开始的子数组
手动模拟:
输入:nums = [0, 1, 0]
转换后:[-1, 1, -1]
初始:hash = {0: -1}, sum = 0, maxLen = 0
i=0, nums[0]=0 → -1:
sum = -1
hash中没有-1
hash[-1] = 0
hash = {0:-1, -1:0}, maxLen = 0
i=1, nums[1]=1 → 1:
sum = 0
hash中有0!位置是-1
maxLen = max(0, 1-(-1)) = 2
不更新hash(保持第一次出现的位置)
hash = {0:-1, -1:0}, maxLen = 2
i=2, nums[2]=0 → -1:
sum = -1
hash中有-1!位置是0
maxLen = max(2, 2-0) = 2
不更新hash
hash = {0:-1, -1:0}, maxLen = 2
结果:2
解释:[0,1] 或 [1,0] 长度为2
我踩的坑总结
1. 语法错误(基础但致命)
单引号vs变量
// ❌ 错误
hash['s[i]' - 'a']++ // 's[i]'是多字符常量
// ✅ 正确
hash[s[i] - 'a']++ // s[i]是变量
数组类型选择
// ❌ 错误
char hash[26] = {0}; // char范围太小
// ✅ 正确
int hash[26] = {0}; // int足够存计数
循环变量笔误
// ❌ 错误
for(int i = 0; i < n; i++) {
hash[s[1]]++; // 永远是第二个字符
}
// ✅ 正确
for(int i = 0; i < n; i++) {
hash[s[i]]++; // 循环变量i
}
2. 数组比较方式
int hash1[26] = {0}, hash2[26] = {0};
// ❌ 错误
if(hash1 == hash2) return true; // 比较的是地址
// ✅ 正确
for(int i = 0; i < 26; i++) {
if(hash1[i] != hash2[i]) return false;
}
return true;
为什么?
- 数组名是指针,
hash1 == hash2比较的是地址 - unordered_map可以直接用
==比较 - 这是C++ STL容器的便利性
3. abs()括号位置 ⭐⭐⭐
// ❌ 错误
abs(hash[nums[i]] - i <= k)
// 计算顺序:
// 1. hash[nums[i]] - i <= k → bool(true/false)
// 2. abs(bool) → abs(0或1)
// 完全错了!
// ✅ 正确
abs(hash[nums[i]] - i) <= k
// 计算顺序:
// 1. hash[nums[i]] - i → 差值
// 2. abs(差值) → 绝对值
// 3. 绝对值 <= k → 判断
教训: 涉及运算符优先级时,务必用括号明确表达意图!
4. 更新下标的必要性
// LeetCode 219
for(int i = 0; i < n; i++) {
if(hash.count(nums[i]) && abs(hash[nums[i]] - i) <= k)
return true;
hash[nums[i]] = i; // 必须每次都更新!
}
为什么?
- 只有保存最近一次的下标,才能找到最小的距离
- 例如:
nums = [1, 0, 1, 1], k = 1- i=2: 距离i=0太远,但更新后
- i=3: 距离i=2刚好满足
5. 数据结构理解
vector只有1个类型参数
// ✅ 正确
vector<int> // int数组
vector<string> // string数组
vector<vector<int>> // 二维数组
// ❌ 错误
vector<int, int> // 不存在!
vector<vector<int, int>> // 不存在!
pair有2个类型参数
// ✅ 正确
pair<int, int> // 两个int
vector<pair<int, int>> // pair数组
为什么不用pair<string, string>?
- pair只能存2个值,固定的
- 每组异位词数量是不固定的,可能1个、2个、3个…
- 所以要用
vector<string>,可以存任意多个
典型模板总结
模板1:边遍历边查找(两数之和)
unordered_map<int, int> hash; // <元素值, 下标>
for(int i = 0; i < n; i++) {
int target = k - nums[i];
if(hash.count(target)) {
// 找到了
return {hash[target], i};
}
hash[nums[i]] = i; // 先查找后插入
}
关键: 先查找后插入,避免找到自己
模板2:统计频次
unordered_map<T, int> hash; // <元素, 次数>
for(auto x : arr) {
hash[x]++;
}
模板3:加减抵消法(字符重排)
int hash[26] = {0};
// s1加,s2减
for(int i = 0; i < n; i++) {
hash[s1[i] - 'a']++;
hash[s2[i] - 'a']--;
}
// 检查是否全为0
for(int i = 0; i < 26; i++) {
if(hash[i] != 0) return false;
}
return true;
模板4:排序识别(字母异位词)
unordered_map<string, vector<string>> hash;
for(auto& s : strs) {
string key = s;
sort(key.begin(), key.end()); // 排序后作为key
hash[key].push_back(s); // 分组
}
// 提取结果
vector<vector<string>> ret;
for(auto& [k, v] : hash) {
ret.push_back(v);
}
模板5:前缀和+哈希表(子数组个数)
unordered_map<int, int> hash; // 前缀和/余数 → 次数
hash[0] = 1; // 虚拟起点
int sum = 0, ret = 0;
for(auto x : nums) {
sum += x;
// 查找符合条件的前缀和
if(hash.count(sum - k)) { // 或其他条件
ret += hash[sum - k];
}
hash[sum]++; // 或 hash[sum%k]++
}
return ret;
变体:
- 和为k:查找
hash[sum - k] - 整除k:查找
hash[(sum%k+k)%k],存余数 - 和为0:查找
hash[sum]
模板6:前缀和+哈希表(最长子数组)
unordered_map<int, int> hash; // 前缀和 → 第一次出现的位置
hash[0] = -1; // 虚拟起点
int sum = 0, maxLen = 0;
for(int i = 0; i < n; i++) {
sum += nums[i];
if(hash.count(sum)) {
maxLen = max(maxLen, i - hash[sum]);
} else {
hash[sum] = i; // 只记录第一次
}
}
return maxLen;
关键: 只记录第一次出现的位置,让长度最大
容器选择决策树
需要存储什么?
├─ 只判断存在性
│ └─ unordered_set<T>
│
├─ 需要统计次数
│ └─ unordered_map<T, int>
│
├─ 元素与下标
│ └─ unordered_map<T, int>
│
├─ 前缀和问题
│ ├─ 查询区间和 → vector<int>(数组)
│ ├─ 子数组个数 → unordered_map<int, int>(前缀和→次数)
│ └─ 最长子数组 → unordered_map<int, int>(前缀和→位置)
│
└─ 分组(嵌套)
└─ unordered_map<T, vector<T>>
我的理解
1. 哈希表的本质
用空间换时间:
- 暴力法:O(n²),遍历查找
- 哈希表:O(n),O(1)查找
建立映射关系:
- 元素 → 下标
- 元素 → 次数
- 元素 → 位置
- 特征 → 一组元素
2. "边遍历边查找"的精髓
不需要:
- 先全部插入
- 再遍历查找
而是:
- 遍历的同时查找
- 查找完再插入
- 一次遍历搞定
3. 前缀和+哈希表的理解
为什么不用数组?
- 数组:知道下标,查询和
- 哈希表:知道和,查询个数/位置
hash里存什么?
- 找个数:存 “前缀和 → 次数”
- 找长度:存 “前缀和 → 位置”
查什么?
- 和为k:查
hash[sum - k] - 整除k:查
hash[sum % k](存余数) - 和为0:查
hash[sum]
4. 容器选择的关键
问自己三个问题:
- 我要存什么?(决定value的类型)
- 我用什么作为key?(决定key的类型)
- 一个key对应几个value?(决定用set还是map)
我的薄弱环节
- ✅ 语法细节 - 单引号、数组类型、循环变量(已克服)
- ✅ 数组比较 - 不能直接用
==(已理解) - ✅ 运算符优先级 - abs()括号位置(已掌握)
- ✅ 数据结构理解 - vector vs pair(已搞懂)
- ⚠️ 前缀和+哈希表 - 有时还是要想一会儿(需要继续练)
后记
从Day01第一次见哈希表,到Day19完全掌握,用了17天。
中间经历了:
- Day01:懵懂,只知道"快"
- Day07-08:前缀和+哈希表,很迷糊
- Day19:5道题突破,终于理解了
最大的收获:
- 容器选择 - 知道什么时候用set、map、数组
- “边遍历边查找” - 哈希表的核心技巧
- 前缀和+哈希表 - 从困惑到理解
- 踩坑经验 - 语法细节、运算符优先级
现在看到哈希表的题,能很快判断:
- 用什么容器
- 存什么、查什么
- 怎么避免重复
哈希表这个专题,算是真的理解了。
更多推荐
所有评论(0)