代码随想录算法训练营第六天|Leetcode242 有效的字母异位词、Leetcode349 两个数组的交集、Leetcode202 快乐数、Leetcode1 两数之和
● Hash Table
HashTable
HashTable叫做哈希表,有时候也被叫做散列表。哈希表是根据关键码的值直接进行数据访问的数据结构。 哈希表是基于数组的,因此我们可以将哈希表中的关键码理解为数组的index,然后通过index直接对数组元素进行访问。
当我们需要快速判断一个元素是否出现在集合中时,就需要通过哈希表解决问题。
哈希函数(HashFunction)
哈希表采用的是一种转换思想。在哈希表中一个重要的概念是如何将键或关键字(key)
转换成数组下标,而这个过程则由哈希函数
完成,但是并非所有键或关键字
都需要通过哈希函数(HashFunction)
将其转换为index,有的键或关键字
可以直接作为数组下标。
假设我们需要用哈希表存放班级学生信息,我们直到学生具有学号
和姓名
两个基本属性。当我们将学号
作为key
时,可以直接作为index使用;但当我们将姓名
作为key
时,就需要通过哈希函数完成下标转换的操作。
哈希函数的写法有很多,但不管怎么实现哈希函数,都需要满足三个基本条件:
(1)哈希函数计算得到的哈希值为非负整数;
因为数组的下标是从0开始,所以哈希函数生成的哈希值也应该是非负数
(2)如果key1 = key2,那么hash(key1) == hash(key2);
同一个key生成的哈希值应该是一样的,因为我们需要通过key查找哈希表中的数据
(3)如果key1 != key2,那么hash(key1) != hash(key2)。
两个不一样的值通过哈希函数之后可能才生相同的值,因为我们把巨大的空间转出成较小的数组空间时,不能保证每个数字都映射到数组空白处,这样就会产生
哈希冲突/哈希碰撞(HashCollision)
哈希冲突/哈希碰撞(HashCollision)
当我们使用名字作为key
存放学生信息的时候,哈希函数构造过程可能导致两个不同的value
产生相同的key
,此时就需要我们解决冲突。
哈希冲突不可避免,常用解决哈希冲突的方法有两种:开放地址发
和链表法
。
开放地址法
在开放地址发中,当数据不能直接存放在哈希函数计算的key
时,就需要尝试寻找其他空位置存放。在开放地址法中有三种方式:线性探测法
、二次探测法
和再哈希法
。
线性探测法
所谓的线性探测法,就是当我们发现index = n
被占用时,就尝试index = n + 1
是否为空向后探测,直到发现空位置时填入。
二次探测法
在线性探测法构建的哈希表容易发生数据聚集,一旦聚集形成就会越来越到,导致之后数据操作效率降低。
二次探查则是为了防止出现数据聚集,其探测相隔较远的位置,而非相邻位置填入数据。但当所有映射到同一位置的关键字在寻找空位时,探测的位置都是一样的,因此二次探查又出现了新的聚集问题。
再哈希法
双哈希是为了消除原始聚集和二次聚集问题,不管是线性探测还是二次探测,每次的探测步长都是固定的。双哈希是除了第一个哈希函数外再增加一个哈希函数用来根据关键字生成探测步长,这样即使第一个哈希函数映射到了数组的同一下标,但是探测步长不一样,这样就能够解决聚集的问题。
第二个哈希函数必须具备如下特点:
(1)第二个哈希函数和第一个哈希函数不同;
(2)不能输出为0,因为步长为0,每次探测都是指向同一个位置,将进入死循环,经过试验得出stepSize = constant-(key%constant);形式的哈希函数效果非常好,constant是一个质数并且小于数组容量。
链表法
拉链法就是要选择适当的哈希表的大小,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。
比较
如果使用开放地址法,对于小型的哈希表,双哈希法要比二次探测的效果好,如果内存充足并且哈希表一经创建,就不再修改其容量,在这种情况下,线性探测效果相对比较好,实现起来也比较简单,在装载因子低于0.5的情况下,基本没有什么性能下降。
如果在创建哈希表时,不知道未来存储的数据有多少,使用链表法要比开放地址法好,如果使用开放地址法,随着装载因子的变大,性能会直线下降。
当两者都可以选时,使用链表法,因为链表法对应不确定性更强,当数据超过预期时,性能不会直线下降。
常见的三种哈希结构
当我们需要使用哈希表解决问题时,一般会选择以下三种数据结构:数组
、set
和map
。
C++对于set
和map
分别提供了三种数据结构,因此其底层实现不同,因此查询和增除效率以及对于数值是否重复不同。
集合 | 底层实现 | 有序性 | 数值是否重复 | 能够更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
set | 红黑树 | 有序 | 否 | 否 | O(log n) | O(log n) |
multiset | 红黑树 | 有序 | 是 | 否 | O(log n) | O(log n) |
unordered_set | 哈希表 | 无序 | 否 | 否 | O(1) | O(1) |
红黑树是一种平衡二叉搜索树,所以key值是有序的,但key不可以修改,改动key值会导致整棵树的错乱,所以只能删除和增加。
映射 | 底层实现 | 有序性 | 数值是否重复 | 能够更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
map | 红黑树 | 有序 | 否 | 否 | O(log n) | O(log n) |
multimap | 红黑树 | 有序 | 是 | 否 | O(log n) | O(log n) |
unordered_map | 哈希表 | 无序 | 否 | 否 | O(1) | O(1) |
当使用集合解决哈希问题时,要求查询效率和增删效率最优,优先选择unordered_set
,如果集合有序,则选择set
;如果及要求有序又要有重复数据,选择multiset
;映射相同。
但对于什么情况下使用数组
、set
和map
呢?
当数据规模相对小一点的时候使用数组
,数组规模相对大或者数据分布特别分散的使用set
,最后当需要key-value
时使用map
。
哈希法突出了空间换时间的思想,使用额外的数组、set
或map
存放数据,实现快速查找。
哈希表的长度一般是定长的,在存储数据之前需要数据规模。并且尽可能地避免频繁扩容。但如果设计的太大,那么就会浪费空间,因为存储完所有数据仍有很大空间剩余;如果太小则容易发生哈希冲突,体现不出哈希表的效率。因此哈希表的大小必须要尽可能地减小哈希冲突,并且尽可能地不浪费空间,选择合适的哈希表的大小是提升哈希表性能的关键。
哈希表的效率关键在于采用的哈希算法和哈希冲突。哈希冲突越低,效率越高。为了降低哈希冲突,需要采用大于实际存储数据数量的哈希表,这就是空间换时间的原理。
● Leetcode242 有效的字母异位词
题目链接:Leetcode242 有效的字母异位词
视频讲解:代码随想录|有效的字母异位词
题目描述:给定两个字符串 s 和 t ,编写一个函数来判断 t 是否是 s 的字母异位词。
注意:若 s 和 t 中每个字符出现的次数都相同,则称 s 和 t 互为字母异位词。
示例 1:
输入: s = “anagram”, t = “nagaram”
输出: true
示例 2:
输入: s = “rat”, t = “car”
输出: false
● 解题思路
方法一:排序
首先判断两个字符串长度是否相同,如果两个字符串不一样长,就不需要再逐个字符进行比较。
对两个字符串排序后判断s == t
,返回结果即可。
因为该算法的时间和空间复杂度取决于排序算法的时间和空间复杂度,因此如下:
时间复杂度:O(nlogn)
空间复杂度:O(logn)
方法二:哈希法
定义大小为26的哈希表存储第一个字符串每个字符出现的频率,然后遍历第二个字符串对其上每个位置进行--
操作,最后假如某一个位置的值不为0
,则证明第一个数组或者第二个数组多或少某个元素几个。
● 代码实现
方法一:排序
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;
}
};
方法二:哈希法
class Solution {
public:
bool isAnagram(string s, string t) {
int hashtable[26] = {0};
//记录s中每个字符出现的次数并记录在对应位置
for(int i = 0; i < s.length(); i++)
{
hashtable[s[i] - 'a']++;
}
//通过t中每个字符出现的次数修改hashtable的记录
for(int i = 0; i < t.length(); i++)
{
hashtable[t[i] - 'a']--;
}
//判断
for(int i = 0; i < 26; i++)
{
if(hashtable[i] != 0) return false;
}
return true;
}
};
● Leetcode349 两个数组的交集
题目链接:Leetcode349 两个数组的交集
视频讲解:代码随想录|两个数组的交集
题目描述:给定两个数组 nums1 和 nums2 ,返回 它们的交集 。输出结果中的每个元素一定是 唯一 的。我们可以 不考虑输出结果的顺序 。
示例 1:
输入:nums1 = [1,2,2,1], nums2 = [2,2]
输出:[2]
示例 2:
输入:nums1 = [4,9,5], nums2 = [9,4,9,8,4]
输出:[9,4]
解释:[4,9] 也是可通过的
● 解题思路
本题可以使用数组作为哈希结构,也可以使用set
作为哈希结构解决问题。对于数据量相对不太大的时候使用数组,比采用set
进行哈希处理的时候效率更高。
But anyways,采用哪种哈希结构的解题思路都是一样的。定义两个哈希表Hashtable
并使用第一个数组nums1
对其处理,result
存放最后的返回结果。对hashtable
处理结束后,使用第二个数组nums2
对其进行遍历,将相同值放入result
中保存。
时间复杂度:O(m+n)
空间复杂度:O(n)
● 解题思路
数组
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
int hash[1005] = {0};
unordered_set<int> result;
for(int i = 0; i < nums1.size(); i++)
{
hash[nums1[i]] = 1;
}
for(int i = 0; i < nums2.size(); i++)
{
if(hash[nums2[i]] == 1)
{
result.insert(nums2[i]);
}
}
return vector<int>(result.begin(), result.end());
}
};
哈希表
class Solution {
public:
vector<int> intersection(vector<int>& nums1, vector<int>& nums2) {
unordered_set<int> hash(nums1.begin(), nums1.end());
unordered_set<int> result;
for(int i = 0; i < nums2.size(); i++)
{
if(hash.find(nums2[i]) != hash.end())
{
result.insert(nums2[i]);
}
}
return vector<int>(result.begin(), result.end());
}
};
● Leetcode202 快乐数
题目链接:Leetcode202 快乐数
题目描述:编写一个算法来判断一个数 n 是不是快乐数。
「快乐数」 定义为:
对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。
然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。
如果这个过程 结果为 1,那么这个数就是快乐数。
如果 n 是 快乐数 就返回 true ;不是,则返回 false 。
示例 1:
输入:n = 19
输出:true
解释:
12 + 92 = 82
82 + 22 = 68
62 + 82 = 100
12 + 02 + 02 = 1
示例 2:
输入:n = 2
输出:false
● 解题思路
通过解读题目,我们需要了解两个问题:确认快乐数的条件
和什么情况下就证明n不是快乐数
。
确认n为快乐数的条件很简单,就是当加和sum == 1
时返回true
即可;
但什么情况下说明n不是快乐数呢?根据题目我们能清楚的是:sum在每一次循环中都会出现,用sum来获得每一位数平方的加和。假如当我们在之后的某一个sum和前面某一个sum相同的时候,该区间内的所有sum
都将循环出现,也就说明无法得到1。
因此我们可以通过将sum
的值放入hashtable
中,当出现重复sum
时返回false
即可。正因为如此,当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法。
时间复杂度:O(logn)
空间复杂度:O(logn)
● 代码实现
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;
}
}
};
● Leetcode1 两数之和
题目链接:Leetcode1 两数之和
视频讲解:代码随想录|两数之和
题目描述:给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
你可以按任意顺序返回答案。
示例 1:
输入:nums = [2,7,11,15], target = 9
输出:[0,1]
解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。
示例 2:
输入:nums = [3,2,4], target = 6
输出:[1,2]
示例 3:
输入:nums = [3,3], target = 6
输出:[0,1]
● 解题思路
遍历数组nums
中的元素,同时在unordered_set
寻找target - nums[i]
是否存在。如果存在返回其下标组,否则将新元素的key-value
插入unordered_set
。
● 代码实现
方法一:暴力枚举
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
int n = nums.size();
for(int i = 0; i < n; i++)
{
for(int j = i + 1; j < n; j++)
{
if(nums[i] + nums[j] == target)
{
return {i, j};
}
}
}
return {};
}
};
方法二:哈希法
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};
}
map.insert({nums[i], i});
}
return {};
}
};
● 总结
(1)为什么本题使用哈希:
我们知道使用哈希法时是需要查询一个元素是否出现过,或者一个元素是否在集合里的时候。当我们使用hash解决本题的时候,因此会将数组中遍历过的元素插入到hashtable中,在之后遍历其他元素的时候,正是在hashtable中查询target - nums[i]
是否在集合中存在,因此可以使用hash。
(2)为什么使用本题使用unordered_map
:
对于本题而言,我们需要知道在hashtable中是否存在某个元素,同时还需要直到这个元素在原数组中的位置才能在最后将其返回,因此必须使用map
。而相对于multimap
和map
的key是有序的,且底层实现为红黑树,因为本题并不需要key
为升序,因此我们选择unordered_map
效率更好。
(3)unordered_map
在本题中的作用:
unordered_map
存储遍历过的元素,才能寻找和之后元素相对应的元素是否存在。
(4)unordered_map
的key
和value
存放的内容:
判断元素是否出现,这个元素就要作为key,所以数组中的元素作为key,有key对应的就是value,value用来存下标。