哈希表理论基础
1.哈希表(英文名字为Hash table,也翻译为散列表)是根据关键码的值而直接进行访问的数据结构。直白来讲其实数组就是一张哈希表,哈希表中关键码就是数组的索引下标,然后通过下标直接访问数组中的元素。
2.哈希表的作用:一般哈希表都是用来快速判断一个元素是否出现集合里。
例如,要查询一个名字是否在这所学校里。要枚举的话时间复杂度是O(n),但如果使用哈希表的话, 只需要O(1)就可以做到。我们只需要初始化把这所学校里学生的名字都存在哈希表里,在查询的时候通过索引直接就可以知道这位同学在不在这所学校里了。将学生姓名映射到哈希表上就涉及到了hash function ,也就是哈希函数。
3.哈希函数,是把任意长度的输入通过hash算法(散列算法)变换成固定长度的输出,该输出就是hashcode(散列值/哈希码)。hashcode就是在hash表中有对应的位置;这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。
例如,把学生的姓名直接映射为哈希表上的索引,然后就可以通过查询索引下标快速知道这位同学是否在这所学校里了。如果hashCode得到的数值大于哈希表的大小了,也就是大于tableSize了,此时为了保证映射出来的索引数值都落在哈希表上,我们会在再次对数值做一个取模的操作,这样我们就保证了学生姓名一定可以映射到哈希表上了。如果学生的数量大于哈希表的大小,此时就算哈希函数计算的再均匀,也避免不了会有几位学生的名字同时映射到哈希表 同一个索引下标的位置。接下来哈希碰撞登场
4.哈希碰撞:例如,小李和小王都映射到了索引下标 1 的位置,这一现象叫做哈希碰撞。一般哈希碰撞有两种解决方法, 拉链法和线性探测法。
拉链法:小李和小王在索引1的位置发生了冲突,发生冲突的元素都被存储在链表中。 这样我们就可以通过索引找到小李和小王了。其实拉链法就是要选择适当的哈希表的大小,这样既不会因为数组空值(哈希表空值)而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。
线性探测法:使用线性探测法,一定要保证tableSize大于dataSize(数据规模是dataSize,哈希表的大小为tableSize)。 我们需要依靠哈希表中的空位来解决碰撞问题。例如,冲突的位置,放了小李,那么就向下找一个空位放置小王的信息。所以要求tableSize一定要大于dataSize ,要不然哈希表上就没有空置的位置来存放冲突的数据了。
5.常见的三种哈希表结构:数组、集合(set)、map(映射)
数据小用数组,数据大用set,数据比较散用map
6.总结:当我们遇到了要快速判断一个元素是否出现在集合里的时候,就要考虑哈希法。但是哈希法也是牺牲了空间换取了时间,因为我们要使用额外的数组,set或者是map来存放数据,才能实现快速的查找。
思路:
1.暴力解法:两层for循环,同时还要记录字符是否重复出现,很明显时间复杂度是 O(n^2)
2.哈希法:数组其实就是一个简单哈希表,而且这道题目中字符串只有小写字符,那么就可以定义一个数组,来记录字符串s里字符出现的次数。该题是数组在哈希表里边的一个非常经典的应用
代码:
哈希法
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
hash=[0]*26 # 定义一个哈希数组大小为26(数组索引0到25代表字符a到字符z),默认数组里面的值为零
for i in s: # 统计s字符串里所有字符出现的频率,将其索引对应的元素加一
hash[ord(i)-ord("a")] += 1 # ord()函数返回的结果是对应字符的ASCII码
for i in t: # 统计t字符串里所有字符出现的频率,将其索引对应的元素减一
hash[ord(i)-ord("a")] -= 1
for i in hash: # 遍历哈希数组
if i!=0:
return False # 数组如果有的元素不为零0,说明字符串s和t一定是谁多了字符或者谁少了字符,返回False
return True
- 时间复杂度: O(n)
- 空间复杂度: O(1)
思路:这道题目,主要要学会使用一种哈希数据结构:set,这个数据结构可以解决很多类似的问题。这道题用暴力的解法时间复杂度是O(n^2),那来看看使用哈希法进一步优化。
用数组来做哈希表也是不错的选择,但是要注意,使用数组来做哈希的题目,是因为题目都限制了数值的大小。而这道题目如果没有限制数值的大小,就无法使用数组来做哈希表了(数组的下标索引大小有限)。而且如果哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费。
那有同学可能问了,遇到哈希问题我直接都用set不就得了,用什么数组啊。直接使用set 不仅占用空间比数组大,而且速度要比数组慢,set把数值映射到key上都要做hash计算的。不要小瞧这个耗时,在数据量大的情况,差距是很明显的。
数组优点. 1 不需要hash运算(直接用下标来做哈希映射) 2. 不需要额外开辟新的空间
代码:
1.使用集合
class Solution:
def intersection(self, nums1: List[int], nums2: List[int]) -> List[int]:
return list(set(nums1) & set(nums2)) # set()函数将数组nums1和nums2转换为集合;set(nums1) & set(nums2) 得到两个集合的交集;list()函数将集合转换为列表
补充:
Python set() function的用法
假设有两个集合:
set1 = {1, 2, 3, 4, 5}
set2 = {3, 4, 5, 6, 7}
求并集,返回包含两个集合中所有唯一元素的集合。
# 使用运算符 |
union_set = set1 | set2
print(union_set) # 输出:{1, 2, 3, 4, 5, 6, 7}
求交集,返回两个集合中共同存在的元素组成的集合。
# 使用运算符 &
intersection_set = set1 & set2
print(intersection_set) # 输出:{3, 4, 5}
求差集,返回在第一个集合中但不在第二个集合中的元素组成的集合。
# 使用运算符 -
difference_set = set1 - set2
print(difference_set) # 输出:{1, 2}
求对称差集,返回两个集合中不重复的元素组成的集合,即属于其中一个集合但不属于交集的元素。
# 使用运算符 ^
symmetric_difference_set = set1 ^ set2
print(symmetric_difference_set) # 输出:{1, 2, 6, 7}
2.使用字典和集合
class Solution:
def intersection(self, nums1: List[int], nums2: List[int]) -> List[int]:
table = {} # 创建一个空字典table
for num in nums1: # 遍历数组nums1
table[num] = table.get(num, 0) + 1 # 将数组中的元素存储到字典中
res = set() # 创建一个集合res,用来存储结果(交集)
for num in nums2: # 遍历数组nums2
if num in table: # 如果数组nums2的元素在字典key中存在
res.add(num) # 在集合res内添加数组nums2的元素
del table[num] # 在字典中删除num(key)和其对应的value
return list(res) #list()函数将集合转换为列表
补充:
get()
函数是字典对象的方法,用于检索指定键对应的值。与使用中括号[]
来访问字典值不同,get()
函数提供默认值,以便在键不存在时返回默认值而不是抛出异常。
get(key, default)
的语法包括两个参数:
key
:要检索的键。default
(可选):如果键不存在时返回的默认值。如果不提供default
参数,函数将返回None
。
3.使用数组
class Solution:
def intersection(self, nums1: List[int], nums2: List[int]) -> List[int]:
count1 = [0]*1001 # 定义大小为1001的数组,数组元素初始值设为0
count2 = [0]*1001
result = [] # 定义一个空数组
for i in nums1: # 遍历数组nums1
count1[i] += 1 # 将数组nums1的元素作为数组count1的索引,并将索引对应的元素值加1
for i in nums2: # 遍历数组nums2
count2[i] += 1 # 同上
for k in range(1001): # 遍历数组count1、count2
if count1[k]*count2[k]>0: # 相乘大于零说明两个数组的相同索引对应的元素值都大于零
result.append(k) # 往数组中添加元素
return result
# 使用数组版本2
class Solution:
def intersection(self, nums1: List[int], nums2: List[int]) -> List[int]:
hash = [0]*1001 # 定义大小为1001的哈希数组,数组元素初始值设为0
result = [] # 定义一个空数组
for i in nums1: # 遍历数组nums1
hash[i] = 1 # 将数组nums1的元素作为哈希数组的索引,并将索引对应的元素值设置为1
for i in nums2: # 遍历数组nums2
if hash[i]==1:
hash[i]=0
result.append(i)
return result
思路:
题目中说了会无限循环,那么也就是说求和的过程中,平方和会重复出现,这对解题很重要
当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法了。
所以这道题目使用哈希法,来判断这个平方和是否重复出现,如果重复了就是return false, 否则一直找到平方和为1为止。
代码:
class Solution:
def isHappy(self, n: int) -> bool:
record = set() # 定义一个空集合
while n not in record: # 如果n不在集合里,继续循环
record.add(n) # 将元素n添加到集合
new_num = 0 # 将平方和初始化为0
n_str = str(n) # 将n转换为字符串
for i in n_str: # 遍历字符串,取出n每个位置上的数
new_num+=int(i)**2 # n每个位置上的数字的平方和
if new_num==1: # 平方和是否为1
return True # 平方和为1,返回true
else:
n = new_num # 将平方和赋值给n,进行下一次循环
return False # 平方和重复出现,结束循环,返回false
- 时间复杂度: O(logn)
- 空间复杂度: O(logn)
思路:
1.很明显暴力的解法是两层for循环查找,时间复杂度是O(n^2)
2.哈希法(map):首先我再强调一下 什么时候使用哈希法,当我们需要查询一个元素是否出现过,或者一个元素是否在集合里的时候,就要第一时间想到哈希法。
本题呢,我就需要一个集合来存放我们遍历过的元素,然后在遍历数组的时候去询问这个集合,某元素是否遍历过,也就是是否出现在这个集合。那么我们就应该想到使用哈希法了。
因为本题,我们不仅要知道元素有没有遍历过,还要知道这个元素对应的下标,需要使用 key value结构来存放,key来存元素,value来存下标,那么使用map正合适。
映射(map)具体到Python中就是字典(dict),字典dict是Python中重要的数据结构,在字典中,每一个键都对应一个值,其中键与值的关系就叫做映射,也可以说是每一个键都映射到一个值上。
再来看一下使用数组和set来做哈希法的局限。
- 数组的大小是受限制的,而且如果元素很少,而哈希值太大会造成内存空间的浪费。
- set是一个集合,里面放的元素只能是一个key,而两数之和这道题目,不仅要判断y是否存在而且还要记录y的下标位置,因为要返回x 和 y的下标。所以set 也不能用。
此时就要选择另一种数据结构:map ,map是一种key value的存储结构,可以用key保存数值,用value再保存数值所在的下标。
代码:
1.暴力解法
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
for i in range(len(nums)):
for j in range(i+1,len(nums)):
if nums[i]+nums[j]==target:
return [i,j]
2.哈希法(map)
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
records = dict() # 创建一个空字典
for index, value in enumerate(nums): # 使用enumerate()函数遍历数组nums
if target-value in records: # 在map(字典)中寻找是否有匹配的key(等于target-value的key值是否在字典中存在)
return [records[target-value], index] # 返回字典中key对应value和当前数组nums元素的索引
records[value] = index # 如果没找到匹配对,就把当前遍历的元素和下标加入到map中(在字典中添加键值对)
return []
- 时间复杂度: O(n)
- 空间复杂度: O(n)
补充:
Python 的 enumerate() 函数
enumerate() 函数属于非常有用的高级用法,这个函数的基本应用就是用来遍历一个数据对象(如列表、元组或字符串),它在遍历的同时还可以得到当前元素的索引位置。
enumerate(iteration, start)函数默认包含两个参数,其中iteration参数为需要遍历的参数,比如字典、列表、元组等,start参数为开始的参数,默认为0(不写start那就是从0开始)。enumerate函数有两个返回值,第一个返回值为从start参数开始的数,第二个参数为iteration参数中的值。
b = [1,2,3,4,5,6]
for i , item in enumerate(b):
print(i, item)
"""
输出:
0 1
1 2
2 3
3 4
4 5
5 6
"""