哈希

 

哈希表(Hash Table)及散列法(Hashing)

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


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

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

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

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

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

平方散列法的缺点是显而易见的,所以我们能不能找出一个理想的乘数,而不是拿value本身当作乘数呢?答案是肯定的。

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

这几个“理想乘数”是如何得出来的呢?这跟一个法则有关,叫黄金分割法则,而描述黄金分割法则的最经典表达式无疑就是著名的斐波那契数列,如果你还有兴趣,就到网上查找一下“斐波那契数列”等关键字,我数学水平有限,不知道怎么描述清楚为什么,另外斐波那契数列的值居然和太阳系八大行星的轨道半径的比例出奇吻合,很神奇,对么?

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

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


看起来不错,以后就用斐波那契散列法吧。

不过我们要注意了,前面提到的都是针对整数的散列法,那如果不是整数呢?下面给出一些参考算法,我把其它类型的数据转变为32位整数,之后的处理前面已经说了。

1,浮点数的散列法

unsigned int HashingDouble(double d)
{
if (d==0)
return 0;
else
{
int exponent; // 指数
double mantissa = frexp(d, &exponent); //把一个浮点数分解为尾数和指数 返回尾数  d = 尾数 * 2^指数
return (unsigned int)((2*mantissa-1) * (~0U));
}
}

2,字符串的散列法

unsigned int HashingString(char *str, int iLen)
{
unsigned int hsval = 2654435769;
int i;
int iShift = 0;
for(i=0; i<iLen; i++)
{
hsval ^= (str[i]<<iShift);
iShift+=3;
if(iShift>24)
iShift=0;
}
return hsval;
}

扩展 
    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表算法

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

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

函数一、以下的函数生成一个长度为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、1、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;
}

    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),现在仔细看看这个算法吧:

typedef struct
{
int nHashA;
int nHashB;
char bExists;
......
} SOMESTRUCTRUE;

一种可能的结构体定义?

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

int GetHashTablePos( har *lpszString, SOMESTRUCTURE *lpTable ) 
//lpszString要在Hash表中查找的字符串,lpTable为存储字符串Hash值的Hash表。
{
int nHash = HashString(lpszString); //调用上述函数二,返回要查找字符串lpszString的Hash值。
int nHashPos = nHash % nTableSize;

if ( lpTable[nHashPos].bExists && !strcmp( lpTable[nHashPos].pString, lpszString ) )
{ //如果找到的Hash值在表中存在,且要查找的字符串与表中对应位置的字符串相同,
return nHashPos; //则返回上述调用函数二后,找到的Hash值
}
else
{
return -1;
}
}

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

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

     MPQ使用文件名哈希表来跟踪内部的所有文件。但是这个表的格式与正常的哈希表有一些不同。首先,它没有使用哈希作为下标,把实际的文件名存储在表中用于验证,实际上它根本就没有存储文件名。而是使用了3种不同的哈希:一个用于哈希表的下标,两个用于验证。这两个验证哈希替代了实际文件名。

    当然了,这样仍然会出现2个不同的文件名哈希到3个同样的哈希。但是这种情况发生的概率平均是:1:18889465931478580854784,这个概率对于任何人来说应该都是足够小的。现在再回到数据结构上,Blizzard使用的哈希表没有使用链表,而采用"顺延"的方式来解决问题,看看这个算法:

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

int GetHashTablePos( char *lpszString, MPQHASHTABLE *lpTable, int nTableSize )
{
const int HASH_OFFSET = 0, HASH_A = 1, HASH_B = 2;

int nHash = HashString( lpszString, HASH_OFFSET );
int nHashA = HashString( lpszString, HASH_A );
int nHashB = HashString( lpszString, HASH_B );
int nHashStart = nHash % nTableSize;
int nHashPos = nHashStart;

while ( lpTable[nHashPos].bExists )
{
/*如果仅仅是判断在该表中时候存在这个字符串,就比较这两个hash值就可以了,不用对
*结构体中的字符串进行比较。这样会加快运行的速度?减少hash表占用的空间?这种
*方法一般应用在什么场合?
*/
if (   lpTable[nHashPos].nHashA == nHashA
&& lpTable[nHashPos].nHashB == nHashB )
{
return nHashPos;
}
else
{
nHashPos = (nHashPos + 1) % nTableSize;
}

if (nHashPos == nHashStart)
break;
}
return -1;
}

上述程序解释:

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

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

--------------------------------------------
补充1、一个简单的hash函数:

/*key为一个字符串,nTableLength为哈希表的长度
*该函数得到的hash值分布比较均匀
*/
unsigned long getHashIndex( const char *key, int nTableLength )
{
unsigned long nHash = 0;

while (*key)
{
nHash = (nHash<<5) + nHash + *key++;
}

return ( nHash % nTableLength );
}

 

补充2、一个完整测试程序:  
    哈希表的数组是定长的,如果太大,则浪费,如果太小,体现不出效率。合适的数组大小是哈希表的性能的关键。哈希表的尺寸最好是一个质数。当然,根据不同的数据量,会有不同的哈希表的大小。对于数据量时多时少的应用,最好的设计是使用动态可变尺寸的哈希表,那么如果你发现哈希表尺寸太小了,比如其中的元素是哈希表尺寸的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下测试通过:

#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、1、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;
}

memcache的哈希

    /** 
* Internal private hashing method.
*
* This is the original hashing algorithm from other clients.
* Found to be slow and have poor distribution.
*
* @param key String to hash
* @return hashCode for this string using our own hashing algorithm
*/
private static long origCompatHashingAlg( String key ) {
long hash = 0;
char[] cArr = key.toCharArray();

for ( int i = 0; i < cArr.length; ++i ) {
hash = (hash * 33) + cArr[i];
}

return hash;
}

