【问题标题】:Good hash function for unordered set of ID's用于无序 ID 集的良好哈希函数
【发布时间】:2016-12-16 15:45:43
【问题描述】:

我遇到以下问题:

  • 给定一组无序、任意大的 id(例如 32 位空间中的 1、2、5、6、8)
  • 在更大的空间(例如 64 位)中计算哈希码。

简单的方法是为每个单独的 ID 计算一个哈希函数,然后对所有内容进行异或运算。但是,如果您有一个 32 位空间用于 ID 和一个 64 位空间用于散列函数,这可能不是解决此问题的最佳方法(冲突等......)。

我一直在考虑使用 Murmur3 终结器,然后对结果进行异或运算,但我想出于同样的原因,这也行不通(我不确定是否诚实)。同样,简单地将值相乘也应该有效(因为 ab = ba),但我不确定哈希函数有多“好”。

显然,我想到了对 ID 进行排序,然后 Murmur3 会做得很好。不过,如果可以避免的话,我不想排序。

这样的哈希函数有什么好的算法?

更新

好吧,我想我可能有点困惑。

Why is XOR the default way to combine hashes? 上的第二个答案实际上解释了组合哈希函数。在那里提出的案例中,XOR 被认为是一个糟糕的哈希函数,因为“dab”产生与“abd”相同的代码。就我而言,我希望这些东西生成相同的哈希值 - 但我也想尽量减少 -say- “abc” 也生成与 -say- “abd” 相同的哈希值的机会.

大多数散列函数的全部目的是,如果您向它们提供数据,它们很有可能使用完整的键空间。通常,这些散列函数利用数据是顺序这一事实,并乘以一个大数来打乱位。简单来说:

var hash = SomeInitialConstant;
foreach (var id in ids) {
  hash = hash * SomeConstant + hashCode(id);
}
// ... optionally shuffle bits around as finalizer
return hash;

现在,如果 ID 始终处于相同的顺序,这可以正常工作。但是,如果 ID 是无序的,这将不起作用,因为 x * constant + y 不是可交换的。

如果您对 ID 进行平方,我认为您最终不会使用整个哈希空间。考虑一下如果你有很大的数字会发生什么,比如 100000、100001 等。它们的平方是 10000000000、10000200001 等等。你不可能得到一个正方形来生成像 900000 这样的数字(仅仅因为 sqrt(900000)是一个带分数的数字)。

更一般地说,10000000000 和 10000200001 之间的所有哈希空间很可能都会丢失。但是,-say- 0 和 10 之间的空间会发生很多冲突,因为小数平方之间的可用哈希空间也很小。

使用大密钥空间的全部目的显然是减少冲突。我希望有一个相当大的哈希空间(比如 256 位),以确保在现实生活场景中几乎不存在冲突。

【问题讨论】:

  • 您希望输出是 32 位哈希码还是 64 位哈希码?
  • @JimMischel 我希望我的输出位于更大的域中(例如 64 位)。顺便说一句,我刚刚注意到 64 位作为示例;最终我想搬到 f.ex。 256 位以避免可能的冲突。
  • 投反对票的能解释一下吗?
  • 我没有投反对票,但我确实考虑了一会儿:这个问题有点令人困惑,不得不花点时间来解决它。无论如何,about combining hashes。好消息是您不必担心“a xor a = 0”(如果 ID 是唯一的),而交换性实际上是您的朋友。
  • 顺便说一句:900000 是两个平方的 sum(900*900 + 300*300)

标签: algorithm hash set


【解决方案1】:

