哈希表
代码随想录刷题笔记
理论基础
哈希表是根据关键码的值而直接进行访问的数据结构。
直白来讲其实数组就是一张哈希表。
哈希表中关键码就是数组的索引下标,然后通过下标直接访问数组中的元素,如下图所示:
哈希表一般解决什么问题呢?一般哈希表都是用来快速判断一个元素是否出现集合里。
例如要查询一个名字是否在这所学校里。
要枚举的话时间复杂度是O(n),但如果使用哈希表的话, 只需要O(1)就可以做到。
我们只需要初始化把这所学校里学生的名字都存放(映射)在哈希表里,在查询的时候通过索引直接就可以知道这位同学在不在这所学校里了。
将学生姓名映射到哈希表上就涉及到了hash function ,也就是哈希函数。
哈希函数
哈希函数如下图所示,通过hashCode把名字转化为数值,一般hashcode是通过特定编码方式,可以将其他数据格式转化为不同的数值,这样就把学生名字映射为哈希表上的索引数字了。
为了保证映射出来的索引数值都落在哈希表上,我们会在再次对数值做一个取模的操作,就要我们就保证了学生姓名一定可以映射到哈希表上了。
此时问题又来了,哈希表我们刚刚说过,就是一个数组。
如果学生的数量大于哈希表的大小怎么办,此时就算哈希函数计算的再均匀,也避免不了会有几位学生的名字同时映射到哈希表 同一个索引下标的位置。
接下来哈希碰撞登场
哈希碰撞
如图所示,小李和小王都映射到了索引下标 1 的位置,这一现象叫做哈希碰撞。
一般哈希碰撞有两种解决方法, 拉链法和线性探测法。
拉链法
刚刚小李和小王在索引1的位置发生了冲突,发生冲突的元素都被存储在链表中。 这样我们就可以通过索引找到小李和小王了
(数据规模是dataSize, 哈希表的大小为tableSize)
其实拉链法就是要选择适当的哈希表的大小,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。
线性探测法
使用线性探测法,一定要保证tableSize大于dataSize。 我们需要依靠哈希表中的空位来解决碰撞问题。
例如冲突的位置,放了小李,那么就向下找一个空位放置小王的信息。所以要求tableSize一定要大于dataSize ,要不然哈希表上就没有空置的位置来存放 冲突的数据了。如图所示:
常见的三种哈希结构
当我们想使用哈希法来解决问题的时候,我们一般会选择如下三种数据结构。
- 数组
- set (集合)
- map(映射)
这里数组就没啥可说的了,我们来看一下set。
在C++中,set 和 map 分别提供以下三种数据结构,其底层实现以及优劣如下表所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hjLbd1IW-1680264369061)(…/…/…/lenovo/AppData/Roaming/Typora/typora-user-images/image-20220809123154382.png)]
std::unordered_set底层实现为哈希表,std::set 和std::multiset 的底层实现是红黑树,红黑树是一种平衡二叉搜索树,所以key值是有序的,但key不可以修改,改动key值会导致整棵树的错乱,所以只能删除和增加。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tVJfadLL-1680264369061)(…/…/…/lenovo/AppData/Roaming/Typora/typora-user-images/image-20220809123214284.png)]
std::unordered_map 底层实现为哈希表,std::map 和std::multimap 的底层实现是红黑树。同理,std::map 和std::multimap 的key也是有序的(这个问题也经常作为面试题,考察对语言容器底层的理解)。
当我们要使用集合来解决哈希问题的时候,
优先使用unordered_set,因为它的查询和增删效率是最优的,
如果需要集合是有序的,那么就用set,
如果要求不仅有序还要有重复数据的话,那么就用multiset。
那么再来看一下map ,在map 是一个key value 的数据结构,map中,**对key是有限制,对value没有限制的,**因为key的存储方式使用红黑树实现的。
其他语言例如:java里的HashMap ,TreeMap 都是一样的原理。可以灵活贯通。
虽然std::set、std::multiset 的底层实现是红黑树,不是哈希表,但是std::set、std::multiset 依然使用哈希函数来做映射,只不过底层的符号表使用了红黑树来存储数据,所以使用这些数据结构来解决映射问题的方法,我们依然称之为哈希法。 map也是一样的道理。
这里在说一下,一些C++的经典书籍上 例如STL源码剖析,说到了hash_set hash_map,这个与unordered_set,unordered_map又有什么关系呢?
实际上功能都是一样一样的, 但是unordered_set在C++11的时候被引入标准库了,而hash_set并没有,所以建议还是使用unordered_set比较好,这就好比一个是官方认证的,hash_set,hash_map 是C++11标准之前民间高手自发造的轮子。
总结
当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法。
但是哈希法也是牺牲了空间换取了时间,因为我们要使用额外的数组,set或者是map来存放数据,才能实现快速的查找。
如果在做面试题目的时候遇到需要判断一个元素是否出现过的场景也应该第一时间想到哈希法!
数组作为哈希表
使用数组来做哈希的题目,都限制了数值的大小,例如只有小写字母,或者数值大小在[0- 10000] 之内等等。但如果没有数值的限制就不能够使用数组解决。
242. 有效的字母异位词
给定两个字符串 s 和 t ,编写一个函数来判断 t 是否是 s 的字母异位词。
注意:若 s 和 t 中每个字符出现的次数都相同,则称 s 和 t 互为字母异位词。
示例 1:
输入: s = "anagram", t = "nagaram"
输出: true
示例 2:
输入: s = "rat", t = "car"
输出: false
说明: 你可以假设字符串只包含小写字母。
解题思路:
有效的字母异位词:两个字符串有相同的字母组成且所包含的各个字母的个数相同,但可以字母组成的位置不同即为异位词。(两个完全相同的字符串也是有效的)
哈希法解题:
在理论基础中介绍过可用作哈希法的三种常见的数据结构:数组(元素范围可控,且个数较少)、set(数值很大)、map(key对应value时)。具体选择可以看理论基础篇
所以从上述三种结构中选择。由于本题,字符串只包含小写字母(26个,且ASCII码是连续的)。
所以可以定义一个数组hash[26]用来存放第一个字符串中每一个字母出现的频率,然后遍历第二个字符串,在hash数组中做对应的减法;最终如果哈希数组中所有元素对应的值都为0,那么就是有效的字母异位词。
代码细节:
(1)定义哈希数组大小为26
(2)统计字符串s中所有字母出现的频率
遍历字符串s中的所有字母,将其映射到哈希数组对应位置,做++操作(hash[s[i] - ‘a’]++); 这样hash[0]对应的就是’a’
(3)统计字符串t中所有字母出现的频率
不需要新开一个哈希数组记录出现频率,而是可以遍历字符串t中的所有字母,将出现的字母对应的上一步统计的哈希数组上做 – 操作(hash[t[i] - ‘a’]–)
(4)遍历整个hash数组,判断hash数组中所有元素对应的值是否均为0,如果全为0就是有效的,否则(hash[i] != 0)为无效(return false)。
参考代码:
class Solution {
public:
bool isAnagram(string s, string t) {
int hash[26] = {0}; //定义hash数组并且初始化为0
for(int i = 0; i < s.size(); i++){
hash[s[i] - 'a']++;
}
for(int i = 0; i < t.size(); i++){
hash[t[i] - 'a']--;
}
for(int i = 0; i < 26; i++){
//hash数组中如果有元素不为0,则说明字符串中有些对应字母缺失或多余
if(hash[i] != 0){
return false;
}
}
//hash数组中所有元素都为0,则说明s和t是有效的字母异位词
return true;
}
};
相似-383. 赎金信
给你两个字符串:ransomNote 和 magazine ,判断 ransomNote 能不能由 magazine 里面的字符构成。
如果可以,返回 true ;否则返回 false 。
magazine 中的每个字符只能在 ransomNote 中使用一次。
示例 1:
输入:ransomNote = "a", magazine = "b"
输出:false
示例 2:
输入:ransomNote = "aa", magazine = "ab"
输出:false
示例 3:
输入:ransomNote = "aa", magazine = "aab"
输出:true
提示:
1 <= ransomNote.length, magazine.length <= 105
ransomNote 和 magazine 由小写英文字母组成
解题思路:
这道题和上一道242题很像,不同之处是,上一道要求严格,a、b字符串要求组成以及数量上必须严格相同。而这道题目求得是字符串a是否能组成字符串b,而不管字符串b是否能组成字符串a。
在本题中需要判断的是第一个字符串ransom能不能由第二个字符串magazines里面的字符构成。
这里要注意:magazine 中的每个字符只能在 ransomNote 中使用一次。
使用哈希解法有以下思路:
首先要考虑hash数组中记录的是ransomNote还是magazine里的字母?
由于magazine中所包含的字母可以不在ransomNote中出现(hash[magazine[i] - ‘a’] >= hash[ransomNote[i] - ‘a’]),且ransomNote在magazine中对应的字母数量必须小于或等于(hash[magazine[i] - ‘a’] >= hash[ransomNote[i] - ‘a’])。
所以可以先记录magazine中字母出现的次数,然后再用ransomNote验证哈希数组中是否包含了ransom所需要的所有字母,如果出现了在本次遍历中,如果出现了hash数组小于0的情况,那么就说明不符合条件,直接退出return false即可。
而上一道题由于条件比较苛刻,不能够出现冗余的字母,所以需要在遍历完t字符串后,对整个hash数组进行遍历,只要出现非0值(b中出现了a中没有的字母,在本题中可以有,但是在242上一题中就不能有这种情况)就return false。
参考代码:
class Solution {
public:
bool canConstruct(string ransomNote, string magazine) {
int hash[26] = {0};
//先统计magazine中字母个数
for(int i = 0; i < magazine.size(); i++){
hash[magazine[i] - 'a']++;
}
//在统计reasomNote中字母个数
for(int i = 0; i < ransomNote.size(); i++){
hash[ransomNote[i] - 'a']--;
//如果用完magazine中的字母 也就是hash数组对应值小于0,那么就说明ransomNote 不能由 magazine 里面的字符构成。
if(hash[ransomNote[i] - 'a'] < 0){
return false;
}
}
return true;
}
};
上面两道题目用map确实可以,但使用map的空间消耗要比数组大一些,因为map要维护红黑树或者符号表,而且还要做哈希函数的运算。所以数组更加简单直接有效!
49. 字母异位词分组
待解决,使用map哈希解法
438. 找到字符串中所有字母异位词
待解决
set作为哈希表
349. 两个数组的交集
给定两个数组 nums1
和 nums2
,返回 它们的交集 。输出结果中的每个元素一定是 唯一 的。我们可以 不考虑输出结果的顺序 。
示例 1:
输入:nums1 = [1,2,2,1], nums2 = [2,2]
输出:[2]
示例 2:
输入:nums1 = [4,9,5], nums2 = [9,4,9,8,4]
输出:[9,4]
解释:[4,9] 也是可通过的
提示:
1 <= nums1.length, nums2.length <= 1000
0 <= nums1[i], nums2[i] <= 1000
解题思路:
这道题目,主要要学会使用一种哈希数据结构,unordered_set,这个数据结构可以解决很多类似的问题。
注意题目特意说明:输出结果中的每个元素一定是唯一的,也就是说输出的结果的去重的, 同时可以不考虑输出结果的顺序。
这道题第一优化思路就是使用哈希表来解决,因为哈希表适用于解决给定一个元素,判断在集合中是否出现过。
具体使用哪种数据结构作为哈希表,则需要具体情况具体分析:
1、用数组做哈希表可以解决这道题目,把nums1的元素,映射到哈希数组的下标上,然后在遍历nums2的时候,判断是否出现过就可以了。
但是要注意,使用数组来做哈希的题目,都限制了数值的大小,例如只有小写字母,或者数值大小在[0- 10000] 之内等等。
本题中:0 <= nums1[i], nums2[i] <= 1000,所以使用哈希数组解决也可以。
代码细节:
(1)初始化
定义一个数组设置数组大小,并初始化设置初值为0。int hash[1005] ={0};
定义一个unordered_set数据类型的result(unordered_set有自动去重功能)( unordered_set result_set; )
(2)遍历nums1中所有元素,将其映射到hash数组中,nums1中出现的字母在hash数组中做记录即可。(hash[nums[i]] = 1;)
(3) 遍历nums2中每个元素,通过hash数组判断该元素是否在nums1中出现。如果出现,则将该元素加入到result中。并且不需要做去重操作,因为unordered_set有自动去重功能。
(4)最终需要返回数组形式,所以需要将return vector(result_set.begin(), result_set.end()); 转变为数组形式。
由于力扣改了 题目描述 和 后台测试数据,增添了 数值范围:数组都是 1000以内的。所以使用数组来做的话,效率会更高一些。(使用set,每次将set集合中加入一个值,会进行一次哈希运算同时开辟一个新的空间,相对比较费事)而使用数组,直接使用下标进行哈希映射效率会高一些。
数组做哈希表参考代码:
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> result_set; // 存放结果,之所以用set是为了给结果集去重
int hash[1005] = {0}; // 默认数值为0
for (int num : nums1) { // nums1中出现的字母在hash数组中做记录
hash[num] = 1;
}
for (int num : nums2) { // nums2中出现话,result记录
if (hash[num] == 1) {
result_set.insert(num);
}
}
//将set转化为数组的形式,并且返回该数组
return vector<int>(result_set.begin(), result_set.end());
}
};
但如果没有数值的限制就不能够使用数组解决。
例如说:如果我的输入样例数值很大或者数值分布很分散时, 此时使用数组的下标做映射时,定义一个2亿大小的数组来做哈希表不太现实, 不同的语言对数组定义的大小都是有限制的, 即使有的语言可以定义这么大的数组,那也是对内存空间造成了非常大的浪费。
2、此时就要使用另一种结构体:set ,
关于set,C++ 给提供了如下三种可用的数据结构(详细解释见理论基础篇)
- std::set
- std::multiset
- std::unordered_set
std::set和std::multiset底层实现都是红黑树,std::unordered_set的底层实现是哈希表,
使用unordered_set(可以理解为一个无限存装的数组) 读写(映射以及取值操作时)效率是最高的,并不需要对数据进行排序,而且还不要让数据重复,所以选择unordered_set。
解题思路:
由于本题时求两个数组的交集,所以我们可以
将nums1中的所有数值放入哈希表中(nums1->处理哈希表),
遍历nums2中的每一个元素(nums2->查询哈希表),
在哈希表中查找该元素是否出现过,若出现过,则放入result集合中,最终进行去重后,输出result中的交集元素。
代码细节:
(1)初始化并将nums1放入哈希表
首先定义一个unordered_set数据类型的result(unordered_set有自动去重功能)( unordered_set result_set; )
接着定义哈希表nums_set,也使用unordered_set数据类型,同时可以直接将num1数组初始化转变为unordered_set存储结构( unordered_set nums_set(nums1.begin(), nums1.end());)
(2)判断nums2中元素是否在nums1中出现过,从而求交集
遍历nums2数组,判断每个元素是否在哈希表中出现过( if (nums_set.find(num) != nums_set.end()) ),如果在nums_set中找到该元素,则放入result集合中(result_set.insert(num);)。并且不需要做去重操作,因为unordered_set有自动去重功能。
(3)最终需要返回数组形式,所以需要将return vector(result_set.begin(), result_set.end()); 转变为数组形式
参考代码
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) { //使用set解决
unordered_set<int> result_set; // 存放结果,之所以用set是为了给结果集去重
unordered_set<int> nums_set(nums1.begin(), nums1.end());//定义nums_set,将nums1中元素直接存入nums_set中
for(int num : nums2){
//C++ set find()函数用于查找具有给定值val的元素。
//如果找到元素,则返回指向该元素的迭代器,否则返回指向集合末尾的迭代器,即set :: end()。
//所以如果返回值不是nums_set.end(),则说明hash数组中存在该元素,并将其添加到result中
if(nums_set.find(num) != nums_set.end()){
//将set转化为数组的形式,并且返回该数组
result_set.insert(num);
}
}
return vector<int>(result_set.begin(), result_set.end());
}
};
相似350. 两个数组的交集 II
map待解决
202. 快乐数
编写一个算法来判断一个数 n 是不是快乐数。
「快乐数」 定义为:
对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。
然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。
如果这个过程 **结果为 1,**那么这个数就是快乐数。
如果 n 是 快乐数 就返回 true ;不是,则返回 false 。
示例 1:
输入:n = 19
输出:true
解释:
1^2 + 9^2 = 82
8^2 + 2^2 = 68
6^2 + 8^2 = 100
1^2 + 0^2 + 0^2 = 1
示例 2:
输入:n = 2
输出:false
解题思路:
本题中关键词为: 无限循环,那么也就是说求和的过程中,sum会重复出现,这对解题很重要!
于是找到sum中元素是否重复出现,正符合哈希法解决快速判断一个元素是否出现集合里的情况。
所以这道题目可以使用哈希法,来判断sum是否重复出现,如果重复出现就return false;否则直到找到sum为1,return true;
判断sum是否重复出现,由于sum数量未知,所以可以使用unordered_set,作为哈希表。
还有一个难点,就是求和过程。要熟悉取数值各个位上的单数操作
参考代码:
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作为哈希表,最终查找是否有重复元素
unordered_set<int> set;
//由于不知道循环次数,所以使用while(1),并且使用unordered_set
while(1){
//每次得到sum
int sum = getSum(n);
//如果各位数平方和为1,则为快乐数
if(sum == 1){
return true;
}
//如果这个sum在set中能找到(也就是返回的迭代器不是 set.end()),
//说明进入无限循环,即可return false
if(set.find(sum) != set.end()){
return false;
} else {
//否则未找到对应sum,将此次求出的sum加入到set中并进行下一次循环
set.insert(sum);
}
//为下一次循环求sum做准备
n = sum;
}
}
};
map作为哈希表
1. 两数之和
给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
你可以按任意顺序返回答案。
示例 1:
输入:nums = [2,7,11,15], target = 9
输出:[0,1]
解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。
示例 2:
输入:nums = [3,2,4], target = 6
输出:[1,2]
示例 3:
输入:nums = [3,3], target = 6
输出:[0,1]
提示:
2 <= nums.length <= 104
-109 <= nums[i] <= 109
-109 <= target <= 109
只会存在一个有效答案
解题思路:
本题时map作为哈希表经典题目。
- 为什么想到使用哈希法解决?
当遇到判断一个元素是否在集合中出现过时,一般使用哈希法解决。本题中,在遍历数组中一个元素时,需要判断是否需要的另一个元素在之前遍历过。如果之前遍历过,则找到了和为目标值 target 的那 两个 整数。
- 为什么选择map作为哈希表?
当我们找到需要的元素时,同时还需要直到这个元素在数组中的下标。所以需要记录数组元素数值以及对应下标—两个元素,所以就可以使用map做哈希映射,元素的数值以及对应下标分别对应map中的key和value。
- 使用数组和set来做哈希法的局限,以及map介绍
数组的大小是受限制的,而且如果元素很少,而哈希值太大会造成内存空间的浪费。
set是一个集合,里面放的元素只能是一个key,而两数之和这道题目,不仅要判断y是否存在而且还要记录y的下标位置,因为要返回x 和 y的下标。所以set 也不能用。
此时就要选择另一种数据结构:map ,map是一种key value的存储结构,可以用key保存数值,用value在保存数值所在的下标。
C++中map,有三种类型:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-W96Yput2-1680264369062)(…/…/…/lenovo/AppData/Roaming/Typora/typora-user-images/image-20220812215117448.png)]
std::unordered_map 底层实现为哈希表,std::map 和std::multimap 的底层实现是红黑树。
同理,std::map 和std::multimap 的key也是有序的(这个问题也经常作为面试题,考察对语言容器底层的理解)。
这道题目中并不需要key有序,选择std::unordered_map 效率更高!
- 为什么元素的数值以及对应下标分别对应map中的key和value,相反顺序对应key和value呢?
根据题意,是要查找需要的元素是否出现过,也就是要查询该元素对应数值,所以将元素的数值作为key,才是map的作用—在最快的时间内查找key(元素的数值)是否在map中出现过。所以 ,map中的存储结构为 {key:数据元素,value:数组元素对应的下标}。
- map用来做什么?
map是用来存放已经访问过的元素,因为遍历数组的时候,需要记录我们之前遍历过哪些元素和对应的下标,这样才能找到与当前元素相匹配的(也就是相加等于target)
- map解题过程
依次遍历数组中各个元素x,由于已知target,所以只需向map(存放遍历过的元素)中查询是否有和目前遍历元素匹配的数值(target - x)。
如果有,则找到匹配项。返回map中查找到的下标(value)以及当前遍历元素的下标。
如果没有,则将目前遍历的元素放入map中,因为map存放的就是我们访问过的元素。
- c++中使用哪种map结构?
std::unordered_map 底层实现为哈希表,std::map 和std::multimap 的底层实现是红黑树。
由于本题不需要key有序,所以使用unordered_map用来存储与读写效率更高。
代码细节:
(1)初始化unordered_map类型的map结构作为哈希表
(2)遍历数组,在遍历过程中,去map中寻找对应需要查询的另一个整数元素(target - nums[i])是否已经遍历过。此处注意map是如何遍历的。(auto iter = map.find(target - nums[i]); )iter为迭代器,接收map查询结果。
(3)如果要查找的元素在map中出现过(iter != map.end()),那么返回对应元素所存放的value(iter->second)即查找元素下标,以及当前遍历数组元素(i)
(4)如果要查找的元素在map中没有出现,那么将此时访问的元素(已经变为访问过的元素和下标)加入到map中。(map.insert(pair<int, int>(nums[i], i));)
(5)最终,如果遍历结束后,还是没有找到答案,则返回空(return {})即可。
参考代码:
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
//初始化map,存放已经遍历过的元素
std::unordered_map <int, int> map;
//遍历整个数组
for(int i = 0; i < nums.size(); i++){
//遍历当前元素,在mao中寻找是否有匹配的key
auto iter = map.find(target - nums[i]);
//如果查找到匹配元素,则返回查找到元素的value即下标,以及当前元素下标
if(iter != map.end()){
return {iter->second, i};
}
//如果没有查找到匹配元素,则将访问过的元素和下标加入到map中
map.insert(pair<int, int>(nums[i], i));
}
//遍历结束,还是没有找到答案
return {};
}
};
454. 四数相加 II
给你四个整数数组 nums1、nums2、nums3 和 nums4 ,数组长度都是 n ,请你计算有多少个元组 (i, j, k, l) 能满足:
-
0 <= i, j, k, l < n
-
nums1[i] + nums2[j] + nums3[k] + nums4[l] == 0
示例 1:
输入:nums1 = [1,2], nums2 = [-2,-1], nums3 = [-1,2], nums4 = [0,2]
输出:2
解释:
两个元组如下:
1. (0, 0, 0, 1) -> nums1[0] + nums2[0] + nums3[0] + nums4[1] = 1 + (-2) + (-1) + 2 = 0
2. (1, 1, 0, 0) -> nums1[1] + nums2[1] + nums3[0] + nums4[0] = 2 + (-1) + (-1) + 0 = 0
示例 2:
输入:nums1 = [0], nums2 = [0], nums3 = [0], nums4 = [0]
输出:1
解题思路:
本题咋眼一看好像和0015.三数之和 (opens new window),0018.四数之和 (opens new window)差不多,其实差很多。
本题是使用哈希法的经典题目,而0015.三数之和 (opens new window),0018.四数之和 (opens new window)并不合适使用哈希法,因为三数之和和四数之和这两道题目使用哈希法在不超时的情况下做到对结果去重是很困难的,很有多细节需要处理。
题目概述:在四个数组中,分别各取一个元素,组成一个四元组,使得四个元素之和为0,最终返回有多少个满足条件的四元组个数,而不是具体哪些四元组。本题与四数之和区别为,本题不需要对符合条件的元组进行去重操作,因为取自不同位置的元素对应不同的含义。
- 为什么会想到使用哈希法解题?
整体思路可以为:
首先只遍历A、B两个数组,将每次取出的的元素**(a+b)放入一个集合。**
接着遍历C、D两个数组,同样也是(c+d)。与此同时判断(c+d)中是否有需要的元素(判断0-(c+d)是否在集合中出现过),如果有,则在(c+d)中找到了匹配项,将出现过的次数做一个统计,记录符合条件的四元祖个数。
这样每次遍历两个数组,时间复杂度可以控制到最小:O(n^2)
- 使用什么样的哈希结构解题呢?
由于四个数组中数值大小不可控,所以数组下标做映射排除。
接着考虑set或map。由于这道题不仅要统计(a+b)在集合中是否出现过,还需要统计(a+b)出现的次数(两数组中可能会有多种组合形式得到相同的结果),才可以进一步和(c+d)做映射。所以选择map做哈希表解决问题。
注意:如果0-(c+d)在map集合中出现过的话,那么说明有匹配项,count应该加对应(a+b)的value值,也就是(a+b)在map中出现过的次数。
代码细节:
(1)初始化
首先定义 一个unordered_map类型的map
接着定义 一个int变量count,用来统计 a+b+c+d = 0 出现的次数
(2)预处理A、B数组,存入map集合
循环遍历A数组里嵌套循环遍历B数组,使用map,map对应key放a和b两数之和,value 放a和b两数之和出现的次数(map[a + b]++)。
(3)遍历C、D数组,在map集合中寻找是否有符合条件的元素。
循环遍历C数组里嵌套循环遍历D数组,每次查找map中是否出现过 0-(c+d) ,如果0-(c+d) 在map中出现过的话,就用count把map中key对应的value也就是出现次数统计出来(count += map[0 - (c + d)];)。
(4)遍历完毕,return count
可以发现,这道题整体思路,和242.有效的字母异位词代码风格(一次循环处理A数组并将其元素存入选择的容器中,另一次循环处理B数组,在容器中查询是否出现过想要的元素)相似,只不过使用的哈希结构不同。
参考代码:
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
unordered_map<int, int> umap; //key:a+b的数组,value:a+b数值出现的次数
int count = 0; // 统计a+b+c+d = 0 出现的次数
// 遍历nums1和nums2数组,统计两个数组元素之和,和出现的次数,放到map
for(int a : nums1){
for(int b : nums2){
umap[a + b]++;
}
}
// 遍历nums3和nums4数组,找到如果 0-(c+d) 在map中出现过的话,就把map中key对应的value也就是出现次数统计出来。
for(int c : nums3){
for(int d : nums4){
if(umap.find(0 - (c + d)) != umap.end()){
count += umap[0 - (c + d)];
}
}
}
return count;
}
};
15. 三数之和
给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有和为 0 且不重复的三元组。
注意:答案中不可以包含重复的三元组。
示例 1:
输入:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]]
示例 2:
输入:nums = []
输出:[]
示例 3:
输入:nums = [0]
输出:[]
解题思路:
注意[0, 0, 0, 0] 这组数据
题解1:哈希解法
有了上面的铺垫,可以想到也使用哈希法解决。两层for循环确定a和b的数值,然后使用哈希法确定0-(a+b)是否在数组中出现过。—时间复杂度O(n^2)
但是这道题需要不可以包含重复的三元组,所以可以将符合条件的三元组放进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]) { //三元组元素a去重
continue;
}
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]) { // 三元组元素b去重
continue;
}
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;
}
};
题解2:双指针解法
使用双指针法时,首先一定要对数组进行排序(因为最终返回的是符合条件三元组的值,而不是对应的下标,所以可以排序)。
首先定义i,使用for循环遍历i,即为a;
接着定义两指针left和right,则b就是数组left的下标,c就是数组right的下标。
如果nums[i] + nums[left] + nums[right] > 0,则三数相加较大,由于i是for循环依次遍历(固定),所以为了使三个数之和减小,可以将right向左移动一位(数组有序)
相反,如果nums[i] + nums[left] + nums[right] < 0,则三数相加较小,所以为了使三个数之和变大,可以将left向右移一位(数组有序)
直到出现三数之和等于零时,就是对应的答案,就可以存入一个二维数组result中。
但本题有一个细节,需要去重操作,题目要求,结果集中不能有重复的三元组。具体操作可参考以下:
代码细节:
(1)初始化
首先需要将nums数组进行排序
接着定义二维数组result存放符合条件的三元组
(2)遍历nums数组(i为数组中a的下标),
1.对a(nums[i])的操作及去重
如果nums[i] > 0,可以直接return result查找完毕,退出程序,因为数组已经是排过序的了,如果第一个a直接大于零,则后面两个数无论是多少都不可能等于零。
a(nums[i])的去重:
去重是判断nums[i] == nums[i + 1] 还是 nums[i] == nums[i - 1] 呢?
题目要求是,三元组不可以重复,而三元组里面的元素是可以重复的(例如[0, 0, 0]符合条件)。
nums[i] == nums[i + 1] 是判断i位置和它的下一位位置是否相等,但有一种情况,当left指向nums[i]的下一位时,这样的意思就是判断a和b是否相等,而题目中可以允许a,b相等。
nums[i] == nums[i - 1]则是判断i位置和它的前一位位置是否相等,这样left和right都不会有影响。这里还要注意一点,由于是和nums[i - 1]作对比,所以需要在if中判断i是否大于零,防止下表中出现负数。
若出现以上情况,则continue,进入下一次循环。
2.对b(nums[left])和c(nums[right])的操作及去重
操作:
(1)初始化
right初始为nums.size - 1
left初始化为i + 1
(2)移动指针
while循环条件中是right > left
移动逻辑:
如果if( nums[i] + nums[left] + nums[right] > 0),right–
如果else if( nums[i] + nums[left] + nums[right] < 0),left++
如果else(也就是nums[i] + nums[left] + nums[right] = 0),将该三元组放入result中,result.push();并且同时将两个指针向中间收缩。
b(nums[left])和c(nums[right])的去重:
思路同a(nums[i])的去重,但需要注意,right判断的是while(left < right && nums[right] == nums[right - 1] ) right–;
left判断的是while(left < right && nums[left] == nums[left +1] ) left++;
注意去重操作要放在while循环收获结果(result.push())之后执行(包含在else之中),如果放在循环最前面,会造成left和right一直移动直到相遇,但是没有将结果返回(例如数组为[0,0,0,0,0])
参考代码:
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
vector<vector<int>> result; //定义一个二维数组result,存放符合条件的三元组
sort(nums.begin(), nums.end()); //将数组进行排序
// 找出a + b + c = 0
// a = nums[i], b = nums[left], c = nums[right]
for(int i = 0; i < nums.size(); i++){
// 排序之后如果第一个元素已经大于零,那么无论如何组合都不可能凑成三元组,直接返回结果就可以了
if(nums[i] > 0) return result;
//注意此处a的去重方式并且对i大小还需要进行判断
if(i > 0 && nums[i] == nums[i - 1]){
continue;
}
//初始化left和right指针,分别指向a的下一位和数组最后一位
int left = i + 1;
int right = nums.size() - 1;
while(left < right){
// 去重复逻辑如果放在这里,0,0,0 的情况,可能直接导致 right<=left 了,从而漏掉了 0,0,0 这种三元组
if(nums[i] + nums[left] + nums[right] > 0) right--;
else if(nums[i] + nums[left] + nums[right] < 0) left++;
//找到符合条件的三元组,将其放入result中
else {
result.push_back(vector<int>{nums[i], nums[left], nums[right]});
// 去重逻辑应该放在找到一个三元组之后,对b 和 c去重
while(left < right && nums[left] == nums[left + 1]) left++;
while(left < right && nums[right] == nums[right - 1]) right--;
//找到答案时,双指针同时收缩
right--;
left++;
}
}
}
return result;
}
};
思考:三数之和可以使用双指针法,之前的1.两数之和可不可以使用双指针法呢?
不可以,因为1.两数之和要求返回的是所以下标,而双指针法要求一定要排序,一旦排序之后原数组的索引下标就被改变了,所以不能使用,但如果1题要求返回的是数值的话,就可以使用双指针法。
18. 四数之和
给你一个由 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]]
示例 2:
输入:nums = [2,2,2,2,2], target = 8
输出:[[2,2,2,2]]
解题思路:
整体思路:
本题在整体思路上,和15.三数之和思路基本相同,都可以使用双指针解法。四数之和与三数之和不同的是,多了一层for循环,即两层for循环(时间复杂度O(n ^ 2)),外层nums[k](确定a),内层nums[i](确定b),还是通过left和right不断向中间移动,寻找符合题意的四元组(nums[k] + nums[i] + nums[left] + nums[right] == target)
实现细节:
第一层for循环(对nums[k])
- 剪枝操作:
上一题中,a的判断,如果nums[i] > 0,可以直接return。本题中是否可以延续这种思路,也就是如果nums[k] > target,直接return呢?答案是不可以,因为,有可能a > target了,但是b < 0,这样的话,a+b 小于target,之后计算(a+b+c+d)有可能就等于target(例如[-4, -1, 0, 0] ,target= -5)。
所以,可以这样做if(nums[k] > target && nums[k] > 0) break;
- 去重操作:
对nums[k]的去重操作与上一题相同,if(k > 0 && nums[k] == nums[k - 1]) continue;
第二层for循环(对nums[i])
以上操作为对k的剪枝和去重操作,对i的剪枝与去重操作也同上,只不过初始化i 为 k + 1。
-
剪枝操作:
由于已经进入此层循环已经确定了k的值,所以,要将(nums[k] + nums[i]) 看为一个整体,进行剪枝
所以是:if((nums[k] + nums[i]) > target &&(nums[k] + nums[i]) > 0) break;
-
去重操作:
注意此处判断不是k > 0,而是i > k + 1,因为i是从k + 1开始,而要判断nums[i] == nums[i - 1],所以需要i > k + 1也就是:if (i > k + 1 && nums[i] == nums[i - 1]) continue;
left和right的移动与去重和15.三数之和逻辑相同。
注意:当判断 nums[k] + nums[i] + nums[left] + nums[right] 和 target关系时,可能 nums[k] + nums[i] + nums[left] + nums[right]会溢出(例如:[-1000000000,-1000000000,-1000000000,-1000000000] -1),所以,需要将 nums[k] + nums[i] + nums[left] + nums[right] 转为long长整型进行判断,避免溢出。
参考代码:
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[i] + nums[k]) > target && (nums[i] + nums[k]) > 0){
break;
}
//对nums[i]去重
if(i > k + 1 && nums[i] == nums[i - 1]){
continue;
}
int left = i + 1;
int right = nums.size() - 1;
while(left < right){
// nums[k] + nums[i] + nums[left] + nums[right] > target 会溢出
if((long) nums[k] + nums[i] + nums[left] + nums[right] > target) right--;
// nums[k] + nums[i] + nums[left] + nums[right] < target 会溢出
else if((long) nums[k] + nums[i] + nums[left] + nums[right] < target) left++;
else{ //找到符合条件的四元组,需要加入result并且进行去重操作
result.push_back(vector<int>{nums[k], nums[i], nums[left], nums[right]});
//对nums[left]和nums[right]的去重操作
while(left < right && nums[left] == nums[left + 1]) left++;
while(left < right && nums[right] == nums[right - 1]) right--;
//找到答案时,双指针同时向内收缩,进行下一次寻找
right--;
left++;
}
}
}
}
return result;
}
};
双指针法
我们来回顾一下,几道题目使用了双指针法。
双指针法将时间复杂度:O(n^2)的解法优化为 O(n)的解法。也就是降一个数量级,题目如下:
链表相关双指针题目:
- 206.反转链表(opens new window)
- 19.删除链表的倒数第N个节点(opens new window)
- 面试题 02.07. 链表相交(opens new window)
- 142题.环形链表II(opens new window)
双指针法在字符串题目中还有很多应用,后面还会介绍到。
总结
哈希表理论基础
当需要快速判断一个元素是否出现集合里时,一般来说可以选择哈希表来解决。
对于哈希表,要知道哈希函数和哈希碰撞在哈希表中的作用.
-
哈希函数是把传入的key映射到符号表的索引上。
-
哈希碰撞处理有多个key映射到相同索引上时的情景,处理碰撞的普遍方式是拉链法和线性探测法。
接下来是常见的三种哈希结构:
- 数组
- set(集合)
- map(映射)
在C++语言中,set 和 map 都分别提供了三种数据结构,每种数据结构的底层实现和用途都有所不同,在关于哈希表,你该了解这些! (opens new window)中给出了详细分析,这一知识点很重要!
当我们要使用集合来解决哈希问题的时候,
优先使用unordered_set,因为它的查询和增删效率是最优的,
如果需要集合是有序的,那么就用set,
如果要求不仅有序还要有重复数据的话,那么就用multiset。
只有对这些数据结构的底层实现很熟悉,才能灵活使用,否则很容易写出效率低下的程序。
哈希表经典题目
数组作为哈希表
一些应用场景就是为数组量身定做的。
使用数组来做哈希的题目,都限制了数值的大小,例如只有小写字母,或者数值大小在[0- 10000] 之内等等。但如果没有数值的限制就不能够使用数组解决。
在242.有效的字母异位词 (opens new window)中,我们提到了数组就是简单的哈希表,但是数组的大小是受限的!
这道题目包含小写字母,那么使用数组来做哈希最合适不过。
在383.赎金信 (opens new window)中同样要求只有小写字母,那么就给我们浓浓的暗示,用数组!
本题和242.有效的字母异位词 (opens new window)很像,242.有效的字母异位词 (opens new window)是求 字符串a 和 字符串b 是否可以相互组成,在383.赎金信 (opens new window)中是求字符串a能否组成字符串b,而不用管字符串b 能不能组成字符串a。
一些同学可能想,用数组干啥,都用map不就完事了。
上面两道题目用map确实可以,但使用map的空间消耗要比数组大一些,因为map要维护红黑树或者符号表,而且还要做哈希函数的运算。所以数组更加简单直接有效!
set作为哈希表
在349. 两个数组的交集 (opens new window)中我们给出了什么时候用数组就不行了,需要用set。
这道题目没有限制数值的大小,就无法使用数组来做哈希表了。
主要因为如下两点:
- 数组的大小是有限的,受到系统栈空间(不是数据结构的栈)的限制。
- 如果数组空间够大,但哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费。
所以此时一样的做映射的话,就可以使用set了。
关于set,C++ 给提供了如下三种可用的数据结构:(详情请看关于哈希表,你该了解这些! (opens new window))
- std::set
- std::multiset
- std::unordered_set
std::set和std::multiset底层实现都是红黑树,std::unordered_set的底层实现是哈希, 使用unordered_set 读写效率是最高的,本题并不需要对数据进行排序,而且还不要让数据重复,所以选择unordered_set。
在202.快乐数 (opens new window)中,我们再次使用了unordered_set来判断一个数是否重复出现过。
map作为哈希表
在1.两数之和 (opens new window)中map正式登场。
来说一说:使用数组和set来做哈希法的局限。
-
数组的大小是受限制的,而且如果元素很少,而哈希值太大会造成内存空间的浪费。
-
set是一个集合,里面放的元素只能是一个key,而两数之和这道题目,不仅要判断y是否存在而且还要记录y的下标位置,因为要返回x 和 y的下标。所以set 也不能用。
-
map是一种
<key, value>
的结构,本题可以用key保存数值,用value在保存数值所在的下标。所以使用map最为合适。
C++提供如下三种map::(详情请看关于哈希表,你该了解这些! (opens new window))
- std::map
- std::multimap
- std::unordered_map
std::unordered_map 底层实现为哈希,std::map 和std::multimap 的底层实现是红黑树。
同理,std::map 和std::multimap 的key也是有序的(这个问题也经常作为面试题,考察对语言容器底层的理解),1.两数之和 (opens new window)中并不需要key有序,选择std::unordered_map 效率更高!
在454.四数相加 (opens new window)中我们提到了其实需要哈希的地方都能找到map的身影。
本题咋眼一看好像和18. 四数之和 (opens new window),15.三数之和 (opens new window)差不多,其实差很多!
关键差别是本题为四个独立的数组,只要找到A[i] + B[j] + C[k] + D[l] = 0就可以,不用考虑重复问题,而18. 四数之和 (opens new window),15.三数之和 (opens new window)是一个数组(集合)里找到和为0的组合,可就难很多了!
用哈希法解决了两数之和,很多同学会感觉用哈希法也可以解决三数之和,四数之和。
其实是可以解决,但是非常麻烦,需要去重导致代码效率很低。
在15.三数之和 (opens new window)中我给出了哈希法和双指针两个解法,大家就可以体会到,使用哈希法还是比较麻烦的。
所以18. 四数之和,15.三数之和都推荐使用双指针法!
总结
对于哈希表的知识相信很多同学都知道,但是没有成体系。
本篇我们从哈希表的理论基础到数组、set和map的经典应用,把哈希表的整个全貌完整的呈现给大家。
同时也强调虽然map是万能的,详细介绍了什么时候用数组,什么时候用set。
希表理论基础.html))
- std::map
- std::multimap
- std::unordered_map
std::unordered_map 底层实现为哈希,std::map 和std::multimap 的底层实现是红黑树。
同理,std::map 和std::multimap 的key也是有序的(这个问题也经常作为面试题,考察对语言容器底层的理解),1.两数之和 (opens new window)中并不需要key有序,选择std::unordered_map 效率更高!
在454.四数相加 (opens new window)中我们提到了其实需要哈希的地方都能找到map的身影。
本题咋眼一看好像和18. 四数之和 (opens new window),15.三数之和 (opens new window)差不多,其实差很多!
关键差别是本题为四个独立的数组,只要找到A[i] + B[j] + C[k] + D[l] = 0就可以,不用考虑重复问题,而18. 四数之和 (opens new window),15.三数之和 (opens new window)是一个数组(集合)里找到和为0的组合,可就难很多了!
用哈希法解决了两数之和,很多同学会感觉用哈希法也可以解决三数之和,四数之和。
其实是可以解决,但是非常麻烦,需要去重导致代码效率很低。
在15.三数之和 (opens new window)中我给出了哈希法和双指针两个解法,大家就可以体会到,使用哈希法还是比较麻烦的。
所以18. 四数之和,15.三数之和都推荐使用双指针法!
总结
对于哈希表的知识相信很多同学都知道,但是没有成体系。
本篇我们从哈希表的理论基础到数组、set和map的经典应用,把哈希表的整个全貌完整的呈现给大家。
同时也强调虽然map是万能的,详细介绍了什么时候用数组,什么时候用set。