哈希表理论基础
哈希表的定义和作用
哈希表(也叫散列表),Hash Table,即根据关键码的值来直接访问元素的数据结构。数组其实就是哈希表的一种,其中数组索引就是关键码,在数组中我们根据索引下标直接访问到数组元素。
哈希表最重要的作用是能快速查询一个元素是否在集合里。
哈希函数
哈希函数(hash function),作用是把关键码映射为哈希表的索引下标,即 index = HashFunction(key)。常见的哈希函数即除留余数法, HashFunction(key) = hashcode(key) % tableSize. 其中 tableSize 指哈希表的大小。
关键字不一定是数值,可能是字符串类型,而 hashcode 就是通过特定编码方式将其他数据格式转化为不同数值的方法。
哈希碰撞
如上述所言,哈希函数可以将关键字映射为索引,但是如果哈希函数将多个关键字映射到了同一下标,则会造成冲突,即哈希碰撞。
Q1:如何减少冲突?
构造更合适的哈希函数,尽量使函数的映射关系为 一对一 而不是 多对一。
Q2:如何处理冲突?
哈希碰撞的两种解决方法:拉链法、线性探测法
拉链法:
将发生哈希冲突的元素存储在链表中,哈希表中对应索引下标的元素存放的则是指向该链表的指针。
线性探测法(要保证 dataSize > tableSize):
利用哈希表剩余的空间存储冲突的数据,例如,当数据发现位置已经被占用,则寻找下一个空位。
三种哈希结构
三种常见的哈希结构分别是数组、map(映射)、set(集合)。其中 map 和 set 是C++中两个主要的关联容器(关联容器中的元素是按照关键字来保存和访问的)。map中的元素是键值对(key-value),set 的元素即关键字 key。C++ 标准模板库(STL)根据 是否有序 和 是否可重复 将 map 和 set 进行分类,其中,允许重复关键字的命名中包含单词 multi,无序都以单词 unordered 开头。
// 头文件
#include <set> // 包括 set 和 multiset
#include <unordered_set> // 包括 unordered_set
#include <map> // 包括 map 和 multimap
#include <unordered_map> // 包括 unordered_map
set 集合
如果需要使用集合解决哈希问题,优先使用 unordered_set,因为其查询和增删效率最优。
集合 | 底层实现 | 分类 | 能否更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|
set | 红黑树 | 有序不重复 | 不能 | O(log n) | O(log n) |
multiset | 红黑树 | 有序可重复 | 不能 | O(log n) | O(log n) |
unordered_set | 哈希表 | 无序不重复 | 不能 | O(1) | O(1) |
map 映射
映射 | 底层实现 | 分类 | 能否更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|
map | 红黑树 | key 有序不重复 | 不能 | O(log n) | O(log n) |
multimap | 红黑树 | key 有序可重复 | 不能 | O(log n) | O(log n) |
unordered_map | 哈希表 | key 无序不重复 | 不能 | O(1) | O(1) |
有效的字母异位词
题干
题目:给定两个字符串 s 和 t ,编写一个函数来判断 t 是否是 s 的字母异位词(若 s 和 t 中每个字符出现的次数都相同,则称 s 和 t 互为字母异位词)。其中 s 和 t 仅包含小写字母。
示例 1: 输入: s = "anagram", t = "nagaram" 输出: true
示例 2: 输入: s = "rat", t = "car" 输出: false
思路
首先理解字母异位词的意思,是指字符串 t 是 字符串 s 的字母打乱顺序的结果。那么只需要保证 t 和 s 的字母、字母个数相同即可判定是t 是字母异位词。
方法一:遍历字符串 s 和字符串 t,用两个数组 sCount[26] 和 tCount[26]分别记录下 s 和 t 中26个字母出现的次数。再次遍历两个数组对比是否有字母出现的次数不同。
方法二:看了代码随想录的题解,思想关键也是用数组存储字母出现的次数,不同的是只需要用一个数组存储 s 的字母次数即可,而在遍历字符串 t 时将字母次数减一,如果最后数组中字母次数都为0,说明 t 是 s 的字母异味词 。
代码
方法一
class Solution {
public:
bool isAnagram(string s, string t) {
// 字符串长度不同直接返回 false
if (s.size() != t.size()){
return false;
}
int sCount[26] = {0}; // 初始化为 0
int tCount[26] = {0};
// 程序到这一步说明 s 和 t 长度肯定相同
// 因此可以只用一个for循环同时遍历两个字符串
for (int i = 0; i < s.size(); ++i) {
sCount[s[i]-'a']++;
tCount[t[i]-'a']++;
}
for (int i = 0; i < 26; ++i) {
if (sCount[i] != tCount[i]){
return false;
}
}
return true;
}
};
方法二
class Solution {
public:
bool isAnagram(string s, string t) {
int sCount[26] = {0}; // 初始化为 0
// 因为分别用两个 for 循环遍历 s 和 t 两个字符串,所以不需要像方法一一样比较长度
for (int i = 0; i < s.size(); ++i) {
sCount[s[i]-'a']++;
}
for (int i = 0; i < t.size(); ++i) {
sCount[t[i]-'a']--;
}
for (int i = 0; i < 26; ++i) {
if (sCount[i] != 0){
return false;
}
}
return true;
}
};
两个数组的交集
题干
题目:给定两个数组 nums1 和 nums2,返回它们的交集。输出结果中的每个元素一定是 唯一 的。我们可以 不考虑输出结果的顺序 。
思路
方法一:由于交集元素唯一且不需要考虑顺序,那么可以用 无序且不可重复集合 unordered_set 来存储其中一个数组的元素,之后无需用两个 for 循环遍历数组,只需在集合中进行查询对比,因为集合查询的效率远远高于数组。首先遍历数组 nums1,将元素存储在集合 numSet 中。之后遍历数组 nums2,如果 nums2 的元素也在集合中则将其存储到交集数组中,同时删除集合中的该元素,删除是为了避免数组 nums2 之后可能有重复的交集元素。时间复杂度O(n)
方法二暴力解法:两个for循环遍历数组,逐一比较数组中的元素找出相同的交集。时间复杂度O(n^2)
代码
方法一
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
vector<int> result; // 存储结果
unordered_set<int> numSet(nums1.begin(),nums1.end()); // 用集合存储数组去重
for (int num : nums2) {
if (numSet.find(num) != numSet.end()){
result.push_back(num); // 交集元素只需要 push_back 一次就够了,所以之后要删除
// 同时在集合中删除该元素,免得 数组 nums2 后续元素有重复
numSet.erase(num);
}
}
return result;
}
};
方法二:暴力解法
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
vector<int> result; // 存储结果
for (int i = 0; i < nums1.size(); ++i) {
for (int j = 0; j < nums2.size(); ++j) {
if (nums1[i] == nums2[j]){
// find 函数在头文件 #include <algorithm> 中
if (find(result.begin(),result.end(),nums1[i]) == result.end()){
result.push_back(nums1[i]);
}
}
}
}
return result;
}
};
快乐数
题干
题目:编写一个算法来判断一个数 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
思路
解决此题最关键的是两个问题:
Q1:如何判定 n 是快乐数?
n 最后能变成 1。
Q2:如何判定 n 是非快乐数?
n 变化过程中出现了循环,即出现了重复。
代码
class Solution {
public:
// 求每次变化后 n 变为了啥
int replace(int n){
int num = 0; // 记录每一位数字
int sum = 0; // 记录平方和
while (n > 0){
num = n%10;
sum += num*num;
n = n/10;
}
return sum;
}
bool isHappy(int n) {
unordered_set<int> nums;
// 存储每次变换后的数,无重复集合,如果之后新的平方和已经出现在该集合中,说明出现了循环
bool flag;
while (1){
if (n == 1){ // 当 n 变为 1 时,说明是快乐数
flag = true;
break;
}
if (nums.find(n) == nums.end()){
// 没找到说明是新的数,插入集合中
nums.insert(n);
} else{
// 如果找到了之前的数,说明循环,不是快乐数
flag = false;
break;
}
n = replace(n); // 不断替换成每一位的平方和
}
return flag;
}
};
两数之和
题干
题目:给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。你可以假设每种输入只会对应一个答案。但是,数组中同一个位置的元素在答案里不能重复使用两遍。你可以按任意顺序返回答案。2 <= nums.length <= 10^4,nums[ i ] 和 target 都可能是负数。
示例:
给定 nums = [2, 7, 11, 15], target = 9
因为 nums[0] + nums[1] = 2 + 7 = 9
所以返回 [0, 1]
思考
方法一暴力解法:两两对比数组中的元素,找到 和 为 target 的下标。时间复杂度 O(n^2),在力扣中运行时间为一百多毫秒。
方法二map:看了题解,可以在一次遍历数组时,用 map 的键值对存储前面遍历过的数组中的元素和对应的下标,在后续遍历时我们需要查询 map 是否存在能和之后的元素配对成 target 的数值,如果有则返回,如果没有则存入map中。时间复杂度为 O(n),在力扣中运行时间大概为10毫秒以内。
Q1:为什么是用 map 存储遍历过元素?
因为我们在找 target 时面对两个问题,一是两个元素是否匹配其和为 target,二是如果匹配那么两个元素的数组下标是什么?我们不仅需要操作元素值,也需要查询到数组下标,而map的键值对刚好可以将元素值和下标进行存储和映射。map 可以直接通过元素值返回数组下标。
Q2:map 是不可重复的,那么遍历数组时碰到前后有重复元素时是什么情况?
假如之前map已经存储了元素 3,而后续遍历数组时又碰到元素 3,如果 target 为6,刚好配对,直接 return;如果 target 不为 6,那么不配对,此时将后面的元素 3 替代掉原来map 中的元素 3,虽然键值对中的数组下标改变了,但是元素值没变,并不影响之后的配对。
代码
方法一:暴力解法
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
vector<int> index; // 存储结果下标
for (int i = 0; i < nums.size(); ++i) {
for (int j = i+1; j < nums.size(); ++j) {
if (nums[i]+nums[j] == target){
index.push_back(i);
index.push_back(j);
return index;
}
}
}
return {};
}
};
方法二:map
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
unordered_map<int,int> pastNum; // 可以无序
vector<int> result;
for (int i = 0; i < nums.size(); ++i) {
if (pastNum.find(target-nums[i]) != pastNum.end()){
// 找到配对的
result.push_back(pastNum[target-nums[i]]);
result.push_back(i);
return result;
} else{
pastNum.insert({nums[i],i});
}
}
return {}; // 如果for循环找不到满足条件的,返回空
}
};