开篇问题:
在使用qq或者抖音时候,会出现有多少个共同好友或者共同关注的人(单向好友),通过本篇讲述如何实现共同好友的查询。
散列表
散列表来源于数组,它借助散列函数对数组这种数据结构进行扩展,利用的是数组支持按照下标随机访问元素的特性。散列表两个核心问题是散列函数设计和散列冲突解决。散列冲突有两种常用的解决方法,开放寻址法和链表法。散列函数设计的好坏决定了散列冲突的概率,也就决定散列表的性能。
散列表的英文叫“Hash Table”,也叫它“哈希表”或者“Hash 表”,散列表用的是数组支持按照下标随机访问数据的特性,是数组的一种扩展。
散列表利用了数组按照下标随机访问的时候时间复杂度是 O(1) 的特性。通过散列函数把元素的键值映射为下标,然后将数据存储在数组中对应下标的位置。当按照键值查询元素时,用同样的散列函数,将键值转化数组下标,从对应的数组下标的位置取数据。

散列函数
散列函数,可以定义成hash(key),其中 key 表示元素的键值,hash(key) 的值表示经过散列函数计算得到的散列值。
散列函数,顾名思义,它是一个函数。我们可以把它定义成hash(key),其中 key 表示元素的键值,hash(key) 的值表示经过散列函数计算得到的散列值。
散列函数设计的三点基本要求:
- 散列函数计算得到的散列值是一个非负整数;
- 如果 key1 = key2,那 hash(key1) == hash(key2);
- 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。
对于第一点,因为数组下标是从 0 开始的,所以散列函数生成的散列值也要是非负整数。
对于第二点,相同的 key,经过散列函数得到的散列值也应该是相同的。
对于第三点,几乎无法找到一个完美的无冲突的散列函数,即便像业界著名的MD5、SHA、CRC等哈希算法,也无法完全避免这种散列冲突。而且,因为数组的存储空间有限,也会加大散列冲突的概率。针对散列冲突问题,需要通过其他途径来解决。
解决散列冲突问题的两种方法
常用的散列冲突解决方法有两类,开放寻址法(open addressing)和链表法(chaining)。
1. 开放寻址法
开放寻址法的核心思想是,如果出现了散列冲突,就重新探测一个空闲位置,将其插入。
探测新的位置的方法有线性探测(Linear Probing)、二次探测(Quadratic probing)和双重散列(Double hashing)
线性探测:
往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。
下图黄色的色块表示空闲位置,橙色的色块表示已经存储了数据。

图中散列表的大小为 10,在元素 x 插入散列表之前,已经 6 个元素插入到散列表中。x 经过 Hash 算法之后,被散列到位置下标为 7 的位置,但是这个位置已经有数据了,所以就产生了冲突。于是我顺序地往后一个一个找,遍历到尾部都没有找到空闲的位置,于是我们再从表头开始找,直到找到空闲位置 2,于是将其插入到这个位置。
在散列表中查找元素,会先通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的元素;否则就顺序往后依次查找。如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中。

使用线性探测法解决冲突的散列表,对于删除操作,会将被删除的元素特殊标记为 deleted。当线性探测查找的时候,遇到标记为 deleted 的空间,并不是停下来,而是继续往下探测。

线性探测法存在的问题:
当散列表中插入的数据越来越多时,散列冲突发生的可能性越来越大,空闲位置越来越少,线性探测的时间越来越久。极端情况下,可能需要探测整个散列表,所以最坏情况下的时间复杂度为 O(n)。同理,在删除和查找时,也有可能会线性探测整张散列表,才能找到要查找或者删除的数据。
二次探测:
线性探测每次探测的步长是 1,它探测的下标序列是 hash(key)+0,hash(key)+1,hash(key)+2……而二次探测探测的步长就变成了原来的“二次方”,也就是说,它探测的下标序列就是 hash(key)+0,hash(key)+1^2,hash(key)+2^2……
双重散列:
并不只用一个散列函数,而是同时使用一组散列函数 $hash1(key),hash2(key),hash3(key)……$先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。
2. 链表法
在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。

