【哈希】关于哈希表和哈希函数的理解与应用

0.概述

哈希,或者说散列,在教科书上写的都比较详细,通常包括的内容有散列的方法,散列冲突的解决等。本文暂且不表这些基础知识,更多的重点在于哈希的一些应用和题目,对于哈希表、哈希函数从来没有学习过或者已经遗忘大部分的同学,建议先去阅读相关内容,否则本文不会成为一篇值得阅读的内容。

1.哈希的函数的定义以及性质

之所以要介绍这一小节,主要是几乎所有的哈希函数的应用都离不开定义和性质,也正是因为哈希函数拥有这些性质才在各种场景发挥着优秀的性能。

定义:

  • out = f(in),其中输入域为无穷,值域为有限域。

哈希函数的定义就是这么简单,就是一个函数,将无限域的输入值映射到有限域的一个函数,具体的这个函数是什么,其实并不是固定的,但是也不是随意的,但是只要能够符合以下的几个性质,并且能够有效地运用到具体的实际当中就可以称作哈希函数,比较著名的哈希函数有MD5,SHA1等密码领域的算法。

性质:

  • 输入域有限,输出域无限;
  • 无随机性,同一输入一定有相同的输出,但是不同的输入也可能有相同的输出;
  • 强离散性,相似的输入经过散列函数后得到的结果大相径庭;
  • 均匀性,对于输入经过散列函数后得到的结果等概率分布在输出域上;

简单的理解一下这几个性质,所谓的无随机性是很好理解的,因为根据定义我们知道输入域是大于输出域的,所以不同的输入是可能散列到同一结果的。关于离散性和均匀性其实说的是同一个事儿,原输入可以被均匀的充分的打乱,也就是散列结果的疏密是比较均匀的,这样的好处也是显而易见的,我们肯定不希望申请的存放输出的空间得到浪费,另外即使第二条说不同输入可能映射到相同的输出,我们也不希望结果密集的存放在相同的几个索引上吧,否则哈希表的数据结构查起来是相当没有效率的。

2.哈希表的扩容和复杂度

同样的,这里也不会详细解释什么是哈希表,关于哈希表的数据结构还是很重要的,目前在java工程师面试中,HashMap几乎是人人都会的知识点了,关于HashMap的介绍博主本人写了两篇相关的长篇幅:深入理解HashMap(一)深入理解HashMap(二)。感兴趣的读者可以研读,其中比较详细的介绍了java中哈希表的使用和数据结构的实现。

如果你了解哈希表,一定知道出现哈希冲突的时候,是在相同的索引向后挂节点的(比如链表)。但是由于随着数据的变多,链表节点越挂越多,性能变低,所以关于哈希表有一套扩容机制,但是这个扩容机制各个语言有着不同的实现,比如我写的深入理解HashMap(一)中就介绍了hashmap的扩容机制。我们这里把问题简单化,我们就用最简单的扩容方法,就是将哈希表*2,比如原本申请的key值数组长度为8,下表索引为0~7,扩容后索引变为0~15,由于哈希表长度变化,所以要重新计算哈希值,这样链表长度也就能减少一半(散列性、均匀性)。教科书上一般都写的是哈希表的CRUD时间复杂度为O(1),在不扩容的情况下,确实如此,因为哈希表数据结构的物理存储结构只有两种:顺序存储结构和链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式),在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组。比如我们要新增或查找某个元素,我们通过把当前元素的关键字,通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。但是扩容就需要重新计算哈希值,导致总时间复杂度变为多少呢,是O(N*logN),单次时间复杂度就变成\frac{O(N*logN)}{N}  。这很好理解,扩容的次数几乎就是logN,比如说32个数,初始哈希表为2,扩容次数就是log_{2}^{32},每一次扩容重新计算哈希值得代价都是全量N,所以得到总时间复杂度为O(N*logN),对于N个数均摊下来单次的时间复杂度就是\frac{O(N*logN)}{N} 了。

那么对于使用C++的朋友呢,就是以上默认结果了,但是这是可以优化的,优化成O(1),这个O(1)指的是对于用户来说的,比如说java语言,扩容的时间是被jvm托管的,这个过程是离线完成的,对于用户来说调用的结构一直是一个构建好的哈希表。


在具体语言中,对于哈希表的实现还是不尽相同的,所以对于刷算法题,通常建议使用java和C++,因为这两种语言纯粹的语法也可以,封装好的api也有。但是对于主流语言中的Python,用过Python的都知道,Python的数组几乎可以干所有的事情,这对于刷题人来说可解释性就没有那么强了。


