哈希表理论基础
1、什么是哈希表?
哈希表是一种数据结构,根据关键码值(key value)进行之直接访问,关键码值映射到表中一个位置来访问记录,以加快查找的速度。简单来说,数组就是一种哈希表。
2、什么情况下可以使用哈希表?
当我们要快速判断一个元素是否出现集合中,则可以使用哈希表。
3、哈希函数的过程
输入的name首先通过hashCode转化为数值,然后对哈希表长度取余,这就得到了在哈希表中的index值。而如果学生数量大于哈希表大小,则会出现不同名字映射到同一个索引下标的情况,这就是哈希碰撞。
4、哈希碰撞的解决方法
1)拉链法
简单来说,就是在发生冲突的位置,将冲突元素存储在链表中,通过索引分别找到冲突元素。
2)线性探测法
这种方法简单来说就是当发生冲突时,先把一个放在当前位置,另一个则向下查找空位存放,所以这种方法就要求tablesize必须要大于datasize。
5、常见的三种哈希结构
三种哈希结构:数组、set(数组)、map(映射)
集合 | 底层实现 | 是否有序 | 数值是否可以重复 | 能否更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
std::set | 红黑树 | 有序 | 否 | 否 | O(log n) | O(log n) |
std::multiset | 红黑树 | 有序 | 是 | 否 | O(logn) | O(logn) |
std::unordered_set | 哈希表 | 无序 | 否 | 否 | O(1) | O(1) |
映射 | 底层实现 | 是否有序 | 数值是否可以重复 | 能否更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
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) |
unordered_map与unordered_set的主要区别在于:
1、set中只有键(key),而map包含键值对(key value)
2、set中的元素是无序不重复的,map中的键key是无序不重复的,但是值是可以重复的(键值对不重复即可)。
242.有效的字母异位词
题目链接:242. 有效的字母异位词 - 力扣(Leetcode)
视频链接:学透哈希表,数组使用有技巧!Leetcode:242.有效的字母异位词_哔哩哔哩_bilibili
解体思路:
字母异位词的含义是:除了字母顺序不同,所有的字母个数和字母类型都一样。
因为本题的元素值有限,所以可以使用数组,将长度为26的数组下标分别对应26个字母,数组值对应字母出现的次数。遍历S字符串,当出现一个字母就在数组中字母对应位置++,再遍历T字符串,同样出现一个字母就在相应位置–,最后判断hash数组是否为0,则可以知道两个字符串是否是除了顺序之外完全一样了。
本题有一个关键点在于:如何将26个字母分别对应到hash数组的位置,因为字母都是小写的,所以ASCII码是连续的,那么就可以利用ASCII码的相对位置来对应。hash[s[i] - ‘a’]通过再数组下标引用的位置用字母相减的方法,就能计算出两个字母ASCII码的相对距离,这样就把字母a默认为了index=0的位置,后续字母依次对应。
总结:本题可知使用hash数组的常规用法,遍历容器,然后将容器中的元素对应的数组的每个位置,出现一次就在对应位置++;
class Solution { //哈希表法
public:
bool isAnagram(string s, string t) {
int hash[26]={0}; //数组本身就是一种hash表,创建一个26位的数组
for(int i = 0;i<s.size();i++){ //遍历s字符串
hash[s[i] - 'a']++; //s[i]是s字符串第i位的字母,作为下标括号里的运算时候,两个字母编译器会自动取他们的ASCII码值计算
} //所以这里默认就把“a”当作了是index=0的位置,当s[i]=“a”,此时index=0,所以hash表的第一位+1,以此类推
for(int j = 0;j<t.size();j++){ //遍历t字符串
hash[t[j] - 'a']--; //在s字符串遍历完以后的hash基础上,遇到一个字母就-1
}
for(int k = 0;k<26;k++){
if(hash[k] != 0)return false; //如果两个字符串所组成的字母和个数完全相同,那么++--之后是结果应该是hash全为0,如果不为0那么就一定不同
}
return true; //如果没有返回false,那么就肯定是相同了,所以返回true
}
};
349. 两个数组的交集
题目链接:349. 两个数组的交集 - 力扣(Leetcode)
视频链接:学透哈希表,set使用有技巧!Leetcode:349. 两个数组的交集_哔哩哔哩_bilibili
解体思路:
方法一、数组法
因为本题给出了元素格式限制,所以可以使用数组进行解决。
同样的操作,先把第一个容器中所有元素对应到hash数组中的每个位置,然后遍历,对应位置+1。然后遍历另一个容器,如果对应位置有值,就把该位置对应的元素塞入结果容器,因为只需要元素内容即可,且输出元素去重,不考虑顺序和下标等问题,所以可以使用unordered_set作为结果容器。
class Solution { //使用hash数组解决
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> result; //定义用于存储结果的set
int hash[1002]={0}; //根据提示可知nums1和nums2的长度和数值都不会大于1000,所以定义一个长度比1000大一点的数组
for(int num: nums1){ //遍历nums1,并把当前遍历的元素赋值给num
hash[num]=1; //因为nums1数值都不会大于1000,所以在定义的hash数组中必定能找到num作为下标对应的位置
} //也就是说如果nums1中出现了num这个数字,就在hash数组中index为num的位置标一个1表示有这个数
for(int num: nums2){ //遍历nums2,并把当前nums2赋值给num
if(hash[num]==1){ //如果hash数组中index=num的位置有值,则表示nums1中也有num这个数字
result.insert(num); //也就是说nums1和nums2中都有num这个数,所以是交际,把num存入result中
}
}
return vector<int>(result.begin(),result.end()); //将result结果利用迭代器村民如vector中
}
};
方法二、set法
如果本题的容器长度未知,可能非常大的情况下就不能使用数组了,而又因为本题中交集元素输出的结果是去重的(例如两个容器中都有两个2,输出结果只输出一个2)这时就可以使用unordered_set先把一个容器的数放入其中(因为这里直接去重也并不影响),然后遍历另一个容器,在set中查找当前遍历的这个元素是否存在,如果存在就把该元素塞入结果容器中,同样的结果容器需要去重,所以也是用unordered_set。
class Solution { //使用set解决
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> result; //声明一个用于存储交集结果的set,unordered_set可以去重,且不在乎顺序
unordered_set<int> nums(nums1.begin(),nums1.end()); //用迭代器遍历nums1容器,把所有元素存入nums这个set中
for( auto num: nums2){ //C++11新特性:基于范围的 for 循环,遍历nums2的内容,同时将当前被遍历的元素存储到“:”前的声明num中
if(nums.find(num) != nums.end()){ //find函数的返回值为迭代器或指针,因为end()不在查找范围,所以如果没查找到就返回end()
result.insert(num); //所以find函数经常使用返回是否等于end来判断是否查找成功
} //查找成功则将当前num存入result中
}
return vector<int>(result.begin(),result.end()); //利用迭代器将unordered_set存入vector中
}
};
202. 快乐数
题目链接:202. 快乐数 - 力扣(Leetcode)
解体思路:
快乐数定义:对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和,然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。如果 可以变为 1,那么这个数就是快乐数。
方法一、使用hash数组
第一个关键点:如果计算一个数各个数位上数字的平方和。通过对10取余得到个位数,计算平方,然后再除以10去掉个位数(因为是整形),如此循环,直到最高位/10之后变成小数,整形变换之后为0就跳出循环了。
根据本题给的数字n范围,可知其最大为10位数,而10位数的最大各位数字平方和为810,所以数组只要比810大一点,就可以存入所有的平方和数字。通过while循环迭代计算平方和,其结果根据题目所说只有两种情况:循环和等于1。每次计算后将结果存入数组中,如果当前计算的sum在数组中出现过了那么说明就进入循环了,输出false。
class Solution { //使用数组来解决
public:
int get_num(int n){ //计算一个数字各个位置上的数字平方和
int sum=0;
while(n>0){
sum += (n%10) *(n%10); //n%10表示数字对10取余数也就是个位数,sum加上个位数的平方
n /=10; //n/10表示去除掉个位数,这样循环,就可以从个位开始,十位百位千位,遍历所有的数位计算平方和
}
return sum; //返回本次计算和sum
}
bool isHappy(int n) {
int hash[815]={0}; //因为n的范围为2^31-1,也就是10位数,取10位数上全为9,则最大的平方和为9^2*10=810;所以数组比810大一点即可
int sum = get_num(n); //计算sum
while(sum !=1){ //如果sum不为1进入循环
if(hash[sum]==1)return false; //如果hash数组的index=sum位置=1即sum这个数曾经出现过,则表示要进入循环了,输出false
else hash[sum]++; //如果该位置没有数,则把这一位填1,表示出现了sum
sum = get_num(sum); //迭代计算下一个sum
}
return true; //sum为1直接输出true
}
};
方法二、set法
根据进入循环的意思我们可以知道,就是同一个平方和再次出现,那么我们就可以用unordered_set存放每次平方和,然后迭代计算进行查找,如果在集合中找到了本次的sum则说明进入了循环,则输出false,如果没找到就把本次sum存入set中。
class Solution { //使用unordered_set来解决
public:
int get_num(int n){ //计算一个数字各个位置上的数字平方和
int sum=0;
while(n>0){
sum += (n%10) *(n%10); //n%10表示数字对10取余数也就是个位数,sum加上个位数的平方
n /=10; //n/10表示去除掉个位数,这样循环,就可以从个位开始,十位百位千位,遍历所有的数位计算平方和
}
return sum; //返回本次计算和sum
}
bool isHappy(int n) {
unordered_set<int>hash; //定义一个int类型的unordered_set
int sum=get_num(n); //调用计算平方和函数
while(sum !=1){ //如果sum不为1,则进入循环
if(hash.find(sum) !=hash.end())return false; //通过find函数查找set中有没有当前的sum,如果有sum则表示sum计算重复了,后续将进入循环
else hash.insert(sum); //如果没找到,则将该sum存入set中
sum = get_num(sum); //迭代计算下一个sum
}
return true; //如果最终计算sum为1,则直接返回true
}
};
1. 两数之和
题目链接:1. 两数之和 - 力扣(Leetcode)
视频链接:梦开始的地方,Leetcode:1.两数之和,学透哈希表,map使用有技巧!_哔哩哔哩_bilibili
解题思路:
方法一、两层for循环暴力法
class Solution { //两层for循环暴力求解法
public:
vector<int> twoSum(vector<int>& nums, int target)
{
int i,j;
int n = nums.size();
for(int i =0;i<n-1;i++) //遍历数组nums除了最后一个的所有数字
{
for(int j =i+1;j<n;j++) //遍历i之后的所有数
{
if(nums[i]+nums[j]==target) //如果两个数相加等于目标值
{
return {i,j}; //就返回当前的i和j
}
}
}
return {i,j}; //外层for也需要一个return
};
};
方法二、map法
因为本地需要返回下标,也就是说在容器中需要存放数值和下标,所以就可以使用unordered_map来存放key和value了。
需要注意的是,unordered_map的定义方式:unordered_map<int,int> map,第一个值为key,用first指向它,第二个为value,用second指向它。
class Solution { //使用unordered_map解决
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int,int> map; //定义一个map,由一个key元素值和其对应的下标value组成都是int型
for(int i = 0;i<nums.size();i++){ //遍历数组
if(map.find(target - nums[i])!=map.end()){ //用find函数查找map中是否由和当前nums之和等于target的另一个数,这一组数对
return{map.find(target - nums[i])->second,i}; //如果有则返回这个数对的第二个值,也就是其value下标,和当前nums[i]的i
}
map.insert(pair<int,int>(nums[i],i)); //如果没有的话则把当前nums的值和下标作为key和value塞进map中,pair<,>表示一个数对
}
return {}; //如果上述for循环结束都没有return则返回一个数组,因为返回类型要是vector,
} //所以不能直接返回false或者0
};
454.四数相加II
题目链接:454. 四数相加 II - 力扣(Leetcode)
视频链接:学透哈希表,map使用有技巧!LeetCode:454.四数相加II_哔哩哔哩_bilibili
解体思路:
本题的核心思路是将四个数分为两组,这样就退化成了两数之和。因为需要同即满足要求的三元组个数,所以就可以使用unordered_map作为存储容器,它的key是数字和,而value是出现的次数,这一点和数组是十分类似的。
首先两层for循环把数字a和b相加,和作为key,出现次数作为value存入map中,然后再两层for循环,在map中寻找符合与当前c+d和为0的a+b,并把a+b出现的次数也就是map的value值赋给count。
class Solution { //用unordered_map解决
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
unordered_map<int,int> map; //本题看似是四数相加,其实可以拆成两组,每两个数之和看作一个数,则退化成了两数相加=target
for(int a :nums1){ //本题需要统计出现次数,所以有两个量需要存储,一个是和的值另一个是和的值出现的次数,所以用map
for(int b:nums2){
map[a+b]++; //两层循环遍历两个容器,把和作为key,出现的次数作为value,简单来说要求谁谁就作为value
}
}
int count = 0;
for(int c:nums3){ //遍历另外两个容器
for(int d:nums4){
if(map.find(0-(c+d)) !=map.end()){ //在map中查找与c+d之和为0的值key
count +=map[0-(c+d)]; //把符合的key对应的value值也就是出现的次数+=给count
}
}
}return count;
}
};
383. 赎金信
题目链接:383. 赎金信 - 力扣(Leetcode)
解题思路:
本题想表达的意思就是:所有的赎金信上的字母要在magazine上的字符串中能找到,并且只能使用一次。
本题与242.有效的字母异位词有相似之处,242中需要两个字符串彼此都要找到且一一对应,而本题只需要从赎金信的string到magazine的单向对应。
方法一、暴力法
两层for循环遍历两个字符串,如果有相同的字符串就删除赎金信上的字母,最后如何赎金信上的字母都被删除完了就说明在mag上都能找到赎金信上需要的字母。
需要注意的点:1、必须是magazine在外层循环;2、在内层循环符合条件后删除当前的ran字母,然后break出当前内层循环。
上述两点配合起来,才能避免一个maga字母对应多个ran字母的问题。
class Solution { //暴力法
public:
bool canConstruct(string ransomNote, string magazine) {
for (int i = 0; i < magazine.length(); i++) { //这里之所以是mag在外层是因为要让mag的每个元素只能用一次
for (int j = 0; j < ransomNote.length(); j++) { //遍历ran
if (magazine[i] == ransomNote[j]) { //如果相等
ransomNote.erase(ransomNote.begin() +j); //就删除当前位置这个元素,erase函数需要用迭代器删除,或者直接删除元素值
break; //
}
}
}
if(ransomNote.size()==0)return true; //如果赎金信元素为空则全都能找到
return false; //否则就找不全
}
};
方法二、hash数组法
因为本题又是关于字母的问题是否出现在另一个集合中的问题,所以依然可以使用哈希表解决,并且这里字母个数有限所以可以使用数组法。首先把magazine的所有字母都存入数组中,key是字母的相对序号,value是出现的次数,这步操作相当于是把mag上所有的字母都剪下来备用了。然后再遍历ran字符产,当有对应的字母就–,如果出现某一位有负数,就说明ran上需要的个数在mag中不够,则返回false,不然就返回true。
class Solution { //hash数组解法
public:
bool canConstruct(string ransomNote, string magazine) {
int hash[26]={0};
if(ransomNote.size()>magazine.size())return false; //其实这里不需要也是可以实现功能的,但是这个判据可以减少不必要的后续计算,省内存
for(int i=0; i<magazine.size();i++){ //跟有效字母异位词的思想差不多,都是先遍历一个将元素存入数组++,然后遍历另一个
hash[magazine[i]-'a']++; //数组如果有这个元素就--
}
for(int i=0 ;i<ransomNote.size();i++){
hash[ransomNote[i]-'a']--;
if(hash[ransomNote[i]-'a']<0)return false; //如果出现某一位上为负数,也就是说这个字母在ran中出现的比mag中的多,那必然无法组成
}
return true;
}
};
15. 三数之和
题目链接:15. 三数之和 - 力扣(Leetcode)
视频链接:梦破碎的地方!| LeetCode:15.三数之和_哔哩哔哩_bilibili
解题思路:
方法一、哈希法
本题乍一看似乎可以用两层for循环确定 a 和b 的数值,再使用哈希法来确定 0-(a+b) 是否在数组里出现过。该思路没有问题,但是因为题目中要求三元组不能重复,将三元组放入vector中然后进行去重操作是非常费时的,并且剪枝和去重也比较复杂,因此本题不建议使用哈希法。
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
vector<vector<int>> result;
sort(nums.begin(), nums.end());
// 找出a + b + c = 0
// a = nums[i], b = nums[j], c = -(a + b)
for (int i = 0; i < nums.size(); i++) {
// 排序之后如果第一个元素已经大于零,那么不可能凑成三元组
if (nums[i] > 0) break;
if (i > 0 && nums[i] == nums[i - 1])continue; //三元组元素a去重
unordered_set<int> set;
for (int j = i + 1; j < nums.size(); j++) {
if (j > i + 2&& nums[j] == nums[j-1]&& nums[j-1] == nums[j-2]) continue;// 三元组元素b去重
int c = 0 - (nums[i] + nums[j]);
if (set.find(c) != set.end()) {
result.push_back({nums[i], nums[j], c});
set.erase(c);// 三元组元素c去重
}
else set.insert(nums[j]);
}
}
return result;
}
};
方法二、双指针法
第一个数是外层遍历,然后再第一个数后面分别设置一个左右指针,三个数相加,如果大于0,则right指针–,如果小于0,则left指针++。
本题一个关键点:如果出现了重复的数字,那么意味着后面会有基于该数字的同样的组合,所以同样的数字需要去除,那么如何去重呢?如果用nums[i] ==nums[i+1]来判断,那么会导致同一个三元组中有同样数字的这种情况被错误排除了(题目说的是不能有重复的三元组,但没有说一个三元组中不能有重复数字),所以要使用nums[i] ==nums[i-1],即前一个数已经判定过是否可以,那么后一个同样的数就没有再次判断的必要,是需要遍历到下一个相同的数字后再跳过。
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()-2;i++){ //遍历元素,因为后面有left和right且不相等,所以到size-2
if(nums[i]>0) break; //因为正向排序,所以如果当前元素都大于0了,那后续都为正,和就不可能为0,直接返回
if(i>0 && nums[i] ==nums[i-1]) continue; //给第一个元素去重,因为第一个元素必为负,如果重复了,那么后两个元素的选择肯定
int left = i+1; //存在重复,例如第一个为-3,则后两个可以为(0,3)/(1,2),那么下一个-3对应的也是一样的
int right = nums.size()-1; //定义左右指针,两个指针分别向中间移动
while(right>left){ //不可以相等,相等了则指向同一个元素,那么三元组的元素就相同了
if(nums[i]+nums[left]+nums[right]>0)right--; //和>0,那说明较大的数太大了,所以right--,让大数减小
else if(nums[i]+nums[left]+nums[right]<0)left++; //同理
else{ //如果和为0,符合要求
result.emplace_back(vector<int>{nums[i],nums[left],nums[right]}); //先把当前第一个找到的三元组存入结果中
while(right>left && nums[left]==nums[left+1]) left++; //然后去重复left
//while(right>left && nums[right]==nums[right-1]) right--; //这里可以对right也去重复,但其实因为left+right和为定值
//right--; //只要left不重复,那么满足的right也必定不重复,所以可以不用对right去重
left++; //left走向下一位(与上一位不同)
}
}
}
return result;
}
};
18. 四数之和
题目链接:18. 四数之和 - 力扣(Leetcode)
视频链接:难在去重和剪枝!| LeetCode:18. 四数之和_哔哩哔哩_bilibili
解题思路:
本题的解题思路和三数之和是相同的,同样是使用双指针法,只不过在外层多套了一个for循环,多一次剪枝和去重的操作;并且因为是四数相加,每个数的范围是(2^31)-1=2147483647,本题四个数相加需要考虑是否会有溢出情况,所以再相加的时候强转为double,防止相加之后溢出最大值从而变成了负数。
本题一个恶心的测试案例就是位置测试是否溢出:
[1000000000,1000000000,1000000000,1000000000]//四个数都在范围内,但是相加之后溢出,结果是-1852516353,显然是小于target
-294967296
class Solution { //双指针法,和三数之和类似
public:
vector<vector<int>> fourSum(vector<int>& nums, int target) {
vector<vector<int>>result;
sort(nums.begin(),nums.end());
for(int i=0;i<nums.size();i++){
if(nums[i]>target && nums[i]>0)break; //如果当a是大于0的(为了排除负数越加越小),且大于target
if(i>0&&nums[i] ==nums[i-1])continue; //a去重
for(int j = i+1;j<nums.size();j++){
if(nums[i] + nums[j]>target && nums[j]>0)break; //当b是大于0的(为了排除负数越加越小)。且a+b大于target
if(j>i+1&&nums[j] ==nums[j-1])continue; //b去重
int left = j+1;
int right = nums.size()-1; //定义左右指针
while(right>left){
if((double)nums[i]+nums[j]+nums[left]+nums[right]>target)right--; //四个数相加大于target
else if((double)nums[i]+nums[j]+nums[left]+nums[right]<target)left++;//这里用double强转类型,是因为四个int相加会溢出int的范围
else{
result.emplace_back(vector<int>{nums[i],nums[j],nums[left],nums[right]});
while(right>left &&nums[left]==nums[left+1])left++; //和三数之和类似只要让left去重即可,right自适应
left++;
}
}
}
}
return result;
}
};