目录
(一)哈希表理论基础
1. 哈希表
哈希表是根据关键码的值而直接进行访问的数据结构,一般用来快速判断一个元素是否出现集合里。
2. 哈希函数
哈希函数,把元素直接映射为哈希表上的索引,然后就可以通过查询索引下标快速知道这个元素是否在这个集合里。
哈希函数通过 hashCode 把元素转化为数值,一般 hashcode 是通过特定编码方式,可以将其他数据格式转化为不同的数值,这样就把元素映射为哈希表上的索引数字了。
如果 hashCode 得到的数值大于哈希表的大小,为了保证映射出来的索引数值都落在哈希表上,我们会在再次对数值做一个取模的操作,这样就保证了元素一定可以映射到哈希表上。
3. 哈希碰撞
如果元素的数量大于哈希表的大小,此时就算哈希函数计算得再均匀,也避免不了会有几个元素同时映射到哈希表同一个索引下标的位置。有几个元素映射到同一个索引下标这一现象叫做哈希碰撞。
一般哈希碰撞有两种解决方法, 拉链法和线性探测法。
(1)拉链法
发生冲突的元素都被存储在链表中,要选择适当的哈希表的大小,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。
(2)线性探测法
使用线性探测法一定要保证 tableSize 大于 dataSize。 我们需要依靠哈希表中的空位来解决碰撞问题。例如冲突的位置放了小李,那么就向下找一个空位放置小王的信息。所以要求 tableSize 一定要大于 dataSize ,不然哈希表上就没有空置的位置来存放冲突的数据了。
4. 常见的三种哈希结构
当我们想使用哈希法来解决问题的时候,我们一般会选择如下三种数据结构。
-
数组
-
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) |
unordered_set 底层实现为哈希表,set 和 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) |
unordered_map 底层实现为哈希表,map 和 multimap 的底层实现是红黑树。同理,map 和 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 也是一样的道理。
(二)有效的字母异位词
1. 题目描述
给定两个字符串 s
和 t
,编写一个函数来判断 t
是否是 s
的字母异位词。
注意:若 s
和 t
中每个字符出现的次数都相同,则称 s
和 t
互为字母异位词。
2. 思路
数组就是一个简单哈希表,而且这道题目中字符串只有小写字符,那么就可以定义一个数组,来记录字符串s里字符出现的次数。
3. 解题过程
难易程度:简单
标签:哈希表、字符串、排序
class Solution {
public:
bool isAnagram(string s, string t) {
if(s.size() != t.size()){
return false;
}
vector<int> count(26, 0);
// 计数,s出现一次增加,t出现的减少
for(int i = 0; i < s.size(); i++){
count[s[i] - 'a']++;
count[t[i] - 'a']--;
}
// 如果是字母异位词,count中所有应该为0
for(int i = 0; i < 26; i++){
if(count[i] != 0){
return false;
}
}
return true;
}
};
4. 相关题目
(1)383. 赎金信 - 力扣(LeetCode)
① 题目描述
给你两个字符串:ransomNote 和 magazine ,判断 ransomNote
能不能由 magazine
里面的字符构成。
如果可以,返回 true
;否则返回 false
。
magazine
中的每个字符只能在 ransomNote
中使用一次。
② 解题过程
class Solution {
public:
bool canConstruct(string ransomNote, string magazine) {
if(ransomNote.size() > magazine.size()){
return false;
}
vector<int> count(26, 0);
for(int i = 0; i < magazine.size(); i++){
count[magazine[i] - 'a']++;
}
for(auto & c : ransomNote){
count[c - 'a']--;
if(count[c - 'a'] < 0){
return false;
}
}
return true;
}
};
(2)49. 字母异位词分组 - 力扣(LeetCode)
我的做法:统计每个字符串中字母出现的次数,存数组哈希表,成了一个二维的n*26的数组,(中间的错误思路:将这个二维的数组中的每行存为一个字符串,得到长度为n的一个字符串数组,然后进行比较。问题:如果字母出现次数超过一位数,比较可能出现问题)然后暴力对比,三重循环。
方法一:排序
由于互为字母异位词的两个字符串包含的字母相同,因此对两个字符串分别进行排序之后得到的字符串一定是相同的,故可以将排序之后的字符串作为哈希表的键。
class Solution { public: vector<vector<string>> groupAnagrams(vector<string>& strs) { unordered_map<string, vector<string>> mp; for (string& str: strs) { string key = str; sort(key.begin(), key.end()); mp[key].emplace_back(str); } vector<vector<string>> ans; for (auto it = mp.begin(); it != mp.end(); ++it) { ans.emplace_back(it->second); } return ans; } };
emplace_back()
和push_abck()
的区别是:push_back()
在向 vector 尾部添加一个元素时,首先会创建一个临时对象,然后再将这个临时对象移动或拷贝到 vector 中(如果是拷贝的话,事后会自动销毁先前创建的这个临时元素);而emplace_back()
在实现时,则是直接在 vector 尾部创建这个元素,省去了移动或者拷贝元素的过程。一般情况下,emplace_back()
方法比push_back()
方法效率高。方法二:计数
由于互为字母异位词的两个字符串包含的字母相同,因此两个字符串中的相同字母出现的次数一定是相同的,故可以将每个字母出现的次数使用字符串表示,作为哈希表的键。由于字符串只包含小写字母,因此对于每个字符串,可以使用长度为26的数组记录每个字母出现的次数。
class Solution { public: vector<vector<string>> groupAnagrams(vector<string>& strs) { // 自定义对 array<int, 26> 类型的哈希函数 // 定义了一个lambda函数arrayHash,用于计算array<int, 26>类型的哈希值。 // 它使用了一个名为fn的哈希函数对象,对arr中的每个元素进行哈希计算,并通过accumulate函数累加哈希值。 auto arrayHash = [fn = hash<int>{}] (const array<int, 26>& arr) -> size_t { return accumulate(arr.begin(), arr.end(), 0u, [&](size_t acc, int num) { return (acc << 1) ^ fn(num); }); }; // 定义了一个无序映射(unordered_map),其中键是array<int, 26>类型,值是vector<string>类型。 // decltype(arrayHash)用于指明哈希函数的类型,并将其作为第二个参数传递给unordered_map构造函数, // 以便在插入元素时使用自定义的哈希函数。 unordered_map<array<int, 26>, vector<string>, decltype(arrayHash)> mp(0, arrayHash); for (string& str: strs) { array<int, 26> counts{}; int length = str.length(); for (int i = 0; i < length; ++i) { counts[str[i] - 'a'] ++; } mp[counts].emplace_back(str); } vector<vector<string>> ans; for (auto it = mp.begin(); it != mp.end(); ++it) { ans.emplace_back(it->second); } return ans; } };
438. 找到字符串中所有字母异位词 - 力扣(LeetCode)
我的做法:暴力,把p的字母次数统计出来,将其与每个s的相同长度子串比较。
方法一:滑动窗口 思路
根据题目要求,我们需要在字符串
s
寻找字符串p
的异位词。因为字符串p
的异位词的长度一定与字符串 p 的长度相同,所以我们可以在字符串 s 中构造一个长度为与字符串 p 的长度相同的滑动窗口,并在滑动中维护窗口中每种字母的数量;当窗口中每种字母的数量与字符串 p 中每种字母的数量相同时,则说明当前窗口为字符串 p 的异位词。在算法的实现中,我们可以使用数组来存储字符串 p 和滑动窗口中每种字母的数量。同时,当字符串 s 的长度小于字符串 p 的长度时,字符串 s 中一定不存在字符串 p 的异位词。但是因为字符串 s 中无法构造长度与字符串 p 的长度相同的窗口,所以这种情况需要单独处理。方法二:优化的滑动窗口
在方法一的基础上,我们不再分别统计滑动窗口和字符串 p 中每种字母的数量,而是统计滑动窗口和字符串 p 中每种字母数量的差;并引入变量 differ 来记录当前窗口与字符串 p 中数量不同的字母的个数,并在滑动窗口的过程中维护它。在判断滑动窗口中每种字母的数量与字符串 p 中每种字母的数量是否相同时,只需要判断 differ 是否为零即可。
class Solution { public: vector<int> findAnagrams(string s, string p) { int sLen = s.size(), pLen = p.size(); if (sLen < pLen) { return vector<int>(); } vector<int> ans; vector<int> count(26); for (int i = 0; i < pLen; ++i) { ++count[s[i] - 'a']; --count[p[i] - 'a']; } int differ = 0; for (int j = 0; j < 26; ++j) { if (count[j] != 0) { ++differ; } } if (differ == 0) { ans.emplace_back(0); } for (int i = 0; i < sLen - pLen; ++i) { if (count[s[i] - 'a'] == 1) { // 窗口中字母 s[i] 的数量与字符串 p 中的数量从不同变得相同 --differ; } else if (count[s[i] - 'a'] == 0) { // 窗口中字母 s[i] 的数量与字符串 p 中的数量从相同变得不同 ++differ; } --count[s[i] - 'a']; if (count[s[i + pLen] - 'a'] == -1) { // 窗口中字母 s[i+pLen] 的数量与字符串 p 中的数量从不同变得相同 --differ; } else if (count[s[i + pLen] - 'a'] == 0) { // 窗口中字母 s[i+pLen] 的数量与字符串 p 中的数量从相同变得不同 ++differ; } ++count[s[i + pLen] - 'a']; if (differ == 0) { ans.emplace_back(i + 1); } } return ans; } };
5. 滑动窗口框架
void slidingWindow(string s, string t) {
unordered_map<char, int> need, window;
for (char c : t) need[c]++;
int left = 0, right = 0;
int valid = 0;
while (right < s.size()) {
// c 是将移入窗口的字符
char c = s[right];
// 右移窗口
right++;
// 进行窗口内数据的一系列更新
...
/*** debug 输出的位置 ***/
printf("window: [%d, %d)\n", left, right);
/********************/
// 判断左侧窗口是否要收缩
while (window needs shrink) {
// d 是将移出窗口的字符
char d = s[left];
// 左移窗口
left++;
// 进行窗口内数据的一系列更新
...
}
}
}
本题应用:
vector<int> findAnagrams(string s, string t) {
unordered_map<char, int> need, window;
for (char c : t) need[c]++;
int left = 0, right = 0;
int valid = 0;
vector<int> res; // 记录结果
while (right < s.size()) {
char c = s[right];
right++;
// 进行窗口内数据的一系列更新
if (need.count(c)) {
window[c]++;
if (window[c] == need[c])
valid++;
}
// 判断左侧窗口是否要收缩
while (right - left >= t.size()) {
// 当窗口符合条件时,把起始索引加入 res
if (valid == need.size())
res.push_back(left);
char d = s[left];
left++;
// 进行窗口内数据的一系列更新
if (need.count(d)) {
if (window[d] == need[d])
valid--;
window[d]--;
}
}
}
return res;
}
(三)两个数组的交集
1. 题目描述
给定两个数组 nums1
和 nums2
,返回 它们的 交集 。输出结果中的每个元素一定是 唯一 的。我们可以 不考虑输出结果的顺序 。
2. 思路
如果哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费,此时就要使用另一种结构体了,set
set的用法:插入(insert)、判断是否存在元素(count)、删除(erase)、查找某个指定元素的迭代器(find)
unordered_set<int> nums_set(nums1.begin(), nums1.end());
3. 解题过程
难易程度:简单
标签:数组、哈希表、双指针、二分查找、排序
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> nums_set(nums1.begin(), nums1.end());
vector<int> result;
for(auto & num : nums2){
if(nums_set.count(num)){
result.emplace_back(num);
nums_set.erase(num);
}
}
return result;
}
};
4. 相关题目
350. 两个数组的交集 II - 力扣(LeetCode)
(四)快乐数
1. 题目描述
编写一个算法来判断一个数 n
是不是快乐数。
「快乐数」 定义为:
- 对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。
- 然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。
- 如果这个过程 结果为 1,那么这个数就是快乐数。
如果 n
是 快乐数 就返回 true
;不是,则返回 false
。
2. 思路
题目中说了会 无限循环,那么也就是说求和的过程中,sum会重复出现,这对解题很重要!
3. 解题过程
难易程度:简单
标签:哈希、数学、双指针
(1)哈希集合
其实题目中就给了提示无限循环,如果注意到这个就并不难解。
class Solution {
public:
// 计算每个位置上的数字的平方和
int calculate(int n){
int result = 0;
while(n){
result += (n % 10) * (n % 10);
n /= 10;
}
return result;
}
bool isHappy(int n) {
unordered_set<int> nums;
while(n != 1){
// 如果重复出现就不是快乐数
if(nums.count(n)){
return false;
}
nums.insert(n);
n = calculate(n);
}
return true;
}
};
(2)双指针
使用 “快慢指针” 思想,找出循环:“快指针” 每次走两步,“慢指针” 每次走一步,当二者相等时,即为一个循环周期。此时,判断是不是因为 1 引起的循环,是的话就是快乐数,否则不是快乐数。
class Solution { public: int bitSquareSum(int n) { int sum = 0; while(n > 0) { int bit = n % 10; sum += bit * bit; n = n / 10; } return sum; } bool isHappy(int n) { int slow = n, fast = n; do{ slow = bitSquareSum(slow); fast = bitSquareSum(fast); fast = bitSquareSum(fast); }while(slow != fast); return slow == 1; } };
(五)两数之和
1. 题目描述
给定一个整数数组 nums
和一个整数目标值 target
,请你在该数组中找出 和为目标值 target
的那 两个 整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
你可以按任意顺序返回答案。
2. 思路
这道题 我们需要 给出一个元素,判断这个元素是否出现过,如果出现过,返回这个元素的下标。判断元素是否出现,这个元素就要作为 key,所以数组中的元素作为 key,有 key 对应的就是 value,value 用来存下标。所以 map 中的存储结构为 {key:数据元素,value:数组元素对应的下标}。
3. 解题过程
难易程度:简单
标签:数组、哈希表
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 it = map.find(target - nums[i]);
if(it != map.end()){
return {i, it->second};
}
// 把这个数插进去
map.insert(pair<int, int>(nums[i], i));
}
return {};
}
};
4. 关于map的一点补充
map 是 STL 的一个关联容器,它提供一对一的数据处理能力(有序键值对),第一个元素称为关键字(key,第二个称为关键字的值(value),其中关键字是唯一的。
pair 是“二元结构体”的替代品,将两个元素捆绑在一起,节省编码时间。相当于以下定义:
struct pair
{
typename1 first;
typename2 second;
}
二者配合使用:
#include<iostream>
#include<map>
using namespace std;
map<int,string> mp;
int main() {
pair<int, string> p;
p.first = 0;
p.second = "haha";
pair<int, string> p1;
p1 = make_pair(1,"haha1");
pair<int, string> p2 = make_pair(2,"haha2");
pair<int, string> p3(3,"haha3");
mp.insert(p);
mp.insert(p3);
mp.insert(p1);
mp.insert(p2);
mp.insert(make_pair(99,"hah99"));
mp.insert(pair<int, string>(9,"hah9"));
for(map<int,string>::iterator it = mp.begin(); it!= mp.end(); it++ )
cout << it->first <<" "<< it->second << endl;
return 0;
}