哈希表基础知识
1.哈希表能解决什么问题
一般用来快速判断一个元素是否出现在集合里。
2、哈希碰撞
多个元素映射到同一个索引下标的位置上
一般哈希碰撞有两种解决方法, 拉链法和线性探测法。
拉链法
刚刚小李和小王在索引1的位置发生了冲突,发生冲突的元素都被存储在链表中。这样我们就可以通过索引找到小李和小王了
(数据规模是dataSize, 哈希表的大小为tableSize)
其实拉链法就是要选择适当的哈希表的大小,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。
线性探测法
使用线性探测法,一定要保证tableSize大于dataSize。我们需要依靠哈希表中的空位来解决碰撞问题。
例如冲突的位置,放了小李,那么就向下找一个空位放置小王的信息。所以要求tableSize一定要大于dataSize ,要不然哈希表上就没有空置的位置来存放 冲突的数据了。如图所示:
3、常见的三种哈希结构
数组
set(集合)
map(映射)
在C++中,set 和 map 分别提供以下三种数据结构,其底层实现以及优劣如下表所示:
有序指的是插入数据自动排序,默认从小到大。
set<int> s1;//创建一个set容器
set<int>::iterator pos=s1.find(30);
if(pos!=s1.end())
//如果查找得到值30
std::unordered_set底层实现为哈希表,std::set 和std::multiset 的底层实现是红黑树,红黑树是一种平衡二叉搜索树,所以key值是有序的,但key不可以修改,改动key值会导致整棵树的错乱,所以只能删除和增加。
std::unordered_map 底层实现为哈希表,std::map 和std::multimap 的底层实现是红黑树。同理,std::map 和std::multimap 的key也是有序的(这个问题也经常作为面试题,考察对语言容器底层的理解)。
当我们要使用集合来解决哈希问题的时候,优先使用unordered_set,因为它的查询和增删效率是最优的,如果需要集合是有序的,那么就用set,如果要求不仅有序还要有重复数据的话,那么就用multiset。
那么再来看一下map ,在map 是一个key value 的数据结构,map中,对key是有限制,对value没有限制的,因为key的存储方式使用红黑树实现的。
//创建map
unordered_map<int,int> m1;
//插入
m1.insert(pair<int,int>(nums[i],i));
4、总结
我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法。
但是哈希法也是牺牲了空间换取了时间,因为我们要使用额外的数组,set或者是map来存放数据,才能实现快速的查找。
一、哈希表数组(关键:题目中限制了数值的大小/个数)
使用数组来做哈希的题目,是因为题目都限制了数值的大小
1.(242)判断两个打乱顺序的字符是否相同
给定两个字符串,判断是否为字母异位词(若 s 和 t 中每个字符出现的次数都相同,则称 s 和 t 互为字母异位词。)(题中说了字符均为小写字母)
如果给一个大字符串,一个小字符串,问大字符串里字符能不能组成小字符串,和本题同样的思路。
int hash[26]={0};
//法1:
if(s.size()!=t.size()) return false;
for(char ch:s)
{
hash[ch-'a']++;
}
for(char ch:t)
{
hash[ch-'a']--;
if( hash[ch-'a']<0) return false;
}
/*此版本2可通过,但比版本1耗时
if(s.size()!=t.size()) return false;
for(int i=0;i<s.size();i++)
{
hash[s[i]-'a']++;
}
for(int i=0;i<t.size();i++)
{
hash[t[i]-'a']--;
if( hash[t[i]-'a']<0) return false;
}
*///法3:用时较长,sort函数时间复杂度为O(nlogn)
sort(s.begin(),s.end());
sort(t.begin(),t.end());
if(s==t) return true;
return false;
2.(1002)给定一个字符串数组,找出每个字符串中的公共字符
给你一个字符串数组 words ,请你找出所有在 words 的每个字符串中都出现的共用字符( 包括重复字符),并以数组形式返回。你可以按 任意顺序 返回答案。
(每个字符串只由小写字母组成,故考虑哈希表数组)
整个字符串数组中数组的出现频率是每个字符串中字符出现频率的最小值。
就是把每个字符串数组的字符的出现频率都记下来,每个字母的出现频率取最小值,最小值就是公共出现次数。
创建两个数组,第一个数组hash[26]用来记录字符串0的字符情况,第二个hashother[26]用来记录剩余字符串的字符情况。
以及以数组形式如何将数值转化为字符形式,i+‘a’;
class Solution {
public:
vector<string> commonChars(vector<string>& words) {
//整个字符串数组中字符出现的公共次数等于每个字符串中字符出现的次数的最小值
//把字符串数组分为两个部分,第一个字符串和其他字符串
int hash[26]={0};
for(int i=0;i<words[0].size();i++)
{
hash[words[0][i]-'a']++;
}
for(int i=1;i<words.size();i++)
{
int hashother[26]={0};
for(int j=0;j<words[i].size();j++)
{
hashother[words[i][j]-'a']++;
}
for(int k=0;k<26;k++)
{
hash[k]=min(hash[k],hashother[k]);
}
}
//题中说明以数组形式输出
vector<string> ans;
for(int i=0;i<26;i++)
{
while(hash[i])
{
string s(1, i + 'a'); // char -> string . string(int n,char c)//使用n个字符c初始化
ans.push_back(s);//如何将i值转化为字符
hash[i]--;
}
}
return ans;
}
};
二、哈希表set
1.(349)两个数组,求公共数字
给定两个数组 nums1 和 nums2 ,返回 它们的交集 。输出结果中的每个元素一定是 唯一 的。我们可以 不考虑输出结果的顺序 。
输入:nums1 = [1,2,2,1], nums2 = [2,2]
输出:[2]
1 <= nums1.length, nums2.length <= 1000
0 <= nums1[i], nums2[i] <= 1000
法1:
由于限制了数值大小,可按照(1002)题数组的做法去求,遍历数组求数值出现次数最小值
用数组,求出数组1的数组中数值的出现频率,同理求出数组2的,求两个数组数值出现频率的最小值,返回出现频率不为0的数值。
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
//用数组空间复杂度会高
int hash1[1001]={0};
int hash2[1001]={0};
vector<int> ans;
for(int i=0;i<nums1.size();i++)
{
hash1[nums1[i]]++;
}
for(int i=0;i<nums2.size();i++)
{
hash2[nums2[i]]++;
}
for(int i=0;i<=1000;i++)
{
hash1[i]=min(hash1[i],hash2[i]);
if(hash1[i]!=0)
{
ans.push_back(i);
}
}
return ans;
}
};
法2:
如果不限制数值大小,就无法使用数组来做哈希表了。如果哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费。
此时就要使用另一种结构体了,set ,关于set,C++ 给提供了如下三种可用的数据结构:
std::set
std::multiset
std::unordered_set
std::set和std::multiset底层实现都是红黑树,std::unordered_set的底层实现是哈希表, 使用unordered_set 读写效率是最高的,并不需要对数据进行排序,而且还不要让数据重复,所以选择unordered_set。
将数组1存入unordered_set中,遍历数组2判断其值是否出现在nums1中,如果存在,存入ans中。
注意:要求ans值不重复,用unordered_set,另外这种结构的输入方式是insert;
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> s1(nums1.begin(),nums1.end());
unordered_set<int> ans;
for(int num:nums2)
{
if(s1.find(num)!=s1.end())
{
ans.insert(num);
}
}
return vector<int>(ans.begin(),ans.end());//因为函数开头是 vector<int>
那有同学可能问了,遇到哈希问题我直接都用set不就得了,用什么数组啊。
直接使用set 不仅占用空间比数组大,而且速度要比数组慢,set把数值映射到key上都要做hash计算的。
不要小瞧 这个耗时,在数据量大的情况,差距是很明显的。
2.(202)判断是否为快乐数
编写一个算法来判断一个数 n 是不是快乐数。
「快乐数」定义为:对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和,然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。如果 可以变为 1,那么这个数就是快乐数。
如果 n 是快乐数就返回 True ;不是,则返回 False 。
输入:19
输出:true
解释:
1^2 + 9^2 = 82
8^2 + 2^2 = 68
6^2 + 8^2 = 100
1^2 + 0^2 + 0^2 = 1
在该题中,判断是否出现过,用unordered_set
class Solution {
public:
int cal(int n)
{
int sum=0;
while(n!=0)
{
sum+=(n%10)*(n%10);
n=n/10;
}
return sum;
}
bool isHappy(int n) {
int t=cal(n);
unordered_set<int> s1;
s1.insert(t);
//如果不是快乐数,会无限循环,怎么停下来?
//求和的过程中,sum会重复出现,使用哈希法,来判断这个sum是否重复出现,如果重复了就是return false
while(t)
{
t=cal(t);
if(t==1) return true;//首先判断t是否为1,不为1再选择插入或返回false;
if(s1.find(t)==s1.end())//没找到
{
s1.insert(t);
}
else
{
return false;
}
}
return false;
}
};
三、哈希表map
1.(1)给定一个t,求一数组中两数之和为t的数值下标
给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
你可以按任意顺序返回答案。
示例 1:
输入:nums = [2,7,11,15], target = 9
输出:[0,1]
解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。
刚开始的想法是用set去做,但是对于【3,2,4】,t=6的情况不适合,这种对于map很合适。
这题呢,则要使用map,那么来看一下使用数组和set来做哈希法的局限。
1、数组的大小是受限制的,而且如果元素很少,而哈希值太大会造成内存空间的浪费。
2、set是一个集合,里面放的元素只能是一个key,而两数之和这道题目,不仅要判断y是否存在而且还要记录y的下表位置,因为要返回x 和 y的下表。所以set 也不能用。
此时就要选择另一种数据结构:map ,map是一种key value的存储结构,可以用key保存数值,用value在保存数值所在的下表。判断值,判断的是key值。
这道题目中并不需要key有序,选择std::unordered_map 效率更高!
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
/*
//如果用set,那么数组是【3,2,4】,target为6,返回值会是【0,1,2】,而不是【1,2】
multiset<int> s1(nums.begin(),nums.end());
vector<int> ans;
for(int i=0;i<nums.size();i++)
{
if(s1.find(target-nums[i])!=s1.end())
{
ans.push_back(i);
}
}
return ans;
*/
unordered_map<int,int> m1;
for(int i=0;i<nums.size();i++)
{
unordered_map<int,int>:: iterator iter=m1.find(target-nums[i]);
if(iter!=m1.end())//找到了
{
return {iter->second,i};
}
m1.insert(pair<int,int>(nums[i],i));
}
return {};//有返回值,但如果没有符合的,就要返回{}空
}
};
2.(454)给定一个t,求四数组中各数之和为t的次数
给你四个整数数组 nums1、nums2、nums3 和 nums4 ,数组长度都是 n ,请你计算有多少个元组 (i, j, k, l) 能满足:
0 <= i, j, k, l < n
nums1[i] + nums2[j] + nums3[k] + nums4[l] == 0
和两数之和道理一样,只不过value值变为了某数的出现频率。注意map的value值如何变化的。
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
unordered_map<int,int> m1;//key值用来放nums1+nums2,value值用来放出现次数
for(int a:nums1)
{
for(int b:nums2)
{
m1[a+b]++;
}
}
int count=0;
for(int c:nums3)
{
for(int d:nums4)
{
auto iter=m1.find(-c-d);
if(iter!=m1.end())//查找得到a+b+c+d=0的值
{
count+=iter->second;
}
}
}
return count;
}
};
2.(15)(三数之和)给定一个t,求一数组中和为t的三个元素
这种情况,用双指针法更合适,一个for循环,一个指针指向i,一个指针指向i+1,另一个指针指向数组尾部,
拿这个nums数组来举例,首先将数组排序,然后有一层for循环,i从下表0的地方开始,同时定一个下表left 定义在i+1的位置上,定义下表right 在数组结尾的位置上。
依然还是在数组中找到 abc 使得a + b +c =0,我们这里相当于 a = nums[i] b = nums[left] c = nums[right]。
接下来如何移动left 和right呢, 如果nums[i] + nums[left] + nums[right] > 0 就说明 此时三数之和大了,因为数组是排序后了,所以right下表就应该向左移动,这样才能让三数之和小一些。
如果 nums[i] + nums[left] + nums[right] < 0 说明 此时 三数之和小了,left 就向右移动,才能让三数之和大一些,直到left与right相遇为止。
时间复杂度:O(n^2)。
注意优化:
1、排序后,如果nums[i]大于0,直接break;
(此举执行用时直接减少16ms;)
2、考虑去重
(1)i去重
(2)right/left去重
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
sort(nums.begin(),nums.end());
vector<vector<int>> ans;
if(nums.size()<3) return ans;
for(int i=0;i<nums.size();i++)
{
if(nums[i]>0) break;
//i有重复
if(i>0 && nums[i]==nums[i-1])
{
continue;
}
int right=nums.size()-1;
int left=i+1;
while(right>left)
//三元组必须有left/right两个数,所以不能写等于
{
if(nums[i]+nums[left]+nums[right]>0)
{
right--;
}
else if(nums[i]+nums[left]+nums[right]<0)
{
left++;
}
else
{
ans.push_back(vector<int>{nums[i],nums[left],nums[right]});
//right/left有重复
while(right>left && nums[right]==nums[right-1])
{
right--;
}
while(right>left && nums[left]==nums[left+1])
{
left++;
}
right--;
left++;
}
}
}
return ans;
}
};
3.(18)(四数之和)给定一个t,求一数组中和为t的四个元素
给你一个由 n 个整数组成的数组 nums ,和一个目标值 target 。请你找出并返回满足下述全部条件且不重复的四元组 [nums[a], nums[b], nums[c], nums[d]] (若两个四元组元素一一对应,则认为两个四元组重复):
0 <= a, b, c, d < n
a、b、c 和 d 互不相同
nums[a] + nums[b] + nums[c] + nums[d] == target
你可以按 任意顺序 返回答案 。
示例 1:
输入:nums = [1,0,-1,0,-2,2], target = 0
输出:[[-2,-1,1,2],[-2,0,0,2],[-1,0,0,1]]
方法同上,只不过多加了一个for循环,另外有2点需要注意:
1、由于target为任意值,之前的判断不适用。
//if(nums[i]>target) break; 因为target是任意值,
//如果target为负数,例如target=-8,nums=[-3,-3,-2,0]
2、考虑四数相加溢出的情况
// if(nums[i]+nums[j]+nums[left]+nums[right]>target)
改为if(nums[i]+nums[j]>target-nums[left]-nums[right])
总结
1、什么时候用数组?
一般都是给定的nums或者sting大小范围确定,例如只由小写字母组成或者确定大小的数组组成时,考虑用数组;
2、求公共字符或者数组一定要记住:
整个字符串数组中数组的出现频率是每个字符串中字符出现频率的最小值。
这句话同样适用于数值。
可用于判断两个打乱顺序的字符是否相同,用于判断A字符集和是否包含B字符,适用于求公共字符、公共数字。
3、求一个数组里面多数之和是否为t,考虑双指针法。
4、求多个数组里面每个数组里的一个数之和是否为t,考虑用map或者set查找。