3.哈希算法的应用--大文件统计高频数字

题目:

有一个大文件,其中存放着40亿个无符号的整数数字,统计出来出现次数最多的数,内存限制1G。

解析:

无符号的整数数字取值范围为0~2^{32}-1,故是有可能40亿个数完全不同的。这是最坏的情况,我们假设这种最坏情况,我们定义哈希表为table<int key, int value>,key-value指的是某个数(key)出现的次数(value),也就是一条记录占了8个Byte(字节)的空间,40亿个不同的数字需要8 * 40亿个Byte(字节)空间,也就是32个G,另外哈希表本身的一些信息数据占据了一部分,我们这里就大约认为是32G的空间,但是题目只提供1G的内存,所以创建传统的哈希表肯定是不可取的。这个问题主要是由于数字太多,而且是不同的数字。。

优化:

解决方式就是把大文件分成小文件,由于40亿个数是K种数(K最小为一种,最大为40亿种),我们令每个数字经过哈希函数得到的输出再对100取模,这样得到的结果必然是0~99。即:temp = f(in)out = temp%100。
        经过这样的计算,我们就将大文件切分为了100个小文件,把40亿个数字均匀的分到了100个文件中,而且得到的每个文件种数字的种类大致是一样的,这是由哈希函数的均匀性决定的,之所以开篇用了不少篇幅介绍了哈希函数相关的内容,就是为了更好的去理解具体应用,之后还是有很多这样的情况。那么现在我们这40亿个数最差情况是都不相同,也就是有40亿种数字,则每个小文件中大概有4000w个数,1G内存肯定是足够的(其实分成几个文件是由给定内存决定的,这里假设100个是可以的,其实还可以更小一些)。根据哈希函数的性质,同一种数不可能被散列到不同的小文件上,也就是说同一种数一定会落到同一个小文件内。我们用1G内存去统计0号文件中出现次数最多的数,然后释放,再去统计1号文件中的出现次数最多的数字,直到99号文件,最终100个数字词频最高的则返回,就是结果。

4.设计RandomPool结构

题目描述:

  • insert(key);不重复插入,即相同的key值只记录一次。
  • delete(key);
  • getRandom();等概率随机返回任一key。

要求时间复杂度为O(1);

解析:

本题还是比较简单的,不重复插入就不介绍了,用图的形式把建好插入的结构展现出来,然后去分析。我们需要建立两张hash表一张是正常的key-value存放,另一张是把第一张表的value当做key,key当做value存,这样做的目的是为了实现getRandom()函数,数据就用26个字母作为操作数据,另外需要一个size变量,初始值为0。

keyvaluesize = 0

key

value
"A"0size = 10"A"
"B"1size = 2"B"1
...................................
"Z"25size = 2625"Z"

 

getRandom()函数实现使用Math函数库就可以,size值记录了记录条数,所以随机返回0~25中的一个值可以写:

int index = Math.random(size);
int reskey = table2.get(index); //table2(HashMap<int, String>)为哈希表2
String resValue = table1.get(reskey); //table1(HashMap<String, int>)为哈希表1

本题的坑位出现在delete(key)函数的实现上,因为删除后,索引就不再连续,之后再次调用getRandom()函数的时候,就需要进行进一步的判断,因为哈希表随着插入和删除,不会出现大量的空白索引,Math.random(size)也无法产生等概率的随机数了(准确说是不一定存在),时间复杂度不再是O(1)了,因为当出现不存在的索引,就会再次获取随机数,这样最差情况就是O(N)级别的了,因为最差就是仅剩下一条记录,而且每次获得随机的index都没取到,直到第N次。

优化:

删除的时候不是简单的删除,而是从哈希表的索引值末值去取值,用它去覆盖要删除的记录,然后size--。比如要删除"A"-0这条记录,我们去获取"Z"-25,然后覆盖掉"A"-0,变成:

keyvaluesize = 0

key

value
"Z"0size = 10"Z"
"B"1size = 2"B"1
...................................
"Y"24size = 2524

"Y"

 

5.布隆过滤器

布隆过滤器是一个只支持从一个集合中检验某个记录是否出现过的过滤器。

5.1位图

定义:

位图就是bit类型的数组,就是以一个位的0-1表示信息。

示例:

int[] arr = new int[10];//该整型数组占40个Byte,也就是320bit

/*比如arr[0]表示0~31位置上的状态
 *    arr[1]表示32~63位置上状态
 *依次类推
 */

