理论基础
当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法。 这句话很重要,在做哈希表题目都要思考这句话。
哈希表中元素的插入过程:
1. 得到key;
2. 通过hash函数得到hash值;
3. 得到桶号(一般都为 hash值对 桶数/表大小 求模);
4. 判断该key下是否有其他value,若无则存放key和value在桶内,若有则用拉链法等解决hash碰撞问题。
当我们想使用哈希法来解决问题的时候,一般会选择如下三种数据结构。
- 数组
- 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的存储方式使用红黑树实现的。
虽然std::set、std::multiset 的底层实现是红黑树,不是哈希表,std::set、std::multiset 使用红黑树来索引和存储,不过给我们的使用方式还是哈希法的使用方式,即key和value。所以使用这些数据结构来解决映射问题的方法,我们依然称之为哈希法。 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来存放数据,才能实现快速的查找。
如果在做面试题目的时候遇到需要判断一个元素是否出现过的场景也应该第一时间想到哈希法!
242. 有效的字母异位词
思路:
数组其实就是一个简单哈希表,而且这道题目中字符串只有小写字符,那么就可以定义一个大小为26的数组,来记录字符串s里字符出现的次数。之后在遍历字符串t的时候,对t中出现的字符映射哈希表索引上的数值再做-1的操作。最后判断哈希表里的所有元素是否都为0即可。
操作过程如下:
题解:
class Solution {
public:
bool isAnagram(string s, string t) {
int record[26] = {0};
for (int i = 0; i < s.size(); i++) {
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) return false;
}
return true;
}
};
349. 两个数组的交集
思路:
使用unordered_set:
题解:
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> ans;
unordered_set<int> set(nums1.begin(), nums1.end());
for (int num : nums2) {
if (set.find(num) != set.end()) {
ans.insert(num);
}
}
return vector<int> (ans.begin(), ans.end());
}
};
可以留意如下遍历写法:
for (int num : nums2)
以及如下赋值写法:
return vector<int> (ans.begin(), ans.end());
202. 快乐数
思路:
本题难点在于证明:n要么最终得1,要么进入循环,不会有其他情况。
怎么知道它会继续变大,而不是最终得到 111 呢?可以仔细想一想,每一位数的最大数字的下一位数是多少。
Digits Largest Next
1 9 81
2 99 162
3 999 243
4 9999 324
13 9999999999999 1053
对于 3 位数的数字,它不可能大于243。这意味着它要么被困在 243以下的循环内,要么跌到 1。4位或4位以上的数字在每一步都会丢失一位,直到降到 3 位为止。所以我们知道,最坏的情况下,算法可能会在 243以下的所有数字上循环,然后回到它已经到过的一个循环或者回到 1。但它不会无限地进行下去。
即使在代码中不需要处理这种情况,你仍然需要理解为什么它永远不会发生,这样你就可以证明为什么你不处理它。
题解:
class Solution {
public:
int getNewN (int n) {
int sum = 0;
while (n != 0) {
sum += (n % 10) * (n % 10);
n /= 10;
}
return sum;
}
bool isHappy(int n) {
unordered_set<int> set;
while (n != 1) {
pair<unordered_set<int>::iterator, bool> ret = set.insert(n);
if (!ret.second) return false;
n = getNewN(n);
}
return true;
}
};
1. 两数之和
思路:
首先再强调一下 什么时候使用哈希法,当我们需要查询一个元素是否出现过,或者一个元素是否在集合里的时候,就要第一时间想到哈希法。
本题就需要一个集合来存放遍历过的元素,然后在遍历数组的时候去询问这个集合,某元素是否遍历过,也就是 是否出现在这个集合。
那么我们就应该想到使用哈希法了。
因为本题,我们不仅要知道元素有没有遍历过,还要知道这个元素对应的下标,需要使用 key value结构来存放,key来存元素,value来存下标,那么使用map正合适。
再来看一下使用数组和set来做哈希法的局限。
- 数组的大小是受限制的,而且如果元素很少,而哈希值太大会造成内存空间的浪费。
- set是一个集合,里面放的元素只能是一个key,而两数之和这道题目,不仅要判断y是否存在而且还要记录y的下标位置,因为要返回x 和 y的下标。所以set 也不能用。
此时就要选择另一种数据结构:map ,map是一种key value的存储结构,可以用key保存数值,用value再保存数值所在的下标。
题解:
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> map;
for (int i = 0; i < nums.size(); i++) {
unordered_map<int, int>::iterator it = map.find(target - nums[i]);
if (it != map.end()) {
return {it->second, i};
}
map.insert(pair<int, int> (nums[i], i));
}
return {};
}
};
454. 四数相加Ⅱ
思路:
本题是使用哈希法的经典题目,而15. 三数之和 以及 18. 四数之和 并不合适使用哈希法,因为三数之和和四数之和这两道题目使用哈希法在不超时的情况下做到对结果去重是很困难的,很有多细节需要处理。
而这道题目是四个独立的数组,只要找到A[i] + B[j] + C[k] + D[l] = 0就可以,不用考虑有重复的四个元素相加等于0的情况,所以相对于题目18. 四数之和,题目15.三数之和,还是简单了不少。
如果本题想难度升级:就是给出一个数组(而不是四个数组),在这里找出四个元素相加等于0,答案中不可以包含重复的四元组。
本题解题步骤:
- 首先定义 一个unordered_map,key放a和b两数之和,value 放a和b两数之和出现的次数。
- 遍历A和B数组,统计两个数组元素之和,和出现的次数,放到map中。
- 定义int变量count,用来统计 a+b+c+d = 0 出现的次数。
- 在遍历C和D数组,找到如果 0-(c+d) 在map中出现过的话,就用count把map中key对应的value也就是出现次数统计出来。
- 最后返回统计值 count 即可。
题解:
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
unordered_map<int, int> record;
int count = 0;
for (int num1 : nums1) {
for (int num2 : nums2) {
record[num1 + num2]++;
}
}
for (int num3 : nums3) {
for (int num4 : nums4) {
if (record.find(0 - num3 - num4) != record.end()) {
count += record[0 - num3 - num4];
}
}
}
return count;
}
};
其中,对unordered_map中元素的value值进行赋值的操作值得注意:
record[num1 + num2]++;
383. 赎金信
思路:
“你可以假设两个字符串均只含有小写字母。” 说明只有小写字母,这一点很重要。类似242. 有效的字母异位词。
题解:
class Solution {
public:
bool canConstruct(string ransomNote, string magazine) {
int record[26] = {0};
for (int i = 0; i < magazine.size(); i++) {
record[magazine[i] - 'a']++;
}
for (int i = 0; i < ransomNote.size(); i++) {
record[ransomNote[i] - 'a']--;
if (record[ransomNote[i] - 'a'] < 0) {
return false;
}
}
return true;
}
};
15. 三数之和
思路:
这道题使用哈希法并不合适。两层for循环就可以确定 a 和b 的数值了,可以使用哈希法来确定 0-(a+b) 是否在 数组里出现过,其实这个思路是正确的,但是有一个非常棘手的问题,就是题目中说的不可以包含重复的三元组。
把符合条件的三元组放进vector中,然后再去重,这样是非常费时的,很容易超时。
并且去重的过程不好处理,有很多小细节,如果在面试中很难想到位。
这道题目使用双指针法要比哈希法高效一些,过程如下:
拿这个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)。
题解:
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
vector<vector<int>> ans;
sort(nums.begin(), nums.end());
for (int i = 0; i < nums.size() - 2; i++) {
if (nums[i] > 0) {
break;
}
if (i > 0 && nums[i] == nums[i - 1]) {
continue;
}
int left = i + 1;
int right = nums.size() - 1;
while (left < right) {
int sum = nums[i] + nums[left] + nums[right];
if (sum == 0) {
ans.push_back({nums[i], nums[left], nums[right]});
left++;
while (nums[left] == nums[left - 1] && left < right) {
left++;
}
right--;
while (nums[right] == nums[right + 1] && left < right) {
right--;
}
} else if (sum < 0){
left++;
} else {
right--;
}
}
}
return ans;
}
};
18. 四数之和
思路:
四数之和 与 15. 三数之和 都是使用双指针法,只需在三数之和的基础上再套一层for循环。
三数之和的时间复杂度是O(n^2),四数之和的时间复杂度是O(n^3) 。
那么一样的道理,五数之和、六数之和等等都采用这种解法。
对于三数之和,双指针法就是将原本暴力O(n^3)的解法,降为O(n^2)的解法,四数之和的双指针解法就是将原本暴力O(n^4)的解法,降为O(n^3)的解法。
本题是要求在一个集合中找出四个数相加等于target,同时四元组不能重复。前面的454. 四数相加Ⅱ是四个独立的数组,只要找到A[i] + B[j] + C[k] + D[l] = 0就可以,不用考虑有重复的四个元素相加等于0的情况,所以相对于本题还是简单了不少。
题解:
class Solution {
public:
vector<vector<int>> fourSum(vector<int>& nums, int target) {
if (nums.size() < 4) {
return {};
}
vector<vector<int>> ans;
sort(nums.begin(), nums.end());
for (int a = 0; a < nums.size() - 3; a++) {
if (a > 0 && nums[a] == nums[a - 1]) {
continue;
}
for (int b = a + 1; b < nums.size() - 2; b++) {
if (b > a + 1 && nums[b] == nums[b - 1]) {
continue;
}
int c = b + 1;
int d = nums.size() - 1;
while (c < d) {
long sum = (long)nums[a] + nums[b] + nums[c] + nums[d];
if (sum == target) {
ans.push_back({nums[a], nums[b], nums[c], nums[d]});
c++;
while (c < d && nums[c] == nums[c - 1]) {
c++;
}
d--;
while (c < d && nums[d] == nums[d + 1]) {
d--;
}
} else if (sum < target) {
c++;
} else {
d--;
}
}
}
}
return ans;
}
};
总结感悟
一般来说哈希表都是用来快速判断一个元素是否出现集合里。
对于哈希表,要知道哈希函数和哈希碰撞在哈希表中的作用。
哈希函数是把传入的key映射到符号表的索引上。
哈希碰撞处理有多个key映射到相同索引上时的情景,处理碰撞的普遍方式是拉链法和线性探测法。
接下来是常见的三种哈希结构,需要知道什么时候该用什么数据结构来实现哈希表:
- 数组
- set(集合)
- map(映射)
只有对这些数据结构的底层实现很熟悉,才能灵活使用,否则很容易写出效率低下的程序。
数组适用于哈希值比较集中、大小有限的场景,在这些特定情况下,由于map等要维护红黑树或者符号表,而且还要做哈希函数的运算,因此使用数组更节约空间,简单直接有效。
然而数组的大小是有限的,受到系统栈空间(不是数据结构的栈)的限制。如果数组空间够大,但哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费。这种情况下就需要使用set。
数组的大小是受限制的,而且如果元素很少,而哈希值太大会造成内存空间的浪费。set是一个集合,里面放的元素只能是一个key。map是一种<key, value>
的结构,例如1. 两数之和这道题,可以用key保存数值,用value在保存数值所在的下标。此时使用map最为合适。
四数相加Ⅱ、三数之和、四数之和三道题看似差不多,其实差很多。
关键差别是 四数相加Ⅱ 为四个独立的数组,只要找到A[i] + B[j] + C[k] + D[l] = 0就可以,不用考虑重复问题,而三数之和、四数之和是在一个数组(集合)里找到和为目标值的组合,就难很多了(需要剪枝去重)。
三数之和、四数之和用哈希法其实是可以解决,但是非常麻烦,需要去重导致代码效率很低。推荐使用双指针法!