LeetCode哈希表
LeetCode哈希表刷题记录
基础知识
哈希函数:将哈希表中元素的键值映射为元素存储位置的函数
- 满足函数的基本特征,相同的输入一定会被映射到相同的输出 H a s h ( k e y 1 ) ≠ H a s h ( k e y 2 ) Hash(key1) \neq Hash(key2) Hash(key1)=Hash(key2) 则 k e y 1 ≠ k e y 2 key1 \neq key2 key1=key2
- 不同的输入也有可能被映射到相同的输出 H a s h ( k e y 1 ) = = H a s h ( k e y 2 ) Hash(key1) == Hash(key2) Hash(key1)==Hash(key2) 则 k e y 1 ≠ k e y 2 key1 \neq key2 key1=key2 和 k e y 1 = = k e y 2 key1 == key2 key1==key2 都有可能
- 得到的输出(哈希值)是固定长度的
图片来源: hash fucntion
哈希表:图中的输入 e.g John Smith 叫做键(key),通过哈希函数的映射,得到值(value),存放value的数组就叫做哈希表
快速判断一个元素是否属于某个集合(快速查找)
- 插入:输入key(John Smith)通过哈希函数得到 value = 1,就把John Smith这个输入存储在数组索引为1的区块中
- 查找:
查找’John Smith’ 将 ‘John Smith’ 通过哈希函数得到value = 1,索引哈希表的1区块,发现存储着’John Smith’,找到了
查找’CinderGirl’ 将’CinderGirl’ 通过哈希函数得到value = 3(假设),索引哈希表的3区块,发现存储’Sandra Dee’,找不到’CinderGirl’所以’CinderGirl’不在哈希中
常见的哈希函数:
- 线性: H a s h ( k e y ) = k e y Hash(key) = key Hash(key)=key 或者 H a s h ( k e y ) = a × k e y + b Hash(key) = a \times key + b Hash(key)=a×key+b 适用于key为整数的情况(一般会把其他类型的数据先转化为整数),当key不连续分布且间隔较大的时候,这种方法比较浪费存储空间
- 取余:假设哈希表的表长为
m
m
m, 一般取一个不大于
m
m
m且最接近m的质数
p
p
p,
H
a
s
h
(
k
e
y
)
=
k
e
y
m
o
d
p
Hash(key) = key\mod p
Hash(key)=keymodp
为什么取质数?选择质数可以尽可能的减少冲突。当key不是p的倍数时, g c d ( p , k e y ) = 1 gcd(p,key) = 1 gcd(p,key)=1(如果p为质数)根据费马小定理 a p − 1 ≡ 1 m o d p a^{p-1} \equiv 1 \mod p ap−1≡1modp 所以 a a a的幂次在模 p p p下具有周期性,周期为 p − 1 p-1 p−1,假设 k i k_i ki 是随机分布的整数,当p是质数的时候, k i m o d p k_i \mod p kimodp的结果会在1到 p − 1 p-1 p−1之间均匀分布 - 平方取中间值:key平方扩大相近数之间的差别,然后根据哈希表长度取关键字平方值的中间几位数为哈希地址 e.g H a s h ( k e y ) = ( k e y × k e y ) / / 100 m o d 100 Hash(key) = (key \times key) //100 \mod 100 Hash(key)=(key×key)//100mod100
哈希冲突(Hash Collision)
哈希冲突(Hash Collision):不同的关键字映射到同一哈希地址,即 k e y 1 ≠ k e y 2 key1 \neq key2 key1=key2 但是 H a s h ( k e y 1 ) = = H a s h ( k e y 2 ) Hash(key1) == Hash(key2) Hash(key1)==Hash(key2)
补充:Bloom Fliter中冲突的概率计算(结合一下学校知识)
布隆过滤器:用来快速判断一个元素是否在一个集合里面的
由长度为 m m m的数组和 k k k个哈希函数组成
当一个元素被插入Bloom Filter的时候,使用 k k k个哈希函数,得到 k k k个地址,把数组对应的地址全部标记为1
以下计算Bloom Filter误判(false positive)的概率(即该元素不属于集合,但被判定属于集合的概率)
- m m m是布隆过滤器数组的长度
- n n n输入元素的数量
- k k k使用的哈希函数的数量
- 某个哈希函数将某一位设置为 1 的概率是 1 m \frac{1}{m} m1,某个哈希函数不将某一位设置为 1 的概率是 1 − 1 m 1 - \frac{1}{m} 1−m1
- 对于一个元素,使用
k
k
k个哈希函数后,某一位仍然为 0 的概率是:
( 1 − 1 m ) k \left(1 - \frac{1}{m}\right)^k (1−m1)k - 将 n n n个元素放入布隆过滤器后,某一位仍然为 0 的概率是: ( 1 − 1 m ) k n \left(1 - \frac{1}{m}\right)^{kn} (1−m1)kn
- 某一位被设置为 1 的概率是: P { b = 1 } = 1 − ( 1 − 1 m ) k n P\{b=1\} = 1 - \left(1 - \frac{1}{m}\right)^{kn} P{b=1}=1−(1−m1)kn
- 误判的概率:误判发生在所有 k k k个哈希函数映射的位置都为 1 时$ P{\text{false positive}} = \left[1 - \left(1 - \frac{1}{m}\right){kn}\right]k ,利用泰勒展开: ,利用泰勒展开: ,利用泰勒展开:e^x \approx 1 + x 可以得到: 可以得到: 可以得到:\left(1 - \frac{1}{m}\right)^{kn} \approx \left(e{-\frac{1}{m}}\right){kn} = e^{-\frac{kn}{m}}$ 因此,误判的概率可以近似为: P { false positive } ≈ ( 1 − e − k n m ) k P\{\text{false positive}\} \approx \left(1 - e^{-\frac{kn}{m}}\right)^k P{false positive}≈(1−e−mkn)k 可以用这个公式来确定需要选用多少位的布隆过滤器 m m m或者多少个哈希函数 k k k才能达到误判率低于某个值
补充:生日悖论 Birthday Paradox
只有23个人的教室,两个生日是同一天的概率大于50%
解决哈希冲突的两种方法:
开放地址
发生冲突就寻找后续的空地址,直到找到为止
H
(
i
)
=
(
H
a
s
h
(
i
)
+
F
(
i
)
)
m
o
d
m
H(i) = (Hash(i) + F(i)) \mod m
H(i)=(Hash(i)+F(i))modm
常见的
F
(
i
)
=
i
F(i) = i
F(i)=i,即发生冲突则向右移动一位,看看有没有冲突,若有空位则存进去
链地址
将具有相同哈希地址的元素存储在一个线性链表中
哈希表变存储头节点指针的数组
- 插入关键字的时候,通过哈希函数得到链表的头指针,然后插入链表的头部(插入链表头部方便)
- 查询关键字的时候,只需要遍历对应链表寻找元素
图片来源: geekforgeeks
数据类型 | 实现方式 | 是否有序 | 元素/键是否唯一 | 是否允许修改 | 查询效率 | 增删效率 | 适用场景 |
---|---|---|---|---|---|---|---|
列表 (List) | 动态数组 | 有序 | 元素可重复 | 是 | O(n) | append O(1) remove O(n) | 存储有序数据,支持索引访问 |
集合 (Set) | 哈希表 | 无序 | 元素唯一 | 是 | O(1) | O(1) | 去重、快速查找元素是否存在 |
字典 (Dict) | 哈希表 | 无序 (Python 3.7+ 有序) | 键唯一,值可重复 | 是 | O(1) | O(1) | 存储键值对,快速查找、插入、删除 |
defaultdict | 哈希表 (带默认值) | 无序 (Python 3.7+ 有序) | 键唯一,值可重复 | 是 | O(1) | O(1) | 需要默认值的字典操作 |
题1:设计哈希集合
class MyHashSet:
def __init__(self):
self.size = 997 #接近1000的质数
self.hashtable = [[] for _ in range(self.size)]
# 二维数组,模拟数组+链表结构
def hash(self, key: int) -> int:
#定义哈希函数
return key % self.size
def add(self, key: int) -> None:
hashvalue = self.hash(key)
if key not in self.hashtable[hashvalue]:
self.hashtable[hashvalue].append(key)
def remove(self, key: int) -> None:
hashvalue = self.hash(key)
if key in self.hashtable[hashvalue]:
self.hashtable[hashvalue].remove(key)
def contains(self, key: int) -> bool:
hashvalue = self.hash(key)
if key in self.hashtable[hashvalue]:
return True
else:
return False
设计哈希映射
class MyHashMap:
def __init__(self):
self.tablesize = 997
self.hashtable = [[] for _ in range(self.tablesize)]
def hash(self,key) -> int:
return key % self.tablesize
def put(self, key: int, value: int) -> None:
hashvalue = self.hash(key)
find = False
for i in range(len(self.hashtable[hashvalue])):
if key == self.hashtable[hashvalue][i][0]:
self.hashtable[hashvalue][i][1] = value
find = True
if not find:
self.hashtable[hashvalue].append([key,value])
def get(self, key: int) -> int:
hashvalue = self.hash(key)
for i in range(len(self.hashtable[hashvalue])):
if key == self.hashtable[hashvalue][i][0]:
return self.hashtable[hashvalue][i][1]
return -1
def remove(self, key: int) -> None:
hashvalue = self.hash(key)
for i in range(len(self.hashtable[hashvalue])):
if key == self.hashtable[hashvalue][i][0]:
self.hashtable[hashvalue].remove(self.hashtable[hashvalue][i])
break
题2: 检测异位词
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
alpha = [0] * 26
for i in s:
alpha[ord(i)-ord('a')] += 1
for i in t:
alpha[ord(i)-ord('a')] -= 1
for i in range(26):
if alpha[i] != 0:
return False
return True
升级版:判断ransomNote
字符串是否可以用magazine
里面的字母构成
class Solution:
def canConstruct(self, ransomNote: str, magazine: str) -> bool:
alpha = [0] * 26
for letter in magazine:
alpha[ord(letter) - ord('a')] += 1
for letter in ransomNote:
if alpha[ord(letter) - ord('a')] == 0:
return False
else:
alpha[ord(letter) - ord('a')] -= 1
return True
题3: 返回集合交集
由于数据的最大值
≤
1000
\leq 1000
≤1000这道题目可以继续用数组作为哈希表,但是如果数据的值很大的话,用数组会造成很大的存储空间浪费,所以可以考虑dict
字典结构来作哈希表
class Solution:
def intersection(self, nums1: List[int], nums2: List[int]) -> List[int]:
intersect = []
h = {}
for i in nums1:
if i not in h:
h[i] = 1
for i in nums2:
if i in h:
intersect.append(i)
del h[i] #清空键值对,以免重复添加
return intersect
还可以用类似合并数组的思想,排序后再求交集
class Solution:
def intersection(self, nums1: List[int], nums2: List[int]) -> List[int]:
intersect = []
nums1.sort()
nums2.sort()
ptr1 = 0
ptr2 = 0
while ptr1 < len(nums1) and ptr2 < len(nums2):
if nums1[ptr1] == nums2[ptr2]:
intersect.append(nums1[ptr1])
ptr1 += 1
ptr2 += 1
while ptr1 < len(nums1) and nums1[ptr1] == nums1[ptr1-1]: # 去重
ptr1 += 1
elif nums1[ptr1] < nums2[ptr2]:
ptr1 += 1
else:
ptr2 += 1
return intersect
进阶:返回时考虑出现次数,以出现次数少的为准
初写:
class Solution:
def intersect(self, nums1: List[int], nums2: List[int]) -> List[int]:
dict1 = {}
dict2 = {}
result = []
for i in nums1:
if i in dict1:
dict1[i] += 1
else:
dict1[i] = 1
for i in nums2:
if i in dict2:
dict2[i] += 1
else:
dict2[i] = 1
for key in dict1:
if key in dict2:
for _ in range(min(dict1[key],dict2[key])):
result.append(key)
return result
优化,完全不需要遍历两次,第一次dict1
记录了元素在iist1
中出现的次数,遍历dict2的时候只需要逐步减掉次数就行,当dict1
对应key的次数被减到0的时候,把它从字典里删除
class Solution:
def intersect(self, nums1: List[int], nums2: List[int]) -> List[int]:
dict1 = {}
result = []
for i in nums1:
if i in dict1:
dict1[i] += 1
else:
dict1[i] = 1
for i in nums2:
if i in dict1:
dict1[i] -= 1
result.append(i)
if dict1[i] == 0:
del dict1[i]
return result
题4:寻找快乐数(每位平方和多次相加最终能为1的数)
class Solution:
def isHappy(self, n: int) -> bool:
list1 = []
nex = n
list1.append(n)
while nex != 1:
string = str(nex)
count = 0
for i in string:
count += int(i) * int(i)
nex = count
if nex in list1:
return False
else:
list1.append(nex)
return True
算各位数字的和用转换成string来算,虽然方便了写程序,但实际上算法的时间复杂度变高 O ( l o g n ) − > O ( n ) O(logn) -> O(n) O(logn)−>O(n)
class Solution:
def getNext(self, n: int) -> int:
total_sum = 0
while n > 0:
n, digit = divmod(n, 10)
total_sum += digit ** 2
return total_sum
def isHappy(self, n: int) -> bool:
num_set = set()
while n != 1 and n not in num_set:
num_set.add(n) ]
n = self.getNext(n)
return n == 1
如果直接进行数学计算的话,算 total_sum
的循环只需要循环
log
10
n
\log_{10} n
log10n 次,判断是否为快乐数的循环,循环次数随着n
的数位减小而减小
本思路使用数据是否重复来检验是否进入无限循环
其实结合一下昨天的链表判断有无环,用快慢指针的想法也行,都能检测是否进入无限循环
题5:两数之和为target
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
dic = {}
for idx in range(len(nums)):
if nums[idx] in dic:
return [idx, dic[nums[idx]]]
else:
dic[target - nums[idx]] = idx
题6:找出是否存在满足差值小于valueDiff
下标索引差值小于indexDiff
的一对(i,j)
一道非常综合的难题,考察了滑动窗口 二分查找
思路分析:因为哈希表只能记录一个条件,不能同时记录两个条件,我一开始想着把 [ n u m s [ i ] − v a l u e D i f f , n u m s [ i ] + v a l u e D i f f ] [nums[i]-valueDiff, nums[i]+valueDiff] [nums[i]−valueDiff,nums[i]+valueDiff]全部都存到哈希表(dict)中,这样下次检索,直接判断索引差值是否符合条件
class Solution:
def containsNearbyAlmostDuplicate(self, nums: List[int], indexDiff: int, valueDiff: int) -> bool:
dic = {}
for i in range(len(nums)):
if nums[i] in dic:
if abs(dic[nums[i]] - i) <= indexDiff:
return True
else:
for j in range(nums[i]-valueDiff, nums[i]+valueDiff+1):
dic[j] = i
else:
for j in range(nums[i]-valueDiff, nums[i]+valueDiff+1):
dic[j] = i #即使这个值被保存过了,也存新的index i,因为i比较小
return False
但是这样超出内存限制了,想着要优化,不要在dic里面存那么多东西,只要是index超过indexDiff的,全部可以从dict里面去除,但是用dict结构很难实现这一点,所以想到用left和right双指针的滑动窗口来保证index的差值,然后对窗口进行排序(因为在窗口里的数,都满足indexDiff,排序之后index乱套了也无所谓),使用二分查找找和right最接近的值,判断是否满足ValueDiff
class Solution:
def containsNearbyAlmostDuplicate(self, nums: List[int], indexDiff: int, valueDiff: int) -> bool:
from bisect import bisect_left # 调用函数实现二分查找
from sortedcontainers import SortedList
window = SortedList() #维护一个长度不超过indexDiff的窗口,这个窗口是有序的
left = 0
right = 0
while right < len(nums):
window.add(nums[right])
if right - left > indexDiff:
#没有+1哦,right left本身就是索引
window.remove(nums[left])
left += 1
idx = bisect_left(window,nums[right])
if idx > 0 and abs(window[idx-1] - nums[right]) <= valueDiff:
return True
if idx + 1 < right - left + 1 and abs(window[idx+1] - nums[right]) <= valueDiff:
#可以优化成 idx < right - left只是为了自己看起来更清楚
return True
right += 1
return False
题7:四数相加 从四个数组里面找nums1[i] + nums2[j] + nums3[k] + nums4[l] = 0的(i,j,k,l)总共有多少对
本题的关键在于想着如何去优化,四个for循环的暴力解 O ( n 4 ) O(n^4) O(n4)的时间复杂度,因为涉及到查找问题,其实与检测异位词还有两数之和为target是一样的,只不过之前是两数之和/处理两个数组,现在有四个数组,所以一对对处理,把复杂度降低到 O ( n 2 ) O(n^2) O(n2)
class Solution:
def fourSumCount(self, nums1: List[int], nums2: List[int], nums3: List[int], nums4: List[int]) -> int:
dic = {}
count = 0
for i in nums1:
for j in nums2:
if i + j in dic:
dic[i+j] += 1
else:
dic[i+j] = 1
for i in nums3:
for j in nums4:
if -i - j in dic:
count += dic[-i-j]
return count
题8:三数之和为0,返回[[nums[i], nums[j], nums[k]]需要去重
这题需要回看复习 第一遍不能自己构建一个可以跑通的思路,难点主要在去重
方法一:哈希表
class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
nums.sort()
result = []
for i in range(len(nums) - 2):
if nums[i] > 0:
# 最小的第一个数大于0,一定不存在满足要求的三元组
return result
if i > 0 and nums[i] == nums[i-1]:
continue
# 去重,如果开头为nums[i]已经在之前遍历过了,就可以直接跳过
table = set() #记录需要的数
k = i+1
while k < len(nums):
#寻找k,三元组中的第三个元素
if -nums[k] - nums[i] in table:
#存在
result.append([nums[i], -nums[k]-nums[i], nums[k]])
while k < len(nums) - 1 and nums[k] == nums[k+1]:
k += 1
table.add(nums[k])
k += 1
return result
注意:
- 第二重循环遍历的是
k
还是j
如果遍历的是j
那么k
还需要单独一层循环来处理,不能降低算法时间复杂度,而且很难去重,所以用k
作为第二层循环的变量,j
卡在k
和i
中间,可以把那些出现过的k
作为j
的备选项,加入set
if i > 0 and nums[i] == nums[i-1]
continue条件 去重i
是当nums[i]
这个数值已经尝试过作为三元组的开头了,就不需要再尝试一遍,所以是和前一个比较,如果和后一个比较,就会导致默认第二个数nums[j]
不能等于第一个数nums[i]
,会漏掉[-1, -1, 2]
这种情况k
的初始值i+1
虽然k
代表第三个元素,但是已经遍历过的k
要作为j
的后背选项加入table
所以还是要从i+1
开始避免遗漏while k < len(nums) - 1 and nums[k] == nums[k+1]:
循环是在if
语句里面还是外面如果放在if
外面,相当于逻辑上默认 j ≠ k j \neq k j=k,因为同样的元素只会被add
进入table
一次,之后k
变成k+1
不同于被add
进入table
的元素,处理[-2,1,1]
会有问题
方法二:滑动窗口
class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
nums.sort()
result = []
for i in range(len(nums) - 2):
# i为三元组的第一个元素,从头遍历到尾部
if nums[i] > 0:
return result
if i > 0 and nums[i] == nums[i-1]:
continue
# 去重
left = i + 1
right = len(nums) - 1
# left和right分别代表j和k
while left < right:
if nums[i] + nums[left] + nums[right] == 0:
result.append([nums[i], nums[left], nums[right]])
#去重
while nums[left] == nums[left+1] and left + 1 < right:
left += 1
while nums[right] == nums[right-1] and right - 1 > left:
right -= 1
left += 1
right -= 1
elif nums[i] + nums[left] + nums[right] > 0:
right -= 1
else:
left += 1
return result
注意:
- 双指针的设计
left
和right
初始化为i + 1
和len(nums) - 1
而不是i + 1
和i + 2
是为了方便移动,对于已经排序的数组,如果right = i + 2
,就很难设计当nums[i] + nums[left] + nums[right] < 0
时指针如何移动,是移动left
还是right
,如果移动right
那么什么时候移动left
,反之亦然,如果同时移动,相当于限制死了left
right
永远相邻。如果用一头一尾的设计,就刚好能处理大于小于两种情况 - 什么时候
while nums[left] == nums[left+1] and left + 1 < right
去重:一定是在找到一组后,不然相当于限制了 n u m s [ l e f t ] ≠ n u m s [ r i g h t ] nums[left] \neq nums[right] nums[left]=nums[right] 如果非要把去重逻辑放外面写,可以类比i
的去重,if i > 0 and nums[i] == nums[i-1]:
,程序如下 - 去重完别忘了
left += 1
right -= 1
更新值 - 若题干要求返回下标索引的话,就无法用双指针法,因为双指针法要排序,排序之后索引失效啦
class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
nums.sort()
result = []
for i in range(len(nums) - 2):
# i为三元组的第一个元素,从头遍历到尾部
if nums[i] > 0:
return result
if i > 0 and nums[i] == nums[i-1]:
continue
# 去重
left = i + 1
right = len(nums) - 1
# left和right分别代表j和k
while left < right:
while left > i + 1 and nums[left] == nums[left-1] and left < right:
left += 1
while right < len(nums) - 1 and nums[right] == nums[right + 1] and left < right:
right -= 1
if left < right:
if nums[i] + nums[left] + nums[right] == 0:
result.append([nums[i], nums[left], nums[right]])
left += 1
right -= 1
elif nums[i] + nums[left] + nums[right] > 0:
right -= 1
else:
left += 1
return result
题9:四数之和为target,四个数来自于同一个数组,不能重复
和三数之和用同样的方式降低时间复杂的
- 排序,方便进行双指针遍历
- 双指针遍历减少一重循环
class Solution:
def fourSum(self, nums: List[int], target: int) -> List[List[int]]:
nums.sort()
result = []
for i in range(len(nums)-3):
# 可以尝试做剪枝操作,但是要注意条件
# 不是
# if nums[i] > target:
# break
# 因为target可以是负数
if nums[i] > target and nums[i] > 0:
# 去重
break
if i > 0 and nums[i] == nums[i-1]:
continue
for j in range(i+1, len(nums)-2):
if nums[i] + nums[j] > target and nums[j] > 0:
break
# 如果nums[j] > 0 的话后面的数一定大于0,只要是大于0的数,越加越大
if j > i + 1 and nums[j] == nums[j-1]:
# 去重
continue
left = j + 1
right = len(nums) - 1
# 双指针遍历,思路同三数之和
while left < right:
if nums[i] + nums[j] + nums[left] + nums[right] == target:
result.append([nums[i], nums[j], nums[left], nums[right]])
# 去重
while left < right and nums[left] == nums[left+1]:
left += 1
while right > left and nums[right] == nums[right-1]:
right -= 1
left += 1
right -=1
elif nums[i] + nums[j] + nums[left] + nums[right] < target:
left += 1
else:
right -= 1
return result
让我思考的点:
left += 1
right += 1
放在去重前还是后:如果放在去重前,可能会因为left < right
而推出循环,但实际上此时去重还没有去干净,而如果放在去重后,如果因为left < right
而退出循环,那么加1过后,在外层循环判断时,一定能正确退出