哈希表理论基础
哈希表
⚠️ 当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法
但是哈希法也是牺牲了空间换取了时间,因为我们要使用额外的数组,set或者是map来存放数据,才能实现快速的查找。
哈希表是根据关键码的值而直接进行访问的数据结构。
哈希表中关键码就是数组的索引下标,然后通过下标直接访问数组中的元素,如下图所示:
例如要查询一个名字是否在这所学校里。
要枚举的话时间复杂度是O(n),但如果使用哈希表的话, 只需要O(1)就可以做到。
我们只需要初始化把这所学校里学生的名字都存在哈希表里,在查询的时候通过索引直接就可以知道这位同学在不在这所学校里了。
将学生姓名映射到哈希表上就涉及到了hash function ,也就是哈希函数。
哈希函数
哈希函数,把学生的姓名直接映射为哈希表上的索引,然后就可以通过查询索引下标快速知道这位同学是否在这所学校里了。
哈希函数如下图所示,通过hashCode把名字转化为数值,一般hashcode是通过特定编码方式,可以将其他数据格式转化为不同的数值,这样就把学生名字映射为哈希表上的索引数字了。
如果hashCode得到的数值大于 哈希表的大小了,也就是大于tableSize了,怎么办呢?
此时为了保证映射出来的索引数值都落在哈希表上,我们会在再次对数值做一个取模的操作,这样我们就保证了学生姓名一定可以映射到哈希表上了。
此时问题又来了,哈希表我们刚刚说过,就是一个数组。
如果学生的数量大于哈希表的大小怎么办,此时就算哈希函数计算的再均匀,也避免不了会有几位学生的名字同时映射到哈希表 同一个索引下标的位置。
接下来哈希碰撞登场
哈希碰撞
如图所示,小李和小王都映射到了索引下标 1 的位置,这一现象叫做哈希碰撞。
一般哈希碰撞有两种解决方法, 拉链法和线性探测法。
拉链法
刚刚小李和小王在索引1的位置发生了冲突,发生冲突的元素都被存储在链表中。 这样我们就可以通过索引找到小李和小王了
(数据规模是dataSize, 哈希表的大小为tableSize)
其实拉链法就是要选择适当的哈希表的大小,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。
线性探测法
使用线性探测法,一定要保证tableSize大于dataSize。 我们需要依靠哈希表中的空位来解决碰撞问题。
例如冲突的位置,放了小李,那么就向下找一个空位放置小王的信息。所以要求tableSize一定要大于dataSize ,要不然哈希表上就没有空置的位置来存放 冲突的数据了。如图所示:
常见的哈希结构
- 数组
- 字典
- 集合
-
Python 中主要哈希结构的整理表格:
哈希结构 定义 特点 示例 字典 ( dict
)键值对存储结构,使用哈希表实现 键必须是不可变的,平均时间复杂度 O(1) my_dict = {'key1': 'value1'}
集合 ( set
)无序、不重复的元素集合 只存储唯一元素,平均时间复杂度 O(1) my_set = {1, 2, 3, 4}
collections.defaultdict
字典的子类,提供默认值 自动创建缺失键的初始值 from collections import defaultdict; my_defaultdict = defaultdict(int)
collections.Counter
字典的子类,计数可哈希对象的出现次数 自动处理计数,提供获取最常见元素的方法 from collections import Counter; my_counter = Counter(['a', 'b', 'a'])
collections.OrderedDict
保持插入顺序的字典 在插入顺序方面的扩展,提供其他方法如移动元素到末尾 from collections import OrderedDict; my_ordered_dict = OrderedDict()
frozenset
不可变的集合类型 可以用作字典的键或集合的元素 my_frozenset = frozenset([1, 2, 3])
242. 有效的字母异位词
给定两个字符串 s
和 t
,编写一个函数来判断 t
是否是 s
的 字母异位词。
菜鸡想出来方法(本来更复杂)
Counter
是一个字典子类,可以方便地统计可哈希对象的数量
from collections import Counter
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
if len(s) != len(t):
return False
cnt = Counter(t) # 只需要统计 t 的字符
for x in s:
cnt[x] -= 1
if cnt[x] < 0: # 如果某个字符的计数小于 0,直接返回 False
return False
return True # 如果所有字符计数都正确,返回 True
时间复杂度
- 计数
t
的字符:使用Counter(t)
的时间复杂度是 O(n),其中 n 是字符串t
的长度。 - 遍历
s
:在第二个循环中,你遍历字符串s
,时间复杂度也是 O(n)。
因此,总的时间复杂度是 O(n) + O(n) = O(n)。
空间复杂度
- Counter:使用
Counter
存储字符计数,最坏情况下需要 O(m) 的空间,其中 m 是不同字符的数量。对于英文字母来说,最大为 26,因此可以认为是 O(1)。
因此,总的空间复杂度是 O(m),在最坏情况下可以认为是 O(1)。
改进
from collections import Counter
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
# 如果长度不同,直接返回 False
if len(s) != len(t):
return False
# 使用 Counter 统计字符出现的次数
return Counter(s) == Counter(t)
- 时间复杂度: O(n)
- 空间复杂度: O(m) 或 O(1)(取决于字符集的大小)
例子
假设我们有两个字符串:
s = "anagram"
t = "nagaram"
步骤解析
-
长度检查:
len(s) = 7
len(t) = 7
- 因为长度相同,继续执行。
-
字符计数:
Counter(s)
会计算出:Counter({'a': 3, 'n': 1, 'g': 1, 'r': 1, 'm': 1})
Counter(t)
会计算出:Counter({'a': 3, 'n': 1, 'g': 1, 'r': 1, 'm': 1})
-
比较计数:
- 两个
Counter
对象相等:Counter(s) == Counter(t) # True
- 因此,返回
True
,表示s
和t
是字母异位词。
- 两个
⚠️哈希表法
(数组--最多只有26个字母 可控):
需要把字符映射到数组也就是哈希表的索引下标上,因为字符a到字符z的ASCII是26个连续的数值,所以字符a映射为下标0,相应的字符z映射为下标25。
遍历 字符串s的时候,只需要将 s[i] - ‘a’ 所在的元素做+1 操作即可,并不需要记住字符a的ASCII,只要求出一个相对数值就可以了。 这样就将字符串s中字符出现的次数,统计出来了。
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
cnt = [0] * 26
for c in s:
cnt[ord(c) - ord('a')] += 1
for c in t:
cnt[ord(c) - ord('a')] -= 1
for c in range(26):
if cnt[c] != 0:
return False
return True
# return all(c == 0 for c in cnt)
# 检查 cnt 列表中的所有元素是否都为 0
- 时间复杂度: O(n)
- 空间复杂度: O(1)
ord
是 Python 的一个内置函数,用于返回字符对应的 Unicode 码点(整数值)。
ord(char)
- 参数:
char
是一个字符(长度为 1 的字符串)。 - 返回值:返回该字符对应的 Unicode 码点(整数)。
print(ord('a')) # 输出: 97
print(ord('z')) # 输出: 122
print(ord('A')) # 输出: 65
print(ord('Z')) # 输出: 90
print(ord('1')) # 输出: 49
ord(c) - ord('a')
用于计算字符 c
在哈希表中的索引,从而方便地更新计数器。比如,对于字母 'a',该表达式计算的结果为 0,对于 'b' 为 1,以此类推。
49. 字母异位词分组
给你一个字符串数组,请你将 字母异位词 组合在一起。可以按任意顺序返回结果列表。
字母异位词 是由重新排列源单词的所有字母得到的一个新单词。
注意到,如果把 aab,aba,baa 按照字母从小到大排序,我们可以得到同一个字符串 aab。
所以当且仅当两个字符串排序后一样,这两个字符串才能分到同一组。
根据这一点,我们可以用哈希表来分组,把排序后的字符串当作 key,原字符串组成的列表(即答案)当作 value。
最后把所有 value 加到一个列表中返回。
可以替换为普通的字典(
dict
)。不过,在使用普通字典时,你需要手动检查键是否存在,如果不存在则初始化一个空列表
class Solution:
def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
d = defaultdict(list)
for s in strs:
d[''.join(sorted(s))].append(s)
return list(d.values())
s = "eat"
sorted_chars = sorted(s) # 返回 ['a', 'e', 't']返回一个字符列表。
result = ''.join(sorted_chars) # 使用 join 连接成字符串 返回'aet'
append(s)
将原始字符串 s
添加到这个键对应的列表中,形成字母异位词的分组。假设你有以下字符串列表:
strs = ["eat", "tea", "tan", "ate"]
d = defaultdict(list)
for s in strs:
key = ''.join(sorted(s)) # 生成排序后的字符串作为键
d[key].append(s) # 将原字符串添加到对应的列表中
# 输出字典的内容
print(d)
defaultdict(<class 'list'>, {'aet': ['eat', 'tea', 'ate'], 'ant': ['tan']})
349. 两个数组的交集
给定两个数组 nums1
和 nums2
,返回 它们的 交集 。输出结果中的每个元素一定是 唯一 的。我们可以 不考虑输出结果的顺序 。
提示:
1 <= nums1.length, nums2.length <= 1000
0 <= nums1[i], nums2[i] <= 1000
如果没有限制数值大小:用set
如果哈希值比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费。
方法一: 使用字典和集合
class Solution:
def intersection(self, nums1: List[int], nums2: List[int]) -> List[int]:
table = {}#创建一个字典:实现实际上是基于哈希表
for num in nums1:
table[num] = table.get(num, 0)+1
# get 方法用于从字典 table 中获取键 num 的值。默认值为0
# table = {1: 2, 2: 2}
res = set()
for num in nums2:
if num in table:
res.add(num)
# 注意列表list用append,集合set用add
del table[num]
# 也可以不删除,set会自动去重。使用 del 可以稍微提高效率,防止在 table 中存储无用的元素,尤其是在处理大型数组时。
return list(res)
可以使用
collections.Counter
来替代字典table
进行元素计数# 使用 Counter 来统计 nums1 中的元素 :count = Counter(nums1)
复杂度分析
-
时间复杂度:
- 构建哈希表的时间复杂度是 O(n),其中 n 是
nums1
的长度。 - 遍历
nums2
的时间复杂度是 O(m),其中 m 是nums2
的长度。 - 因此,总的时间复杂度是 O(n + m)。
- 构建哈希表的时间复杂度是 O(n),其中 n 是
-
空间复杂度:
- 哈希表的空间复杂度是 O(n),用于存储
nums1
中的元素。 - 结果集合
res
的空间复杂度是 O(min(n, m)),因为它存储的是交集中的元素。 - 总的空间复杂度是 O(n)。
- 哈希表的空间复杂度是 O(n),用于存储
方法二:使用数组
class Solution:
def intersection(self, nums1: List[int], nums2: List[int]) -> List[int]:
count1 = [0]*1001
count2 = [0]*1001
result = []
for i in range(len(nums1)):
count1[nums1[i]]+=1
for j in range(len(nums2)):
count2[nums2[j]]+=1
for k in range(1001):
if count1[k]*count2[k]>0:
result.append(k)
return result
方法三:使用集合
class Solution:
def intersection(self, nums1: List[int], nums2: List[int]) -> List[int]:
return list(set(nums1) & set(nums2))
202. 快乐数
编写一个算法来判断一个数 n
是不是快乐数。
「快乐数」 定义为:
- 对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。
- 然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。
- 如果这个过程 结果为 1,那么这个数就是快乐数。
如果 n
是 快乐数 就返回 true
;不是,则返回 false
。
判断思路:在替换的过程中,如果出现了曾经出现过的结果,那么就会进入无限循环
学到的:对取数值各个位上的单数操作 for i in str(n)
集合/数组 都可:换加入的函数即可 append/add
class Solution:
def isHappy(self, n: int) -> bool:
record = set() # record = []
while n != 1:
n = sum(int(i)**2 for i in str(n)) #str(n) 的作用是将整数 n 转换为字符串
# str(n) # 返回 '19'
# for i in str(n) 会依次处理 '1' 和 '9'。
# 计算 1^2 + 9^2 = 1 + 81 = 82
if n in record:
return False
record.add(n)
return True
1. 两数之和
给定一个整数数组 nums
和一个整数目标值 target
,请你在该数组中找出 和为目标值 target
的那 两个 整数,并返回它们的数组下标。
你可以假设每种输入只会对应一个答案,并且你不能使用两次相同的元素。
你可以按任意顺序返回答案。
方法一:使用字典
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
record = {} # record = dict()
for i,x in enumerate(nums):
if target - x in record:
return [i, record[target-x]]
record[x] = i
时间复杂度
- 外层循环:遍历
nums
的每个元素,时间复杂度为 O(n),其中 n 是nums
的长度。 - 查找补数:由于
record
是一个字典,查找操作的平均时间复杂度为 O(1)。
空间复杂度:O(n)