//取178位的状态
int numIndex = 178 / 32;  // 178/32得到在数组arr上位于0~10的哪个位置,这里答案是5
int bitIndex = 178 % 32; // 178%32得到在arr[5]上的160~191比特位置的哪个位置,得到18
int s = (arr[numIndex] >> bitIndex) & 1; //得到第178位的状态

//把178位的信息改为1
arr[numIndex] = arr[numIndex] | (1 << bitIndex)

//把178位的信息改为0
arr[numIndex] = arr[numIndex] & (~(1 << bitIndex))

5.2黑名单URL问题

题目描述:

现在有一个大文件中存放着100亿条URL,每条URL大小为64Byte,如何判断一条URL是否在此文件中出现过。

分析:

如果把100亿条URL全部放入HashSet里,100亿*64 = 640G,这需要640G的内存,几乎是不现实的选择,如果存到硬盘中,读取又花费相当长的时间,如何将这100亿条URL记录放到内存当中呢?

应用:

布隆过滤器是有常见应用场景的,比如说日常我们使用搜索引擎,如何避免用户搜寻到敏感不健康的链接,如何筛选URL黑名单等。

解答:

首先实现一个长度为m的位图,另外我们还需要K个独立的哈希函数,对每一个URL都进行K个哈希函数的计算,得到K个哈希值,这K个哈细值对m进行取模运算,得到需要改变为1状态的bit位置。然后换下一个URL,知道把100亿个URL全部操作完成,布隆过滤器就完成了。下面是个伪代码的实现过程。

int[] bitArr = new int[m];
int i = 0;
//把每一个URL的信息写进布隆过滤器中
while(url){

    //每个URL都进行K个独立的哈希函数的运算,对应K个位置的信息
    while(i < K){

        //求该URL在第i个哈希函数下求得需要改变位图位置
        int temp = hashFunction_i(url);
        int res_bit_position = temp % m;

        //把在该哈希函数求的的bit位置信息状态改为1
        int numIndex = res_bit_position / 32;
        int bitIndex = res_bit_position % 32;
        bitArr[numIndex] = arr[numIndex] | (1 << bitIndex);

        i++;
    }

    url++;
}

那么现在我们有了布隆过滤器,已经将URL都填进了黑名单中,那么接下来就是如何检测某一个URL是否出现在黑名单当中。依旧是伪代码实现过程如下:

i = 0;
int res = 1;
while(i < K) {

    //求url_x在第i个哈希函数下求得需要获取的位图位置
    int temp = hashFunction_i(url_x);
    int res_bit_position = temp % m;

    //取url_x在该哈希函数下求得的位置的状态
    int numIndex = res_bit_position / 32;  
    int bitIndex = res_bit_position % 32; 
    s = (arr[numIndex] >> bitIndex) & 1;
 
    //将K次位置的信息进行与操作
    res = s & res;
}

//如果res为1也就是,每次求得的位置都为1,可以认为该URL在黑名单中,返回true
return res?true:false;

 以上就是整个布隆过滤器的实现和检测过程,但是读者可能会疑惑,还有两个未知量没有讨论,位图的大小m和K个独立的哈希函数,这两个参数呢放到下一小结5.3中讨论。


单记录的大小是不影响布隆过滤器的大小的,也就是布隆过滤器大小不跟单样本大小有关,只和样本个数有关。 


5.3失误率

由于哈希函数的性质,不同的输入也有可能获得相同的结果,所以布隆过滤器是有一定失误率的,但是这个失误类型的判错是不会判错必为已存在信息的,通俗说就是宁可错杀一千,绝不放过一个。只可能将好人误判为坏人,不会将坏人判成好人的。

失误率是和上一小节末要讨论的两个参数,位图大小m和哈希函数个数K有关系的。这个很好理解,位图越短,那么位图状态为1的比例就越高,失误率越高;位图太长又很浪费。那么究竟多少合适呢,科学家们已经帮我们算好了,具体推导过程非常复杂,完全没有必要去浪费时间研读,直接记住公式就好了:

m = -\frac{N*ln^{P}}{(ln^{2})^2}K = ln^{2} * \frac{M}{N}

P是失误率,还是以URL的例题来说,假设100亿个URL,也就是公式中的N,我们的预期失误率为0.0001,万分之一,那么计算的结果为m的大小为26G,K为13,二者都为向上去整。理论值就是这样的,但是在实际应用中m可以根据实际情况大一点点,这样求得的m,K都要略大于理论值。如此一来,实际失误率也会比预期的失误率更小,我们也可以反推求得实际的失误率为:

P_{real} = (1-e^{-\frac{n*K}{m} })^K

下面两张坐标图简单直观地表明了P m K之间的关系。

6.在分布式存储中运用的一致性哈希原理

当下大数据的火热使得HDFS应用广泛,而分布式数据存储中就用了哈希算法,这里重点说为何要使用一致性哈希。

最原始的分布式数据库大概长下面这个样子:

底层的数据服务器实际含有三台,逻辑服务器统一封装,对外表现为一个数据库入口,当新来一条record的时候,该record进行哈希运算,结果对3取模,得到的结果就是要存入哪台物理数据服务器的编号。这样做不仅分担了数据库的压力,而且由于哈希函数的离散性的性质,三台数据库服务器都将负载均衡。在工程上,hashkey的选择是有讲究的,是专门用来计算哈希值的输入,种类最好是多一些,这样对于操作频率存在差异的数据,就可以做到负载均衡。比如对比姓名和国家,姓名就很适合作为hash key,而国家就不合适,因为姓名种类很多,重名很小,但是对于国家,种类较少,能够经常上热搜榜的,用来被搜索和查询的国家数量就更少了,这样就很容易对某一台或某几台数据服务器造成很大的负担,而对冷门的国家名称所在的服务器来说,又是性能过剩的。

以上是最传统最原始的分布式数据存储,那么它有什么缺点呢?缺点就是,一旦数据服务器需要扩增,则原来计比如说上图三台变成四台,那么最初计算的服务器编号选择就失效了,此时应该是对4进行求模,这样一来,扩增数据服务器的代价就很高了,数据迁移将耗费大量的时间,毕竟要全部重新计算哈希值,重新选择存放的数据库服务器。那么如何解决呢,就是本节要讨论的一致性哈希原理。

我们考虑把三台服务器在逻辑上看作在一个环上,具体的操作呢,我们可以把每台机器的hostname作为hash key,计算出来哈希值,然后将这个哈希值再次进行哈希,得到环上对应的位置。

现在我们尝试插入一条记录record:(zhangsan,25),张三25岁这个记录,我们假设以姓名作为hash key计算此条记录的哈希值value,将此条记录存在value在环的顺时针方向上最近的机器上。那么为何这样可以避免前面提到的传统分布式数据库的数据迁移代价呢?我们来看看扩增一台数据服务器是什么样的,假设m4计算之后位于m2和m3之间:

回顾刚提到的插入一条记录的过程,是不是扩增后m3的数据就其实不一定是存在m3上了,加入数据条目计算的value位于m2和m4之间,那么就应该保存在m4上,但是m1和m2呢,并不会被影响,所以也就是原本由m3负责的数据,部分需要迁移到m4上,而与m1,m2完全无关,这样就不需要全量迁移了,大大提高了效率。


 用户面对的是逻辑服务器层,并不知道真实的物理服务器的情况,那么如何顺时针找到是哪台物理数据库服务器呢?其实就是让machine hash code进行排序,这个就是服务器计算应该在环的那个位置的那个值,把所有的值进行排序,这个序列就是顺时针机器的排列顺序,我们让所有的逻辑服务器都备份一份这个序列,也就可以让数据顺时针对应找到应该在的位置了。


缺点:

  1. 机器数量很少时,哈希环不一定平分;
  2. 即便初始哈希环上的机器状态为平分哈希环,扩充后也就不再平衡了;

解决:

使用虚拟节点技术,还是三台物理数据服务器的例子,我们给每个机器分配1000个字符串,看作是1000个虚拟节点。

我们让虚拟节点去枪环,3000个值很交错的分布在哈希环上,这样当一条record插入时,顺时针找到最近的虚拟节点进行插入,但是实际选择的是该虚拟节点对应的物理服务器,比如我们在换上选择了b666这个虚拟节点,实际上这条记录是存在了m2中、这样做的好处不仅在增减服务器时不会出现刚才提到的物理机在哈希环上分布不均匀的情况,而且这样负载也比较均衡。进一步地,通过虚拟节点技术还可以做负载管理得 工作,比如m1机器性能好一些,那么我们就给m1多分配一些字符串,如此一来哈希环上属于m1的虚拟节点就占了更多的位置,m1自然也就分担了更多的负载,可以充分发挥m1性能好的优势。

总结

哈希函数和哈希表其实原理容易懂,但是还是挺难得,希望本篇文章能让你觉得和详细介绍哈希的基础知识的文章收获不同,个人水平有限,如有错误欢迎指正。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值