哈希表全攻略
虽然没有学习过哈希表的底层知识,但第一次接触这个概念,觉得python中的dict和set容器与之概念相近,但本篇blog以c++的算法实现为主。c++中涉及哈希表的数据结构包括了数组、set和map,对应的容器为std::vector,std::unordered_set,std::unordered_map。后两者优先被使用,因为它们的底层是哈希表,查询和增删效率是最优的,若需要集合或key有序,则使用std::set,std::map。若不仅要求有序,还要求有重复数据的话,就用std::multiset,std::multimap。
哈希表的「三数之和」费时超久,噩梦开始的地方!
哈希表三大注意点?
- 哈希表常用来快速判断一个元素是否出现在集合中,
O(1)的时间复杂度一般就可以实现,但是代价是用空间换取了时间。
哈希表理论基础
哈希表(Hash Table)
哈希表是根据关键码的值而直接访问的数据结构 (又被称为Hash Table,散列表)。
其实数组就是一张哈希表,哈希表中关键码就是数组的索引下标,然后通过下标直接访问数组中的元素。可以有效降低时间复杂度。
如查询一个名字是否出现在这所学校里:初始化把这所学校里学生的名字都存在哈希表里,在查询的时候通过索引直接就可以知道这位同学在不在这所学校里。将学生姓名映射到哈希表上就涉及到了hash function。
哈希函数(Hash Function)
哈希函数,就是把学生的姓名直接映射为哈希表上的索引。如下图所示,通过hashCode把名字转化为数值,一般hashCode是通过特定编码方式,可以将其他数据格式转化为不同的数值,这样就把学生名字映射为哈希表上的索引数字了。
在做哈希函数映射的时候,会出现两种情况:
- hashCode得到的数值大于哈希表的大小?--> 会对数值重新做一个取模的操作
- 学生的数量大于哈希表的大小,使得几位学生的名字同时映射到哈希表同一个索引下表的位置 --> 哈希碰撞
哈希碰撞Collisions
在下图中,两位同学都被映射到了同一个下标位置上,这一现象叫做哈希碰撞。
一般将数据规模称为dataSize,哈希表大小称为tableSize。
一般哈希碰撞有两种解决方法,拉链法和线性探测法。
拉链法
将发生冲突位置的元素存储在链表中,通过索引找到小李和小王。
拉链法就是要找到适当的哈希表大小,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。
线性探测法
前提是tableSize>dataSize,即哈希表中有空位。例如冲突的位置放了小李,那么就向下找一个空位来放置小王的信息,所以一定要求tableSize大于dataSize。
常见的三种哈希结构
- 数组
- set(集合)
- map(映射)
在c++中,set和map分别提供了以下三种数据结构,其底层实现及其优劣势如下表: 其中,红黑树作为一种平衡二叉搜索树,所以key值是有序的,但key不可以修改,改动key值会导致整棵树的错乱,所以只能删除和增加。
| 集合 | 底层实现 | 是否有序 | 数值是否可以重复 | 能否更改数值 | 查询效率 | 增删效率 |
|---|---|---|---|---|---|---|
| std::set | 红黑树 | 有序 | 否 | 否 | O(log n) | O(log n) |
| std::multiset | 红黑树 | 有序 | 是 | 否 | O(log n) | O(log n) |
| std::unordered_set | 哈希表 | 无序 | 否 | 否 | O(1) | O(1) |
| 映射 | 底层实现 | 是否有序 | 数值是否可以重复 | 能否更改数值 | 查询效率 | 增删效率 |
|---|---|---|---|---|---|---|
| std::map | 红黑树 | key有序 | key不可重复 | key不可修改 | O(log n) | O(log n) |
| std::multimap | 红黑树 | key有序 | key可重复 | key不可修改 | O(log n) | O(log n) |
| std::unordered_map | 哈希表 | key无序 | key不可重复 | key不可修改 | O(1) | O(1) |
Leetcode相关题目及解法要义
242. 有效的字母异位
此题解题思路在于为26个小写字母创建一个长度为26的arr数组,并在对应位置上进行次数的加减运算,判断最后状态时的位置数值。 注意c++创建数组的写法,当{}内数据长度不足数组长度时,用0填充。 此外,在遍历字符串s的时候,只需要得到s[i] - 'a' 的相对值即可,不需要记住字符a的ASCII。
class Solution {
public:
bool isAnagram(string s, string t) {
int record[26] = {0};
for (int i = 0; i < s.size(); i++) {
// 并不需要记住字符a的ASCII,只要求出一个相对数值就可以了
record[s[i] - 'a']++;
}
for (int i = 0; i < t.size(); i++) {
record[t[i] - 'a']--;
}
for (int i = 0; i < 26; i++) {
if (record[i] != 0) {
// record数组如果有的元素不为零0,说明字符串s和t 一定是谁多了字符或者谁少了字符。
return false;
}
}
// record数组所有元素都为零0,说明字符串s和t是字母异位词
return true;
}
};
在使用python解题时,需要用ord获得字符a的ASCII值,并计算。或者使用collections中的defaultdict,该函数可以让dict不存在的key默认初始化的value为0。
# python解法1
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
record = [0] * 26
for i in range(len(s)):
#并不需要记住字符a的ASCII,只要求出一个相对数值就可以了
record[ord(s[i]) - ord("a")] += 1
print(record)
for i in range(len(t)):
record[ord(t[i]) - ord("a")] -= 1
for i in range(26):
if record[i] != 0:
#record数组如果有的元素不为零0,说明字符串s和t 一定是谁多了字符或者谁少了字符。
return False
return True
# python解法2
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
from collections import defaultdict
s_dict = defaultdict(int)
t_dict = defaultdict(int)
for x in s:
s_dict[x] += 1
for x in t:
t_dict[x] += 1
return s_dict == t_dict
349. 两个数组的交集
如果哈希值比较少,比较分散的话,就不适合用数组进行处理,数组处理会带来空间的极大浪费。
此时,需要使用set相关结构体,包括std::set,std::multiset,std::unordered_set。
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
// 这个题目需要用到 std:: unordered set容器,之前没有用到过;
// set的添加用.insert
// set的元素个数统计为.count(key)
// set的查找用.find(key),返回的是迭代器,若存在,返回该键的元素的迭代器;若不存在,返回set.end();
unordered_set<int> resultSet;
// 在vector中放数据,用的是
// vector<int> v;
// v.push_back();
// 每个容器都有自己的迭代器,迭代器是用来遍历容器中的元素
// v.begin() 返回迭代器,这个迭代器指向容器中第一个数据;
// v.end() 返回迭代器,这个迭代器指向容器元素的最后一个元素的下一个位置
// vector<int>:: iterator 拿到vector<int>这种容器的迭代器类型;
unordered_set<int> nums1Set(nums1.begin(), nums1.end()); // 这个是unordered set容器初始化的写法吧...,传入一个开始和终止的迭代器
for (int num: nums2){ // 这个for的写法是第一次见,类似 in
if (nums1Set.find(num) != nums1Set.end()){ //若不是最后一个元素下一个位置的返回,就说明这个num在nums1_set中找到了
resultSet.insert(num);
}
}
return vector<int>(resultSet.begin(), resultSet.end()); // 也就是不定义变量名称直接返回
}
};
202. 快乐数
此题需要掌握各个位上的单数之和的计算技巧 n%10,即用%计算模,再n/=10。
其次,需要明确进入无限循环的条件,此时直接退出循环,返回false。
class Solution {
public:
// 取数值各个位上的单数之和
int getSum(int n) {
int sum = 0;
while (n) {
sum += (n % 10) * (n % 10);
n /= 10;
}
return sum;
}
bool isHappy(int n) {
unordered_set<int> set;
while(1) {
int sum = getSum(n);
if (sum == 1) {
return true;
}
// 如果这个sum曾经出现过,说明已经陷入了无限循环了,立刻return false
if (set.find(sum) != set.end()) {
return false;
} else {
set.insert(sum);
}
n = sum;
}
}
};
1. 两数之和
作为力扣的第一题,可以使用时间复杂度为O(n^2)的暴力解法。由于哈希法的提示点在于:当我们需要查询一个元素是否出现过,或者一个元素是否在集合里的时候,就要使用哈希法解决。
此题,则可以用一个set/map存储所有出现过的元素,然后去查询是否曾经在此出现过。又由于需要查询对应下标,需要使用key value结构来存放,因此,选择map。
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
// 这个题目的关键在于用一个map作为出现过的数字的索引字典
// map中所有元素都是pair;
// 常见的有四种插入方式,可以用最简单的m.insert(pair<int, int>(1,10))
std::unordered_map<int, int> show_map; //已经出现的做索引记录,key为数值,value为索引
unordered_set<int> recordSet; // 避免返回的索引重复
int id = 0;
for (int num: nums){
// 若出现在了show_map的key中,则返回
cout << num << " "<< id << endl;
if (show_map.find(target-num) != show_map.end()){
recordSet.insert(id);
cout << show_map.find(target-num)->second << " " << id << endl; //
recordSet.insert(show_map[target-num]); //c++中如何在map中返回key呢? 是不是会自动排序?
}
show_map.insert(pair<int, int>(num, id++)); //注意看这里的写法!是如何将set结果用vector返回的
}
return vector<int>(recordSet.begin(), recordSet.end());
}
};
454. 四数相加II
此题相对于15小题和18小题简单了很多,可以分两组进行运算,中间结果用哈希表进行存储。
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
// 分组做
// 这个题目只需要返回有几组就可以了,比较简单
std::unordered_map<int, int> m1; //先统计前两个整数数组的所有可能情况;
int answer = 0;
for (int a: nums1){
for (int b: nums2){
m1[a+b] ++ ; //这样写比较简单,说到底还是c++不熟练
// if (m1.find(a+b) == m1.end()) {
// m1.inset(make_pair(a+b,0));
// }
// else {
// m1.find(a+b)->second ++; // 记录有几组
// }
}
}
for (int c: nums3) {
for(int d: nums4){
if (m1.find(-c-d) != m1.end()){
answer += m1[-c-d];
}
}
}
return answer;
}
};
383. 赎金信
此题还是只涉及26个小写字母,可以直接用数组实现哈希表的功能。
class Solution {
public:
bool canConstruct(string ransomNote, string magazine) {
// 为什么字母不能存在map里面?但实际上只有26个字母,所以存放在26个长度的数组里会更好
int arr[26] = {0}; // 利用数组实现哈希表的做法
for (int i = 0; i < magazine.size(); i++){
std::cout << magazine[i] << std::endl;
arr[magazine[i] - 'a'] ++ ;;
} // 先用magazine向上加
for (int j = 0; j < ransomNote.size(); j++) {
arr[ransomNote[j] - 'a'] --;
}
// 若元素中出现了负数,说明不是赎金信
for (int num: arr){
if (num < 0) return 0;
}
return 1;
}
};
15. 三数之和
这个题目是梦破碎的地方,此题本来是可以用哈希表来暴力解,最后去重,但是这样做在leetcode中超时了。
注意:此题需要掌握哈希表和双指针两种解法,但是前者的去重逻辑比较细节,很容易搞错。
两种解法的时间复杂度都是O(n^2)。
哈希法C++代码:
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
// 对于数组来说,== 是用来判断内存地址是否一致,无法判断内部元素是否相同
// 但是vector可以用==来判断vector内部元素及其顺序是否完全一致的
// 这个题目单纯的用暴力解法很容易超时;
vector<vector<int>> result;
std::sort(nums.begin(), nums.end()); // 提前排序的好处在于先判断最小数是否小于0,且最后的三元组也会是排好序的
// 寻找 a+b+c = 0; 三者的索引从小到大
for (int i = 0; i < nums.size(); i++){
if (nums[i] > 0) break; // 当nums已经排好序了,后面都只会是正的;
if (i > 0 && nums[i] == nums[i-1]) continue; // 由于输出的三元组元素不重复,a去重操作;
unordered_set<int> set; // 在每次i遍历时重新生成,用来存放已经出现过的nums[j]作为备选c,等j再做遍历的时候,c若是为曾经出现过的nums[j],则为答案
for (int j = i + 1; j < nums.size(); j++){
// 注意b的去重,只有(j > i + 2 && nums[j] == nums[j-1]) 这2个约束是不够的[-2.0,1,1,2] 会忽略[-2,1,1]这个答案
if (j > i + 2 && nums[j] == nums[j-1] //只有两个连续出现,就跳过c的确定是不合理的,会忽略一些组合,所以要连续三个相同才跳过
&& nums[j] == nums[j-2]) continue;
int c = 0 - nums[i] - nums[j]; // 倒取c,并做记录,如果c出现,
if (set.find(c) != set.end()){ // 说明j在后方定位到了
result.push_back({nums[i], nums[j], c}); // 存入结果
set.erase(c); //j确定的b在继续向后的过程中,如果连续再次遇到一个相同的b,则c会被反复使用,以[0,0,0,0,0]作为调试对象
} else {
set.insert(nums[j]); //把j遍历途中的数值存放起来,且不重复,map用的是insert,vector用的是push_back
}
}
}
return result;
}
};
双指针法c++代码
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
// 利用双指针法解题,主要思想就是利用left,right去做的逼近
vector<vector<int>> result;
std::sort(nums.begin(), nums.end()); // 先必须要做排序
for (int i = 0; i < nums.size(); i++){
if (nums[i] > 0 ) return result; // 这个时候a的向后滑动就没有意义了
// 需要对a进行去重的处理
if (i > 0 && nums[i] == nums[i-1]){
continue; // 这里需要记住一个反例[-1,-1,0,2],所以是跟前一个比,而不是跟后一个比
}
int left = i + 1;
int right = nums.size()-1; //注意这里的写法,是右闭的
// 这里的思想就是若是left+right+nums[i] > 0 right就向左移动,同理<0,left就向右移动
while(left < right){ // 接近二分法的思路
if (nums[i] + nums[left] +nums[right] < 0){
left++;
} else if (nums[i] + nums[left] +nums[right] > 0){
right--;
} else {
cout << i <<endl;
result.push_back({nums[i], nums[left], nums[right]});
// 不能先去重, [0,0,0,0]就会没有答案了
// 双指针先去重到最后一个位置,如[-1,-1,-1,0,0,1,1,2] left应该到第三个-1
while (left < right && nums[left] == nums[left+1]){
left++;
} //如果不加上left<right约束的话,[0,0,0]会报错,因为left+1的索引会超出nums的长度范围
while (left < right && nums[right] == nums[right-1]){
right--;
}
left++;
right--;
}
}
}
return result;
}
};
18. 四数之和
此题还是用双指针法,只是在三数之和的外面再加一层for循环。
整体思想就是两层for循环nums[k] + nums[i]为确定值,依然是循环内用left和right做双指针,找出nums[k] + nums[i] + nums[left] + nums[right] == target的情况,时间复杂度为O(n^3)。
class Solution {
public:
vector<vector<int>> fourSum(vector<int>& nums, int target) {
// 用双指针方法解
std::sort(nums.begin(), nums.end()); //先排序
vector<vector<int>> result;
for (int i = 0; i < nums.size(); i++){ // 开始遍历第一个元素a
if (nums[i] > target/4) { //注意!!这里对a的剪枝操作
break;
}
if (i > 0 && nums[i] == nums[i-1]){// 做a的去重
continue; //跳过该元素,因为这个元素在a位置的解答已经完成
}
// cout << "**************" << nums[i] << "********************" << endl;
for (int j = i + 1; j < nums.size(); j++){ //开始遍历第二个元素b
cout << nums[j] << endl;
if (j < nums.size() - 3 && nums[i]+nums[j] > target/2) { // 注意这里对b的剪枝操作, 后面跟break,中止这个b的循环,而不是返回
break;
};
if (j > i+1 && nums[j] == nums[j-1]){ // b的去重
continue;
}
int left = j + 1;
int right = nums.size() - 1; //右闭合
while(left < right){
if ((long) nums[i]+nums[j]+nums[left]+nums[right] > target){ //注意这里的类型,防止溢出,测试用例
//[-1000000000,-1000000000,-1000000000,-1000000000]
// -1
// right左移
right --;
} else if ((long) nums[i]+nums[j]+nums[left]+nums[right] < target){ // 注意这里对(long)的使用,似乎c++中对于计算结果的类型转换需要用括号(long) 而不是 long
left++;
} else { // 也就是出现了==target的情况
result.push_back({nums[i],nums[j],nums[left],nums[right]});
// 避免c,d的重复
while(left < right && nums[left]==nums[left+1]){
left ++;
}
while(left< right && nums[right]==nums[right-1]){
right--;
}
left++;
right--;
}
}
}
}
return result;
}
};