【不习算法】只会快速查询?你真的懂哈希表吗
引言
考虑这样一个场景,在某次破案行动中,警方已经获取目标嫌疑人的身份证号,现在有一个身份证信息数据库,存储着身份证号与个人信息的数据。此时警方需要通过身份证号在名单中获取目标嫌疑人的姓名、年龄、地址等信息,考虑这是一个千万级的数据库,如何查询才是有效的呢?
我们考虑该国家的身份证是8位,目标身份证号为50585346,这时候出现了几种不同的查询方法:
A警察采用了顺序遍历的方法,从这个字典中一页一页的往后翻00000000、00000001、…这显然是一种非常麻烦的方法;B警察稍微聪明一点,拿起字典从中间随便翻起一页,看到号码为26000000,目标身份证号比该号码大,于是又在后部分中翻起一页…这种“二分查找”的方法大大提高了查询效率。
稍稍分析就可以看出A警察的查询效率为O(n), B警察的查询效率为O(log n),有没有一种方法能够实现最理想的查询,达到常数级的查询效率呢?这时哈希表便出现了,哈希表正是这样一种存储结构,能够实现最理想的查询效率,没有一种数据结构能在查询方面比哈希表更优秀。
然而很多人对于哈希表作用的理解,到“快速查询”这一步就停止了,其实哈希表的作用远大于此,本文以LeetCode中几道题目为案例,对哈希表的多种功能进行剖析。
知识储备:python实现哈希表的方法
Python字典的基本操作
Python中实现哈希表最常用的方法就是字典,下面对Python字典的基本操作进行总结:
方法 | 作用 |
---|---|
Dict = {} | 创建字典 |
Dict[“a”] = 1 | 键a的值为1 |
Dict.get(“a”) | 获取键a的值,没有则返回空 |
Dict.has_key(key) | 字典是否有键a,没有则返回False,有返回True |
Dict.clear() | 字典清空 |
Dict.keys() | 以列表返回一个字典所有的键 |
Dict.values() | 以列表返回一个字典所有的值 |
Dict.items() | 以列表返回一个字典所有的(键, 值) 元组数组 |
通过Python库实现哈希表
虽然在大多数情况下,通过Python字典实现哈希表已经足够了。然而针对一些特殊情况(比如后面的例题6),哈希表的值为一些特殊的变量(如列表时),我们需要提前对哈希表值的变量类型进行申明,这时候我们可以利用python的Collection库,具体实现方法如下:
from collections import defaultdict
dictn = defaultdict(list) #该哈希表的值为列表类型
功能1:哈希表实现快速查询
例1:LeetCode001 两数之和
LeetCode001 两数之和
给定一个整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的那两个 整数,并返回他们的数组下标。
你可以假设每种输入只会对应一个答案。但是,数组中同一个元素不能使用两遍。
示例:
给定 nums = [2, 7, 11, 15], target = 9
因为 nums[0] + nums[1] = 2 + 7 = 9
所以返回 [0, 1]
很多人入门LeetCode都是从这道题目入手的,也是通过这道题了解到哈希标的强大。采用普通暴力破解方法解题时,需要对列表中的元素两两组合进行遍历,时间代价为O(n2),这显然不是最优的解决办法。
于是我们引入哈希表快速查询的机制,对于列表中每一个元素num[i],都存在一个“配对值”com[i],使得num[i]+com[i]=target。于是我们可以用一个哈希字典来存储这样的映射关系,以题目示例中的nums数组为例,这样的映射关系为:
nums = [2, 7, 11, 15] #原始序列
hashmap = {2:7,7:2,11:-2,15:-6} #转换的哈希字典
这样一来,我们仅仅通过对原始序列进行一次遍历,每访问一个元素检查该元素是否为哈希字典中的key,如果是则返回正确的答案,如果否则将这一组新的映射关系添加到字典中,整个过程的时间代价为O(n)。具体代码实现如下:
class Solution(object):
def twoSum(self, nums, target):
hashmap={}
for i,num in enumerate(nums):
if hashmap.get(target-num) is not None:
return [hashmap.get(target - num),i]
hashmap[num] = i
这道题目就是一道典型的通过哈希表实现快速查询的案例,这是LeetCode的第一道题目,难度不高。下面我们通过LeetCode的一道Medium题目对哈希表实现快速查询进行巩固。
例2:LeetCode560 和为K的子数组(依图面试)
LeetCode560 和为K的子数组(依图面试)
给定一个整数数组和一个整数 k,你需要找到该数组中和为 k 的连续的子数组的个数。
示例 1 :
输入:nums = [1,1,1], k = 2
输出: 2 , [1,1] 与 [1,1] 为两种不同的情况。
这道题目是依图面试的原题,如果采用滑动窗口、动态规划,时间代价都不能满足需求,巧妙用哈希表 Θ(n)时间代价便可解决。本质上该题目是两数之和的变形题,建议读者在学习两数之后,自行继续代码书写进行巩固。
以下为参考的案例代码:
class Solution(object):
def subarraySum(self, nums, k):
pdict = {}
result = 0
pre = [0]
pdict[k] = 1
for i in range(len(nums)): # iterate all pre_num in pnums
pre.append(pre[-1] + nums[i])
p = pre[-1]
if pdict.has_key(p): # if p in dict, add result
result = result + pdict[p]
target = k + p # tagget number to find
if not pdict.has_key(target): # if targer not in pdict
pdict[target] = 1 # set value of target to 1
else:
pdict[target] = pdict[target] + 1 # add value by one
return result
功能2:哈希表实现计数
哈希表本质上是一种”键与值映射“的数据结构,这种映射关系使得哈希表在查找功能上表现优异。同时,正是存在这种映射关系,我们可以利用哈希表实现计数功能,此时哈希表的键为需要计数的元素,哈希表的值为该元素出现的次数。
例3:LeetCode242 有效的字母异位词
LeetCode242 有效的字母异位词
给定两个字符串 s 和 t ,编写一个函数来判断 t 是否是 s 的字母异位词。
示例 1:
输入: s = "anagram", t = "nagaram"
输出: true
示例 2:
输入: s = "rat", t = "car"
输出: false
这道题目难度不高,只要对”字母异位词“的含义理解了,很快就能解决。对于两个字符串A和B,如果字符串A中不同元素出现的次数和字符串B中不同元素出现的次数一样,那么字符串A和字符串B就是一组字母异位词。针对”统计元素出现的次数“这样的计数问题,哈希表就是一种有效的解决办法。
下面是参考代码的实现:
class Solution(object):
def isAnagram(self, s, t):
dict_s = {}
if len(s) != len(t):
return False
for cs in s:
if not dict_s.has_key(cs):
dict_s[cs] = 1
else:
dict_s[cs] = dict_s[cs] + 1
for ts in t:
if not dict_s.get(ts) or dict_s.get(ts) == 0:
return False
dict_s[ts] = dict_s[ts] - 1
return True
例4:LeetCode242 两个数组的交集 II
LeetCode242 两个数组的交集 II
给定两个数组,编写一个函数来计算它们的交集。
示例 1:
输入:nums1 = [1,2,2,1], nums2 = [2,2]
输出:[2,2]
示例 2:
输入:nums1 = [4,9,5], nums2 = [9,4,9,8,4]
输出:[4,9]
哈希表可以对字符串中元素出现的次数计数,同样地,也可以对数组中元素出现的次数计数。例4是一道利用数组元素计数解题的例题,建议读者参考例3的思路进行解决。
以下为参考代码:
class Solution(object):
def intersect(self, nums1, nums2):
dictn = {}
resultlist = []
for num in nums1:
if not dictn.get(num):
dictn[num] = 1
else:
dictn[num] = dictn[num] + 1
for num in nums2:
if dictn.get(num) and dictn.get(num) != 0 :
resultlist.append(num)
dictn[num] = dictn[num] - 1
return resultlist
功能3:哈希表实现位置记录
例5:LeetCode166 从分数到小数
LeetCode166 从分数到小数
给定两个整数,分别表示分数的分子 numerator 和分母 denominator,以字符串形式返回小数。
如果小数部分为循环小数,则将循环的部分括在括号内。
示例 1:
输入: numerator = 1, denominator = 2
输出: "0.5"
示例 2:
输入: numerator = 2, denominator = 1
输出: "2"
示例 3:
输入: numerator = 2, denominator = 3
输出: "0.(6)"
做这道题目之前,需要有一个数学定理的支撑:
当余数出现循环时,其对应的商也会出现循环。
因此我们需要通过哈希表记录余数和商出现的位置。
那么问题来了,下面那种映射关系才是考虑周全的呢?
A. 余数 --> 位置
B. 余数+商 --> 位置
答案应该是B。读者可通过1/7和1/6两个示例进行对比验证。
哈希表仅仅只是这道题目的解决工具,而这道题目的难点在于分类讨论与边界条件的考虑。该问题可分成整数部分和小数部分两部分进行解决,而每部分的算法设计中又要分不同情况进行考虑,尤其需要对特殊情况进行考虑。
边界条件是LeetCode面试的考察重点,尤其对于测试岗位的面试来说,边界条件是否考虑周全是面试官所重点关注的。下面给出一些可能的边界条件,这些边界条件,你考虑全了吗?
类 | 子类型 | 案例 |
---|---|---|
0的影响 | 除数为0 | 0/1 |
0的影响 | 被除数为0 | 1/0 |
结果不完整 | 只有整数部分 | 4/2 |
结果不完整 | 只有小数部分 | 1/3 |
负数的影响 | 分子为负数 | (-1)/ 3 |
负数的影响 | 分母为负数 | (2)/(- 3) |
负数的影响 | 分子分母均为负数 | (-2)/(- 3) |
小数点后的不同情况 | 小数点后需要添加0 | 1/30 |
小数点后的不同情况 | 小数点后为有限小数 | 1/8 |
小数点后的不同情况 | 小数点后为无限循环小数 | 1/7 |
小数点后的不同情况 | 小数点后为无限循环不小数 | 该情况不可能出现 |
下面给出参考代码:
class Solution(object):
def fractionToDecimal(self, numerator, denominator):
result = ""
dictn = {} # to store the postion of the fractional part
position = 0
repeat = True
def add(result, position, nstr):
return result + nstr, position + len(nstr)
#special case
if numerator * denominator < 0:
result, position = add(result, position, "-")
if denominator == 0:
return
numerator, denominator = abs(numerator), abs(denominator)
# calculate the part before "."
if numerator >= denominator:
result, position = add(result, position, str(numerator / denominator))
numerator = numerator % denominator
else:
result, position = add(result, position, "0")
# calculate the part after "."
# numerator % demoninator == 0
if numerator == 0:
return result
else:
result, position = add(result, position, ".")
# add zero
numerator = numerator * 10
while numerator < denominator :
result, position = add(result, position, "0")
numerator = numerator * 10
while numerator % denominator != 0 :
#print(dictn)
reminder = numerator % denominator
key = str(reminder) + " " + str(numerator / denominator) # Attention!!! We must treat both reminder and numerator / denominator as key
if not dictn.get(key) :
dictn[key] = position
position = position + 1
else:
position = dictn.get(key)
repeat = False
break
result = result + str(numerator / denominator)
numerator = reminder * 10
if repeat:
result = result + str(numerator / denominator)
else:
result = result[:position] + "(" + result[position:] + ")"
return result
功能4:哈希表对同一特性的元素进行存储
例6:LeetCode1424 对角线遍历 II
LeetCode1424 对角线遍历 II
给你一个列表 nums ,里面每一个元素都是一个整数列表。请你依照下面各图的规则,按顺序返回 nums 中对角线上的整数。
示例1:
输入:nums = [[1,2,3],[4,5,6],[7,8,9]]
输出:[1,4,2,7,5,3,8,6,9]
示例2:
输入:nums = [[1,2,3,4,5],[6,7],[8],[9,10,11],[12,13,14,15,16]]
输出:[1,6,2,8,7,3,9,4,12,10,5,13,11,14,15,16]
方法1:暴力破解法
该题目首先想到的是暴力破解法,按照从上到小、从左到右的顺序进行遍历,具体的遍历方法如下图所示。然而这种方法会出现超时的现象。
class Solution(object):
def findDiagonalOrder(self, nums):
def findmaxclength(nums):
maxl = 0
for i in nums:
maxl = max(len(i), maxl)
return maxl
maxc = findmaxclength(nums)
maxr = len(nums)
print(maxc, maxr)
resultlist = []
# first column: from top to end
for i in range(maxr):
r_id = i
while r_id >= 0: # i - c_id : row id c_id : column_id
if i - r_id <= len(nums[r_id]) - 1: # to prevent index out of range
resultlist.append(nums[r_id][i - r_id])
r_id = r_id - 1
# last row: from left to right
for i in range(maxr, maxr + maxc):
r_id = maxr - 1
while r_id >= 0:
if i - r_id <= len(nums[r_id]) - 1: # to prevent index out of range
resultlist.append(nums[r_id][i - r_id])
r_id = r_id - 1
return resultlist
方法2:哈希表法
如何对暴力破解中的超时问题进行优化呢?我们可以利用哈希表,将row+col相等的数存在列表中放入哈希表。哈希表的键为row+col,哈希表中的值为row+col相等的数所存放的列表,这样可以大大减少访问次数。
from collections import defaultdict
class Solution(object):
def findDiagonalOrder(self, nums):
dictn = defaultdict(list)
resultlist = []
for row in range(len(nums)):
for col in range(len(nums[row])):
dictn[row + col].append(nums[row][col])
for key in sorted(dictn.keys()):
resultlist.extend(dictn[key][::-1])
return resultlist
一点小总结
哈希表数据结构在解决LeetCode问题时,有以下几种功能:
1.通过哈希表实现快速查询
2.通过哈希表进行计数
3.通过哈希表进行位置记录
4.通过哈希表对同一特性的元素进行存储