一、哈希表理论基础
什么时候想到用哈希法? (重要)
当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法。 这句话很重要,在做哈希表题目都要思考这句话。
1.哈希表
首先什么是 哈希表,哈希表(英文名字为Hash table,国内也有一些算法书籍翻译为散列表,大家看到这两个名称知道都是指hash table就可以了)。
哈希表是根据关键码的值而直接进行访问的数据结构。
这么这官方的解释可能有点懵,其实直白来讲其实数组就是一张哈希表。
哈希表中关键码就是数组的索引下标,然后通过下标直接访问数组中的元素,如下图所示:
那么哈希表能解决什么问题呢,一般哈希表都是用来快速判断一个元素是否出现集合里。
例如要查询一个名字是否在这所学校里。
要枚举的话时间复杂度是O(n),但如果使用哈希表的话, 只需要O(1)就可以做到。
我们只需要初始化把这所学校里学生的名字都存在哈希表里,在查询的时候通过索引直接就可以知道这位同学在不在这所学校里了。
将学生姓名映射到哈希表上就涉及到了hash function ,也就是哈希函数。
2.哈希函数
哈希函数,把学生的姓名直接映射为哈希表上的索引,然后就可以通过查询索引下标快速知道这位同学是否在这所学校里了。
哈希函数如下图所示,通过hashCode把名字转化为数值,一般hashcode是通过特定编码方式,可以将其他数据格式转化为不同的数值,这样就把学生名字映射为哈希表上的索引数字了。
如果hashCode得到的数值大于 哈希表的大小了,也就是大于tableSize了,怎么办呢?
此时为了保证映射出来的索引数值都落在哈希表上,我们会在再次对数值做一个取模的操作,就要我们就保证了学生姓名一定可以映射到哈希表上了。
此时问题又来了,哈希表我们刚刚说过,就是一个数组。
如果学生的数量大于哈希表的大小怎么办,此时就算哈希函数计算的再均匀,也避免不了会有几位学生的名字同时映射到哈希表 同一个索引下标的位置。
接下来哈希碰撞登场
(1)哈希碰撞
如图所示,小李和小王都映射到了索引下标 1 的位置,这一现象叫做哈希碰撞。
一般哈希碰撞有两种解决方法, 拉链法和线性探测法。
(2)拉链法
刚刚小李和小王在索引1的位置发生了冲突,发生冲突的元素都被存储在链表中。 这样我们就可以通过索引找到小李和小王了
(数据规模是dataSize, 哈希表的大小为tableSize)
其实拉链法就是要选择适当的哈希表的大小,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。
(3)线性探测法
使用线性探测法,一定要保证tableSize大于dataSize。 我们需要依靠哈希表中的空位来解决碰撞问题。
例如冲突的位置,放了小李,那么就向下找一个空位放置小王的信息。所以要求tableSize一定要大于dataSize ,要不然哈希表上就没有空置的位置来存放 冲突的数据了。如图所示:
其实关于哈希碰撞还有非常多的细节,感兴趣的同学可以再好好研究一下,这里我就不再赘述了。
3.常见的三种哈希结构
当我们想使用哈希法来解决问题的时候,我们一般会选择如下三种数据结构。
-
数组
-
set (集合)
-
map(映射)
这里数组就没啥可说的了,我们来看一下set。
在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标准之前民间高手自发造的轮子。
4.总结
总结一下,当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法。
但是哈希法也是牺牲了空间换取了时间,因为我们要使用额外的数组,set或者是map来存放数据,才能实现快速的查找。
如果在做面试题目的时候遇到需要判断一个元素是否出现过的场景也应该第一时间想到哈希法!
二、 242.有效的字母异位词
题目链接:242. 有效的字母异位词 - 力扣(LeetCode):string中只有小写字母。
本题关键:数组在哈希表中的应用
我的思路:为两个字符串数组分别创建2个数组,用于记录这两个string中出现过的字符以及每个字符出现的次数。记录完成后,先比较这两个数组的字符数,如果字符数量不相同,那么她们肯定不是异位词;如果数组里面字符数量相同,就再去判断每个字符出现的个数是否一样。
卡子哥思路:(是我的思路的简化简洁版)只维护一个hash[26]的数组,将第一个string出现过的字符映射到hash数组里面,让hash数组中对应的位置的元素值++;然后再做第二个string到hash数组的映射,让hash数组中对应位置的元素值--;结束以后遍历hash数组的值,如果数组中的值全为0,则说明第一个和第二个string是异位词
使用哈希的算法题,通常要使用 数组、set、map 这三个数据结构中的一个:
-
在哈希值比较小且范围可控的情况下——》使用数组即可
-
如果数值很大我们就用set
-
如果k要对应value的话,我们就要用map
我一开始的代码:
class Solution { public: bool isAnagram(string s, string t) { char x1[26]={0}; for(int i=0;i<s.size();i++) { x1[s[i]-'a']++; } for(int j=0;j<t.size();j++) { x1[t[j]-'a']--; } for(int k=0;k<26;k++) { if(x1[k]!=0) { return false; } } return true; } };
但是我的代码有一个测试用例通过不了:
发现我的代码和卡子哥的不同之处:定义数组的时候数组类型不同,我的数组类型搞错了
卡子哥代码:
class Solution { public: bool isAnagram(string s, string t) { int record[26] = {0}; for (int i = 0; i < s.size(); i++) { // 并不需要记住字符a的ASCII,只要求出一个相对数值就可以了 record[s[i] - 'a']++; } for (int i = 0; i < t.size(); i++) { record[t[i] - 'a']--; } for (int i = 0; i < 26; i++) { if (record[i] != 0) { // record数组如果有的元素不为零0,说明字符串s和t 一定是谁多了字符或者谁少了字符。 return false; } } // record数组所有元素都为零0,说明字符串s和t是字母异位词 return true; } };
值得学习的地方:
1.不需要记得字母的ASCII码的值,在哈希表中需要使用的时候字符-'a'
的方式就可以代表每个字符的标号了
2.如果数组中的元素初始化的时候,我们想要全部初始化为一个值,如全部初始化为0,那么输入int record[26] = {0};即可。不用输入int record[26] = [0,0,......,0];
三、 349. 两个数组的交集
题目链接:力扣
题目要求的注意点:
-
交集元素是要去重的
-
不考虑输出结果的顺序
-
力扣上这道题以前的测试数据是会上亿的(也就是数组里面的元素值会上亿),现在改成了元素值和数组长度都小于等于1000——》如果是以前那种测试数据很大,如果要映射为数组下标的话,我们需要创建一个很大的数组,这样会浪费很多存储空间,所以就用set;现在的题目数据不大,直接用数组就行
1.用set解题
(1)思路:
哈希表的作用:解决“给你一个元素,判断这个元素在某个数据结构里面有没有出现过” 的问题;只要有类似的关系,就第一个想到使用哈希表——》然后再具体分析用数组还是set还是map——》元素值不大,可以用数组;元素值不大,但是太散,用数组的话会浪费很多空间,所以这种情况也适合用set;map后面的题目会讲到
解题——》先遍历数组1中的元素,将数组中元素值全部映射到哈希表中;然后再变量数组2的元素,每个数组2的元素我们都到哈希表里面去查,看有没有出现过,只要出现过,说明这些元素就是数组1 和 数组2 的交集元素。
set在c++中分为unordered_set(底层是哈希)和multi_set(底层是红黑树)——我们选择用unordered_set,因为unordered_set可以去重,而multiset不能去重。(所以刷题的时候选择一个合适的数据结构也很重要)
(2)代码
class Solution { public: vector<int> intersection(vector<int>& nums1, vector<int>& nums2) { unordered_set<int> result_set; // 存放结果,之所以用set是为了给结果集去重 //第一步,先将数组1进行哈希运算,存储到unordered_set<int> nums_set unordered_set<int> nums_set(nums1.begin(), nums1.end()); //第二步,查找数组2中和数组1相同的值有哪些 for (int num : nums2) {//范围for循环--》依次取出nums2中的元素赋给num if (nums_set.find(num) != nums_set.end()) { result_set.insert(num);// 发现nums2中的元素在nums_set里已存在,就将这个元素放入结果集 } } return vector<int>(result_set.begin(), result_set.end());//因为题目要求返回的是vector,所以还需要做一步将unordered_set<int>类型的result_set转变为vector的操作 } };
-
时间复杂度: O(mn)
-
空间复杂度: O(n)
2.使用数组解题
现在力扣改了 题目描述 和 后台测试数据,增添了 数值范围:
-
1 <= nums1.length, nums2.length <= 1000
-
0 <= nums1[i], nums2[i] <= 1000
所以就可以 使用数组来做哈希表了, 因为数组都是 1000以内的。
对应C++代码如下:
class Solution { public: vector<int> intersection(vector<int>& nums1, vector<int>& nums2) { unordered_set<int> result_set; //用unordered_set存放结果,之所以用set是为了给结果集去重 int hash[1005] = {0}; // 创建数组作为哈希表,初始化数组内数值全为0;数大小设置的比1000大一点就可以了 for (int num : nums1) { // nums1中出现的字母在hash数组中做记录 hash[num] = 1;//注意这里是让对应下标的数组值等于1,而不是让里面的值++ } for (int num : nums2) { // nums2中出现话,result记录 if (hash[num] == 1) { result_set.insert(num); } } return vector<int>(result_set.begin(), result_set.end()); } };
3.拓展
那有同学可能问了,遇到哈希问题我直接都用set不就得了,用什么数组啊。
直接使用set 不仅占用空间比数组大,而且速度要比数组慢,set把数值映射到key上都要做hash计算的。
不要小瞧 这个耗时,在数据量大的情况,差距是很明显的。
四、202.快乐数
题目链接:力扣
编写一个算法来判断一个数 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
1.思路
这道题目看上去貌似一道数学问题,其实并不是!
(关键一) 题目中说了会 无限循环,那么也就是说求和的过程中,sum会重复出现,这对解题很重要!
正如,当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法了。
所以这道题目使用哈希法,来判断这个sum是否重复出现,如果重复了就是return false, 否则一直找到sum为1为止。
判断sum是否重复出现就可以使用unordered_set。
(关键二) 还有一个难点就是求和的过程,如果对取数值各个位上的单数操作不熟悉的话,做这道题也会比较艰难。
代码如下:
class Solution { public: // 取数值各个位上的单数的平方之和——因为这个操作对应判断一个n是不是快乐数的过程中需要多次用到,所以单独封装成一个函数——》千万不要觉得封装成一个函数是很麻烦的时候,应该要觉得这是一个很酷的事情 int getSum(int n) { int sum = 0; while (n) { sum += (n % 10) * (n % 10);//每轮循环取出n的个位数,进行平方操作,追加到sum里面 n /= 10;//丢掉n的“个位数字” } return sum; } bool isHappy(int n) { unordered_set<int> set;//用来存储每一轮“数值各个位上的单数的平方之和”,为后续判断循环做准备 while(1) { int sum = getSum(n); if (sum == 1) //最初始的n是快乐数 { return true; } // 如果这个sum曾经出现过,说明已经陷入了无限循环了,立刻return false if (set.find(sum) != set.end()) //这里涉及到set的find()成员函数的功能---》find()函数的执行结果是查找key是否存在,若存在,返回该键的元素的迭代器;若不存在,返回set.end();所以这里比较find()的结果是不是set.end(),如果不是,则证明这个元素在set中被查找到了(也就是之前存在过) { return false; } else { set.insert(sum); } n = sum;//对n值进行迭代,下一轮循环继续求新的n值的“数值各个位上的单数的平方之和” } } };
时间复杂度: O(logn)
空间复杂度: O(logn)
五、1.两数之和
给定一个整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的那 两个 整数,并返回他们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素不能使用两遍。
示例:
给定 nums = [2, 7, 11, 15], target = 9
因为 nums[0] + nums[1] = 2 + 7 = 9,所以返回 [0, 1]
题目的数据集范围:
-
2 <= nums.length <= 104
-
-109 <= nums[i] <= 109
-
-109 <= target <= 109
-
只会存在一个有效答案
1.我的思路
我是结合着哈希表的特点——当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法,去思考的;把nums数组中的全部数字两两之和全部算出来存储到一个哈希表里面,然后再去看target对应的值在哈希表里面是否存在过。——但是当我看到题干的数据集范围时我觉得我这个办法不太行,因为这样计算量过于大了;(所以虽然我尽量把我们想法往“一个数值在一个集合里面是否存在过”去思考了——》我创造了一个集合来存放任意两个数的加和,要判断的数是target;但是并不是这就可行了,还需要多做题做积累“集合的创建”和“需要判断的数值的选取”)
2.卡哥思路
1.遍历nums数组,每遍历一个元素就存放到map中;此外,每遍历第n个元素的时候,都去nums数组中查找这个数组中是否已经存在“target-nums[n]”这个数值,如果有的话,说明找到了 数组中一对加和等于target的元素值。
2.因为题目需要返回下标,所以数组中我们需要存放“数组元素值”和“下标”两个元素——set和数组都不支持存放2个元素,所以要用map!
3.在map中,数组元素值是key,下标是value——》我们要查找的东西作为key!
tip:map有三种——map、unorder_map、multi_map——》unorder_map的存储效率更高,所以选用他
3.代码
class Solution { public: vector<int> twoSum(vector<int>& nums, int target) { std::unordered_map <int,int> map; for(int i = 0; i < nums.size(); i++) { // 遍历当前元素,并在map中寻找是否有匹配的key auto iter = map.find(target - nums[i]); if(iter != map.end()) { return {iter->second, i};//返回查找到的元素的下标(iter->second)和当前元素的下标 } // 如果没找到匹配对,就把访问过的元素和下标加入到map中 map.insert(pair<int, int>(nums[i], i)); } return {}; } };
亮点:
1.写算法题的时候善用auto数据类型真的很爽
2.find()函数没有找到需要查找的数值会返回.end()指针