一. 哈希表理论基础
文章讲解:https://programmercarl.com/%E5%93%88%E5%B8%8C%E8%A1%A8%E7%90%86%E8%AE%BA%E5%9F%BA%E7%A1%80.html
1.哈希表的定义
哈希表是根据关键码的值而直接进行访问的数据结构。从定义上看可能有点难以直接理解,其实举个例子就很容易明白了。数组就是一张最基本的哈希表,关键码就是它的下标索引,我们可以通过下标直接访问数组中的元素。
2.哈希表的使用场景
哈希表一般用来快速判断一个元素是否被包含在集合中。例如,查找某个学生是否在某个学校。根据前面的知识点,我们第一反应采取的方法一般都是用数组查询存储身份证号,然后遍历过去查找,这样的方法最差的情况时间复杂度为O(n),然而一所学校存储的学生信息很多(包括已经毕业的学生),这样做很耗时。因此,在这种情形下最好使用哈希表,时间复杂度只需O(1)。
3.哈希表的原理
再次以上述提到的例子为例,很明显,直接a["小王"]来查看小王是否在学校的做法是不对的,那么如何哈希表是如何实现O(1)复杂度的呢?这里就要提到哈希表非常重要的一个结构了——哈希函数。
(1)哈希函数
哈希函数的原理很简单,就是将各类数据名字映射为数字作为索引下标,然后,我们就可以直接通过这个索引下标直接访问该同学的信息。
例如,将“小李”映射为索引1,那么通过访问a[1]就可以查看小李的相关信息。
如果hashCode得到的数值大于哈希表大小(tableSize)了,怎么办?此时为了保证映射后的索引值都落在哈希表的区间,我们会对原始得到的索引值取一次模(tableSize为模数),这样就能保证得到的数值一定小于tableSize。
但是,又引入了一个新问题,我们知道很多数值除以同一个被除数得到的余数都是相同的结果,说明有很多数据都将使用同一个索引,这又该怎么解决?这里又要提到哈希表另一个非常重要的结构——哈希碰撞了。
(2)哈希碰撞
哈希碰撞就是指多个数据元素被映射到同一索引下标的情况。
两种解决方法:
①拉链法
在索引为1的地方存一个指针,该指针作为头节点,指向后面的小李,小李再指向小王。
(数据规模是dataSize, 哈希表的大小为tableSize)
其实拉链法的关键就是要选择适当的哈希表的大小,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。
②线性探测法
使用线性探测法,一定要保证tableSize大于dataSize。 我们需要依靠哈希表中的空位来解决碰撞问题。例如冲突的位置,放了小李,那么就向下找一个空位放置小王的信息。所以要求tableSize一定要大于dataSize ,要不然哈希表上就没有空置的位置来存放 冲突的数据了。如图所示(具体细节可以查阅教程或者其余博客):
4.常见的三种哈希结果
以下是代码随想录中的原文介绍:
常见的有三种哈希结构: 数组、set(集合)、map(映射)。
数组比较简单,我们主要看看一下set和map。
在C++中,set 和 map 分别提供以下三种数据结构,其底层实现以及优劣如下表所示:
集合 底层实现 是否有序 数值是否可以重复 能否更改数值 查询效率 增删效率 std::set 红黑树 有序 否 否 O(log n) O(log n) std::multiset 红黑树 有序 是 否 O(logn) O(logn) std::unordered_set 哈希表 无序 否 否 O(1) O(1) std::unordered_set底层实现为哈希表,std::set 和std::multiset 的底层实现是红黑树,红黑树是一种平衡二叉搜索树,所以key值是有序的,但key不可以修改,改动key值会导致整棵树的错乱,所以只能删除和增加。
映射 底层实现 是否有序 数值是否可以重复 能否更改数值 查询效率 增删效率 std::map 红黑树 key有序 key不可重复 key不可修改 O(logn) O(logn) std::multimap 红黑树 key有序 key可重复 key不可修改 O(log n) O(log n) std::unordered_map 哈希表 key无序 key不可重复 key不可修改 O(1) O(1) std::unordered_map 底层实现为哈希表,std::map 和std::multimap 的底层实现是红黑树。同理,std::map 和std::multimap 的key也是有序的)。
在哈希值较小,且范围较小的情况下,就用数组。当我们要使用集合来解决哈希问题的时候,优先使用unordered_set,因为它的查询和增删效率是最优的,并且会对数据去重,如果需要集合是有序的,那么就用set,如果要求不仅有序还要有重复数据的话,那么就用multiset。
如果要存两个数据,就要用map,map 是一个key value 的数据结构,map中,对key是有限制,对value没有限制的,因为key的存储方式使用红黑树实现的。
总结:
当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法。但是哈希法也是牺牲了空间换取了时间,因为我们要使用额外的数组,set或者是map来存放数据,才能实现快速的查找。如果在做面试题目的时候遇到需要判断一个元素是否出现过的场景也应该第一时间想到哈希法!
二. LeetCode242.有效的字母异位词
题目链接/文章讲解/视频讲解:https://programmercarl.com/0242.%E6%9C%89%E6%95%88%E7%9A%84%E5%AD%97%E6%AF%8D%E5%BC%82%E4%BD%8D%E8%AF%8D.html
状态:已解决
1.思路
这道题以前做过类似的,当时不知道这是哈希的做法,只觉得是一个巧妙的映射。
类似这种要存储数据并且之后要根据数据名统计每个数据的信息的题,我们总是期望能够直接通过数据的名字直接访问到该数据的信息,因此提出了哈希表这样的快速查询机制。数据名字一般是很多个字符组成的字符串,因此无法直接作为索引下标,需要使用哈希函数,但这道题,每个数据的名字是单个字符,而在计算机中,单字符与数字之间天然是有映射关系的——ASCII表(字符在计算机中是以数值存放的)。例如,将每个字符出现的次数用数组numS存取,那么要访问字母c出现的次数,只需要用numS['c'-'a']即可(小写字符在ASCII表中的十进制值为97~122,将它转换为0~25,只需要将该字符减去a的ASCII码即可)。因此,这道题就很好解决了。用一个数组即可,先遍历其中一个字符串,然后将对应字符的numS值+1即可,然后再遍历另一个字符串,将对应字符的numS值-1,最后检查numS每个元素的值是否为0即可。
2.代码实现
我的版本(用了两个数组分别存放两个字符串26个字母出现的次数,然后挨着判等,比代码随想录给的代码复杂些):
class Solution {
public:
bool isAnagram(string s, string t) {
vector<int> numS(26,0);
vector<int> numT(26,0);
for(int i=0;i<s.size();i++){
numS[s[i]-'a']++;
}
for(int j=0;j<t.size();j++){
numT[t[j]-'a']++;
}
int k=0;
while(k<26 && numS[k] == numT[k]){//k<26必须先判断,否则会报错数组越界
k++;
}
if(k < 26) return false;
else return true;
}
};
文章代码:
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;
}
};
时间复杂度:O(n)
空间复杂度:O(1)
以下情况不适合用数组:哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费。
三. LeetCode349. 两个数组的交集
题目链接/文章讲解/视频讲解:https://programmercarl.com/0349.%E4%B8%A4%E4%B8%AA%E6%95%B0%E7%BB%84%E7%9A%84%E4%BA%A4%E9%9B%86.html
状态:已解决
1.思路
因为题目本质还是查看一个元素是否出现在另一个函数中,因此还是考虑哈希的做法。由于这题数据范围是在1000内,因此用数组也可以,但题目输出结果中的每个元素一定是唯一的,也就是说输出的结果的去重的, 同时可以不考虑输出结果的顺序,因此用数组没有unordered_set方便(自动去重)。
大致思路:让nums1转成unordered_set数据类型,该数据类型可以去重,并且有find(key)方法可以根据key值直接查找元素,故可以我们只需要遍历nums2,然后查找是否每个元素都在nums1里面出现即可。
2.代码实现
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> num1_set(nums1.begin(),nums1.end());
unordered_set<int> result;
for(int num:nums2){
if(num1_set.find(num)!=num1_set.end())//unordered_set的find(key)函数的解释:查找以值为 key 的元素,如果找到,
//则返回一个指向该元素的正向迭代器;反之,则返回一个指向容器中最后一个元素之后位置的迭代器(如果 end() 方法返回的迭代器)。
{
result.insert(num);
}
}
return vector<int>(result.begin(),result.end());
}
};
时间复杂度:O(m+n),m是最后将结果从unorder_set转换成vector。
空间复杂度:O(n) 根据nums分配。
四. LeetCode 202. 快乐数
题目链接/文章讲解:https://programmercarl.com/0202.%E5%BF%AB%E4%B9%90%E6%95%B0.html
状态:已解决
1.思路
这道题我看到的第一反应是没有想法!!做不来,看了文章的第一句话“无限循环,那么也就是说求和的过程中,sum会重复出现,这对解题很重要!”就悟了!!!太妙了!!!
明白了达不到1无限循环其实就是sum会重复出现后,这道题就很简单了——本质还是查找一个元素(新的sum)在所有出现过的sum集合里面是否存在。虽然这道题无需去重,但还是使用unordered_set较好(效率很高)。这样看这道题几乎没有难度,稍微复杂点的就是位求和了。简单来说,就是不断循环求新得到的sum的位平方和,然后先在已有的sum集合里面查找看是否已经存在,若存在,说明该sum是重复元素,也就说明原始数值不可能得到平方和为1的情况,因此返回false,若sum为1了,就直接返回true;
2.代码实现
class Solution {
public:
int sumNum(int num){
int sum = 0;
while(num){
sum += (num%10)*(num%10);
num /= 10;
}
return sum;
}
bool isHappy(int n) {
unordered_set<int> allSum;
int sum = sumNum(n);
while(allSum.find(sum) == allSum.end() && sum!=1){
allSum.insert(sum);
sum = sumNum(sum);
}
if(sum == 1) return true;
else return false;
}
};
时间复杂度:O(logn)
空间复杂度:O(logn)
五. LeetCode 1. 两数之和
题目链接/文章讲解/视频讲解:https://programmercarl.com/0001.%E4%B8%A4%E6%95%B0%E4%B9%8B%E5%92%8C.html
状态:已解决
1.思路
这道题暴力耗时比较长且很麻烦,要求x,y,使得x+y=target,不如换个表达:遍历数组,每次遍历的元素值设为x,请问数组中是否存在y,使得y = target-x?
这样一换之后,就发现此题本质还是在求一个元素是否在一个集合中出现过。由于这道题不是输出bool型变量,而是输出相应的下标,故哈希表不仅要存元素值,还要存元素值对应的下标(涉及两个值),需要使用 key value结构来存放,key来存元素,value来存下标,那么使用map正合适。
其他两个结构在此题的局限性:
- 数组的大小是受限制的,而且如果元素很少,而哈希值太大会造成内存空间的浪费。
- set是一个集合,里面放的元素只能是一个key,而两数之和这道题目,不仅要判断y是否存在而且还要记录y的下标位置,因为要返回x 和 y的下标。所以set 也不能用。
首先明确这个map的含义:key存放已经出现过的元素值,value对应该元素值的下标。
因此,这道题实质就只需要遍历数组,然后对于每个元素值nums[i],在map里面检查是否包含target-nums[i]的元素。如果有,则输出target-nums[i]元素对应的value下标值,结束程序;如果没有,则把nums[i]和i添加到map中去,代表访问过该值。
2.代码实现
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int,int> s;
for(int i=0;i<nums.size();i++){
auto iter = s.find(target-nums[i]);
if(iter != s.end()){
return {iter->second,i};
}else{
s.emplace(nums[i],i);
}
}
return {};
}
};
空间复杂度:O(n)
时间复杂度:O(n)
六. 总结
哈希表一般用来快速判断一个元素是否被包含在集合中,要善于发现每个题目的本质考点。