【代码随想录】哈希表
哈希表理论基础
哈希表就是利用一个hash函数,将元素的值value映射到一个索引范围内。比如通过取模运算将任意整数 X 映射到 [0, TableSize - 1],那么 TableSize 是哈希表的大小,索引范围就是 [0, TableSize - 1]。
根据元素的值就能实现常数级的查找。
主要用于需要判断一个元素是否存在一个集合中。
哈希函数的问题是:如果元素的取值空间远大于索引的取值空间,那么有可能会导致不同的元素被映射到相同的位置,也就是发生了碰撞。对于碰撞的处理有以下几种处理方式:线性探测、二次探测、开链等。
常用的哈希结构:
- 数组
- set (集合)
- map(映射)
在C++的STL中提供基于不同底层数据结构的set和map,详细内容可见个人博客:https://guomw.net/
对于set来说,底层实现和优劣对比如下:(此处省略了unordered_multiset)
集合 | 底层实现 | 是否有序 | 数值是否可以重复 | 能否更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
std::set | 红黑树 | 有序 | 否 | 否 | O(log n) | O(log n) |
std::multiset | 红黑树 | 有序 | 是 | 否 | O(logn) | O(logn) |
std::unordered_set | 哈希表 | 无序 | 否 | 否 | O(1) | O(1) |
底层数据结构是红黑树的set,由于红黑树是一种平衡二叉树,是根据key值进行排序,所以key值不能修改。只能删除和增加。
对于map来说,底层实现和优劣对比如下:
映射 | 底层实现 | 是否有序 | 数值是否可以重复 | 能否更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
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) |
综上,如果想要使用哈希表解决哈希问题,优先使用底层实现为hash table的unordered_set和unordered_map。
哈希表是使用空间换取时间的数据结构,set的元素是一个值,map个每个元素是键值对。
有效的字母异位词
242.有效的字母异位词
给定两个字符串 s 和 t ,编写一个函数来判断 t 是否是 s 的字母异位词。
示例 1: 输入: s = “anagram”, t = “nagaram” 输出: true
示例 2: 输入: s = “rat”, t = “car” 输出: false
说明: 你可以假设字符串只包含小写字母。
思路:使用一个哈希表记录26个小写字母在两个字符串中出现次数。
1 | class Solution { |
时间复杂度O(N + M + 26),遍历S串、T串和hash表各一次;空间复杂度O(1)
相关题目:
383.赎金信
给你两个字符串:ransomNote
和 magazine
,判断 ransomNote
能不能由 magazine
里面的字符构成。
如果可以,返回 true
;否则返回 false
。
magazine
中的每个字符只能在 ransomNote
中使用一次。
思路:使用一个unordered_map记录magazine中字符的数量,然后判断能否满足ransomNote的需求。
1 | class Solution { |
时间复杂度O(N + M),遍历两个字符串的时间;空间复杂度O(S),S指的是magazine中不同的字符个数。
49.字母异位词分组
给你一个字符串数组,请你将 字母异位词 组合在一起。可以按任意顺序返回结果列表。
字母异位词 是由重新排列源单词的所有字母得到的一个新单词。
思路:把排序之后的string作为键,把列表作为值。遍历每一个字符串,然后排序之后作为键值判断是否相等,在键值对应的列表中添加该字符串。
1 | class Solution { |
时间复杂度O,遍历字符串列表的时间复杂度是O(n),对于每一个字符串进行排序的时间复杂度是O(nlogn),对于字符串进行hash函数的复杂度是O(1),插入的时间复杂度是O(1),因此遍历并加入hash_table的时间复杂度是O(n^2logn);空间复杂度O(不同的字母异位词的个数)
438.找到字符串中所有字母异位词
给定两个字符串 s
和 p
,找到 s
中所有 p
的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。
异位词 指由相同字母重排列形成的字符串(包括相同的字符串)。
思路:采用滑动窗口的方法:
1.窗口中是什么:窗口中记录当前子串中有效字母(即在目标字符串中的字符)出现的次数
2.什么时候移动右边界:循环每次向后移动一位
3.什么时候移动左边界:当窗口中元素满足条件时,移动到不满足条件为止
满足条件即字串长度等于目标串的长度,不满足条件就是向后移动一位导致小于目标串长度。
在这里需要判断当前窗口中的字串是否为异位词。
1 | class Solution { |
时间复杂度O(n-m+1),左边界最多移动到这个位置;空间复杂度O(需要的字符个数)
两个数组的交集
349.两个数组的交集
先求第一个数组的集合,然后遍历第第二个数组,将其中在第一个集合中的元素放入到另一个集合中。
1 | class Solution { |
时间复杂度O(n + m),遍历第一个数组一次,遍历第二个数组一次,遍历交集集合一次;空间复杂度O(n)
350.两个数组的交集 II
思路:使用map记录第一个数组每个元素出现次数;然后遍历第二个数组,如果当前元素在第一个数组中出现过并且可用次数大于0则加入结果。
1 | class Solution { |
时间复杂度O(n),遍历两个数组的时间;空间复杂度O(n)
进阶:如果给定的数组已经排好序呢?你将如何优化你的算法?
给好顺序的话就使用两个指针分别指向两个数组,如果两个指针指向元素相同,那么就将该元素加入结果并同时向后移动;如果不同,判断两个元素大小,元素小的那个指针向后移动,直到等于或者大于大的元素值,然后进入下一轮循环。
时间复杂度O(min(n, m)),其中n、m分别是两个数组大小。
进阶:如果 nums1
的大小比 nums2
小,哪种方法更优?
第二种。
进阶:如果 nums2
的元素存储在磁盘上,内存是有限的,并且你不能一次加载所有的元素到内存中,你该怎么办?
上述算法可以解决这个情况,将第一个数组的元素每次出现的次数记录为使用次数,每次找到符合要求的元素就减少一次使用次数。这样就不同担心需要整体遍历完第二个数组才能进行判断。
快乐数
第202题. 快乐数
编写一个算法来判断一个数 n
是不是快乐数。
「快乐数」 定义为:
- 对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。
- 然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。
- 如果这个过程 结果为 1,那么这个数就是快乐数。
如果 n
是 快乐数 就返回 true
;不是,则返回 false
。
思路:每次判断每个位置上的数字的平方和之后,如果结果是1就返回true;否则判断是否出现过该元素,防止出现循环,所以使用set保存历史数据。
1 | class Solution { |
时间复杂度O(logn)。详细参考题解
两数之和
第1题:两数之和
给定一个整数数组 nums
和一个整数目标值 target
,请你在该数组中找出 和为目标值 target
的那 两个 整数,并返回它们的数组下标。
思路1:暴力法:采用双层循环,外层循环遍历每一个元素,内存循环遍历后面的元素,判断两个元素相加是否为target。
时间复杂度O(n^2);空间复杂度O(1)
思路2:采取空间换取时间的做法,在map中保存(target-nums1,nums1对应的下标),nums1表示遍历的每一个数字。遍历时,如果可以根据target-nums1找到键,说明找到一对合适的结果;否则,将(target-nums1,nums1)插入map。
1 | class Solution { |
时间复杂度O(n),遍历数组的时间;空间复杂度O(n)
收获:
- 为什么会想到用哈希表:当需要判断一个元素是否存在时
- 哈希表为什么用map:因为数组的下标只能是int,set没有下标,map用于存一个映射。
- 本题map是用来存什么的:key存target - nums1(第一个加数) value存这个数对应的下标
- map中的key和value用来存什么的
四数相加
第454题:
给定四个包含整数的数组列表 A , B , C , D ,计算有多少个元组 (i, j, k, l) ,使得 A[i] + B[j] + C[k] + D[l] = 0。
为了使问题简单化,所有的 A, B, C, D 具有相同的长度 N,且 0 ≤ N ≤ 500 。所有整数的范围在 -2^28 到 2^28 - 1 之间,最终结果不会超过 2^31 - 1 。
思路1:四层循环遍历四个数组,计算四个数字之和是否为0.
时间复杂度O(n^4);空间复杂度O(1)
思路2:使用unordered_map记录:(和,出现的次数),先记录AB两个数组每个元素相加的元素和的出现次数;O(n^2)
然后遍历所有CD数组元素之和的负值在map中是否出现过,如果出现说明四个数字相加之和为0.O(n^2)
1 | class Solution { |
时间复杂度O(n^2);空间复杂度最坏的情况下两个数组之间的值各不相同看,需要n的平方个空间。
赎金信
第383
收获:之前做过,这里由于hash表的长度是固定的(26个小写字母),所以使用数组更加合适。map要维护红黑树或者哈希表,而且还要做哈希函数,是费时的
三数之和
第15
给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有满足条件且不重复的三元组。
注意: 答案中不可以包含重复的三元组。
思路1:暴力法:找到和为0的三个元素,时间复杂度O(n^3);另外找到之后还需要判断不能重复,如果要去重,就需要令比较的两个三元组先排序,才方便比较。
思路2:采用哈希法:两层for循环可以先确定(a+b)数值,然后判断0-(a+b)是否在数组中出现过,这样整体时间复杂度是O(n^2),但是问题是去重,如果先找到所有元素之后再进行去重,那么需要对于结果三元组中进行排序和比较。
思路3:双指针法,先把数组排序,然后有一层for循环,i从下标0的地方开始,同时定一个下标left 定义在i+1的位置上,定义下标right 在数组结尾的位置上。依然还是在数组中找到 abc 使得a + b +c =0,我们这里相当于 a = nums[i],b = nums[left],c = nums[right]。
接下来如何移动left 和right呢, 如果nums[i] + nums[left] + nums[right] > 0 就说明 此时三数之和大了,因为数组是排序后了,所以right下标就应该向左移动,这样才能让三数之和小一些。
如果 nums[i] + nums[left] + nums[right] < 0 说明 此时 三数之和小了,left 就向右移动,才能让三数之和大一些,直到left与right相遇为止。
然后需要注意的是:(1).去重的逻辑:去重a:应该判断与之前的元素是否相同,由于是排序之后的,如果相同说明要么都找不到,要么找到也是重复的;去重bc,在找到第一对bc之后,应该重复判断与他们相邻的是否相等,相等就应该去重。(2).找到之后两个指针同时跳变
1 | class Solution { |
时间复杂度O(n^2),i遍历数组,剩下两个指针一起遍历剩下元素;空间复杂度O(1)
四数之和
18
题意:给定一个包含 n 个整数的数组 nums 和一个目标值 target,判断 nums 中是否存在四个元素 a,b,c 和 d ,使得 a + b + c + d 的值与 target 相等?找出所有满足条件且不重复的四元组。
注意:
答案中不可以包含重复的四元组。
示例: 给定数组 nums = [1, 0, -1, 0, -2, 2],和 target = 0。 满足要求的四元组集合为: [ [-1, 0, 0, 1], [-2, -1, 1, 2], [-2, 0, 0, 2] ]
思路:就是在三数之和的基础上加层循环。
1 | class Solution { |
时间复杂度O(n^3),空间复杂度O(1)
但是需要注意的是:剪枝的一些细节,比如由于target是一个随机值,不能判断第一个元素大于零就直接返回,因为有可能target是小于第一个元素的。
错误思路:我的思路是两个双指针,错误的地方是:外层如果用双指针,那么当双指针同时增加时,会漏掉元素
这个问题与之前遇到的同时增加的两个前后指针类似。
思考:我的思路:从外层选两个,从内层选两个,
作者的思路:从n个中选一个,从剩下的n-1个中选一个,然后双指针选择剩下的两个
注意:两种方式:(1)从n中选一个,从剩下的n-1中选一个,一共有n(n-1)/2中组合
(2)我的两个指针同时从前后增加,那么只有n/2种组合
而题目明显是要求第一种方式选择,不要漏掉组合。
注意:双指针是如何把从n中选两个的时间复杂度变成n的?
(1)从n中选两个不同的,正常思路就是n的平方;
(2)如果采用双指针,O(n^2)的解法优化为 O(n)的解法,因为一次选中不同的两个?
总结
一般来说哈希表都是用来快速判断一个元素是否出现集合里。
哈希函数是把传入的key映射到符号表的索引上。
接下来是常见的三种哈希结构:
- 数组
- set(集合)
- map(映射)
使用set的理由:
主要因为如下两点:
- 数组的大小是有限的,受到系统栈空间(不是数据结构的栈)的限制。
- 如果数组空间够大,但哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费。
集合 | 底层实现 | 是否有序 | 数值是否可以重复 | 能否更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
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。
使用map的理由:
- 数组的大小是受限制的,而且如果元素很少,而哈希值太大会造成内存空间的浪费。
- set是一个集合,里面放的元素只能是一个key,而两数之和这道题目,不仅要判断y是否存在而且还要记录y的下标位置,因为要返回x 和 y的下标。所以set 也不能用。
C++提供如下三种map:
- std::map
- std::multimap
- std::unordered_map
映射 | 底层实现 | 是否有序 | 数值是否可以重复 | 能否更改数值 | 查询效率 | 增删效率 |
---|---|---|---|---|---|---|
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 的底层实现是红黑树。
在map 是一个key value 的数据结构,map中,对key是有限制,对value没有限制的,因为key的存储方式使用红黑树实现的。
虽然std::set、std::multiset 的底层实现是红黑树,不是哈希表,std::set、std::multiset 使用红黑树来索引和存储,不过给我们的使用方式,还是哈希法的使用方式,即key和value。所以使用这些数据结构来解决映射问题的方法,我们依然称之为哈希法。 map也是一样的道理。
同理,std::map 和std::multimap 的key也是有序的(这个问题也经常作为面试题,考察对语言容器底层的理解),1.两数之和 中并不需要key有序,选择std::unordered_map 效率更高!