今天复习哈希表,Python中哈希表常用dict(各种defaultdict, OrderedDict等等),先看看哈希表基础内容。简单来说,哈希表又叫散列表,将键值对中的键映射到散列表中的一个位置,可以加快查找的速度。对应的映射函数称为哈希函数(散列函数),类似于数组中直接用index获取元素值,dict中代入key获得的哈希函数值就是index,可以O(1)的时间获取键所对应的值。经过哈希函数映射之后,不同的键key如果对应到了同一个散列地址,就会出现哈希冲突,哈希函数的选择比较关键,已知的解决冲突的办法有拉链法(用列表存储散列地址相同的值)和线性探测法(搜索下一个空位),详见代码随想录哈希表章节讲解。
Python中的字典dict是用哈希表实现的,python3.8中关于dict的介绍可以参考Built-in Types — Python 3.8.16 documentation,是当前唯一一种标准的mapping类型。其他特殊的dict位于collections模块中,包括defaultdict和OrderedDict。defaultdict继承于dict,主要特点是可以在定义时指定默认值,比如设定默认值为空列表:d = defaultdict(list),具体使用方法可以参考collections — Container datatypes — Python 3.8.16 documentation;OrderedDict中的键是有序的,可以通过popitem()和move_to_end(),reversed()调整顺序。(collections — Container datatypes — Python 3.8.16 documentation)
题目链接:242. 有效的字母异位词 - 力扣(Leetcode)
是很明确使用哈希表的一道题,只要统计并两个字符串的字符出现次数是否相同即可。常规做法是分别遍历两个字符串进行统计,题目中仅包含小写字母,还能使用大小固定的数组来统计。
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
n1, n2 = len(s), len(t)
c1, c2 = dict(), dict()
if n1 != n2:
return False
# 统计
for i in range(n1):
c1[s[i]] = c1.get(s[i], 0) + 1
c2[t[i]] = c2.get(t[i], 0) + 1
# # 或直接调用Counter
# c1, c2 = collections.Counter(s), collections.Counter(t)
return c1 == c2
总体的时间复杂度是O(n+k),n是字符串的长度,k是不同字符的数量(字典中键的长度,只有小写字母时,k=26)。当输入字符串包含unicode字符时就不好用长度固定的数组了,用哈希表才是更好的选择。看题解还有更巧妙的做法,就是遍历s时做加法,遍历t时做减法,看遍历结束后是否每个字母个数都是0。
题目链接:349. 两个数组的交集 - 力扣(Leetcode)
题目数据量比较小,直接暴力哈希表统计之后,比较两个哈希表的键找交集:
class Solution:
def intersection(self, nums1: List[int], nums2: List[int]) -> List[int]:
c1, c2 = collections.Counter(nums1), collections.Counter(nums2)
res = []
for k in c1:
if k in c2:
res.append(k)
return res
另一种做法是在哈希统计之后,以元素数量较小的哈希表作为查找对象,二分搜索另一个哈希表的键,找到就能加入结果数组。具体实现时用set更方便,直接获取无重复的集合:
class Solution:
def intersection(self, nums1: List[int], nums2: List[int]) -> List[int]:
# c1, c2 = collections.Counter(nums1), collections.Counter(nums2)
res = []
# 二分
c1, c2 = set(), set()
for n in nums1:
c1.add(n)
for n in nums2:
c2.add(n)
c2 = sorted(c2)
for k in c1:
left, right = 0, len(c2) - 1
while left <= right:
mid = left + (right - left) // 2
if c2[mid] == k:
res.append(k)
break
elif c2[mid] < k:
left = mid + 1
else:
right = mid - 1
return res
以数组长度分别为n1,n2,nums1,nums2中不同元素的数量分别为k1,k2,那么第一种实现的具体时间复杂度是O(n1+n2+k1*k2),第二种实现就是O(n1+n2+k2*logk2+k1*logk2)=O(n1+n2+ (k1+k2)*logk2)。
*更正:后面查资料发现,Python的set查找效率不是和list等价的,用in在list中查找是O(n),而在set中仅为O(logn),因此即便实现2不用二分也可以达到logk2。而实现一中dict.keys()比较复杂,它的in操作时间复杂度有待查证,如果实现1也用set,那么它的时间复杂度应为O(n1+n2+k1*logk2),这样看来实现2的二分解法徒增一个排序的时间复杂度了。
题目链接:202. 快乐数 - 力扣(Leetcode)
重新做题时,可以明确非快乐数在一定次数替换后会进入循环,因此当新数在过往出现过就能认定非快乐数了:
class Solution:
def calSquareSum(self, num):
ans = 0
while num:
ans += (num % 10) ** 2
num //= 10
return ans
def isHappy(self, n: int) -> bool:
if n == 1:
return True
occurred = dict() # key: current n, value: next n, 当映射关系已经出现过,证明陷入无限循环,可以返回了
while n > 1:
next_n = self.calSquareSum(n)
if n in occurred: # and occurred[n] == next_n:
return False
occurred[n] = next_n
n = next_n
return True # 跳出循环且没返回时n==1(正整数规定n不为0)
实际上上面的代码还写复杂了,不需要用dict,直接用列表就行,因为查看键n是否在dict中,本质上相当于在key列表中查找。
题目链接:1. 两数之和 - 力扣(Leetcode)
直观做法是,遍历每一个数nums[i],查找数组中是否存在target-nums[i],存在即可返回。因为需要返回下标,所以遍历时用哈希表存储每个数对应的下标:
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
exist = dict()
for i in range(len(nums)):
if target - nums[i] in exist:
return [exist[target-nums[i]], i]
exist[nums[i]] = i
由于题目明确说明用例有且仅有一个有效答案,因此没有什么边界条件。还有一种暴力解法是两个for循环枚举所有可能的组合之和,满足条件的返回下标。