简单的哈希函数

时间:2013-01-18 23:45:13

标签: c function hashtable string-hashing

我正在尝试编写一个 C 程序,该程序使用哈希表来存储不同的单词,我可以使用一些帮助。

首先,我创建一个哈希表,其大小为素数,它最接近我必须存储的单词的数量,然后我使用哈希函数来查找每个单词的地址。 我从最简单的功能开始,将字母加在一起,结果是88%的碰撞。 然后我开始尝试该功能,发现无论我改变它,碰撞都不会低于35%。 现在我正在使用

unsigned int stringToHash(char *word, unsigned int hashTableSize){
  unsigned int counter, hashAddress =0;
  for (counter =0; word[counter]!='\0'; counter++){
    hashAddress = hashAddress*word[counter] + word[counter] + counter;
  }
  return (hashAddress%hashTableSize);
}

这只是我提出的一个随机函数,但它给了我最好的结果 - 大约35%的碰撞。

过去几个小时我一直在阅读有关散列函数的文章,我尝试使用一些简单的函数,比如djb2,但是所有这些都给了我更糟糕的结果。(djb2导致了37%的碰撞,这不是更糟糕,但我期待更好而不是更糟糕的事情) 我也不知道如何使用其他更复杂的一些,例如murmur2,因为我不知道它们所采用的参数(密钥,len,种子)是什么。

即使使用djb2,或者我做错了什么,获得超过35%的冲突是否正常? 什么是关键,len和种子价值?

2 个答案:

答案 0 :(得分:67)

尝试sdbm:

hashAddress = 0;
for (counter = 0; word[counter]!='\0'; counter++){
    hashAddress = word[counter] + (hashAddress << 6) + (hashAddress << 16) - hashAddress;
}

或者djb2:

hashAddress = 5381;
for (counter = 0; word[counter]!='\0'; counter++){
    hashAddress = ((hashAddress << 5) + hashAddress) + word[counter];
}

或Adler32:

uint32_t adler32(const void *buf, size_t buflength) {
     const uint8_t *buffer = (const uint8_t*)buf;

     uint32_t s1 = 1;
     uint32_t s2 = 0;

     for (size_t n = 0; n < buflength; n++) {
        s1 = (s1 + buffer[n]) % 65521;
        s2 = (s2 + s1) % 65521;
     }     
     return (s2 << 16) | s1;
}

// ...

hashAddress = adler32(word, strlen(word));

但这些都不是很好。如果你真的想要好的哈希,你需要更复杂的东西,例如lookup3

请注意,一旦哈希表填充超过70-80%,就会产生大量冲突。这是完全正常的,如果您使用非常好的哈希算法,甚至会发生这种情况。这就是为什么大多数哈希表实现在您向哈希表添加内容并且比率capacity * 1.5已经增加时,会增加哈希表的容量(例如capacity * 2甚至size / capacity)的原因高于0.7至0.8。增加容量意味着创建一个具有更高容量的新哈希表,所有来自当前值的哈希表都被添加到新哈希表中(因此它们必须全部重新加入,因为它们的新索引在大多数情况下会有所不同),新的hastable数组替换旧的,旧的释放/释放。如果你打算散列1000个单词,哈希表容量最低建议为1250,更好1400或甚至1500.