我刚刚检查过:

  • 使用 32 位哈希
  • 在 64K 数组表中
  • 有 64K 项(负载率 = 100%)
  • 8 位值(无符号字符)
  • (数组大小 4...64)
  • 散列函数 := cnt+ (sum cube (arr[i]))
  • 或 := sum(square (zobrist[arr[i]))
  • Zobrist 工作得更好一些,(但数组需要进一步随机化)
  • 对于最佳哈希函数,冲突不会超出预期。
  • 为了避免重新计算(时空权衡),我实际上存储对象内的哈希值
  • 由于碰撞是生活中的事实,您可以将排序推迟到您真正需要它的那一刻 用于最终比较(当链长开始增长到 1 以上时)

#include <stdio.h>
#include <stdlib.h>

struct list {
        struct list *next;
        unsigned hash;
        unsigned short cnt;
        unsigned char *data;
        };

struct list *hashtab[1<<16] = {NULL, };
#define COUNTOF(a) (sizeof a / sizeof a[0])
unsigned zobrist[256] = {0,};
/*************************/
unsigned hash_it(unsigned char *cp, unsigned cnt)
{
unsigned idx;
unsigned long long hash = 0;

for(idx=0; idx < cnt; idx++) {
#if 0   /* cube */
        hash += (cp[idx] * cp[idx] * cp[idx]);
#else
        unsigned val;
        val = zobrist[cp[idx]];
        hash += (val * val);
#endif
        }
#if 0   /* as a tie-breaker: add the count (this avoids pythagorean triplets but *not* taxi-numbers) */
hash += cnt;
#endif
return hash;
}
/*************************/
struct list *list_new(unsigned cnt){
struct list *p;
unsigned idx;

p = malloc( sizeof *p + cnt);
p->data = (unsigned char*)(p+1);
p->cnt = cnt;
p->next = NULL;

for(idx=0; idx < cnt; idx++) {
        p->data[idx] = 0xff & rand();
        }
p->hash = hash_it(p->data, p->cnt);
return p;
}
/*************************/
void do_insert(struct list *this)
{
struct list **pp;
unsigned slot;

slot  = this->hash % COUNTOF(hashtab);
for (pp = &hashtab[slot]; *pp; pp = &(*pp)->next) {;}
*pp = this;
}
/*************************/
void list_print(struct list *this)
{
unsigned idx;
if (!this) return;

printf("%lx data[%u] = ", (unsigned long) this->hash, this->cnt);

for (idx=0; idx < this->cnt; idx++) {
        printf("%c%u"
        , idx ? ',' : '{' , (unsigned int) this->data[idx] );
        }
printf("}\n" );
}
/*************************/
unsigned list_cnt(struct list *this)
{
unsigned cnt;
for(cnt=0; this; this=this->next) { cnt++; }
return cnt;
}
/*************************/
unsigned list_cnt_collisions(struct list *this)
{
unsigned cnt;
for(cnt=0; this; this=this->next) {
        struct list *that;
        for(that=this->next; that; that=that->next) {
                if (that->cnt != this->cnt) continue;
                if (that->hash == this->hash) cnt++;
                }
        }
return cnt;
}
/*************************/
int main(void)
{
unsigned idx, val;
struct list *p;
unsigned hist[300] = {0,};

        /* NOTE: you need a better_than_default random generator
        ** , the zobrist array should **not** contain any duplicates
        */
for (idx = 0; idx < COUNTOF(zobrist); idx++) {
        do { val = random(); } while(!val);
        zobrist[idx] = val;
        }

        /* a second pass will increase the randomness ... just a bit ... */
for (idx = 0; idx < COUNTOF(zobrist); idx++) {
        do { val = random(); } while(!val);
        zobrist[idx] ^= val;
        }
        /* load-factor = 100 % */
for (idx = 0; idx < COUNTOF(hashtab); idx++) {
        do {
          val = random();
          val %= 0x40;
        } while(val < 4); /* array size 4..63 */
        p = list_new(val);
        do_insert(p);
        }

for (idx = 0; idx < COUNTOF(hashtab); idx++) {
        val = list_cnt( hashtab[idx]);
        hist[val] += 1;
        val = list_cnt_collisions(hashtab[idx]);
        if (!val) continue;
        printf("[%u] : %u\n", idx, val);
        for (val=0,p = hashtab[idx]; p; p= p->next) {
                printf("[%u]: ", val++);
                list_print(p);
                }
        }

for (idx = 0; idx < COUNTOF(hist); idx++) {
        if (!hist[idx]) continue;
        printf("[%u] = %u\n", idx, hist[idx]);
        }

return 0;
}
/*************************/

输出直方图(链长,0 := 空槽):

$ ./a.out
[0] = 24192
[1] = 23972
[2] = 12043
[3] = 4107
[4] = 1001
[5] = 181
[6] = 34
[7] = 4
[8] = 2

最后说明:代替 Zobrist[] 的平方和,您也可以将它们异或在一起(假设条目是唯一的)

最后的补充说明:C stdlib rand() 函数可能无法使用。 RAND_MAX 可能只有 15 位:0x7fff (32767)。要填充 zobrist 表,您需要更多值。这可以通过将一些额外的(rand() &lt;&lt; shift) 异或到更高位来完成。


新结果,使用(样本来自)一个非常大的源域(32 个元素 * 8 位),将其散列为 32 位散列键,插入到 1&lt;&lt;20 插槽的散列表中。

Number of elements 1048576 number of slots 1048576
Element size = 8bits, Min setsize=0, max set size=32
(using Cubes, plus adding size) Histogram of chain lengths:
[0] = 386124 (0.36824)
[1] = 385263 (0.36742)
[2] = 192884 (0.18395)
[3] = 64340 (0.06136)
[4] = 16058 (0.01531)
[5] = 3245 (0.00309)
[6] = 575 (0.00055)
[7] = 78 (0.00007)
[8] = 9 (0.00001)

非常接近最优;对于 100% 加载的哈希表,直方图中的前两个条目应该相等,在完美的情况下,都是 1/e。 前两个条目是空槽和只有一个元素的槽。

【讨论】:

  • 到目前为止,感谢您的广泛回答。我需要花一些时间来更详细地研究它;会回复你的。
  • 我理解这种实现的方式意味着拥有一个包含 2^32 个条目的 zobrist 表,因为那是我的关键空间......事实上,我的索引实际上确实在数十亿范围内。尽管如此,用更少的比特来测试它的想法是一个很好的想法(我应该考虑一下......);我在 8 位上做了一些自己的实验,发现大约 10% 的情况会使用 XOR(测试所有 4 个整数系列)在 x^4 情况下发生冲突。我想这仍然太多,但这确实给了我一个开始工作的机会。它还表明 ADD 优于 XOR。
  • 在 2^32 个条目的情况下,zobrist 表可能会变得太大 (32GB)。平方或立方是要走的路。 (我猜)
  • 是的。我也一直在考虑使用乘以常数。因此,我可以使用y=x*[some number],然后使用hash = (x*x)%(2^32) | ((y*y) % 2^32)&lt;&lt;32,而不是使用hash=[x*x]。在我的实验中,它的工作原理与简单平方函数一样好,但使我能够使用更多位(例如,它使用更大的键可以更好地扩展)。我已经在 16 位上对其进行了测试,它的效果与 Xor 和 Add 一样好。虽然这一切都很好,但我仍然没有完全理解碰撞背后的数学;到达那里......
  • 根据经验,立方比平方效果更好。无论如何,数学太难向数学家解释了。
【解决方案2】:

在我的例子中,我希望这些东西生成相同的哈希值 - 但我也想尽量减少 -say- “abc” 也生成与 -say- “abd” 相同的哈希值的机会。

按位异或实际上保证:如果两个大小相同的集合除了一个元素之外是相同的,那么它们必然具有不同的按位异或。 (顺便说一下,带环绕的求和也是如此:如果两个大小相同的集合除了一个元素外是相同的,那么它们必然具有不同的带环绕求和。)

因此,如果您对底部的 32 位使用按位异或,那么您基本上有 32 个“额外”位来尝试进一步减少冲突:减少两组不同大小具有相同校验和的情况,或者两个两个或多个元素不同的集合具有相同的校验和。一个比较简单的方法是选择一个函数f从32位整数映射到32位整数,然后对应用f的结果进行按位异或每个元素。 f 你想要的主要内容:

  • 它应该便宜且易于实施。
  • 它应该将零映射到非零值(这样 { 1, 2, 3 } 和 { 0, 1, 2, 3 } 将具有不同的校验和)。
  • 映射不应涉及以恒定方式(例如位移)重组位,因为 reorganize_bits(a) XOR reorganize_bits(b) 等价于 reorganize_bits( a XOR b),因此它不会向您的校验和添加任何独立信息。
  • 出于同样的原因,映射不应涉及与常量进行异或运算。

以上,joop 建议 f(a) = a2 MOD 232 sup>,这对我来说似乎不错,除了零的问题。也许 f(a) = (a + 1)2 MOD 232 ?

【讨论】:

  • 零大小写是我将 setsize 添加到哈希的原因之一。
【解决方案3】:

这个答案只是为了完整性。

从@joop 的解决方案中,我注意到他使用的位数比我少。此外,他还提出使用 x^3 代替 x^2,这产生了巨大的差异。

在我的代码中,我使用 8 位 id 进行测试,因为生成的密钥空间很小。这意味着我们可以简单地测试所有长度不超过 4 或 5 个 id 的链。哈希空间为 32 位。 (C#) 代码很简单:

static void Main(string[] args)
{
    for (int index = 0; index < 256; ++index)
    {
        CreateHashChain(index, 4, 0);
    }

    // Create collision histogram:
    Dictionary<int, int> histogram = new Dictionary<int, int>();
    foreach (var item in collisions)
    {
        int val;
        histogram.TryGetValue(item.Value, out val);
        histogram[item.Value] = val + 1;
    }

    foreach (var item in histogram.OrderBy((a) => a.Key))
    {
        Console.WriteLine("{0}: {1}", item.Key, item.Value);
    }
    Console.ReadLine();
}

private static void CreateHashChain(int index, int size, uint code)
{
    uint current = (uint)index;

    // hash
    uint v = current * current;
    code = code ^ v;

    // recurse for the rest of the chain:
    if (size == 1)
    {
        int val;
        collisions.TryGetValue(code, out val);
        collisions[code] = val + 1;
    }
    else
    {
        for (int i = index + 1; i < 256 - size; ++i)
        {
            CreateHashChain(i, size - 1, code);
        }
    }
}

private static Dictionary<uint, int> collisions = new Dictionary<uint, int>();

现在,这就是哈希函数。我将写下我对这些发现所做的一些尝试:

x^2

代码:

// hash
uint v = current * current;
code = code ^ v;

结果:很多很多很多的碰撞。事实上,没有一个案例的碰撞次数少于 3612 次。显然我们只使用了 16 位,所以可以很好地解释。无论如何,结果很糟糕。

x^3

代码:

// hash
uint v = current * current * current;
code = code ^ v;

结果:

1: 20991
2: 85556
3: 235878
4: 492362
5: 841527
6: 1220619
7: 1548920
[...]

仍然很糟糕,但同样,我们只使用了 24 位的密钥空间,因此必然会发生冲突。而且,它比使用 x^2 要好得多。

x^4

代码:

// hash
uint v = current * current;
v = v * v;
code = code ^ v;

结果:

1: 118795055
2: 20402127
3: 2740658
4: 329621
5: 38453
6: 4420
7: 495
8: 47
9: 12

正如预期的那样,这要好得多,显然这是因为我们现在使用的是完整的 32 位。

介绍 y

另一种引入更大密钥空间的方法是引入另一个变量-比如-y,它是x 的函数。这背后的想法是x^n 的小值x 将导致小数字,从而有很高的冲突机会;如果x 很小,我们可以通过确保y 将是一个很大的数字并进行位运算来组合两个散列函数来抵消这一点。最简单的方法是使所有位发生位翻转:

// hash
uint x = current;
uint y = (255 ^ current);

uint v1 = (UInt16)(x * x * x);
uint v2 = (UInt16)(y * y * y);
code = code ^ v1 ^ (v2 << 16);

这将导致以下结果:

1: 154971022
2: 6827322
3: 235081
4: 7554
5: 263
6: 9
7: 1

有趣的是,与之前的所有方法相比,这立即产生了更好的结果。它也立即提出了 16 位转换是否有意义的问题。毕竟,x^3 将导致 24 位空间对于较小的 x 值具有较大的间隙。将其与另一个移位的 24 位空间相结合,将更好地利用可用的 32 位。请注意,出于同样的原因,我们仍应移动 16(而不是 8!)。

1: 162671251
2: 3276751
3: 45277
4: 473
5: 5

乘以常数(最终结果)

另一种炸毁 y 键空间的方法是乘加。代码现在变为:

uint x = current;
uint y = (255 ^ current);
y = (y + 7577) * 0x85ebca6b;

uint v1 = (x * x * x);
uint v2 = (y * y * y);
code = code ^ v1 ^ (v2 << 8);

虽然这看起来不像是一种改进,但它的优点是我们可以使用这个技巧轻松地将 8 位序列扩展到任意 n 位序列。我移动了 8 位,因为我不希望 v1 的位过多地干扰 v2 的位。这给出了以下结果:

1: 162668435
2: 3277904
3: 45459
4: 464
5: 5

这个其实还不错!考虑到所有可能的 4 个 id 链,我们只有 2% 的机会发生碰撞。此外,如果我们有更大的链,我们可以使用与 v2 相同的技巧添加更多位(为每个额外的哈希码添加 8 位,因此 256 位哈希应该能够容纳大约 29 个 8 位 id 的链)。

剩下的唯一问题是:我们如何测试这个?正如@joop 在他的程序中指出的那样,数学实际上非常复杂。对于更多位数和更大的链,随机抽样实际上可能被证明是一种解决方案。

【讨论】:

  • 注意:您可以对哈希函数中的项求和,而不是异或。这是填充更高位的更自然方式。移位或相乘这些项只会使高位成为低位的函数。注意:我不明白你的直方图。为什么条目0: xxxx 不存在?
  • 直方图是出​​现的次数,所以 1 是没有碰撞的情况,2 是有 1 次碰撞的情况,等等。至于 XOR/ADD,我测试了求和,惊讶地发现它实际上比异或。
猜你喜欢
  • 1970-01-01
  • 2016-12-07
  • 2010-11-22
  • 2011-10-27
  • 1970-01-01
  • 2014-05-14
  • 2023-02-02
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多