哈希表基础
What is hash table?
哈希表是根据关键码的值而直接进行访问的数据结构。直白来讲哈希表就是数组,可以通过下标直接访问数组之中的元素。
What problem can hash table solve?
一般哈希表都是用来快速判断一个元素是否出现集合里。如果我们需要查询一个元素是否存在于一个大集合中,使用枚举法的时间复杂度为O(n),而用哈希表的话时间复杂度为O(1)。将元素映射到哈希表上就涉及到hash function,也就是哈希函数。
哈希函数
哈希函数如下图所示,通过hashCode把名字转化为数值,一般hashcode是通过特定编码方式,可以将其他数据格式转化为不同的数值,这样就把各个元素映射为哈希表上的索引数字了。
如果hashCode得到的数值大于 哈希表的大小了,也就是大于tableSize了,怎么办呢?
此时为了保证映射出来的索引数值都落在哈希表上,我们会在再次对数值做一个取模的操作(hashCode(name) % tableSize),就要我们就保证了各个元素一定可以映射到哈希表上了。
那么如果元素的数量大于哈希表的大小怎么办,此时就算哈希函数计算的再均匀,也避免不了会有几个元素同时映射到哈希表同一个索引下标的位置,这样就无法实现直接查找。此时就变成一个哈希碰撞问题。
哈希碰撞
如下图所示,两个元素同时映射到索引下标的为1的地方,造成哈希碰撞(Collisions)。
一般哈希碰撞有两种解决方法, 拉链法和线性探测法。
拉链法
刚刚小李和小王在索引1的位置发生了冲突,发生冲突的元素都被存储在链表中。 这样我们就可以通过索引找到小李和小王了
(数据规模是dataSize, 哈希表的大小为tableSize)
其实拉链法就是要选择适当的哈希表的大小,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。
线性探测法
使用线性探测法,一定要保证tableSize大于dataSize。 我们需要依靠哈希表中的空位来解决碰撞问题。
当某一元素通过哈希函数映射到的位置已无空位,造成哈希碰撞,那么就向下找一个空位放置其信息。所以要求tableSize一定要大于dataSize ,要不然哈希表上就没有空置的位置来存放 冲突的数据了。
常见的三种哈希结构
当我们想使用哈希法来解决问题的时候,我们一般会选择如下三种数据结构。
数组
set(集合)
map(映射)
在C++中,set 和 map 分别提供以下三种数据结构,其底层实现以及优劣如下表所示:
std::unordered_set底层实现为哈希表,std::set 和std::multiset 的底层实现是红黑树,红黑树是一种平衡二叉搜索树,所以key值是有序的,但key不可以修改,改动key值会导致整棵树的错乱,所以只能删除和增加。
当我们要使用集合来解决哈希问题的时候,优先使用unordered_set,因为它的查询和增删效率是最优的,如果需要集合是有序的,那么就用set,如果要求不仅有序还要有重复数据的话,那么就用multiset。
std::unordered_map 底层实现为哈希表,std::map 和std::multimap 的底层实现是红黑树。同理,std::map 和std::multimap 的key也是有序的。
在map 中是一个key value 的数据结构,对key是有限制,对value没有限制的,因为key的存储方式使用红黑树实现的。
虽然std::set、std::multiset 的底层实现是红黑树,不是哈希表,std::set、std::multiset 使用红黑树来索引和存储,不过给我们的使用方式,还是哈希法的使用方式,即key和value。所以使用这些数据结构来解决映射问题的方法,我们依然称之为哈希法。 map也是一样的道理。
那么应该怎么选择哈希数据结构呢?
大体上的判断准则是,如果范围可控,用数组就可以了;如果范围很大,就用set;如果数据存在key和value的对应,就用map。
总结
当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法。
但是哈希法也是牺牲了空间换取了时间,因为我们要使用额外的数组,set或者是map来存放数据,才能实现快速的查找。
leetcode 242.有效的字母异位词
暴力排序
代码实现
class Solution {
public:
bool isAnagram(string s, string t) {
if (s.length() != t.length()) {
return false;
}
sort(s.begin(), s.end());
sort(t.begin(), t.end());
return s == t;
}
};
时间复杂度O(nlogn)
空间复杂度O(logn)
细节处理
先判断两个字符串长度是否相同,如果不相同不可能成为异位词。
使用sort()函数对字符串进行排序,再判断两个字符串是否完全相同。
时间复杂度:O(nlogn),其中 n 为 s 的长度。排序的时间复杂度为 O(nlogn),比较两个字符串是否相等时间复杂度为 O(n),因此总体时间复杂度为 O(nlogn +n) = O(nlogn)。
空间复杂度:O(logn),排序需要O(logn) 的空间复杂度。
哈希数组
代码实现
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 j = 0; j < t.size(); j++){
record[t[j] - 'a']--;
}
for(int k = 0; k < 26; k++){
if(record[k] != 0)
return false;
}
return true;
}
};
时间复杂度O(n)
空间复杂度O(1)
细节处理
定义一个数组叫做record,大小为26 ,初始化为0。因为字符a到字符z的ASCII也是26个连续的数值。
需要把字符映射到数组也就是哈希表的索引下标上,因为字符a到字符z的ASCII是26个连续的数值,所以字符a映射为下标0,相应的字符z映射为下标25。
再遍历 字符串s的时候,只需要将 s[i] - 'a' 所在的元素做+1 操作即可,并不需要记住字符a的ASCII,只要求出一个相对数值就可以了。 这样就将字符串s中字符出现的次数,统计出来了。
同样在遍历字符串t的时候,对t中出现的字符映射哈希表索引上的数值再做-1的操作。
leetcode 349.两个数组的交集
哈希set
代码实现
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> result;
unordered_set<int> nums(nums1.begin(), nums1.end());
for(int num: nums2){
if(nums.find(num) != nums.end()){
result.insert(num);
}
}
return vector<int>(result.begin(), result.end());
}
};
时间复杂度O(n+m)
空间复杂度O(n)
细节处理
使用数组来做哈希的题目,是因为题目都限制了数值的大小。而这道题目没有限制数值的大小,就无法使用数组来做哈希表了。而且如果哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费。
本题使用set中的unordered_set,因为其底层实现是哈希表。使用unordered_set 读写效率是最高的,并不需要对数据进行排序,而且它还可以自动对数据进行去重操作。set中的multiset则允许重复数据的出现。
凡是遇到哈希问题都用set并不是一个好的选择,直接使用set 不仅占用空间比数组大,而且速度要比数组慢,set把数值映射到key上都要做hash计算的。在数据量大的情况,耗时差距是很明显的。
时间复杂度的计算:设n和m分别为nums1和nums2的数组长度,使一个unordered_set储存num1中的元素需要O(n),遍历nums2需要O(m),所以时间复杂度为O(m+n)。
排序+双指针法
代码实现
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
sort(nums1.begin(), nums1.end());
sort(nums2.begin(), nums2.end());
int length1 = nums1.size(), length2 = nums2.size();
int index1 = 0, index2 = 0;
vector<int> result;
while(index1 < length1 && index2 < length2){
int num1 = nums1[index1], num2 = nums2[index2];
if(num1 == num2){
if(!result.size() || num1 != result.back()){
result.push_back(num1);
}
index1++;
index2++;
}
else if(num1 < num2){
index1++;
}
else{
index2++;
}
}
return result;
}
};
时间复杂度O(nlogn+mlogm)
空间复杂度O(logm+logn)
细节处理
本题的重点主要在于取交集并去重。在不使用unordered_set的情况下去重,可以考虑从数组的大小排序方面入手,当数组呈顺序排列时(如从小到大时),此时将新元素加入result数组,如果与result数组的前一个元素相同的话,就可认为出现了重复元素,那么此时舍弃这个新加入的元素即可实现去重操作。代码中使用了if(num1 != result.back())语句判断是否出现重复元素。
双指针思路,定义两个指针分别指向经过排序后的数组的第一位,随后依次向后移动,比较两个数组对应的索引的值,发现值相同则将该值push_back进result数组,再根据1中的判断条件看是否出现重复元素。
时间复杂度的分析:m和n分别是两个数组的长度。对两个数组排序的时间复杂度分别是O(mlogm)和O(nlogn),双指针寻找交集元素的时间复杂度是O(m+n),因此时间复杂度O(mlogm+nlogn)。
空间复杂度的分析:m 和 n 分别是两个数组的长度。空间复杂度主要取决于排序使用的额外空间,可见上文sort()的空间复杂度分析。
leetcode 202.快乐数
代码实现
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<int> set;
while(1){
int sum = getSum(n);
if(sum == 1)
return true;
if(set.find(sum) != set.end()){
return false;
}
else{
set.insert(sum);
}
n = sum;
}
}
};
细节处理
题目中说存在无限循环,那么说明,一旦发生无限循环,这个数就不是快乐数。
需要用到多次求和的值进行对比,如果存在与之前求和的值相等的情况,那么就进入了无限循环。
额外定义一个getSum()求和函数,方便在主函数中进行计算。
leetcode 1.两数之和
哈希map
代码实现
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int, int> map;
for(int i = 0; i < nums.size(); i++){
auto iter = map.find(target - nums[i]);
if(iter != map.end())
return {iter->second, i};
else
map.insert(pair<int, int>(nums[i], i));
}
return {};
}
};
时间复杂度O(n)
空间复杂度O(n)
细节处理
本题我们需要一个集合来存放我们遍历过的元素,然后在遍历数组的时候去询问这个集合,某元素是否遍历过,也就是是否出现在这个集合。那么就应该想到使用哈希法了。
本题我们不仅要知道元素有没有遍历过,还有知道这个元素对应的下标,需要使用 key value结构来存放,key来存元素,value来存下标,那么使用map正合适。这道题目中并不需要key有序,选择std::unordered_map 效率更高。
总结
当我们需要查询一个元素是否出现过,或者一个元素是否在集合里的时候,就要第一时间想到哈希法。