什么是哈希表?
一句话总结:哈希表是一种通过"键(Key)"直接访问"值(Value)"的数据结构,它的平均查找、插入和删除时间复杂度都是 O(1)。
它是怎么做到 O(1) 的?靠的是哈希函数。
-
当你存入一个键值对
(Key, Value)时,哈希函数会把Key转换成一个整数(哈希值)。 -
这个整数对应数组中的一个位置(索引),数据就存在这个位置。
-
下次你拿
Key来查找时,只需再用哈希函数算一次,就能立刻定位到数据的位置,不需要像数组那样从头遍历。
哈希表的核心价值在于**"用空间换时间"**,将原本需要 O(N) 遍历查找的操作,降维打击成 O(1) 的快速查找。
题目一:两数之和
cpp
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
// 1. 创建一个哈希表,key是数组中的元素值,value是该元素对应的下标
unordered_map<int, int> hash;
// 2. 遍历整个数组
for(int i = 0; i < nums.size(); i++)
{
// 3. 计算当前元素需要的"另一半"是多少
int x = target - nums[i];
// 4. 在哈希表中查找这个"另一半"是否已经存在
if(hash.count(x))
// 如果存在,说明找到了,直接返回它们的下标
// hash[x] 是之前那个元素的下标,i 是当前元素的下标
return {hash[x], i};
// 5. 如果没找到,就把当前元素和它的下标存入哈希表,供后面的元素匹配
hash[nums[i]] = i;
}
// 6. 如果遍历完都没找到符合条件的两个数(题目保证一定有解,所以正常不会走到这里)
return {-1, -1};
}
};
核心思路:空间换时间 (哈希表)
我们需要在数组中找到两个数 a 和 b,使得 a + b = target。
在遍历数组时,对于当前的数字 nums[i],我们其实需要知道:在它之前是否出现过 target - nums[i] 这个数。如果出现过,那么这两个数就是我们要找的答案。
为了能够快速(O(1) 时间复杂度)地判断一个数是否出现过以及它的下标,我们可以使用哈希表 unordered_map。
为什么不用双重循环?
如果用双重 for 循环暴力求解(外层遍历 i,内层遍历 j > i),时间复杂度是 O(N²) 。当数组非常大时,程序会运行超时。哈希表解法通过记录已经遍历过的信息,将时间复杂度降到了 O(N),是非常典型的"以空间换时间"的策略。
题目二:判断是否互为字符重排
解法一:哈希表统计字符频率
核心思想是"消消乐 ":先统计 s1 的字符,然后遍历 s2,遇到一个就抵消一个。
cpp
class Solution {
public:
bool CheckPermutation(string s1, string s2) {
// 1. 长度不同,直接返回 false
if (s1.size() != s2.size()) return false;
// 2. 用哈希表统计 s1 中每个字符出现的次数
unordered_map<char, int> hash;
for (char c : s1) {
hash[c]++;
}
// 3. 遍历 s2,抵消哈希表中的计数
for (char c : s2) {
// 如果字符不存在,或者数量已经减到 0 了,说明 s2 多了该字符
if (hash.count(c) == 0 || hash[c] == 0) {
return false;
}
hash[c]--;
}
// 4. 遍历完毕且没有提前返回,说明完全匹配
return true;
}
};
优缺点
-
优点: 时间复杂度 O(N),只需遍历两次字符串。通用性强,不限制字符集。
-
缺点: 需要额外的哈希表空间。
解法二:数组计数(针对小写字母的优化)
如果题目明确说明字符串只包含小写英文字母(a-z),我们完全可以用一个长度为 26 的整型数组来代替哈希表,效率更高,代码也更简洁。
cpp
class Solution {
public:
bool CheckPermutation(string s1, string s2) {
if (s1.size() != s2.size()) return false;
int count[26] = {0}; // 统计 a-z 的频率
// s1 字符对应位置 +1,s2 字符对应位置 -1
for (int i = 0; i < s1.size(); i++) {
count[s1[i] - 'a']++;
count[s2[i] - 'a']--;
}
// 检查所有计数是否归零
for (int i = 0; i < 26; i++) {
if (count[i] != 0) return false;
}
return true;
}
};
代码逻辑解析
-
长度检查 :长度不同直接返回
false。 -
同时遍历 :用一个循环同时遍历
s1和s2。 -
加加减减 :
s1中出现的字符,在数组对应位置+1;s2中出现的字符,在数组对应位置-1。 -
结果检查:如果两个字符串互为重排,那么每个字符的净变化量必须是 0。最后遍历一次数组,只要有任何一个位置不为 0,就说明不匹配。
优缺点
-
优点: 极快。没有哈希表的哈希计算开销,纯数组操作,空间固定为 26 个 int。
-
缺点: 仅适用于字符集有限的情况(如纯小写字母)。如果有大写、数字,就不适用了。
题目三:存在重复元素
哈希集合 (Hash Set)
这是最直观、也是最常被写出的解法。
cpp
class Solution {
public:
bool containsDuplicate(vector<int>& nums) {
unordered_set<int> hash;
for (int x : nums) {
// 如果元素已经在集合中,说明找到了重复元素
if (hash.count(x)) return true;
// 否则将元素加入集合
hash.insert(x);
}
return false;
}
};
代码逻辑解析
-
创建一个
unordered_set(哈希集合),用于存储已经遍历过的数字。 -
遍历数组中的每一个数字
x。 -
在集合中查找
x是否已经存在(hash.count(x))。 -
如果存在,直接返回
true(因为题目只要求判断是否存在,找到第一个重复的就可以结束了)。 -
如果不存在,把
x插入到集合中,继续遍历下一个数字。 -
如果遍历完整个数组都没有找到重复元素,返回
false。
优缺点分析
-
优点:
-
时间复杂度最优: 平均时间复杂度为 O(N)。只需遍历一次数组,哈希表的插入和查找操作平均都是 O(1)。
-
提前终止: 只要在数组前部发现有重复元素,就会立刻返回,不需要遍历完整个数组。
-
逻辑简单: 代码非常符合人类直觉,"见过就记下来,再见到就报错"。
-
-
缺点 :
- 空间开销大: 最坏情况下(数组无重复元素),需要把数组里的 N 个元素全部存入哈希表,空间复杂度为 O(N)。如果内存极其受限,这种方法不合适。
题目四:存在重复元素II
cpp
class Solution {
public:
bool containsNearbyDuplicate(vector<int>& nums, int k) {
// 1. 哈希表:记录元素值 -> 该元素最近一次出现的下标
unordered_map<int, int> hash;
// 2. 遍历数组
for(int i = 0; i < nums.size(); i++)
{
// 3. 如果该元素之前出现过
if(hash.count(nums[i]))
{
// 4. 判断当前下标 i 与上次出现的下标之间的距离是否 <= k
if(i - hash[nums[i]] <= k)
return true; // 满足条件,直接返回
}
// 5. 关键步骤:更新哈希表。
// 无论是否满足条件,都要把当前元素的下标更新为 i。
// 因为对于后续的元素来说,当前下标 i 才是最近的。
hash[nums[i]] = i;
}
// 6. 遍历完都没有找到符合条件的重复元素
return false;
}
};
核心思路:记录最新下标
我们需要判断:对于当前遍历到的元素 nums[i],在它前面是否出现过相同的元素,并且它们之间的下标距离不超过 k。
为了高效查找"是否出现过",以及"上一次出现的位置",我们依然可以使用哈希表 unordered_map。
-
Key (键): 数组中的元素值
nums[i]。 -
Value (值): 该元素最近一次 出现的下标
i。
关键逻辑 :当我们遍历到 nums[i] 时:
-
看哈希表里有没有记录过这个元素。
-
如果记录过,取出它上一次出现的下标
hash[nums[i]],计算与当前下标i的距离。 -
如果
i - hash[nums[i]] <= k,说明满足条件,直接返回true。 -
如果不满足,或者哈希表里没有这个元素,我们需要更新 哈希表:
hash[nums[i]] = i。因为当前下标i是距离未来元素最近的位置,保留旧的下标没有意义。
进阶优化(滑动窗口 + 哈希集合)
还有另一种思路:维护一个大小为 k 的滑动窗口 。我们使用一个 unordered_set,保证集合里最多只有 k 个元素。遍历数组时,先检查集合里有没有当前元素,如果有则返回 true;然后把当前元素加入集合,如果集合大小超过了 k,就把最旧的元素(nums[i - k])从集合中移除。
cpp
class Solution {
public:
bool containsNearbyDuplicate(vector<int>& nums, int k) {
unordered_set<int> window;
for (int i = 0; i < nums.size(); ++i) {
if (window.count(nums[i])) return true; // 窗口内有重复
window.insert(nums[i]);
if (window.size() > k) { // 保持窗口大小不超过 k
window.erase(nums[i - k]);
}
}
return false;
}
};
这种方法的空间复杂度被严格控制在 O(k) ,在 k 远小于 N 的时候,比第一种方法更省内存。但是 map 解法(只保留最近下标)在逻辑上更直观,且不需要额外处理窗口大小的判断。
题目五:字母异位词分组
cpp
class Solution {
public:
vector<vector<string>> groupAnagrams(vector<string>& strs) {
// 1. 定义哈希表:Key是排序后的字符串,Value是原始字符串的数组
unordered_map<string, vector<string>> hash;
// 2. 遍历输入的每一个字符串
for(auto& s : strs) // 使用引用遍历,提高效率
{
// 3. 复制当前字符串,用于排序
string tmp = s;
// 4. 对复制的字符串进行排序(例如 "eat" -> "aet")
sort(tmp.begin(), tmp.end());
// 5. 将原始字符串存入以排序后字符串为键的哈希表中
// 如果是第一次遇到这个键,会自动创建一个新的 vector
hash[tmp].push_back(s);
}
// 6. 准备返回结果
vector<vector<string>> ret;
// 7. 遍历哈希表,将所有的 Value(即分组好的异位词数组)提取出来
for(auto& [x, y] : hash) // C++17 结构化绑定,x是键,y是值
{
ret.push_back(y);
}
// 8. 返回最终结果
return ret;
}
};
核心思路:排序 + 哈希表
如何判断两个单词是字母异位词?它们包含的字母种类和数量完全一致。因此,如果我们将这两个单词内部的字母分别进行排序,得到的字符串一定是相同的。
例如:
-
"eat"排序后变成"aet" -
"tea"排序后变成"aet" -
"ate"排序后变成"aet"
我们可以利用这个特性:
-
创建一个哈希表,
Key(键) 存储排序后的字符串 ,Value(值) 存储原始字符串的集合(即一组字母异位词)。 -
遍历输入的字符串数组,对于每一个字符串,将其排序,得到
tmp。 -
以
tmp为键,将原始字符串s存入哈希表中对应的数组里。 -
最后,把哈希表中所有的
Value提取出来,就是分组后的结果。
复杂度分析
假设数组中有 N 个字符串,每个字符串的最大长度为 K。
-
时间复杂度:O(N * K * log(K))
-
我们需要遍历
N个字符串(外层循环)。 -
对于每个字符串,我们需要将其复制并排序。对长度为
K的字符串进行排序的时间复杂度是O(K * log K)。 -
哈希表的插入操作平均是 O(1)。因此总的时间复杂度为
O(N * K * log(K))。
-
-
空间复杂度:O(N * K)
-
哈希表需要存储所有的字符串,总共消耗
O(N * K)的空间。 -
排序时产生的临时字符串
tmp消耗O(K)的额外空间。 -
因此,总的空间复杂度为
O(N * K)。
-
进阶优化(计数法避免排序)
虽然排序法已经足够优秀,但如果字符串非常长,O(K * log K) 的排序时间可能会成为瓶颈。我们可以将每个字符串的字符出现次数统计出来(例如 a 出现 1 次,e 出现 2 次...),把这个统计信息转化为一个唯一的字符串作为 Key。
因为题目提示字符串只包含小写字母,我们可以用一个长度为 26 的数组或字符串来作为 Key:
cpp
class Solution {
public:
vector<vector<string>> groupAnagrams(vector<string>& strs) {
unordered_map<string, vector<string>> hash;
for (auto& s : strs) {
string key(26, '0'); // 创建一个长度为26的字符串,初始化为 '0'
for (char c : s) {
key[c - 'a']++; // 统计字符出现的次数
}
// 例如 "eat" -> "10001000000000000000000000"
hash[key].push_back(s);
}
vector<vector<string>> ret;
for (auto& [key, group] : hash) {
ret.push_back(group);
}
return ret;
}
};
-
时间复杂度: 降为 O(N * K)。因为统计字符只需要遍历一次字符串。
-
空间复杂度: 依然是 O(N * K),因为 Key 的长度变成了固定的 26。
直接寻址表和哈希表
"用数组模拟哈希表" (通常称为直接寻址表 )和**"标准库中的哈希表"** (如 C++ 的 unordered_map / unordered_set)虽然目的相似(都是为了实现 O(1) 的快速查找),但在底层原理、性能、内存消耗和适用场景上有着巨大的区别。
1. 核心原理的区别
-
数组模拟哈希表(直接寻址):
-
原理: 极其简单粗暴。它直接把元素的值(Key)当作数组的下标(Index)。
-
映射关系:
数组[Key] = Value或者数组[Key] = true。 -
例如:要统计字符串中小写字母的出现次数,字符
'a'的 ASCII 码是 97,我们就把它映射到arr[0],'b'映射到arr[1]。
-
-
标准库哈希表 (
unordered_map/unordered_set):-
原理: 复杂精妙。它内部维护了一个哈希函数 和一个桶数组(Bucket Array)。
-
映射关系:
下标 = 哈希函数(Key) % 桶的数量。 -
例如:存入数字
100000,哈希函数可能算出100000 % 100 = 0,它就会被放进第 0 个桶里。如果发生哈希冲突,还会用到链表或红黑树来解决。
-
2. 时间复杂度的区别
-
数组模拟哈希表:
- 绝对 O(1)。 因为数组支持随机访问,通过下标取值是计算机硬件层面最基础的操作。没有任何冲突处理,没有哈希计算,速度极快。
-
标准库哈希表:
-
平均 O(1),最坏 O(N)。 虽然有哈希函数,但计算哈希值需要时间。如果发生严重的哈希冲突(所有元素都映射到同一个桶),查找会退化成遍历链表,时间复杂度变成 O(N)。
-
此外,当哈希表中的元素越来越多时,它需要扩容(Rehash),这会瞬间导致一次 O(N) 的操作。
-
3. 空间复杂度与内存占用的区别
-
数组模拟哈希表:
-
空间固定且连续。 必须开辟一块连续的内存。
-
致命缺点:空间浪费或越界。 如果数据的范围是
1 ~ 10^9,你不可能开一个大小为 10亿 的数组,内存会直接爆炸。但如果数据只有1, 5, 1000这三个数,你又开了一个大小 1001 的数组,中间的 997 个空间全是浪费的。
-
-
标准库哈希表:
-
动态分配,按需增长。 它只会为实际存在的元素分配内存(加上桶数组的一些额外开销)。
-
空间利用率高。 无论数据的范围跨度有多大(比如只有 3 个元素,但值是 1, 5000, 1000000),它都能很好地处理,不会浪费大量连续内存。
-
4. 适用场景的区别
| 场景特征 | 推荐方案 | 原因 |
|---|---|---|
| 数据范围小且集中(如:小写字母 a-z,数字 0-1000) | 数组模拟 | 极快,代码简单,无哈希开销。 |
| 数据范围极大或未知(如:任意整数、字符串、浮点数) | 标准库哈希表 | 数组开不下,必须用哈希函数压缩空间。 |
| 需要极高的时间性能(如:算法竞赛卡常数) | 数组模拟 | 数组访问比哈希表访问快 5-10 倍以上。 |
| 数据是复杂类型(如:结构体、自定义对象) | 标准库哈希表 | 数组无法直接用复杂对象做下标,除非自己写映射。 |
识别信号
信号一:需要快速判断"某元素是否存在"或"是否重复"
这是最直接的信号。如果题目要求你频繁地检查一个元素之前有没有出现过,用数组遍历是 O(N),用哈希表是 O(1)。
-
关键词: 是否存在、是否重复、唯一、去重。
-
经典例题:
-
存在重复元素: 判断数组里有没有重复数字。-> 用
unordered_set边遍历边检查。 -
两个数组的交集: 找两个数组共有的元素。-> 把一个数组丢进
set,遍历另一个数组去查。 -
1. 两数之和: 找
target - x是否在之前遍历过的数字里。-> 用map记录"值 -> 下标"。
-
信号二:需要统计"元素出现的次数/频率"
如果题目要求你统计字符、数字或字符串出现的次数,哈希表(或数组模拟)是最标准的工具。
-
关键词: 频率、次数、计数、出现多少次。
-
经典例题:
-
判定是否互为字符重排: 判断两个字符串字符数量是否一致。-> 用
map统计 s1,遍历 s2 抵消。 -
有效的字母异位词: 同上。
-
前 K 个高频元素: 统计频率后排序或使用堆。-> 第一步必须用
map统计频率。
-
信号三:需要建立"映射关系"(Key -> Value)
当题目中存在某种一一对应的关系,或者你需要通过一个值快速找到另一个值时。
-
关键词: 映射、对应、查找、转换。
-
经典例题:
- 两数之和: 数字 -> 下标。
信号四:需要将"具有相同特征"的元素归类(分组)
如果题目要求你把一堆东西按照某种规则分成几组,哈希表的 Key 可以存储"特征",Value 存储"属于该特征的所有元素"。
-
关键词: 分组、归类、具有相同属性。
-
经典例题:
- 字母异位词分组: 把字母组成相同的单词放在一起。-> 排序后的字符串作为 Key,原始字符串数组作为 Value。
信号五:暴力解法是双重循环 O(N²),且内层循环是为了"查找"
这是算法优化的终极信号。当你写出两层 for 循环,发现内层循环的作用仅仅是为了"在数组里找一个满足条件的数"时,就可以用哈希表把内层循环优化掉,将整体时间复杂度降为 O(N)。
-
优化逻辑:
-
暴力:外层遍历
i,内层遍历j找target - nums[i]。 -
优化:外层遍历
i,哈希表 O(1) 查找target - nums[i]。
-
-
经典例题: 绝大多数两数之和、三数之和、四数之和的变种,都可以用哈希表优化。