/**
* Internal private hashing method.
*
* This is the new hashing algorithm from other clients.
* Found to be fast and have very good distribution.
*
* UPDATE: This is dog slow under java
*
* @param key
* @return
*/
private static long newCompatHashingAlg( String key ) {
CRC32 checksum = new CRC32();
checksum.update( key.getBytes() );
long crc = checksum.getValue();
return (crc >> 16) & 0x7fff;
}

/**
* Internal private hashing method.
*
* MD5 based hash algorithm for use in the consistent
* hashing approach.
*
* @param key
* @return
*/
private static long md5HashingAlg( String key ) {
MessageDigest md5 = MD5.get();
md5.reset();
md5.update( key.getBytes() );
byte[] bKey = md5.digest();
long res = ((long)(bKey[3]&0xFF) << 24) | ((long)(bKey[2]&0xFF) << 16) | ((long)(bKey[1]&0xFF) << 8) | (long)(bKey[0]&0xFF);
return res;
}

/**
* Returns a bucket to check for a given key.
*
* @param key String key cache is stored under
* @return int bucket
*/
private long getHash( String key, Integer hashCode ) {

if ( hashCode != null ) {
if ( hashingAlg == CONSISTENT_HASH )
return hashCode.longValue() & 0xffffffffL;
else
return hashCode.longValue();
}
else {
switch ( hashingAlg ) {
case NATIVE_HASH:
return (long)key.hashCode();
case OLD_COMPAT_HASH:
return origCompatHashingAlg( key );
case NEW_COMPAT_HASH:
return newCompatHashingAlg( key );
case CONSISTENT_HASH:
return md5HashingAlg( key );
default:
// use the native hash as a default
hashingAlg = NATIVE_HASH;
return (long)key.hashCode();
}
}
}

问题
但到目前为止我们依然面对了这样一个问题:就是服务实例本身发生变动的时候,导致服务列表变动从而会照成大量的cache数据请求会miss,几乎大部分数据会需要迁移到另外的服务实例上。这样在大型服务在线时,瞬时对后端数据库/硬盘照成的压力很可能导致整个服务的crash。

一致性哈希(Consistent Hashing)
在此我们采用了一种新的方式来解决问题,处理服务器的选择不再仅仅依赖key的hash本身而是将服务实例(节点)的配置也进行hash运算。

  1. 首先求出每个服务节点的hash,并将其配置到一个0~2^32的圆环(continuum)区间上。
  2. 其次使用同样的方法求出你所需要存储的key的hash,也将其配置到这个圆环(continuum)上。
  3. 然后从数据映射到的位置开始顺时针查找,将数据保存到找到的第一个服务节点上。如果超过2^32仍然找不到服务节点,就会保存到第一个memcached服务节点上。

在memcached的实际应用,虽然官方的版本并不支持Consistent Hashing,但是已经有了现实的Consistent Hashing实现以及虚节点的实现,第一个实现的是last.fm(国外流行的音乐平台)开发的libketama,
其中调用的hash的部分的java版本的实现(基于md5):
    /**
     * Calculates the ketama hash value for a string 
     * @param s
     * @return
     */
    public static Long md5HashingAlg(String key) {

        if(md5==null) {
            try {
                md5 = MessageDigest.getInstance("MD5");
            } catch (NoSuchAlgorithmException e) {
                log.error( "++++ no md5 algorythm found" );
                throw new IllegalStateException( "++++ no md5 algorythm found");            
            }
        }

        md5.reset();
        md5.update(key.getBytes());
        byte[] bKey = md5.digest();
        long res = ((long)(bKey[3]&0xFF) << 24) | ((long)(bKey[2]&0xFF) << 16) | ((long)(bKey[1]&0xFF) << 8) | (long)(bKey[0]&0xFF);
        return res;
    }
在一致性哈希的算法/策略环境中,按照测试来说时间和分布性都是综合来说比较让人满意的,参见:
http://www.javaeye.com/topic/346682

总结
在我们的web开发应用中的分布式缓存系统里哈希算法承担着系统架构上的关键点。
使用分布更合理的算法可以使得多个服务节点间的负载相对均衡,可以最大程度的避免资源的浪费以及服务器过载。
使用一致性哈希算法,可以最大程度的降低服务硬件环境变化带来的数据迁移代价和风险。
使用更合理的配置策略和算法可以使分布式缓存系统更加高效稳定的为我们整体的应用服务。

展望
一致性哈希的算法/策略来源于p2p网络,其实纵观p2p网络应用的场景,在许多地方与我们的应用有很多相似的地方,可以学习借鉴。
参考文章:
对等网络(P2P)中主流分布式哈希算法比较分析
http://www.ppcn.net/n3443c38.aspx

其他参考文章:
http://www.taiwanren.com/blog/article.asp?id=840
http://www.iwms.net/n923c43.aspx
http://tech.idv2.com/2008/07/24/memcached-004/

相关代码:
last.fm的ketama代码
http://static.last.fm/rj/ketama.tar.bz2

php版的Consistent Hashing实现:Flexihash
主页:
http://paul.annesley.cc/articles/2008/04/flexihash-consistent-hashing-php
google code上的代码主页:
http://code.google.com/p/flexihash/
现在已经移居到github上了:
http://github.com/pda/flexihash/



 http://blog.redfox66.com
 http://blog.csdn.net/wuliming_sc

 http://www.cppblog.com/guogangj/archive/2009/10/15/98699.html


posted on 2012-03-15 15:12  bigshuai  阅读(1416)  评论(0编辑  收藏  举报