第三章 哈希算法
哈希算法又称散列函数算法,是一种查找算法。简单来说,就是把一些复杂的数据,通过某种函数映射关系,映射成更加容易查找的方式。但是这种映射关系有可能会发生多个关键字映射到同一地址的现象,我们称之为冲突。在这种特殊情况下,需要对关键字进行二次或者多次处理。大多数情况下,哈希算法可以实现在常数时间内存储和查找这些关键字。
3.1 什么是哈希
常见的数据查找算法:
- 顺序查找:效率相对较低,不太适合大数据量的查找问题
- 二分查找:查找效率虽然高,但是要求数据必须有序,而数据排序往往需要时间成本
- 深度优先遍历、广度优先遍历:对大数据量的查找问题效率并不高
- 哈希算法:查找速度快,查询、插入、删除操作简单。
哈希算法是什么呢?哈希算法进行查找基本原理是根据数据量预先设置一个长度为M的数组,使用一个哈希函数F并以数据的关键字作为自变量,得到唯一的返回值,返回值的范围为0~M-1,这样就可以利用哈希函数F将数据元素映射到数组的某一位下标并把数据存放在对应位置上。查找时,利用哈希函数F计算数据应该存储在哪里,再到相应的存储位置取出查找的数据。
例如,有一组数据3、11、8、6需要存储,哈希函数为F(x)=x%7,那么利用哈希函数F存储的数据如图
(代入x可得数据的存储位置)
按照统一的哈希函数存储数据,也按照统一的哈希函数查找数据,按照这种方法查找数据,理论上可以达到常数级别的查找效率。
但是,由于关键字的取值可能在一个很大的范围,数据在通过哈希函数进行映射的时候,很难找到一个哈希函数,使得这些关键字不能映射到唯一的值,就会出现多个关键字映射到同一个值的现象,这种现象我们称之为冲突。这就对哈希函数的设计提出了更高的要求,如果哈希函数设计的不好,查询效率可能会降低为顺序查找的效率。找到绝对完美的、没有任何冲突的哈希算法也是较困难的,对于特定的数据集,好的哈希算法少之又少,因此如果想高效解决哈希冲突,降低解决哈希冲突时的查询长度也是一个着重要考虑的方面。
解决哈希冲突的方法有很多,如链地址法、二次再散列法、线性探测再散列、建立一个公共溢出区等。这里只介绍链地址法。
链地址法:处理冲突的方法本质上是一种数组加链表的处理方法,当发生多个数据通过哈希函数映射后得到相同的哈希值时,通常把具有相同哈希地址的关键字放在同一个链表中,称该链表为同义词链表,该链表也被称之为桶。
当把相同哈希地址的关键字数据都放在同一个链表中时,有N个哈希地址就有N个链表。同时,用数组Hash[0…N-1]存放每个链表的头指针,之后把哈希地址为 i 的数据全部以节点的方式插入对应的链表里,如图所示。类比为字典,字典的目录存放每页的概括,每一页的内容是哈希地址相同的关键字。
(链地址法本质上是数组+链表的数据结构)
链地址法存储数据的过程是这样的,首先建立一个数组Hash存储所有链表的头指针,由数据的关键字Key通过对应的哈希函数计算出哈希地址,找到相应的桶号(相同哈希地址的关键字组成的链表的号),之后建立新的节点存储该数据,并把节点放到桶内链表的最后面或者最前面。
和存储数据的方法类似,查找数据的时候,也是由数据的关键字通过哈希函数计算关键字对应的哈希地址,之后顺序比较桶的内部节点是否与所查找的关键字一样知道找到数据为止。如果全部节点都不和关键字一样,则说明哈希表里没有该数据。这个解决冲突的方法对哈希函数的要求很高,如果哈希函数选的不太好的话,哈希表的查找效率会退化为链表的查找,也就是顺序查找。
3.2两个数的和
数学课上,老师给出了一道题目,要出在给定的一些数字里面找出两个数,使得它们的和为N,前提是这些数据中保证有答案,并且只有一个答案。例如给定5个数字:3、4、5、7、10,从中选择两个数使得它们的和为11,这个问题该如何解决呢?
1、问题求解1
可以把数据放到一个数组中,并设置目标值target,问题就变成了在数组中寻找两个数使得它们的和为目标数。
可以使用双指针的方法解决问题
def twoSum(nums, target):
res = [] # 存放结果编号数据
newsums = nums[:] # 深拷贝,把原数据复制到newsums里
newsnums.sort() # 对新数组排序
# 查询两个数使得它们的和为目标值target
return (res[0]+1,res[1]+1) # 返回结果集
数组排序之后,就可以进行查找了。建立两个指针left和right,分别指向新数组的第一个元素和最后一个元素。如果两个指针指向的数据相加的和等于target,则返回这两个数的下标;如果小于目标值,则left指针需要向右移动一位;大于目标值,则right指针向左移动一位。重复该过程直到找到答案。
最终代码
def twoSum(nums, target):
res = [] # 存放结果编号数据
newsums = nums[:] # 深拷贝,把原数据复制到newsums里
newsnums.sort() # 对新数组排序
left = 0
right = len(newnums) - 1 # 定义left和right指针分别指向新数组的开头和结尾
while left < right:
if newnums[left] + newnums[right] == target:
for i in range(0,len(nums)): # 在原始数组中寻找第一个元素的原始下标
if nums[i] == newnums[left]:
res.append(i) # 将下标加入结果集
break
for i in range(len(nums) -1, -1, -1): # 在原始数组中寻找第二个元素的原始下标
if nums[i] == newnums[right]:
res.append(i) # 将下标加入结果集
break
res.sort()
break
elif newnums[left] + newsums[right] < target:
left = left + 1 # 让left指针向右移动一位
elif newnums[left] + newsums[right] > target:
right = right -1 # 让right指针向左移动一位
return (res[0]+1,res[1]+1) # 返回结果集
2、问题求解2
由于双指针算法的前提是数据有序,那么时间就会花费在排序上。接下来,我们就要使用哈希算法来进行求解。
为了使用哈希算法,我们需要先建立一个字典,用于存放数据和下标的对应关系。
def twoSum(num, target):
dict = {} # 建立一个字典,用于存放数据和下标的对应关系
那么,这个字典是如何帮助我们解决问题的呢?本题是寻找两个数使得它们的和为目标数,每当给定一个数m,其实问题就变成了数据集中是否有一个数时target-m,可以通过使用字典记录目前已经出现过那些数字,这样每次出现一个新的数字时,就去字典中查找有没有对应的数字,如果有则说明找到了,没有的话就把该数添加到字典中,以备之后查询使用。
i =#someValue # i为给定的某个值
m = nums[i] # 定义m为当前待查询数字
if target-m in dict: # 判定target-m是否已经在字典中
return (dict[target-m]+1, i+1) # 如果已经存在,则返回则两个数的下标
dict[m] = i # 如果不存在则记录键值对
(注意:字典使用dict[key] = value来记录键值对的关系,本题中键为查询数字,值为编号数据的下标)
使用字典记录数据,再去字典中查找数据的方式就是我们所说的哈希方法。
最终代码
def twoSum(num, target):
dict = {} # 建立一个字典,用于存放数据和下标的对应关系
for i in range(len(nums)):
m = nums[i] # 定义m为当前待查询数字
if target-m in dict: # 判定target-m是否已经在字典中
return (dict[target-m]+1, i+1) # 如果已经存在,则返回则两个数的下标
dict[m] = i # 如果不存在则记录键值对
3.3 单词匹配模式
首先给定两个字符串,一个是单词模式字符串,另一个是目标字符串。之后检查目标字符串是否为给定的单词模式,即求目标字符串中单词出现的规律是否符合单词模式字符串中的规律。
例如,单词模式字符串“一二二一”,目标字符串为“苹果 香蕉 香蕉 苹果”,二者的规律一样,匹配成功。
假如单词模式字符串不变,目标字符串变为“香蕉 苹果 香蕉 苹果”,则二者的规律不一样,匹配成功。
同理,目标字符串变成“香蕉 香蕉 香蕉 香蕉”,二者规律也是不一样的,匹配不成功。
1、问题求解
由上面例子分析可以得出,如果单词模式字符串和目标字符串只存在一对一的对应关系,不存在一对多和多对多的对应关系,就可以说明两个字符串匹配成功。
现在的问题其实已经转换为寻找映射关系的问题,本质上也是一个查找问题。既然是查找问题,第一个想到的就是能不能使用哈希算法。
对于本体来说,我们建立模式字符串中每个字符和目标字符串中每个单词之间的映射关系,而哈希表本身就是一种映射关系,因此可以使用哈希算法来存储这种关系。
第一项任务是建立哈希表来存储数据,由于不仅需要排除一个模式对应多个字符串的情况,还需要排除多个模式对应一个字符串的情况,所以需要建立两个哈希表:hash和used。
hash用来存储模式字符串和目标字符串的对应关系,used记录目前已经使用的字符串。
def wordPattern(wordPattern, input):
word = input.split(' ') # 目标字符串中的单词以空格隔开
if len(word) != len(wordPattern): # 如果两个字符串的长度不一样,那肯定不匹配
return False
hash = {} # 记录模式字符串和目标字符串的对应关系
used = {} # 记录目前已经使用过的字符串都有哪些
首先解决第一个问题。我们每次拿到模式字符串中的一个字符的时候,需要检查一下它是否已经被记录过映射关系,如果出现过就要检查之前的映射关系和这次的单词是否一致,如果不一致则返回不成立,如果第一次出现则将它存储在hash哈希表中。
for i in range(len(wordPattern)):
if wordPattern[i] in hash: # 检查模式字符串中的字符是否已经被记录过映射关系
if hash [wordPattern[i]] != word[i]: # 不是第一次出现,则检查映射关系是否一致
return False
else:
hash [wordPattern[i]] = wordp[i] # 第一次出现,则加入哈希表
return True # 没有任何问题则返回成立
接下来,解决第二个问题。当模式字符串中的某个字符第一次出现时,还需要判断这个单词是否已经和其他的模式字符绑定,这时需要用到used哈希表。每当创建一种映射关系的时候,都需要在used中保存,同时还需要检查证单词是否已经使用过,代码如下。
for i in range(len(wordPattern)):
if wordPattern[i] in hash: # 检查模式字符串中的字符是否已经被记录过映射关系
if hash [wordPattern[i]] != word[i]: # 不是第一次出现,则检查映射关系是否一致
return False
else:
if word[i] in used: # 检查这个单词是否已经使用过,使用过返回不成立
return False
hash [wordPattern[i]] = word[i] # 第一次出现,则加入哈希表
used[word[i]] = True # 在used中保存哪些单词已经使用过
return True # 没有任何问题则返回成立
2、最终代码(可以代入模式字符串为ABCD,目标字符串为一二三四进行思考)
def wordPattern(wordPattern, input):
word = input.split(' ') # 目标字符串中的单词以空格隔开
if len(word) != len(wordPattern): # 如果两个字符串的长度不一样,那肯定不匹配
return False
hash = {} # 记录模式字符串和目标字符串的对应关系
used = {} # 记录目前已经使用过的字符串都有哪些
for i in range(len(wordPattern)):
if wordPattern[i] in hash: # 检查模式字符串中的字符是否已经被记录过映射关系
if hash [wordPattern[i]] != word[i]: # 不是第一次出现,则检查映射关系是否一致
return False
else:
if word[i] in used: # 检查这个单词是否已经使用过,使用过返回不成立
return False
hash [wordPattern[i]] = word[i] # 第一次出现,则加入哈希表
used[word[i]] = True # 在used中保存哪些单词已经使用过
return True # 没有任何问题则返回成立
3.4 猜词游戏
首先先普及一个游戏概念,猜数字(Bulls and Cows)游戏。这个游戏的玩法是这样的:一个人写下几个数字让另外一个人猜,当每次答题方猜完之后,出题方会给答题方一个提示,告诉他刚才的猜测中有多少位数字和确切位置都猜对了(称为“Bulls”,公牛),还有多少位数字猜对了但是位置不对(称为“Cows”,奶牛)。答题方将会根据出题方的提示继续猜,直到猜出秘密数字为止。
可以保证的是,秘密数字和猜测数字的位数是一样的。
我们需要写一个程序,它能够根据秘密数字和朋友的猜测数返回提示,其中用A表示公牛,B表示奶牛。
例如秘密数字为2018,猜测数字为8021,由于0这个数不仅数字猜对了,位置也和秘密数字一直,所以它算一个公牛(A),而其他三位都是数字猜对了,但是位置不对,所以也只能算是奶牛(B),我们的程序应该返回1A3B。
1、问题求解
先解决第一个问题,查找公牛(A)的数量。这个问题比较简单,首先定义一个变量A表示公牛的数量,之后循环把每个位置上的数字都取出来,按位置对比即可,数字一样则让A+1.
def getHint(secret, guess);
A = 0
B = 0
for i in range(len(secret)):
if secret[i] == guess[i]:
A += 1
那么,如何解决计算奶牛的数量的问题呢?为了解决这个问题,我们使用哈希来加快速度,先建立两个字典:
def getHint(secret, guess):
secret_dict = {}
guess_dict = {}
两个字典的用途是记录哪些未知的数字配对了,哪些还没有配对。
最终B的数量是由该数在秘密数字中和在猜测数字中更小的那方决定的。(如秘密数字1123和猜测数字9111,B的数量为1)
为此,我们只需要记录秘密数字和猜测数字中未匹配的数字和它的个数,之后看相同数字的最小数即可。
那么,在分析A的数量的同时,记录其他未匹配数字的数量,代码如下:
def getHint(secret, guess):
secret_dict = {}
guess_dict = {}
A = 0
B = 0
for i in range(len(secret)):
if secret[i] == guess[i]:
A += 1
else:
if secret[i] in secret_dict:
secret_dict[secret[i]] = secret_dict[secret[i]] + 1
else:
secret_dict[secret[i]] = 1
if guess[i] in guess_dict:
guess_dict[guess[i]] = guess_dict[guess[i]] + 1
else:
guess_dict[guess[i]] = 1
记录好了数量,接下来就可以根据两个数组中的数的最小值来判断B的数量了,代码如下:
for digit in secret_dict:
if digit in guess_dict:
B += min(secret_dict[digit],guess_dict[digit])
2、最终代码
def getHint(secret, guess):
secret_dict = {}
guess_dict = {}
A = 0
B = 0
for i in range(len(secret)):
if secret[i] == guess[i]:
A += 1
else:
if secret[i] in secret_dict:
secret_dict[secret[i]] = secret_dict[secret[i]] + 1
else:
secret_dict[secret[i]] = 1
if guess[i] in guess_dict:
guess_dict[guess[i]] = guess_dict[guess[i]] + 1
else:
guess_dict[guess[i]] = 1
for digit in secret_dict:
if digit in guess_dict:
B += min(secret_dict[digit],guess_dict[digit])
return str(A)+"A"+str(B)+"B"
3.5 神奇的词根
词根(root)的概念是,它可以跟其他一些词组成另一个较长的单词,我们称这个词为继承词(successor)。比如,词根dis跟随者单词able(能够),可以组成新的单词disable(不能够)。
有个词根小游戏,玩法是这样的,给定一个由许多词根组成的字典和一个句子。你需要将句子中的所有继承词用词根替换掉。如果继承词中又许多形成他的词根,则用最短的词根替换他。
例如,字典为[“cat”,“bat”,“rat”],句子为"the cattle was rattled by the battery",经过替换,输出句子为"the cat was rat by the bat"
1、问题求解
首先看看暴力破解法。由于输入为一个句子,所以先要将句子转换为列表。
def replaceWords(dict,sentence):
s = sentence.split(" ")
对于字典中的每一个词根来说,依次判断句子中的每个单词是否以这个词根开头,以它开头的话则把该单词用词根替换掉。假如该词根的位数为n,可以通过截取第i个单词(s[i])的前n位和该词根进行比较以判断是否相等。
item = # 某个词根
for i in range(len(s)):
n = len(item)
if item == s[i][:n]:
s[i] = item
再用一个循环遍历所有的词根,对于每个词根都执行相同的操作,最后把所有的代码拼接起来即可。
def replaceWords(dict,sentence):
s = sentence.split(" ")
for item in dict:
for i in range(len(s)):
n = len(item)
if item == s[i][:n]:
s[i] = item
return " ".join(s)
上面的代码虽然能够解决问题,但是效率太低。使用哈希来进行优化。
首先建立两个字典,并做好初始化工作。建立字典使用。collections.defaultdict()建立一个默认字典,它本身是一个字典,只不过Python会自动为它的键赋一个初始值,也就是说,你不显式地为字典的键赋初值Python不会报错。
比如default(int)则创建一个类似dictionary对象,里面任何的values都是int的实例,而且就算是一个不存在的key, d[key] 也有一个默认值,这个默认值是int()的默认值0。代码如下:
def replaceWords(dict, sentence):
d = collections.defaultdict(set)
s = collections.defaultdict(int)
sentence = sentence.split()
接下来,以每一个词根的首字母为键,把每一个词根放到该键所对应的值中去,这里的值是一个集合(set),同时记录下该首字母所对应的词根的最大长度是多少,这样一来,之后对比单词的前缀时,只需要看这个最大长度即可,代码如下:
for w in dict:
d[w[0]].add(w)
s[w[0]] = max(s[w[0]], len(w))
两个字典建好了,如何使用他们呢?首先把每个单词拿出来,查找以该单词开头的词根是否能和这个单词匹配,s字典里记录了某个字母开头的词根的最大长度,从第一位开始截取一直截取到最大长度,如果发现这个子字符串确实是一个词根的话,则修改单词并结束该轮循环。为了方便记录下标和读取单词,使用enumerate()函数来遍历句子,他会把索引放到第一个变量,把元素放到第二个变量,代码如下:
for i, w in enumerate(sentence):
for j in range(s[w[0]]):
if w[:j+1] in d[w[0]]:
sentence[i] = w[:j+1]
break
最后通过join方法连接各个单词即可。
2、最终代码
def replaceWords(dict, sentence):
d = collections.defaultdict(set)
s = collections.defaultdict(int)
sentence = sentence.split()
for w in dict:
print(w[0]) # 词根首字母打印出来
d[w[0]].add(w) # 词根首字母为键,值为词根
s[w[0]] = max(s[w[0]], len(w)) # 词根首字母为键,值为词根长度
for i, w in enumerate(sentence):
for j in range(s[w[0]]):
if w[:j+1] in d[w[0]]:
sentence[i] = w[:j+1]
break
return ' '.join(sentence)