插入的时候只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是 O(1)。当查找、删除一个元素时,同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。
查找或删除操作的时间复杂度跟链表的长度 k 成正比,即 O(k)。对于散列比较均匀的散列函数来说,理论上讲,k=n/m,其中 n 表示散列中数据的个数,m 表示散列表中“槽”的个数。
解决开篇问题:
解释什么是MapReduce:
- 要想实现该功能必须先了解大数据中的MapReduce模型,MapReduce底层是由散列表所构成,Map(映射),Reduce(归纳)。
- Map:举个简单例子来解释mapreduce, 就把它想成是一个映射函数,比如考试的成绩表想成是一张map,此时你同学发现你的成绩上多了十分,此时老师就定义一个"减十"的映射函数,来修正这个错误,此时成绩发生了变化,那么就要创建一张新的成绩表。此时能看出,Map操作是可以同时并发进行的,对高性能要求的应用以及并行计算领域有重要的用处
- Reduce:归纳操作,无疑是把列表中的元素进行合并,还是接着上面的例子,如果说此时老师想看班级的平均分,需要先获取那张成绩表,使用归纳函数进行计算,要想计算平均分还得将这些成绩相加起来(实际上不是这么计算的),实际上是通过odd 和 enev 元素跟自己的相邻的元素相加的方式把列表减半,如此递归运算直到列表只剩下一个元素,然后用这个元素除以人数,就得到了平均分)。
实验验证过程:
1.获取样本数据(虚拟数据):
注:在这已26个大写字母代表一个好友,对共同好友进行虚拟模拟实验环境
数据已散列表存储 本人 : 好友(例如,A:B,C,D,F,E,O)
A:B,C,D,F,E,O
2.实验思路:
- 要想求出共同好友,样本信息是以散列表形式存储的,在python没有散列表这个说法,因为python属于高级语言,已经有封装好的字典来实现散列表
- 、使用大数据技术MapReduce模型,将好友设置为键(key),人设置为值(value),最后提交任务给reduce去合并
3.实验方法:
- 通过两次迭代,找出那些是共同好友
- 把这些人(用共同好友的人)最为key,其好友作为value输出
4.操作流程图
代码示例:
# coding=utf-8
运行结果

A-B C,E
A-C D,F
A-D F,E
A-E B,C,D
A-F B,C,D,E,O
A-G C,D,F,E
A-H C,D,E,O
A-I O
A-J B,O
A-K C,D
A-L D,F,E
A-M F,E
B-C A
B-D E,A
B-E C
B-F C,E,A
B-G C,E,A
B-H C,E,A
B-I A
B-K C,A
B-L E
B-M E
B-O A
C-D F,A
C-E D
C-F D,A
C-G D,F,A
C-H D,A
C-I A
C-K D,A
C-L D,F
C-M F
C-O A,I
D-E L
D-F E,A
思考题
1.假设我们有 10 万条 URL 访问日志,如何按照访问次数给 URL 排序?
解决思路:
遍历 10 万条数据,以 URL 为 key,访问次数为 value,存入散列表,同时记录下访问次数的最大值 K,时间复杂度 O(N)。
如果 K 不是很大,可以使用桶排序,时间复杂度 O(N)。如果 K 非常大(比如大于 10 万),就使用一般的排序方法,复杂度 O(NlogN)。
模拟实现代码:
url_count_dict
2.有两个字符串数组,每个数组大约有 10 万条字符串,如何快速找出两个数组中相同的字符串?
思路:
将两个字符串数组分别存入散列表中,遍历其中一个散列表,每个遍历出来的字符串都去另一个散列表中查找,若找到则说明该字符串为公共字符串。
python简易实现:
arr1