目录
理论基础
哈希表(Hash Table),也称为散列表,是根据关键码值(Key value)而直接进行访问的数据结构。它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数称为哈希函数,存放记录的数组称为哈希表。
哈希表的主要概念和特点:
-
哈希函数:哈希函数是哈希表的核心,它接受一个输入(通常是字符串或整数),并产生一个输出,这个输出通常是一个整数,称为哈希值或哈希码。理想情况下,哈希函数应该能够均匀地分布输入到输出范围,从而减少哈希冲突(即不同的输入产生相同的哈希值)。
-
哈希冲突:由于哈希函数的输出范围通常是有限的(比如一个固定大小的数组索引),因此不同的输入可能会产生相同的哈希值,这就是哈希冲突。解决哈希冲突的方法有很多,常见的有开放寻址法(当冲突发生时,寻找下一个空闲位置)和链地址法(每个哈希表位置存储一个链表,所有映射到该位置的元素都存储在这个链表中)。
-
性能:哈希表在平均情况下提供了常数时间复杂度(O(1))的查找、插入和删除操作,这使得它成为处理大量数据时的理想选择。然而,在最坏情况下(如所有元素都映射到同一个位置),哈希表的性能会退化到线性时间复杂度(O(n))。
常见的三种哈希结构
- 数组
- 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也是有序的
如何选择,使用 set or multiset or unordered_set?
- 当我们要使用集合来解决哈希问题的时候,优先使用unordered_set,因为它的查询和增删效率是最优的
- 如果需要集合是有序的,那么就用set
- 如果要求不仅有序还要有重复数据的话,那么就用multiset。
什么时候用哈希法?
- 当我们需要查询一个元素是否出现过,或者一个元素是否在集合里的时候
242.有效的字母异位词
题目链接:242. 有效的字母异位词 - 力扣(LeetCode)
给定两个字符串 s
和 t
,编写一个函数来判断 t
是否是 s
的字母异位词。
解题思路:
使用哈希表(数组)来记录字符串
s
中每个字符出现的次数,然后遍历字符串t
,逐一减少哈希表中对应字符的计数。把字符映射到数组也就是哈希表的索引下标上,因为字符a到字符z的ASCII是26个连续的数值,所以字符a映射为下标0,相应的字符z映射为下标25。
class Solution {
public:
bool isAnagram(string s, string t) {
// 使用数组记录s中字符出现的次数
int record[26] = {0};
// 遍历s,记录
for(int i = 0; i < s.length(); i++){
record[s[i] - 'a']++;
}
// 遍历t,减去t中字符出现的次数
for(int i = 0; i < t.length(); i++){
record[t[i] - 'a']--;
}
// 查看数组中,是否有元素大于0或者小于0
for(int i = 0; i < size(record); i++){
if(record[i] != 0){
return false;
}
}
return true;
}
};
相关题目:
49.字母异位词分组
题目链接:49. 字母异位词分组 - 力扣(LeetCode)
给你一个字符串数组,请你将 字母异位词 组合在一起。可以按任意顺序返回结果列表。
字母异位词 是由重新排列源单词的所有字母得到的一个新单词。
解题思路:
使用哈希表(unordered_map)来存储这些同形异义词组。
其中键是单词中字母排序后的结果,而值则是所有具有相同排序键的原始单词的列表。
接下来,我们遍历给定的字符串数组(strs
)。对于数组中的每一个单词,我们执行以下步骤:
-
排序单词:将单词中的字母按字母顺序排序,得到一个排序后的字符串。这个排序后的字符串将作为我们在字典中查找或插入的键。
-
查找或插入字典:
- 我们检查排序后的字符串(即排序键)是否已经在字典中作为键存在。
- 如果不存在,我们在字典中创建一个新的条目,键是排序后的字符串,值是一个新的空列表,用于存储具有此排序键的原始单词。
- 如果存在,我们只需将当前的原始单词添加到与该排序键相关联的列表中。
-
继续遍历:我们重复上述步骤,直到遍历完数组中的所有单词。
最后,我们将字典中所有的值(即所有同形异义词的列表)收集起来,并将它们作为结果返回。
class Solution {
public:
vector<vector<string>> groupAnagrams(vector<string>& strs) {
unordered_map<string,vector<string>> mp;
for(const string& word : strs){
string sorted_word = word;
sort(sorted_word.begin(),sorted_word.end());// 对单词进行排序
// 使用排序后的单词作为键
mp[sorted_word].push_back(word);
}
// 提取结果,将map的值(即vector<string>)转换为vector<vector<string>>
vector<vector<string>> result;
for(const auto& pair : mp){
result.push_back(pair.second);
}
return result;
}
};
438.找到字符串中所有字母异位词
题目链接:438. 找到字符串中所有字母异位词 - 力扣(LeetCode)
给定两个字符串 s
和 p
,找到 s
中所有 p
的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。
异位词 指由相同字母重排列形成的字符串(包括相同的字符串)。
解题思路:
采用滑动窗口结合字符频率统计的方法
基本思路是,首先统计字符串
p
中每个字符的频率,然后使用一个滑动窗口在字符串s
上滑动,同时维护一个当前窗口内字符的频率统计。当窗口的大小与p
的长度相等时,我们检查当前窗口的字符频率是否与p
的字符频率相匹配,如果匹配,则找到了一个异位词,记录起始索引。
class Solution {
public:
vector<int> findAnagrams(string s, string p) {
vector<int> result;
unordered_map<char,int> pFreq;
unordered_map<char,int> windowFreq;
// 统计p中字符的频率
for(char c : p){
pFreq[c]++;
}
// 初始化窗口频率表
int left = 0,matched = 0,required = p.size();
for(int i = 0; i < p.size(); i++){
if(pFreq.find(s[i]) != pFreq.end()){
windowFreq[s[i]]++;
if(windowFreq[s[i]] == pFreq[s[i]]){
matched++;
}
}
}
// 如果初始窗口就是答案之一
if(matched == required){
result.push_back(left);
}
// 滑动窗口
for(int right = p.size(); right < s.size(); right++){
// 窗口右移,加入新字符
char rightChar = s[right];
if(pFreq.count(rightChar)){
windowFreq[rightChar]++;
if(windowFreq[rightChar] == pFreq[rightChar]){
matched++;
}
}
// 窗口左移,移除左边界字符
char leftChar = s[left];
if(pFreq.count(leftChar)){
if(windowFreq[leftChar] == pFreq[leftChar]){
matched--;
}
windowFreq[leftChar]--;
}
// 移动左边界
left++;
// 检查是否匹配
if(matched == required){
result.push_back(left);
}
}
return result;
}
};
349. 两个数组的交集
题目链接:349. 两个数组的交集 - 力扣(LeetCode)
给定两个数组 nums1
和 nums2
,返回 它们的 交集。输出结果中的每个元素一定是 唯一 的。我们可以 不考虑输出结果的顺序 。
解题思路:
使用哈希表记录数组元素
注意题目特意说明:输出结果中的每个元素一定是唯一的,也就是说输出的结果的去重的, 同时可以不考虑输出结果的顺序。
所以,这里哈希表采用的是unorder_set
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> result_set; // 存放结果,之所以用set是为了给结果集去重
unordered_set<int> nums_set(nums1.begin(), nums1.end());
for (int num : nums2) {
// 发现nums2的元素 在nums_set里又出现过
if (nums_set.find(num) != nums_set.end()) {
result_set.insert(num);
}
}
return vector<int>(result_set.begin(), result_set.end());
}
};
202. 快乐数
编写一个算法来判断一个数 n
是不是快乐数。
「快乐数」 定义为:
- 对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。
- 然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。
- 如果这个过程 结果为 1,那么这个数就是快乐数。
如果 n
是 快乐数 就返回 true
;不是,则返回 false
。
解题思路:
对于1个数,会有以下三种可能。
- 最终会得到 1。
- 最终会进入循环。
- 值会越来越大,最后接近无穷大。
情况1:
情况2:
情况3:
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。但它不会无限期地进行下去,所以我们排除第三种选择。
利用哈希集合(HashSet)来记录已经出现过的数,以此来检测是否进入了无限循环。这是因为,如果一个数在迭代过程中重复出现,那么它之后的迭代结果也会重复出现,导致无法到达 1。
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;
}
// 如果这个sum曾经出现过,说明已经陷入了无限循环了,立刻return false
if (set.find(sum) != set.end()) {
return false;
} else {
set.insert(sum);
}
n = sum;
}
}
};
我们也可以发现,可以使用快慢指针的思想,判断是否存在环
class Solution {
public:
int getSum(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;
while(slow != fast && fast != 1){
slow = getSum(slow);
fast = getSum(fast);
fast = getSum(fast);
}
return fast == 1;
}
};
1. 两数之和
给定一个整数数组 nums
和一个整数目标值 target
,请你在该数组中找出 和为目标值 target
的那 两个 整数,并返回它们的数组下标。
解题思路:
使用哈希表来存储遍历过的元素及其索引。
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
vector<int> result;
unordered_map<int,int> nums_map;
for(int i = 0; i < nums.size(); i++){
int complement = target - nums[i];
if(nums_map.find(complement) != nums_map.end()){
// 如果找到了补数,则返回它们的索引
result.push_back(nums_map[complement]);
result.push_back(i);
return result;
}
// 如果没找到,将当前元素及其索引添加到哈希表中
nums_map[nums[i]] = i;
}
return result;
}
};
第454题.四数相加II
题目链接:454. 四数相加 II - 力扣(LeetCode)
给你四个整数数组 nums1
、nums2
、nums3
和 nums4
,数组长度都是 n
,请你计算有多少个元组 (i, j, k, l)
能满足:
0 <= i, j, k, l < n
nums1[i] + nums2[j] + nums3[k] + nums4[l] == 0
解题思路:采用哈希表来优化查找过程
我们先固定两个数组(例如
nums1
和nums2
),计算所有可能的nums1[i] + nums2[j]
的和,并将这些和出现的次数存储在一个哈希表中。然后,我们遍历另外两个数组(nums3
和nums4
),对于每一对(nums3[k], nums4[l])
,我们计算它们的相反数-(nums3[k] + nums4[l])
,并检查这个相反数是否存在于我们之前构建的哈希表中。如果存在,则说明找到了一个满足条件的元组,我们只需将对应的计数加到结果中即可。
class Solution {
public:
int fourSumCount(vector<int>& nums1, vector<int>& nums2, vector<int>& nums3, vector<int>& nums4) {
// 用于存储 nums1[i] + nums2[j] 的和及其出现的次数
unordered_map<int,int> record;
int result = 0;
// 遍历 nums1 和 nums2 的所有组合,并统计和的出现次数
for(int x : nums1){
for(int y : nums2){
record[x+y]++;
}
}
// 遍历 nums3 和 nums4 的所有组合,查找 -(nums3[k] + nums4[l]) 是否在 record 中
for(int x : nums3){
for(int y : nums4){
if(record.find(0 - x -y) != record.end()){
result += record[0 - x -y];
}
}
}
return result;
}
};
第15题. 三数之和
给你一个整数数组 nums
,判断是否存在三元组 [nums[i], nums[j], nums[k]]
满足 i != j
、i != k
且 j != k
,同时还满足 nums[i] + nums[j] + nums[k] == 0
。请你返回所有和为 0
且不重复的三元组。
注意:答案中不可以包含重复的三元组。
解题思路:排序和双指针
首先,对数组进行排序,这样可以方便地跳过重复的元素,并利用有序数组的特性来减少搜索空间。我们可以将问题分解为一系列的子问题,即固定一个数,然后在剩余的有序数组中查找两个数,使得它们的和等于目标值(在这个问题中是0减去固定的那个数)。这样,原问题就被转化为了一个更简单的“两数之和”问题。
难点和关键点:去重
在排序后的数组上,我们使用三个指针来遍历数组:
-
固定指针(
i
):这个指针用于遍历数组中的每个元素,并将其视为三元组中的第一个数。由于数组已经排序,我们可以确保在遍历过程中,nums[i]
是递增的。 -
左指针(
left
):这个指针从固定指针的下一个位置开始,向右移动,用于寻找三元组中的第二个数。 -
右指针(
right
):这个指针从数组的末尾开始,向左移动,用于寻找三元组中的第三个数。
在每次将指针 i
向右移动之前,检查 nums[i]
是否与前一个元素 nums[i-1]
相同。如果是,则跳过这个元素,因为以它开头的所有三元组都会与上一个 nums[i-1]
开头的三元组重复。
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[left], c = nums[right]
for (int i = 0; i < nums.size(); i++) {
// 排序之后如果第一个元素已经大于零,那么无论如何组合都不可能凑成三元组,直接返回结果就可以了
if (nums[i] > 0) {
return result;
}
// 去重a
if (i > 0 && nums[i] == nums[i - 1]) {
continue;
}
int left = i + 1;
int right = nums.size() - 1;
while (right > left) {
// 去重复逻辑如果放在这里,0,0,0 的情况,可能直接导致 right<=left 了,从而漏掉了 0,0,0 这种三元组
/*
while (right > left && nums[right] == nums[right - 1]) right--;
while (right > left && nums[left] == nums[left + 1]) left++;
*/
if (nums[i] + nums[left] + nums[right] > 0) right--;
else if (nums[i] + nums[left] + nums[right] < 0) left++;
else {
result.push_back(vector<int>{nums[i], nums[left], nums[right]});
// 去重逻辑应该放在找到一个三元组之后,对b 和 c去重
while (right > left && nums[right] == nums[right - 1]) right--;
while (right > left && nums[left] == nums[left + 1]) left++;
// 找到答案时,双指针同时收缩
right--;
left++;
}
}
}
return result;
}
};
第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
你可以按 任意顺序 返回答案 。
解题思路:“四数之和”问题可以看作是“三数之和”问题的一个扩展
- 在“三数之和”中,通常使用一个外层循环固定一个数,然后使用两个指针在剩余元素中查找另外两个数。
- 在“四数之和”中,则是使用两个外层循环固定前两个数,然后使用两个指针在剩余元素中查找另外两个数。
class Solution {
public:
vector<vector<int>> fourSum(vector<int>& nums, int target) {
vector<vector<int>> result;
sort(nums.begin(),nums.end());
if(nums.size() < 4){
return result;
}
for(int i = 0; i < nums.size() - 3; i++){
if(i > 0 && nums[i] == nums[i - 1]){
continue;
}
for(int j = i + 1; j < nums.size() - 2; j++){
if(j > i + 1 && nums[j] == nums[j - 1]){
continue;
}
int left = j + 1,right = nums.size() - 1;
while(left < right){
long sum = (long)nums[i] + nums[j] + nums[left] + nums[right];
if(sum == target){
result.push_back(vector<int>{nums[i],nums[j],nums[left],nums[right]});
while(left < right && nums[left + 1] == nums[left]){
left++;
}
while(left < right && nums[right - 1] == nums[right]){
right--;
}
left++;
right--;
}else if(sum > target){
right--;
}else if(sum < target){
left++;
}
}
}
}
return result;
}
};
注意:不要判断nums[0] > target 就返回了,三数之和 可以通过 nums[i] > 0 就返回了,因为 0 已经是确定的数了,四数之和这道题目 target是任意值。比如:数组是[-4, -3, -2, -1],target是-10,不能因为-4 > -10而跳过。