哈希表不应该被填充到边缘,至少不是如果它们快速有效(因此它们总是应该有备用容量)。这是散列表的缩小,它们很快(O(1)),但它们通常会浪费更多的空间,而不是将相同的数据存储在另一个结构中(当你将它们存储为排序数组时,对于1000个单词,您只需要1000的容量;缩小尺寸是在这种情况下查找不能快于O(log n)。在大多数情况下,无论如何都不可能使用无冲突哈希表。几乎所有散列表实现都希望碰撞发生,并且通常有某种方式来处理它们(通常碰撞会使查找速度变慢,但哈希表仍然可以工作,并且在很多情况下仍会击败其他数据结构)。

另请注意,如果您使用非常好的哈希函数,如果您使用模数(%裁剪哈希值,如果哈希表具有2个容量的幂,则没有要求,但甚至没有优势。 ) 到底。许多散列表实现总是使用2个容量的原因是因为它们不使用模,而是使用AND(&)进行裁剪,因为AND操作是最快的操作之一将在大多数CPU上找到(模数永远不会比AND快,在最好的情况下它会同样快,在大多数情况下它会慢得多)。如果哈希表使用2个大小的幂,则可以使用AND操作替换任何模块:

x % 4  == x & 3
x % 8  == x & 7
x % 16 == x & 15
x % 32 == x & 31
...

但这仅适用于2种尺寸的电源。如果你使用modulo,2个大小的功能只能买东西,如果哈希是一个非常糟糕的哈希与非常糟糕的比特分布&#34;。坏位分布通常是由不使用任何类型的位移(>><<)的散列或任何其他与位移具有类似效果的操作引起的。

我为你创建了一个精简的lookup3实现:

#include <stdint.h>
#include <stdlib.h>

#define rot(x,k) (((x)<<(k)) | ((x)>>(32-(k))))

#define mix(a,b,c) \
{ \
  a -= c;  a ^= rot(c, 4);  c += b; \
  b -= a;  b ^= rot(a, 6);  a += c; \
  c -= b;  c ^= rot(b, 8);  b += a; \
  a -= c;  a ^= rot(c,16);  c += b; \
  b -= a;  b ^= rot(a,19);  a += c; \
  c -= b;  c ^= rot(b, 4);  b += a; \
}

#define final(a,b,c) \
{ \
  c ^= b; c -= rot(b,14); \
  a ^= c; a -= rot(c,11); \
  b ^= a; b -= rot(a,25); \
  c ^= b; c -= rot(b,16); \
  a ^= c; a -= rot(c,4);  \
  b ^= a; b -= rot(a,14); \
  c ^= b; c -= rot(b,24); \
}

uint32_t lookup3 (
  const void *key,
  size_t      length,
  uint32_t    initval
) {
  uint32_t  a,b,c;
  const uint8_t  *k;
  const uint32_t *data32Bit;

  data32Bit = key;
  a = b = c = 0xdeadbeef + (((uint32_t)length)<<2) + initval;

  while (length > 12) {
    a += *(data32Bit++);
    b += *(data32Bit++);
    c += *(data32Bit++);
    mix(a,b,c);
    length -= 12;
  }

  k = (const uint8_t *)data32Bit;
  switch (length) {
    case 12: c += ((uint32_t)k[11])<<24;
    case 11: c += ((uint32_t)k[10])<<16;
    case 10: c += ((uint32_t)k[9])<<8;
    case 9 : c += k[8];
    case 8 : b += ((uint32_t)k[7])<<24;
    case 7 : b += ((uint32_t)k[6])<<16;
    case 6 : b += ((uint32_t)k[5])<<8;
    case 5 : b += k[4];
    case 4 : a += ((uint32_t)k[3])<<24;
    case 3 : a += ((uint32_t)k[2])<<16;
    case 2 : a += ((uint32_t)k[1])<<8;
    case 1 : a += k[0];
             break;
    case 0 : return c;
  }
  final(a,b,c);
  return c;
}

此代码的性能不如原始代码高度优化,因此它更简单。它也不像原始代码那样便携,但它可以移植到当今使用的所有主要消费者平台。它也完全忽略了CPU端,但这不是一个真正的问题,它将适用于大端和小端的CPU。请记住,它不会为大端和小端CPU上的相同数据计算相同的哈希值,但这不是必需的;它会计算两种CPU的良好散列,唯一重要的是它总是在一台机器上为相同的输入数据计算相同的散列。

您可以按如下方式使用此功能:

unsigned int stringToHash(char *word, unsigned int hashTableSize){
  unsigned int initval;
  unsigned int hashAddress;

  initval = 12345;
  hashAddress = lookup3(word, strlen(word), initval);
  return (hashAddress%hashTableSize);
  // If hashtable is guaranteed to always have a size that is a power of 2,
  // replace the line above with the following more effective line:
  //     return (hashAddress & (hashTableSize - 1));
}

你想知道initval是什么。嗯,这是你想要的任何东西。你可以称之为盐。它会影响哈希值,但由于这个原因,哈希值的质量不会变得更好或更差(至少在一般情况下,它可能会导致非常具体的数据或多或少的冲突)。例如。如果要对相同的数据进行两次散列,则可以使用不同的initval值,但每次都应该生成不同的散列值(不能保证它会,但如果initval不同则很可能;如果它创造了相同的值,那么你必须将其视为一种碰撞,这将是一个非常不幸的巧合。在对相同散列表进行散列数据时,建议不要使用不同的initval值(这样会导致平均更多的冲突)。 initval的另一个用途是,如果要将哈希与其他一些数据组合在一起,在这种情况下,当对其他数据进行哈希处理时,已存在的哈希变为initval(因此,其他数据以及之前的哈希影响了哈希函数的结果)。如果您愿意,甚至可以将initval设置为0,或者在创建哈希表时选择随机值(并且始终对此哈希表实例使用此随机值,但每个哈希表都有自己的随机值)

关于碰撞的说明:

在实践中,碰撞通常不是一个巨大的问题,它通常不会为了避免它们而浪费大量内存。问题是你如何以有效的方式处理它们。

你说你正在处理9000个单词。如果您使用的是未排序的数组,则在数组中查找单词平均需要进行4500次比较。在我的系统上,4500字符串比较(假设字长在3到20个字符之间)需要38微秒(0.000038秒)。因此,即使是这样一个简单,无效的算法,对于大多数目的而言也足够快假设您正在对单词列表进行排序并使用二进制搜索,那么在数组中查找单词平均只需要进行13次比较。 13个比较在时间上几乎没有任何关系,它甚至太小甚至无法可靠地进行基准测试。因此,如果在散列表中找到一个单词需要进行2到4次比较,我甚至不会浪费一秒钟来判断这是否是一个巨大的性能问题。

在您的情况下,具有二进制搜索的排序列表甚至可以击败哈希表。当然,13次比较需要比2-4次比较更多的时间,但是,在哈希表的情况下,您必须首先对输入数据进行散列以执行查找。单独哈希可能已经花费了超过13次比较! 更好哈希,更长它将需要对相同数量的数据进行哈希处理。因此,如果您拥有非常大量的数据,或者您必须经常更新数据(例如,不断向表中添加/删除单词,那么散列表只能在性能方面得到回报,因为这些操作对于散列表的成本低于它们是排序列表)。 hashatble为O(1)的事实只意味着无论它有多大,查找都会大约。总是需要相同的时间。 O(log n)仅表示查找以字数对数增长,这意味着更多的单词,更慢的查找。然而,Big-O符号并未说明绝对速度!这是一个很大的误解。并不是说O(1)算法总是比O(log n)算法执行得更快。 Big-O表示法只会告诉您,如果O(log n)算法对于一定数量的值更快并且您不断增加值的数量,那么O(1)算法肯定会超过O(log n)算法算法在某个时间点,但您当前的字数可能远低于该点。如果不对两种方法进行基准测试,只需查看Big-O表示法就无法说出哪一种更快。

回到碰撞。遇到碰撞怎么办?如果冲突的数量很少,这里我不是指冲突的总数(哈希表中冲突的单词数),而是每个索引一(在同一哈希表索引中存储的单词数) ,所以在你的情况下可能2-4),最简单的方法是将它们存储为链表。如果此表索引到目前为止没有冲突,则只有一个键/值对。如果发生冲突,则存在键/值对的链接列表。在这种情况下,您的代码必须迭代链表并验证每个键,如果匹配则返回值。根据您的数字,这个链表不会有超过4个条目,并且进行4次比较在性能方面是微不足道的。所以找到索引是O(1),找到值(或检测到该键不在表中)是O(n),但这里n只是链表项的数量(所以它最多是4个。

如果碰撞次数增加,链接列表可能会变慢,您还可以存储动态调整大小的键/值对排序数组,从而允许查找O(log n)并再次{{1} }只是该数组中的键数,而不是hastable中的所有键。即使在一个索引处有100次碰撞,找到正确的键/值对最多需要7次比较。那仍然几乎没有。尽管如果你在一个索引上确实有100个冲突,你的哈希算法不适合你的密钥数据,或者哈希表的容量太小。动态大小的排序数组的缺点是添加/删除键比链接列表的情况更多(代码方面,不一定是性能方面)。因此,如果保持冲突的数量足够低,使用链表通常就足够了,在C中自己实现这样的链表并将其添加到现有的哈希表实现中几乎是微不足道的。

我似乎使用了大多数哈希表实现这样的&#34;回退到备用数据结构&#34;处理碰撞。缺点是这些需要一点额外的内存来存储备用数据结构,还需要更多的代码来搜索该结构中的密钥。还有一些解决方案可以在哈希表本身内部存储冲突,并且不需要任何额外的内存。然而,这些解决方案具有一些缺点。第一个缺点是,随着更多数据的添加,每次碰撞都会增加更多碰撞的机会。第二个缺点是,虽然密钥的查找时间与到目前为止的冲突数量呈线性关系(正如我之前所说,每次碰撞都会在添加数据时导致更多冲突),但不在哈希表中的密钥的查找时间会减少甚至更糟最后,如果你执行查找不在哈希表中的键(但是你不知道没有执行查找),查找可能需要在整个哈希表上进行线性搜索(YUCK !!!) 。因此,如果您可以节省额外的内存,那么可以使用其他结构来处理冲突。

答案 1 :(得分:2)

  

首先,我创建一个哈希表,其大小为素数,它是我必须存储的单词数量的关闭,然后我使用哈希函数来查找每个单词的地址。

...

  

return(hashAddress%hashTableSize);

由于不同哈希值的数量与单词数量相当,因此您无法预期会有更低的冲突。

我使用随机哈希(这是你能达到的最好)做了一个简单的统计测试,如果你有#words == #different哈希,我发现26%是限制冲突率。