从头至尾完全解析哈希表算法

说明:本文分为三部份内容,第一部分为一道百度面试题Top K算法的详解;第二部分为关于Hash表算法的详细阐述;第三部分为打造一个最快的Hash表算法。linux

第一部分:Top K 算法详解git

问题描述程序员

百度面试题:github

搜索引擎会经过日志文件把用户每次检索使用的全部检索串都记录下来,每一个查询串的长度为1-255字节。
假设目前有一千万个记录(这些查询串的重复度比较高,虽然总数是1千万,但若是除去重复后,不超过3百万个。一个查询串的重复度越高,说明查询它的用户越多,也就是越热门。),请你统计最热门的10个查询串,要求使用的内存不能超过1G。web

必备知识:面试

什么是哈希表?算法

哈希表(Hash table,也叫散列表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它经过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫作散列函数,存放记录的数组叫作散列表。数组

哈希表的作法其实很简单,就是把Key经过一个固定的算法函数既所谓的哈希函数转换成一个整型数字,而后就将该数字对数组长度进行取余,取余结果就看成数组的下标,将value存储在以该数字为下标的数组空间里。安全

而当使用哈希表进行查询的时候,就是再次使用哈希函数将key转换为对应的数组下标,并定位到该空间获取value,如此一来,就能够充分利用到数组的定位性能进行数据定位文章第2、三部分,会针对Hash表详细阐述数据结构

问题解析:

要统计最热门查询,首先就是要统计每一个Query出现的次数,而后根据统计结果,找出Top 10。因此咱们能够基于这个思路分两步来设计该算法。

即,此问题的解决分为如下俩个步骤

第一步:Query统计
Query统计有如下俩个方法,可供选择:
一、直接排序法
首先咱们最早想到的的算法就是排序了,首先对这个日志里面的全部Query都进行排序,而后再遍历排好序的Query,统计每一个Query出现的次数了。

可是题目中有明确要求,那就是内存不能超过1G,一千万条记录,每条记录是255Byte,很显然要占据2.375G内存,这个条件就不知足要求了。

让咱们回忆一下数据结构课程上的内容,当数据量比较大并且内存没法装下的时候,咱们能够采用外排序的方法来进行排序,这里咱们能够采用归并排序,由于归并排序有一个比较好的时间复杂度O(NlgN)。

排完序以后咱们再对已经有序的Query文件进行遍历,统计每一个Query出现的次数,再次写入文件中。

综合分析一下,排序的时间复杂度是O(NlgN),而遍历的时间复杂度是O(N),所以该算法的整体时间复杂度就是O(N+NlgN)=O(NlgN)。

二、Hash Table法
在第1个方法中,咱们采用了排序的办法来统计每一个Query出现的次数,时间复杂度是NlgN,那么能不能有更好的方法来存储,而时间复杂度更低呢?

题目中说明了,虽然有一千万个Query,可是因为重复度比较高,所以事实上只有300万的Query,每一个Query255Byte,所以咱们能够考虑把他们都放进内存中去,而如今只是须要一个合适的数据结构,在这里,Hash Table绝对是咱们优先的选择,由于Hash Table的查询速度很是的快,几乎是O(1)的时间复杂度。

那么,咱们的算法就有了:维护一个Key为Query字串,Value为该Query出现次数的HashTable,每次读取一个Query,若是该字串不在Table中,那么加入该字串,而且将Value值设为1;若是该字串在Table中,那么将该字串的计数加一便可。最终咱们在O(N)的时间复杂度内完成了对该海量数据的处理。

本方法相比算法1:在时间复杂度上提升了一个数量级,为O(N),但不只仅是时间复杂度上的优化,该方法只须要IO数据文件一次,而算法1的IO次数较多的,所以该算法2比算法1在工程上有更好的可操做性。
第二步:找出Top 10
算法一:普通排序
我想对于排序算法你们都已经不陌生了,这里不在赘述,咱们要注意的是排序算法的时间复杂度是NlgN,在本题目中,三百万条记录,用1G内存是能够存下的。

算法二:部分排序
题目要求是求出Top 10,所以咱们没有必要对全部的Query都进行排序,咱们只须要维护一个10个大小的数组,初始化放入10个Query,按照每一个Query的统计次数由大到小排序,而后遍历这300万条记录,每读一条记录就和数组最后一个Query对比,若是小于这个Query,那么继续遍历,不然,将数组中最后一条数据淘汰,加入当前的Query。最后当全部的数据都遍历完毕以后,那么这个数组中的10个Query即是咱们要找的Top10了。

不难分析出,这样,算法的最坏时间复杂度是N*K, 其中K是指top多少。

算法三:堆
在算法二中,咱们已经将时间复杂度由NlogN优化到NK,不得不说这是一个比较大的改进了,但是有没有更好的办法呢?

分析一下,在算法二中,每次比较完成以后,须要的操做复杂度都是K,由于要把元素插入到一个线性表之中,并且采用的是顺序比较。这里咱们注意一下,该数组是有序的,一次咱们每次查找的时候能够采用二分的方法查找,这样操做的复杂度就降到了logK,但是,随之而来的问题就是数据移动,由于移动数据次数增多了。不过,这个算法仍是比算法二有了改进。

基于以上的分析,咱们想一想,有没有一种既能快速查找,又能快速移动元素的数据结构呢?回答是确定的,那就是堆。
借助堆结构,咱们能够在log量级的时间内查找和调整/移动。所以到这里,咱们的算法能够改进为这样,维护一个K(该题目中是10)大小的小根堆,而后遍历300万的Query,分别和根元素进行对比

思想与上述算法二一致,只是算法在算法三,咱们采用了最小堆这种数据结构代替数组,把查找目标元素的时间复杂度有O(K)降到了O(logK)。
那么这样,采用堆数据结构,算法三,最终的时间复杂度就降到了N‘logK,和算法二相比,又有了比较大的改进。

总结:
至此,算法就彻底结束了,通过上述第一步、先用Hash表统计每一个Query出现的次数,O(N);而后第二步、采用堆数据结构找出Top 10,N*O(logK)。因此,咱们最终的时间复杂度是:O(N) + N’*O(logK)。(N为1000万,N’为300万)。若是各位有什么更好的算法,欢迎留言评论。第一部分,完。

 

第二部分、Hash表 算法的详细解析

什么是Hash
 Hash,通常翻译作“散列”,也有直接音译为“哈希”的,就是把任意长度的输入(又叫作预映射, pre-image),经过散列算法,变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间一般远小于输入的空间,不一样的输入可能会散列成相同的输出,而不可能从散列值来惟一的肯定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。

HASH主要用于信息安全领域中加密算法,它把一些不一样长度的信息转化成杂乱的128位的编码,这些编码值叫作HASH值. 也能够说,hash就是找到一种数据内容和数据存放地址之间的映射关系。

数组的特色是:寻址容易,插入和删除困难;而链表的特色是:寻址困难,插入和删除容易。那么咱们能不能综合二者的特性,作出一种寻址容易,插入删除也容易的数据结构?答案是确定的,这就是咱们要提起的哈希表,哈希表有多种不一样的实现方法,我接下来解释的是最经常使用的一种方法——拉链法,咱们能够理解为“链表的数组”,如图:

ctdwcdjxhxbsf01
左边很明显是个数组,数组的每一个成员包括一个指针,指向一个链表的头,固然这个链表可能为空,也可能元素不少。咱们根据元素的一些特征把元素分配到不一样的链表中去,也是根据这些特征,找到正确的链表,再从链表中找出这个元素。

元素特征转变为数组下标的方法就是散列法。散列法固然不止一种,下面列出三种比较经常使用的:

1,除法散列法
最直观的一种,上图使用的就是这种散列法,公式:
index = value % 16
学过汇编的都知道,求模数实际上是经过一个除法运算获得的,因此叫“除法散列法”。

2,平方散列法
求index是很是频繁的操做,而乘法的运算要比除法来得省时(对如今的CPU来讲,估计咱们感受不出来),因此咱们考虑把除法换成乘法和一个位移操做。公式:
index = (value * value) >> 28   右移,除以2^28。记法:左移变大,是乘。右移变小,是除。
若是数值分配比较均匀的话这种方法能获得不错的结果,但我上面画的那个图的各个元素的值算出来的index都是0——很是失败。也许你还有个问题,value若是很大,value * value不会溢出吗?答案是会的,但咱们这个乘法不关心溢出,由于咱们根本不是为了获取相乘结果,而是为了获取index。

3,斐波那契(Fibonacci)散列法

平方散列法的缺点是显而易见的,因此咱们能不能找出一个理想的乘数,而不是拿value自己看成乘数呢?答案是确定的。

1,对于16位整数而言,这个乘数是40503
2,对于32位整数而言,这个乘数是2654435769
3,对于64位整数而言,这个乘数是11400714819323198485

这几个“理想乘数”是如何得出来的呢?这跟一个法则有关,叫黄金分割法则,而描述黄金分割法则的最经典表达式无疑就是著名的斐波那契数列,即如此形式的序列:0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144,233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946,…。另外,斐波那契数列的值和太阳系八大行星的轨道半径的比例出奇吻合。

对咱们常见的32位整数而言,公式:
index = (value * 2654435769) >> 28

若是用这种斐波那契散列法的话,那上面的图就变成这样了:

ctdwcdjxhxbsf02
很明显,用斐波那契散列法调整以后要比原来的取摸散列法好不少。

适用范围
快速查找,删除的基本数据结构,一般须要总数据量能够放入内存。

基本原理及要点
hash函数选择,针对字符串,整数,排列,具体相应的hash方法。
碰撞处理,一种是open hashing,也称为拉链法;另外一种就是closed hashing,也称开地址法,opened addressing。

扩展
d-left hashing中的d是多个的意思,咱们先简化这个问题,看一看2-left hashing。2-left hashing指的是将一个哈希表分红长度相等的两半,分别叫作T1和T2,给T1和T2分别配备一个哈希函数,h1和h2。在存储一个新的key时,同 时用两个哈希函数进行计算,得出两个地址h1[key]和h2[key]。这时须要检查T1中的h1[key]位置和T2中的h2[key]位置,哪个 位置已经存储的(有碰撞的)key比较多,而后将新key存储在负载少的位置。若是两边同样多,好比两个位置都为空或者都存储了一个key,就把新key 存储在左边的T1子表中,2-left也由此而来。在查找一个key时,必须进行两次hash,同时查找两个位置。

问题实例(海量数据处理)
咱们知道hash 表在海量数据处理中有着普遍的应用,下面,请看另外一道百度面试题:
题目:海量日志数据,提取出某日访问百度次数最多的那个IP。
方案:IP的数目仍是有限的,最多2^32个,因此能够考虑使用hash将ip直接存入内存,而后进行统计。

 

第三部分、最快的Hash表算法

接下来,我们来具体分析一下一个最快的Hasb表算法。
咱们由一个简单的问题逐步入手:有一个庞大的字符串数组,而后给你一个单独的字符串,让你从这个数组中查找是否有这个字符串并找到它,你会怎么作?有一个方法最简单,老老实实从头查到尾,一个一个比较,直到找到为止,我想只要学过程序设计的人都能把这样一个程序做出来,但要是有程序员把这样的程序交给用户,我只能用无语来评价,或许它真的能工做,但…也只能如此了。

最合适的算法天然是使用HashTable(哈希表),先介绍介绍其中的基本知识,所谓Hash,通常是一个整数,经过某种算法,能够把一个字符串”压缩” 成一个整数。固然,不管如何,一个32位整数是没法对应回一个字符串的,但在程序中,两个字符串计算出的Hash值相等的可能很是小,下面看看在MPQ中的Hash算法:

函数1、如下的函数生成一个长度为0x500(合10进制数:1280)的cryptTable[0x500]

函数2、如下函数计算lpszFileName 字符串的hash值,其中dwHashType 为hash的类型,在下面的函数三、GetHashTablePos函数中调用此函数二,其能够取的值为0、一、2;该函数返回lpszFileName 字符串的hash值:

Blizzard的这个算法是很是高效的,被称为”One-Way Hash”( A one-way hash is a an algorithm that is constructed in such a way that deriving the original string (set of strings, actually) is virtually impossible)。举个例子,字符串”unitneutralacritter.grp”经过这个算法获得的结果是0xA26067F3。

是否是把第一个算法改进一下,改为逐个比较字符串的Hash值就能够了呢,答案是,远远不够,要想获得最快的算法,就不能进行逐个的比较,一般是构造一个哈希表(Hash Table)来解决问题,哈希表是一个大数组,这个数组的容量根据程序的要求来定义,例如1024,每个Hash值经过取模运算 (mod) 对应到数组中的一个位置,这样,只要比较这个字符串的哈希值对应的位置有没有被占用,就能够获得最后的结果了,想一想这是什么速度?是的,是最快的O(1),如今仔细看看这个算法吧:

一种可能的结构体定义?

函数3、下述函数为在Hash表中查找是否存在目标字符串,有则返回要查找字符串的Hash值,无则,return -1.

看到此,我想你们都在想一个很严重的问题:“若是两个字符串在哈希表中对应的位置相同怎么办?”,毕竟一个数组容量是有限的,这种可能性很大。解决该问题的方法不少,我首先想到的就是用“链表”,感谢大学里学的数据结构教会了这个百试百灵的法宝,我遇到的不少算法均可以转化成链表来解决,只要在哈希表的每一个入口挂一个链表,保存全部对应的字符串就OK了。事情到此彷佛有了完美的结局,若是是把问题独自交给我解决,此时我可能就要开始定义数据结构而后写代码了。

然而Blizzard的程序员使用的方法则是更精妙的方法。基本原理就是:他们在哈希表中不是用一个哈希值而是用三个哈希值来校验字符串。

MPQ使用文件名哈希表来跟踪内部的全部文件。可是这个表的格式与正常的哈希表有一些不一样。首先,它没有使用哈希做为下标,把实际的文件名存储在表中用于验证,实际上它根本就没有存储文件名。而是使用了3种不一样的哈希:一个用于哈希表的下标,两个用于验证。这两个验证哈希替代了实际文件名。
固然了,这样仍然会出现2个不一样的文件名哈希到3个一样的哈希。可是这种状况发生的几率平均是:1:18889465931478580854784,这个几率对于任何人来讲应该都是足够小的。如今再回到数据结构上,Blizzard使用的哈希表没有使用链表,而采用”顺延”的方式来解决问题,看看这个算法:

函数4、lpszString 为要在hash表中查找的字符串;lpTable 为存储字符串hash值的hash表;nTableSize 为hash表的长度:

上述程序解释:

1.计算出字符串的三个哈希值(一个用来肯定位置,另外两个用来校验)
2. 察看哈希表中的这个位置
3. 哈希表中这个位置为空吗?若是为空,则确定该字符串不存在,返回-1。
4. 若是存在,则检查其余两个哈希值是否也匹配,若是匹配,则表示找到了该字符串,返回其Hash值。
5. 移到下一个位置,若是已经移到了表的末尾,则反绕到表的开始位置起继续查询
6. 看看是否是又回到了原来的位置,若是是,则返回没找到
7. 回到3

ok,这就是本文中所说的最快的Hash表算法。什么?不够快?:D。欢迎,各位批评指正。

——————————————–
补充一、一个简单的hash函数:

补充二、一个完整测试程序:
哈希表的数组是定长的,若是太大,则浪费,若是过小,体现不出效率。合适的数组大小是哈希表的性能的关键。哈希表的尺寸最好是一个质数。固然,根据不一样的数据量,会有不一样的哈希表的大小。对于数据量时多时少的应用,最好的设计是使用动态可变尺寸的哈希表,那么若是你发现哈希表尺寸过小了,好比其中的元素是哈希表尺寸的2倍时,咱们就须要扩大哈希表尺寸,通常是扩大一倍。

下面是哈希表尺寸大小的可能取值:

17,            37,          79,        163,          331,
673,           1361,        2729,       5471,         10949,
21911,          43853,      87719,      175447,      350899,
701819,         1403641,    2807303,     5614657,     11229331,
22458671,       44917381,    89834777,    179669557,   359339171,
718678369,      1437356741,  2147483647

如下为该程序的完整源码,已在linux下测试经过:

 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
#include <stdio.h>  
#include <ctype.h>     //多谢citylove指正。  
//crytTable[]里面保存的是HashString函数里面将会用到的一些数据,在prepareCryptTable  
//函数里面初始化  
unsigned long cryptTable[0x500];  
 
//如下的函数生成一个长度为0x500(合10进制数:1280)的cryptTable[0x500]  
void prepareCryptTable()  
{  
    unsigned long seed = 0x00100001, index1 = 0, index2 = 0, i;  
 
    for( index1 = 0; index1 < 0x100; index1++ )  
    {  
        for( index2 = index1, i = 0; i < 5; i++, index2 += 0x100 )  
        {  
            unsigned long temp1, temp2;  
 
            seed = (seed * 125 + 3) % 0x2AAAAB;  
            temp1 = (seed & 0xFFFF) << 0x10;  
 
            seed = (seed * 125 + 3) % 0x2AAAAB;  
            temp2 = (seed & 0xFFFF);  
 
            cryptTable[index2] = ( temp1 | temp2 );  
       }  
   }  
}  
 
//如下函数计算lpszFileName 字符串的hash值,其中dwHashType 为hash的类型,  
//在下面GetHashTablePos函数里面调用本函数,其能够取的值为0、一、2;该函数  
//返回lpszFileName 字符串的hash值;  
unsigned long HashString( char *lpszFileName, unsigned long dwHashType )  
{  
    unsigned char *key  = (unsigned char *)lpszFileName;  
unsigned long seed1 = 0x7FED7FED;  
unsigned long seed2 = 0xEEEEEEEE;  
    int ch;  
 
    while( *key != 0 )  
    {  
        ch = toupper(*key++);  
 
        seed1 = cryptTable[(dwHashType << 8) + ch] ^ (seed1 + seed2);  
        seed2 = ch + seed1 + seed2 + (seed2 << 5) + 3;  
    }  
    return seed1;  
}  
 
//在main中测试argv[1]的三个hash值:  
//./hash  "arr/units.dat"  
//./hash  "unit/neutral/acritter.grp"  
int main( int argc, char **argv )  
{  
    unsigned long ulHashValue;  
    int i = 0;  
 
    if ( argc != 2 )  
    {  
        printf("please input two arguments/n");  
        return -1;  
    }  
 
     /*初始化数组:crytTable[0x500]*/  
     prepareCryptTable();  
 
     /*打印数组crytTable[0x500]里面的值*/  
     for ( ; i < 0x500; i++ )  
     {  
         if ( i % 10 == 0 )  
         {  
             printf("/n");  
         }  
 
         printf("%-12X", cryptTable[i] );  
     }  
 
     ulHashValue = HashString( argv[1], 0 );  
     printf("/n----%X ----/n", ulHashValue );  
 
     ulHashValue = HashString( argv[1], 1 );  
     printf("----%X ----/n", ulHashValue );  
 
     ulHashValue = HashString( argv[1], 2 );  
     printf("----%X ----/n", ulHashValue );  
 
     return 0;
相关文章
相关标签/搜索