哈希法
-
判断元素是否在集合中出现可以联想到哈希法
-
当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法
-
要枚举的话时间复杂度是O(n),但如果使用哈希表的话, 只需要O(1)就可以做到。
当我们想使用哈希法来解决问题的时候,我们一般会选择如下三种数据结构。
-
数组
-
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也是有序的(这个问题也经常作为面试题,考察对语言容器底层的理解)。
当我们要使用集合来解决哈希问题的时候,优先使用unordered_set,因为它的查询和增删效率是最优的,如果需要集合是有序的,那么就用set,如果要求不仅有序还要有重复数据的话,那么就用multiset。
那么再来看一下map ,在map 是一个key value 的数据结构,map中,对key是有限制,对value没有限制的,因为key的存储方式使用红黑树实现的。
其他语言例如:java里的HashMap ,TreeMap 都是一样的原理。可以灵活贯通。
虽然std::set、std::multiset 的底层实现是红黑树,不是哈希表,std::set、std::multiset 使用红黑树来索引和存储,不过给我们的使用方式,还是哈希法的使用方式,即key和value。所以使用这些数据结构来解决映射问题的方法,我们依然称之为哈希法。 map也是一样的道理。
这里在说一下,一些C++的经典书籍上 例如STL源码剖析,说到了hash_set hash_map,这个与unordered_set,unordered_map又有什么关系呢?
实际上功能都是一样一样的, 但是unordered_set在C++11的时候被引入标准库了,而hash_set并没有,所以建议还是使用unordered_set比较好,这就好比一个是官方认证的,hash_set,hash_map 是C++11标准之前民间高手自发造的轮子。
总结一下,当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法。
但是哈希法也是牺牲了空间换取了时间,因为我们要使用额外的数组,set或者是map来存放数据,才能实现快速的查找。
如果在做面试题目的时候遇到需要判断一个元素是否出现过的场景也应该第一时间想到哈希法!
有效的字母异位词
-
给定两个字符串 s 和 t ,编写一个函数来判断 t 是否是 s 的字母异位词。
示例 1: 输入: s = "anagram", t = "nagaram" 输出: true
示例 2: 输入: s = "rat", t = "car" 输出: false
说明: 你可以假设字符串只包含小写字母。
-
力扣242题
哈希法
数组其实就是一个简单哈希表,而且这道题目中字符串只有小写字符,那么就可以定义一个数组,来记录字符串s里字符出现的次数。
需要定义一个多大的数组呢,定一个数组叫做record,大小为26 就可以了,初始化为0,因为字符a到字符z的ASCII也是26个连续的数值。
为了方便举例,判断一下字符串s= "aee", t = "eae"。
操作动画如下:
定义一个数组叫做hash用来上记录字符串s里字符出现的次数。
需要把字符映射到数组也就是哈希表的索引下标上,因为字符a到字符z的ASCII是26个连续的数值,所以字符a映射为下标0,相应的字符z映射为下标25。
再遍历 字符串s的时候,只需要将 s[i] - ‘a’ 所在的元素做+1 操作即可,并不需要记住字符a的ASCII,只要求出一个相对数值就可以了。 这样就将字符串s中字符出现的次数,统计出来了。
那看一下如何检查字符串t中是否出现了这些字符,同样在遍历字符串t的时候,对t中出现的字符映射哈希表索引上的数值再做-1的操作。
那么最后检查一下,hash数组如果有的元素不为零0,说明字符串s和t一定是谁多了字符或者谁少了字符,return false。
最后如果hash数组所有元素都为零0,说明字符串s和t是字母异位词,return true。
时间复杂度为O(n),空间上因为定义是的一个常量大小的辅助数组,所以空间复杂度为O(1)。
class Solution { public: bool isAnagram(string s, string t) { int hash[26]={0}; //初始化一个大小为26的哈希数组 for(int i=0;i<s.size();i++){ // 并不需要记住字符a的ASCII,只要求出一个相对数值就可以了 hash[s[i]-'a']++; //eg: s[i]='b' 'b'-'a'=1 对应下标为0 } for(int i= 0;i<t.size();i++){ hash[t[i]-'a']--; } for(int i=0;i<26;i++){ if(hash[i]!=0){ // hash数组如果有的元素不为零0,说明字符串s和t 一定是谁多了字符或者谁少了字符。 return false; } } // record数组所有元素都为零0,说明字符串s和t是字母异位词 return true; } };
-
时间复杂度: O(n)
-
空间复杂度: O(1)
两个数组的交集
题意:给定两个数组,编写一个函数来计算它们的交集。
说明: 输出结果中的每个元素一定是唯一的。 我们可以不考虑输出结果的顺序。
-
力扣349题
暴力法
class Solution { public: vector<int> intersection(vector<int>& nums1, vector<int>& nums2) { //暴力解法 用到了unordered_set集合进行去重 底层是哈希表,速度快 int count=0; unordered_set<int> result_set; for(int i=0;i<nums1.size();i++){ for(int j=0;j<nums2.size();j++){ if(nums1[i]==nums2[j]){ result_set.insert(nums1[i]); break; } } } return vector<int>(result_set.begin(), result_set.end()); } };
-
解法时间复杂度是O(n^2)
利用unordered_set
思路:输出结果中的每个元素一定是唯一的,也就是说输出的结果的去重的, 同时可以不考虑输出结果的顺序
-
要注意,使用数组来做哈希的题目,是因为题目都限制了数值的大小。
-
而这道题目没有限制数值的大小,就无法使用数组来做哈希表了。
而且如果哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费。
-
此时就要使用另一种结构体了,set ,关于set,C++ 给提供了如下三种可用的数据结构:
-
std::set
-
std::multiset
-
std::unordered_set
std::set和std::multiset底层实现都是红黑树,std::unordered_set的底层实现是哈希表, 使用unordered_set 读写效率是最高的,并不需要对数据进行排序,而且还不要让数据重复,所以选择unordered_set。
-
class Solution { public: vector<int> intersection(vector<int>& nums1, vector<int>& nums2) { //用到了unordered_set集合进行去重 底层是哈希表,速度快 unordered_set<int> result_set; unordered_set<int> nums_set(nums1.begin(),nums1.end()); for(int i=0;i<nums2.size();i++){ // 发现nums2的元素 在nums_set里又出现过 if(nums_set.find(nums2[i])!=nums_set.end()){ //nums_set.find(nums2[i])返回一个迭代器(类似于指向当前元素的指针),、 // nums_set.end()指向最后一个元素的下一个位置的迭代器(空指针),因此不能对其解引用 result_set.insert(nums2[i]); } } return vector<int>(result_set.begin(), result_set.end()); } };
-
时间复杂度: O(n + m) m 是最后要把 set转成vector
-
空间复杂度: O(n)
使用set的弊端:那遇到哈希问题直接都用set,为什么还用数组啊?
-
直接使用set 不仅占用空间比数组大,而且速度要比数组慢,set把数值映射到key上都要做hash计算的。
-
不要小瞧 这个耗时,在数据量大的情况,差距是很明显的。
后记:利用hash数组
本题后面 力扣改了 题目描述 和 后台测试数据,增添了 数值范围:
-
1 <= nums1.length, nums2.length <= 1000
-
0 <= nums1[i], nums2[i] <= 1000
所以就可以 使用数组来做哈希表了, 因为数组都是 1000以内的。
class Solution { public: vector<int> intersection(vector<int>& nums1, vector<int>& nums2) { // 用到了unordered_set集合进行去重 底层是哈希表,速度快 unordered_set<int> result_set; int hash[1005] = {0}; //利用hash数组,本题测试中的数据为1000以内 for (int i = 0; i < nums1.size(); i++) { hash[nums1[i]] = 1; //将nums1的值在hash数组中对应的下标设置为1 } for (int i = 0; i < nums2.size(); i++) { if (hash[nums2[i]] == 1) { //遍历nums2,如果nums2[i]在hash表中的值为1,说明与nums1有公共元素,可以插入到result_set,没有就是0 result_set.insert(nums2[i]); } } return vector<int>(result_set.begin(), result_set.end()); //将unordered_set转为vector数组进行返回 } };
快乐数
-
题目 :
编写一个算法来判断一个数 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
-
力扣202题
思路一:unordered_set
思路:题目中说了会 无限循环,那么也就是说求和的过程中,sum会重复出现,这对解题很重要!
当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法了。
所以这道题目使用哈希法,来判断这个sum是否重复出现,如果重复了就是return false, 否则一直找到sum为1为止。
判断sum是否重复出现就可以使用unordered_set。
还有一个难点就是求和的过程
class Solution { public: bool isHappy(int n) { unordered_set<int> result; // 创建unordered_set集合用来存放计算的数值 while (true) { // 因为可能会无限循环,所以写死 int sum = get_sumNum(n); // 获取到每个位置上的数字的平方和 if (sum == 1) { // 等于1为快乐数,返回true return true; } else if (result.find(sum) != result.end()) { // 如果当前sum在result中出现,表明已经进入死循环,不会有快乐数 // ,返回false return false; } else { result.insert(sum); // 否则就插入到result中 } n=sum;//更新n的值计算下次每个位置上的数字平方和 } } int get_sumNum(int n) { // 获取每个位置上的数字的平方和 int sum = 0; while (n > 0) { int num = n % 10; // 取模得到个位 sum = sum + num * num; //进行计算 n = n / 10; // 将个位去除掉 } return sum; } };
思路二:快慢指针法
-
力扣官方题解
通过反复调用 getNext(n) 得到的链是一个隐式的链表。隐式意味着我们没有实际的链表节点和指针,但数据仍然形成链表结构。起始数字是链表的头 “节点”,链中的所有其他数字都是节点。next 指针是通过调用 getNext(n) 函数获得。
意识到我们实际有个链表,那么这个问题就可以转换为检测一个链表是否有环。因此我们在这里可以使用弗洛伊德循环查找算法。这个算法是两个奔跑选手,一个跑的快,一个跑得慢。在龟兔赛跑的寓言中,跑的慢的称为 “乌龟”,跑得快的称为 “兔子”。
不管乌龟和兔子在循环中从哪里开始,它们最终都会相遇。这是因为兔子每走一步就向乌龟靠近一个节点(在它们的移动方向上)。
-
算法
我们不是只跟踪链表中的一个值,而是跟踪两个值,称为快跑者和慢跑者。在算法的每一步中,慢速在链表中前进 1 个节点,快跑者前进 2 个节点(对 getNext(n) 函数的嵌套调用)。
如果 n 是一个快乐数,即没有循环,那么快跑者最终会比慢跑者先到达数字 1。
如果 n 不是一个快乐的数字,那么最终快跑者和慢跑者将在同一个数字上相遇。
class Solution { public: bool isHappy(int n) { //快慢指针法 将整个调用getNext(n) 想象成一个隐式链表 int slowRunner=n; //初始化为起始节点 int fastRunner=get_sumNum(n); //初始化为起始的下一个结点 如果也初始化为1的话,必须用do..while()语句先执行一次,否则用while无法进入循环 while(fastRunner!=1&&slowRunner!=fastRunner){ //快指针走的快,所以先到达1,若快慢指针相等,说明有环,直接跳出即可 slowRunner=get_sumNum(slowRunner); //慢指针走一次 fastRunner=get_sumNum(get_sumNum(fastRunner)); //快指针走两次 } return fastRunner==1; //返回结果 } int get_sumNum(int n) { // 获取每个位置上的数字的平方和 int sum = 0; while (n > 0) { int num = n % 10; // 取模得到个位 sum = sum + num * num; //进行计算 n = n / 10; // 将个位去除掉 } return sum; } };
-
时间复杂度:O(logn)
-
空间复杂度:O(1)
class Solution { public: bool isHappy(int n) { //快慢指针法 将整个调用getNext(n) 想象成一个隐式链表 int slowRunner=n; //初始化为起始节点 int fastRunner=n; //初始化为起始的下一个结点 如果也初始化为1的话,必须用do..while()语句先执行一次,否则用while无法进入循环 do{ //快指针走的快,所以先到达1,若快慢指针相等,说明有环,直接跳出即可 slowRunner=get_sumNum(slowRunner); //慢指针走一次 fastRunner=get_sumNum(get_sumNum(fastRunner)); //快指针走两次 }while(fastRunner!=1&&slowRunner!=fastRunner); return fastRunner==1; //返回结果 } int get_sumNum(int n) { // 获取每个位置上的数字的平方和 int sum = 0; while (n > 0) { int num = n % 10; // 取模得到个位 sum = sum + num * num; //进行计算 n = n / 10; // 将个位去除掉 } return sum; } };
两数之和
-
题目:
-
给定一个整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的那 两个 整数,并返回他们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素不能使用两遍。
示例:
给定 nums = [2, 7, 11, 15], target = 9
因为 nums[0] + nums[1] = 2 + 7 = 9
所以返回 [0, 1]
-
力扣1题
暴力解法
class Solution { public: vector<int> twoSum(vector<int>& nums, int target) { //暴力解法 // unordered_set<int> result; vector<int> result(2,0); for(int i =0;i<nums.size();i++){ for(int j=i+1;j<nums.size();j++){ if(nums[i]+nums[j]==target){ result[0]=i; result[1]=j; } } } return result; } };
-
很明显暴力的解法是两层for循环查找,时间复杂度是O(n^2)
哈希法:map
-
当我们需要查询一个元素是否出现过,或者一个元素是否在集合里的时候,就要第一时间想到哈希法。
-
本题呢,我就需要一个集合来存放我们遍历过的元素,然后在遍历数组的时候去询问这个集合,某元素是否遍历过,也就是 是否出现在这个集合。
-
那么我们就应该想到使用哈希法了。
-
因为本题,我们不仅要知道元素有没有遍历过,还要知道这个元素对应的下标,需要使用 key value结构来存放,key来存元素,value来存下标,那么使用map正合适。
再来看一下使用数组和set来做哈希法的局限。
-
数组的大小是受限制的,而且如果元素很少,而哈希值太大会造成内存空间的浪费。
-
set是一个集合,里面放的元素只能是一个key,而两数之和这道题目,不仅要判断y是否存在而且还要记录y的下标位置,因为要返回x 和 y的下标。所以set 也不能用。
此时就要选择另一种数据结构:map ,map是一种key value的存储结构,可以用key保存数值,用value再保存数值所在的下标。
C++中map,有三种类型:
映射 | 底层实现 | 是否有序 | 数值是否可以重复 | 能否更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
std::map | 红黑树 | key有序 | key不可重复 | key不可修改 | O(log n) | O(log n) |
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也是有序的(这个问题也经常作为面试题,考察对语言容器底层的理解)。
这道题目中并不需要key有序,选择std::unordered_map 效率更高!
-
接下来需要明确两点:
-
map用来做什么
-
map中key和value分别表示什么
-
-
map目的用来存放我们访问过的元素,因为遍历数组的时候,需要记录我们之前遍历过哪些元素和对应的下标,这样才能找到与当前元素相匹配的(也就是相加等于target)
-
接下来是map中key和value分别表示什么。
-
这道题 我们需要 给出一个元素,判断这个元素是否出现过,如果出现过,返回这个元素的下标。
-
那么判断元素是否出现,这个元素就要作为key,所以数组中的元素作为key,有key对应的就是value,value用来存下标。
-
所以 map中的存储结构为 {key:数据元素,value:数组元素对应的下标}。
-
-
在遍历数组的时候,只需要向map去查询是否有和目前遍历元素匹配的数值,如果有,就找到的匹配对,如果没有,就把目前遍历的元素放进map中,因为map存放的就是我们访问过的元素。
过程如下:
class Solution { public: vector<int> twoSum(vector<int>& nums, int target) { // unordered_set<int> result; std::unordered_map<int,int> map; //创建一个map集合 key存放nums数组里面的值 value存放下标,因为后续根据key进行查询更加容易 for(int i=0;i<nums.size();i++){ // 遍历当前元素,并在map中寻找是否有匹配的key auto iter= map.find(target-nums[i]); //auto自动判断迭代器类型 if(iter!=map.end()){ //没找到 return {iter->second,i}; } // 如果没找到匹配对,就把访问过的元素和下标加入到map中 map.insert(pair<int, int>(nums[i], i)); } return {}; //没找到返回空 } };
-
时间复杂度: O(n)
-
空间复杂度: O(n)
本题四个重点:
-
为什么会想到用哈希表
-
哈希表为什么用map
-
本题map是用来存什么的
-
map中的key和value用来存什么的