文章目录
- 哈希表理论基础
-
- [1. 哈希表的基本概念](#1. 哈希表的基本概念)
-
- [1.1 基本术语](#1.1 基本术语)
- [1.2 哈希表的特点](#1.2 哈希表的特点)
- [2. 哈希函数](#2. 哈希函数)
-
- [2.1 哈希函数的作用](#2.1 哈希函数的作用)
- [2.2 常见哈希函数](#2.2 常见哈希函数)
- [3. 哈希碰撞](#3. 哈希碰撞)
-
- [3.1 什么是哈希碰撞](#3.1 什么是哈希碰撞)
- [3.2 解决哈希碰撞的方法](#3.2 解决哈希碰撞的方法)
-
- [3.2.1 拉链法(Chaining)](#3.2.1 拉链法(Chaining))
- [3.2.2 线性探测法(Linear Probing)](#3.2.2 线性探测法(Linear Probing))
- [4. C++中的三种哈希结构](#4. C++中的三种哈希结构)
-
- [4.1 数组(Array)](#4.1 数组(Array))
- [4.2 Set(集合)](#4.2 Set(集合))
- [4.3 Map(映射)](#4.3 Map(映射))
- [5. 三种哈希结构的选择](#5. 三种哈希结构的选择)
-
- [5.1 选择原则](#5.1 选择原则)
- [5.2 选择建议](#5.2 选择建议)
- [5.3 性能对比](#5.3 性能对比)
- [6. 哈希表的时间复杂度](#6. 哈希表的时间复杂度)
- [7. 何时使用哈希表](#7. 何时使用哈希表)
-
- [7.1 使用场景](#7.1 使用场景)
- [7.2 判断标准](#7.2 判断标准)
- [8. 哈希表的优缺点](#8. 哈希表的优缺点)
-
- [8.1 优点](#8.1 优点)
- [8.2 缺点](#8.2 缺点)
- [9. 常见题型模板](#9. 常见题型模板)
-
- [9.1 数组作为哈希表模板](#9.1 数组作为哈希表模板)
- [9.2 Set作为哈希表模板](#9.2 Set作为哈希表模板)
- [9.3 Map作为哈希表模板](#9.3 Map作为哈希表模板)
- [9.4 双指针+去重模板(三数之和、四数之和)](#9.4 双指针+去重模板(三数之和、四数之和))
- [9.5 其他技巧模板](#9.5 其他技巧模板)
- [10. 总结](#10. 总结)
哈希表理论基础
1. 哈希表的基本概念
**哈希表(Hash Table)**是一种根据键(Key)直接访问内存存储位置的数据结构,通过哈希函数将键映射到数组中的某个位置,从而实现快速查找、插入和删除操作。
1.1 基本术语
- 键(Key):要存储或查找的数据
- 值(Value):与键关联的数据
- 哈希函数(Hash Function):将键映射到数组索引的函数
- 哈希值(Hash Value):哈希函数计算出的索引值
- 哈希碰撞(Hash Collision):不同的键映射到同一个索引位置
1.2 哈希表的特点
- 快速查找:平均时间复杂度O(1)
- 空间换时间:需要额外的空间存储数据
- 无序性 :
unordered_map和unordered_set不保证元素顺序
示例:
键值对:("apple", 5), ("banana", 3), ("cherry", 8)
哈希函数:h(key) = key[0] - 'a' (简化示例)
映射结果:
"apple" → h("apple") = 0 → 索引0
"banana" → h("banana") = 1 → 索引1
"cherry" → h("cherry") = 2 → 索引2
2. 哈希函数
2.1 哈希函数的作用
哈希函数将任意大小的键映射到固定大小的数组索引,理想情况下应该:
- 均匀分布:将键均匀分布到数组中
- 快速计算:计算哈希值的时间复杂度为O(1)
- 确定性:相同的键总是产生相同的哈希值
2.2 常见哈希函数
- 直接定址法 :
h(key) = key或h(key) = a * key + b - 除留余数法 :
h(key) = key % m(m为数组大小) - 字符哈希 :
h(key) = key[i] - 'a'(适用于小写字母)
示例:
cpp
// 字符哈希:将小写字母映射到0-25
int hash(char c) {
return c - 'a'; // 'a'→0, 'b'→1, ..., 'z'→25
}
// 数字哈希:取模法
int hash(int key, int size) {
return key % size;
}
3. 哈希碰撞
3.1 什么是哈希碰撞
当两个不同的键通过哈希函数映射到同一个索引位置时,就发生了哈希碰撞。
示例:
哈希函数:h(key) = key % 10
键:15 和 25
h(15) = 15 % 10 = 5
h(25) = 25 % 10 = 5 ← 碰撞!
3.2 解决哈希碰撞的方法
3.2.1 拉链法(Chaining)
将发生碰撞的元素存储在同一个位置的链表中。
示例:
索引5:15 → 25 → 35
代码实现:
cpp
// 简化示例
struct Node {
int key;
int value;
Node* next;
};
class HashTable {
private:
vector<Node*> table;
int size;
public:
HashTable(int n) : size(n) {
table.resize(n, nullptr);
}
void insert(int key, int value) {
int index = key % size;
Node* node = new Node{key, value, table[index]};
table[index] = node;
}
};
3.2.2 线性探测法(Linear Probing)
当发生碰撞时,顺序查找下一个空闲位置。
示例:
数组:[_, _, 15, 25, _, _]
插入25时,索引5已被占用,检查索引6,空闲,插入
代码实现:
cpp
class HashTable {
private:
vector<pair<int, int>> table; // (key, value)
int size;
public:
HashTable(int n) : size(n) {
table.resize(n, {-1, -1}); // -1表示空位置
}
void insert(int key, int value) {
int index = key % size;
while (table[index].first != -1) { // 找到空闲位置
index = (index + 1) % size;
}
table[index] = {key, value};
}
};
4. C++中的三种哈希结构
4.1 数组(Array)
特点:
- 适用场景:键的范围较小且连续(如0-25的字母)
- 优点:实现简单,访问速度快,空间效率高
- 缺点:键的范围大时浪费空间
示例:
cpp
// 统计26个小写字母的出现次数
int hash[26] = {0};
for (char c : s) {
hash[c - 'a']++; // 直接使用字符作为索引
}
4.2 Set(集合)
特点:
- 适用场景:只需要判断元素是否存在,不需要存储额外信息
- 三种类型 :
unordered_set:无序,查询和增删效率最高(O(1))set:有序(红黑树),查询和增删效率O(log n)multiset:有序,允许重复元素
示例:
cpp
// 判断元素是否存在
unordered_set<int> uset;
uset.insert(1);
if (uset.find(2) != uset.end()) {
// 元素存在
}
4.3 Map(映射)
特点:
- 适用场景:需要存储键值对,既要判断元素是否存在,又要记录额外信息(如下标、出现次数等)
- 三种类型 :
unordered_map:无序,查询和增删效率最高(O(1))map:有序(红黑树),查询和增删效率O(log n)multimap:有序,允许重复键
示例:
cpp
// 存储元素和下标
unordered_map<int, int> umap;
umap[1] = 0; // 键:元素值,值:下标
if (umap.find(2) != umap.end()) {
int index = umap[2]; // 获取下标
}
5. 三种哈希结构的选择
5.1 选择原则
| 比较项 | 数组 | Set | Map |
|---|---|---|---|
| 适用场景 | 键范围小且连续 | 只需判断存在性 | 需要键值对 |
| 时间复杂度 | O(1) | O(1) | O(1) |
| 空间复杂度 | O(范围大小) | O(n) | O(n) |
| 是否有序 | 是(按索引) | 否(unordered) | 否(unordered) |
| 典型应用 | 字母统计 | 去重、存在性判断 | 两数之和、计数 |
5.2 选择建议
-
数组作为哈希表
- 键的范围小(如0-1000)
- 键连续或接近连续
- 需要统计出现次数
- 典型题目:有效的字母异位词、赎金信
-
Set作为哈希表
- 只需要判断元素是否存在
- 键的范围大或分散
- 需要去重
- 典型题目:两个数组的交集、快乐数
-
Map作为哈希表
- 需要存储键值对
- 需要记录额外信息(下标、出现次数等)
- 典型题目:两数之和、四数相加II
5.3 性能对比
数组 vs Set vs Map:
- 数组:空间效率最高,但受限于键的范围
- Set:比数组占用更多空间,但不受键范围限制
- Map:功能最强大,但空间开销最大
示例对比:
cpp
// 场景:统计0-1000范围内数字的出现次数
// 方法1:数组(推荐)
int arr[1001] = {0};
arr[num]++; // 快速、简单
// 方法2:Map(不推荐,浪费空间)
unordered_map<int, int> umap;
umap[num]++; // 功能相同,但空间开销更大
// 场景:统计大范围或分散的数字(如0, 5, 1000000)
// 方法1:数组(不推荐,浪费空间)
int arr[1000001] = {0}; // 大部分空间浪费
// 方法2:Map(推荐)
unordered_map<int, int> umap;
umap[num]++; // 只存储实际出现的数字
6. 哈希表的时间复杂度
| 操作 | 平均时间复杂度 | 最坏时间复杂度 | 说明 |
|---|---|---|---|
| 查找 | O(1) | O(n) | 最坏情况:所有元素碰撞 |
| 插入 | O(1) | O(n) | 最坏情况:所有元素碰撞 |
| 删除 | O(1) | O(n) | 最坏情况:所有元素碰撞 |
| 遍历 | O(n) | O(n) | 需要访问所有元素 |
注意:
- 平均情况下,哈希表的操作都是O(1)
- 最坏情况下(所有元素碰撞),退化为O(n)
- 实际应用中,哈希函数设计良好时,很少出现最坏情况
7. 何时使用哈希表
7.1 使用场景
-
快速判断元素是否出现
- 查找某个元素是否在集合中
- 判断重复元素
-
统计元素出现次数
- 统计字符、数字的出现频率
- 计数问题
-
存储键值对关系
- 需要同时存储键和值
- 需要根据键快速查找值
-
去重操作
- 去除重复元素
- 保留唯一元素
7.2 判断标准
当遇到以下情况时,考虑使用哈希表:
- 需要快速查找元素
- 需要判断元素是否存在
- 需要统计元素出现次数
- 需要存储键值对关系
- 需要去重操作
示例:
cpp
// 问题:判断数组中是否存在两个数之和等于target
// 思路:使用哈希表存储已访问的元素
unordered_map<int, int> umap; // 键:元素值,值:下标
for (int i = 0; i < nums.size(); i++) {
int complement = target - nums[i];
if (umap.find(complement) != umap.end()) {
return {umap[complement], i}; // 找到答案
}
umap[nums[i]] = i; // 存储当前元素
}
8. 哈希表的优缺点
8.1 优点
- 查找速度快:平均O(1)时间复杂度
- 插入删除快:平均O(1)时间复杂度
- 灵活性高:可以存储任意类型的键值对
8.2 缺点
- 空间开销:需要额外的空间存储哈希表
- 无序性 :
unordered_map和unordered_set不保证顺序 - 哈希碰撞:可能影响性能(最坏情况O(n))
- 键的限制:某些类型需要自定义哈希函数
9. 常见题型模板
9.1 数组作为哈希表模板
模板1:统计字符出现次数(字母异位词)
适用场景:键的范围小且连续(如26个小写字母)
核心思路:
- 使用数组索引直接映射字符
- 先统计一个字符串,再减去另一个字符串
- 最后检查数组是否全为0
模板代码:
cpp
// LeetCode 242. 有效的字母异位词
class Solution {
public:
bool isAnagram(string s, string t) {
int hash[26] = {}; // 初始化为0
// 统计s中字符出现次数
for(int i = 0; i < s.size(); i++) {
hash[s[i] - 'a']++;
}
// 减去t中字符出现次数
for(int j = 0; j < t.size(); j++) {
hash[t[j] - 'a']--;
}
// 检查是否全为0
for(int k = 0; k < 26; k++) {
if(hash[k] != 0) {
return false;
}
}
return true;
}
};
关键点:
- 字符映射:
c - 'a'将小写字母映射到0-25 - 数组大小:26(小写字母数量)
- 时间复杂度:O(n),空间复杂度:O(1)
模板2:判断字符是否足够(赎金信)
适用场景:判断一个字符串的字符是否足够组成另一个字符串
核心思路:
- 先统计可用字符(magazine)
- 再减去需要的字符(ransomNote)
- 检查是否有负数(表示不够)
模板代码:
cpp
// LeetCode 383. 赎金信
class Solution {
public:
bool canConstruct(string ransomNote, string magazine) {
int hash[26] = {0};
// 统计magazine中字符出现次数
for(int i = 0; i < magazine.length(); i++) {
hash[magazine[i] - 'a']++;
}
// 减去ransomNote中需要的字符
for(int j = 0; j < ransomNote.length(); j++) {
hash[ransomNote[j] - 'a']--;
}
// 检查是否有负数(表示不够)
for(int k = 0; k < 26; k++) {
if(hash[k] < 0) {
return false;
}
}
return true;
}
};
关键点:
- 与字母异位词的区别:这里允许magazine有更多字符
- 判断条件:检查是否有负数,而不是是否全为0
模板3:统计数字出现次数(两个数组的交集II)
适用场景:数字范围较小且连续(如0-1000)
模板代码:
cpp
// LeetCode 350. 两个数组的交集II
class Solution {
public:
vector<int> intersect(vector<int>& nums1, vector<int>& nums2) {
vector<int> result;
int arr1[1001] = {0}; // 记录nums1中元素的出现次数
int arr2[1001] = {0}; // 记录nums2中元素的出现次数
// 统计nums1中每个元素出现的次数
for(int i = 0; i < nums1.size(); i++) {
arr1[nums1[i]]++;
}
// 统计nums2中每个元素出现的次数
for(int j = 0; j < nums2.size(); j++) {
arr2[nums2[j]]++;
}
// 找到交集元素,并取较小的出现次数
for(int k = 0; k <= 1000; k++) {
if(arr1[k] > 0 && arr2[k] > 0) {
int minCount = min(arr1[k], arr2[k]);
for(int l = 0; l < minCount; l++) {
result.push_back(k);
}
}
}
return result;
}
};
9.2 Set作为哈希表模板
模板1:判断元素是否存在(两个数组的交集)
适用场景:只需要判断元素是否存在,不需要存储额外信息
核心思路:
- 将第一个数组存入set
- 遍历第二个数组,查找是否在set中
- 使用unordered_set自动去重
模板代码:
cpp
// LeetCode 349. 两个数组的交集
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> result_set;
unordered_set<int> nums_set(nums1.begin(), nums1.end());
for(int num : nums2) {
// 如果nums2中的元素在nums_set中出现过
if(nums_set.find(num) != nums_set.end()) {
result_set.insert(num);
}
}
return vector<int>(result_set.begin(), result_set.end());
}
};
关键点:
find() != end():判断元素是否存在unordered_set:自动去重,效率最高- 数组方法 vs Set方法:
- 数组:键范围小且连续时更高效
- Set:键范围大或分散时更节省空间
模板2:判断循环(快乐数)
适用场景:需要判断是否出现循环,记录已出现的值
核心思路:
- 使用set记录已出现的sum值
- 如果sum重复出现,说明进入循环,返回false
- 如果sum等于1,返回true
模板代码:
cpp
// LeetCode 202. 快乐数
class Solution {
public:
// 计算各位数字的平方和
int getSum(int n) {
int sum = 0;
while (n) {
sum += (n % 10) * (n % 10);
n = n / 10;
}
return sum;
}
bool isHappy(int n) {
unordered_set<int> result;
int sum = 0;
while (1) {
sum = getSum(n);
if (sum == 1) {
return true;
}
// 如果sum重复出现,说明进入循环
if (result.find(sum) != result.end()) {
return false;
} else {
result.insert(sum);
}
n = sum;
}
}
};
关键点:
- 循环判断:使用set记录已出现的值
- 终止条件:sum == 1 或 sum重复出现
9.3 Map作为哈希表模板
模板1:两数之和(需要记录下标)
适用场景:需要存储键值对,既要判断元素是否存在,又要记录额外信息(如下标)
核心思路:
- 遍历数组,对于每个元素,查找target - nums[i]是否在map中
- 如果找到,返回两个下标
- 如果没找到,将当前元素和下标存入map
模板代码:
cpp
// LeetCode 1. 两数之和
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> umap; // 键:元素值,值:下标
for(int i = 0; i < nums.size(); i++) {
// 查找target - nums[i]是否在map中
auto ptr = umap.find(target - nums[i]);
if(ptr != umap.end()) {
return {ptr->second, i}; // 返回两个下标
}
// 将当前元素和下标存入map
umap.insert(pair<int, int>(nums[i], i));
// 或者:umap[nums[i]] = i;
}
return {};
}
};
关键点:
- 为什么用Map不用Set:需要记录下标
- 为什么用Map不用数组:元素值可能很大,数组浪费空间
- 遍历顺序:边遍历边查找,只需一次遍历
模板2:四数相加II(需要记录出现次数)
适用场景:需要统计某个值出现的次数
核心思路:
- 先遍历前两个数组,统计所有可能的和及其出现次数
- 再遍历后两个数组,查找相反数是否在map中
- 累加出现次数
模板代码:
cpp
// LeetCode 454. 四数相加II
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2,
vector<int>& nums3, vector<int>& nums4) {
unordered_map<int, int> umap; // 键:a+b的和,值:出现次数
// 统计nums1和nums2中所有可能的和及其出现次数
for(int a : nums1) {
for(int b : nums2) {
umap[a + b]++; // 自动处理重复键,值累加
}
}
int count = 0;
// 遍历nums3和nums4,查找相反数
for(int c : nums3) {
for(int d : nums4) {
if(umap.find(-(c + d)) != umap.end()) {
count += umap[-(c + d)]; // 累加出现次数
}
}
}
return count;
}
};
关键点:
umap[key]++:自动处理重复键,值累加- 分组思想:将4个数组分成两组,每组两个数组
- 时间复杂度:O(n²),空间复杂度:O(n²)
模板3:字母异位词分组(需要将数组/字符串作为key)
适用场景:需要将复杂结构(如数组、字符串)作为key进行分组
核心思路:
- 将每个字符串转换为统一的key(如字符频次数组转字符串)
- 使用map将相同key的字符串分组
模板代码:
cpp
// LeetCode 49. 字母异位词分组
class Solution {
public:
vector<vector<string>> groupAnagrams(vector<string>& strs) {
unordered_map<string, vector<string>> mp;
for (auto& s : strs) {
// 统计每个字符的出现次数
int freq[26] = {0};
for (char c : s) {
freq[c - 'a']++;
}
// 把频次数组转成字符串作为key,例如:"2#1#0#0#..."
string key;
for (int i = 0; i < 26; i++) {
key += to_string(freq[i]);
key.push_back('#'); // 避免歧义,如"12"和"1#2"
}
mp[key].push_back(s);
}
// 将map的值转换为vector
vector<vector<string>> result;
for (auto& p : mp) {
result.push_back(p.second);
}
return result;
}
};
关键点:
- Key的设计:将字符频次数组转换为字符串
- 分隔符:使用'#'避免歧义(如"12"和"1#2")
- Map的值类型:vector,用于存储同一组的字符串
9.4 双指针+去重模板(三数之和、四数之和)
模板1:三数之和
适用场景:需要去重的多数和问题,不能使用哈希表去重(因为要去除重复的三元组)
核心思路:
- 先排序
- 固定第一个数,使用双指针找后两个数
- 关键:去重逻辑要正确
模板代码:
cpp
// LeetCode 15. 三数之和
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
vector<vector<int>> result;
sort(nums.begin(), nums.end());
for(int i = 0; i < nums.size(); i++) {
// 剪枝:如果第一个数大于0,后面不可能和为0
if (nums[i] > 0) {
return result;
}
// 去重a:正确方法,与前面的数比较
if (i > 0 && nums[i] == nums[i - 1]) {
continue;
}
int left = i + 1;
int right = nums.size() - 1;
while (right > left) {
if (nums[i] + nums[left] + nums[right] > 0) {
right--;
} else if (nums[i] + nums[left] + nums[right] < 0) {
left++;
} else {
result.push_back(vector<int>{nums[i], nums[left], nums[right]});
// 去重b和c:找到答案后再去重
while (right > left && nums[right] == nums[right - 1]) right--;
while (right > left && nums[left] == nums[left + 1]) left++;
// 找到答案时,双指针同时收缩
right--;
left++;
}
}
}
return result;
}
};
关键点:
- 去重a:
i > 0 && nums[i] == nums[i - 1](与前面的数比较) - 去重b和c:找到答案后再去重,避免漏掉如[0,0,0]的情况
- 剪枝:如果第一个数大于0,直接返回
模板2:四数之和
适用场景:四数之和,需要两层循环+双指针
模板代码:
cpp
// LeetCode 18. 四数之和
class Solution {
public:
vector<vector<int>> fourSum(vector<int>& nums, int target) {
vector<vector<int>> result;
sort(nums.begin(), nums.end());
for(int k = 0; k < nums.size(); k++) {
// 一级剪枝
if (nums[k] > target && nums[k] >= 0) {
break;
}
// 去重nums[k]
if (k > 0 && nums[k] == nums[k - 1]) {
continue;
}
for(int i = k + 1; i < nums.size(); i++) {
// 二级剪枝
if (nums[k] + nums[i] > target && nums[k] + nums[i] >= 0) {
break;
}
// 去重nums[i]
if (i > k + 1 && nums[i] == nums[i - 1]) {
continue;
}
int left = i + 1;
int right = nums.size() - 1;
while (right > left) {
// 注意:防止溢出
if ((long) nums[k] + nums[i] + nums[left] + nums[right] > target) {
right--;
} else if ((long) nums[k] + nums[i] + nums[left] + nums[right] < target) {
left++;
} else {
result.push_back(vector<int>{nums[k], nums[i], nums[left], nums[right]});
// 去重nums[left]和nums[right]
while (right > left && nums[right] == nums[right - 1]) right--;
while (right > left && nums[left] == nums[left + 1]) left++;
right--;
left++;
}
}
}
}
return result;
}
};
关键点:
- 两层循环:外层固定k,内层固定i
- 剪枝:一级剪枝和二级剪枝
- 溢出处理:使用long防止int溢出
- 去重:对k、i、left、right都要去重
9.5 其他技巧模板
模板:最长连续序列
适用场景:需要找到连续序列,且要求O(n)时间复杂度
核心思路:
- 使用set去重并快速查找
- 只从序列的起始数字开始查找(即num-1不在set中)
- 从起始数字向后查找连续序列
模板代码:
cpp
// LeetCode 128. 最长连续序列
class Solution {
public:
int longestConsecutive(vector<int>& nums) {
int result = 0;
unordered_set<int> uset;
// 将所有数字存入set
for(const int& num : nums) {
uset.insert(num);
}
// 只从序列的起始数字开始查找
for(const int& num : uset) {
// 如果num-1不在set中,说明num是序列的起始数字
if(!uset.count(num - 1)) {
int currentNum = num;
int currentResult = 1;
// 从起始数字向后查找连续序列
while(uset.count(currentNum + 1)) {
currentNum += 1;
currentResult += 1;
}
result = max(result, currentResult);
}
}
return result;
}
};
关键点:
- 只从起始数字查找:
!uset.count(num - 1) - 时间复杂度:O(n),虽然有两层循环,但每个数字最多被访问两次
- 空间复杂度:O(n)
10. 总结
哈希表是一种空间换时间的数据结构,通过哈希函数将键映射到数组索引,实现快速查找、插入和删除操作。
核心要点:
- 三种哈希结构:数组、Set、Map,根据场景选择
- 哈希函数:将键映射到索引,要求均匀分布
- 哈希碰撞:通过拉链法或线性探测法解决
- 选择原则:键范围小用数组,只需存在性用Set,需要键值对用Map
- 时间复杂度:平均O(1),最坏O(n)
使用建议:
- 优先使用
unordered_set和unordered_map(效率最高) - 需要有序时使用
set和map - 键范围小且连续时优先考虑数组
- 需要存储额外信息时使用Map
常见题型总结:
- 数组哈希:字母异位词、赎金信(键范围小且连续)
- Set哈希:两个数组的交集、快乐数(只需判断存在性)
- Map哈希:两数之和、四数相加II(需要键值对)
- 双指针+去重:三数之和、四数之和(需要去重的多数和)
- 其他技巧:字母异位词分组、最长连续序列