对无序的小整数序列进行哈希

13

背景

我有一个包含数千个整数序列的大集合。每个序列都具有以下属性:

  1. 长度为12;
  2. 序列元素的顺序不重要;
  3. 同一序列中没有元素出现两次;
  4. 所有元素都小于约300。

请注意,属性2和3意味着这些序列实际上是一个集合,但为了最大化访问速度,它们以C数组的形式存储。

我正在寻找一个优秀的C++算法来检查新序列是否已经存在于集合中。如果不存在,则将新序列添加到集合中。我考虑使用哈希表(请注意,我不能使用任何C++11构造或外部库,例如Boost)。将序列进行哈希处理并将其存储在std::set中也是一种选择,因为只要冲突足够稀少,就可以忽略它们。欢迎提出任何其他建议。

问题

我需要一个可交换的哈希函数,即不依赖于序列中元素的顺序。我考虑首先将序列约简为某个规范形式(例如排序),然后使用标准哈希函数(参见下面的参考资料),但我宁愿避免与复制相关的开销(我无法修改原始序列)和排序。就我所知,下面引用的函数都不是可交换的。理想情况下,哈希函数还应利用元素从不重复的事实。速度至关重要。

有什么建议吗?


对序列进行排序,并在各个哈希值上使用 boost::hash_combine - Kerrek SB
为什么霍夫曼编码突然浮现在我的脑海中?完全是压缩库的回忆。 - WhozCraig
1
@Arek'Fu:看看代码。boost::hash_combine有五行。直接复制即可。 - Kerrek SB
@Chris,它们都小于约300,所以那样行不通。 - Arek' Fu
@rici,我会将排序后的序列存储在std::set中。我使用std::memcmp定义了序列之间的弱排序关系。 - Arek' Fu
显示剩余11条评论
5个回答

6

以下是一些基本的想法,可以根据需要进行修改。

  1. 对一个整数进行哈希只是将其转化为原样。

  2. 我们使用boost::hash_combine中的公式来组合哈希值。

  3. 我们对数组进行排序以得到唯一的代表。

代码:

#include <algorithm>

std::size_t array_hash(int (&array)[12])
{
    int a[12];
    std::copy(array, array + 12, a);
    std::sort(a, a + 12);

    std::size_t result = 0;

    for (int * p = a; p != a + 12; ++p)
    {
        std::size_t const h = *p; // the "identity hash"

        result ^= h + 0x9e3779b9 + (result << 6) + (result >> 2);
    }

    return result;
}

更新:取消上述翻译。您刚刚编辑了问题,使其完全不同。
如果每个数字最多为300,则可以将排序后的数组压缩为每个数字的9位,即108位。 "无序"属性仅能节省额外的12!,约为29位,因此并没有真正的区别。
您可以寻找128位无符号整数类型,并直接将已排序的打包整数集存储在其中。或者,您可以将该范围分成两个64位整数,并像上面那样计算哈希:
uint64_t hash = lower_part + 0x9e3779b9 + (upper_part << 6) + (upper_part >> 2);

(或者使用魔数0x9E3779B97F4A7C15,这是64位版本。)

哦,优雅(第二部分)。给你点赞。 - Konrad Rudolph
@Arek'Fu:这些数字是静态确定的吗?如果您事先知道所有值,那么您可以创建一个完美的哈希函数。 - Kerrek SB
1
@KerrekSB:你可能对不排序的要求有些愚蠢,但从学术角度来看,有一些哈希函数可以在这里起作用。到目前为止,所有建议的哈希函数都相当糟糕,无法避免冲突,但这就是你如何判断两个无序序列是否等价的方法。我只是想知道是否有比我们迄今为止想出的更好的方法。 :) - Chris
@Chris 哈希值不能告诉你两个无序序列是否相等,只能告诉你它们不相等...即使哈希值匹配,序列仍然需要排序并进行比较。 - Jim Balter
@JimBalter:这是一个有点“僵尸帖子”的话题,所以我不太记得当时的意思了,但重新阅读一下问题,应该是关于查找哈希函数以帮助查找相等性,所以我猜我当时只是使用简写。你说的完全正确,我想这是隐含的。我的观点是,我认为您可以创建一个函数,使您能够确定事物是否不同,而无需排序。 - Chris
显示剩余6条评论

4

我建议使用sum函数作为哈希值,看看能得到什么结果。虽然这种方法没有利用数据的不重复属性以及它们都小于300的事实,但是速度非常快。

std::size_t hash(int (&arr)[12]) {
    return std::accumulate(arr, arr + 12, 0);
}

由于该函数需要无序的操作,我认为在不排序的情况下利用输入值的有限范围没有明智的方法。如果绝对需要,针对碰撞问题,我会硬编码一个排序网络(即一些ifelse语句)来原地排序这12个值(但我不知道12个值的排序网络会是什么样子,甚至是否实用)。

编辑 在评论讨论后,这里有一个非常好的减少碰撞的方法:在求和前将数组中的每个值提高到某个整数幂。最简单的方法是通过 transform 实现。这确实会生成一个副本,但可能仍然非常快:

struct pow2 {
    int operator ()(int n) const { return n * n; }
};

std::size_t hash(int (&arr)[12]) {
    int raised[12];
    std::transform(arr, arr + 12, raised, pow2());
    return std::accumulate(raised, raised + 12, 0);
}

1
你可以通过“稍微分散”哈希值来减少碰撞。将每个值乘以自身一次、两次甚至三次,以利用更多的32位范围。 - Deestan
2
@Chris 乘法不好,因为0是一个允许的值(即这种方法会导致大量的0冲突)。 - Konrad Rudolph
1
如果你考虑一个集合,其中可能包含数字0到3,只有其中两个数字,则加法哈希将给出一个范围在0到6之间的数字(假设允许重复),这是非唯一的(显然)。将它们的平方相加将给出10个值(0,1,2,4,5,8,9,10,13,18),在这种情况下我认为这是一个唯一的哈希。添加的数字越多,碰撞的可能性就越大,而且提高幂次数可以减少碰撞的数量。 - Chris
2
关键是,使用基本加法,将一个数字增加一,另一个数字减少一,将会得到相同的哈希值。如果你对这些数字进行平方,则不再成立。 - Chris
2
如果计算是矢量化的,那么速度应该足够快。如果不是,可能值得用表查找替换平方(并预先计算一个包含300个平方/立方/伪随机值的表)。 - Evgeny Kluev
显示剩余17条评论

4
你可以在大小为300的位集中切换与12个整数对应的位,然后使用来自boost::hash_combine的公式来组合十个32位整数,实现这个位集。
这提供了可交换的哈希函数,不使用排序,并利用元素从不重复的事实。
如果我们选择任意的位集大小并为每个12个整数的任意数量的位设置或切换(每个300个值要设置/切换的位是通过哈希函数或使用预先计算的查找表确定的),则可以将此方法推广。这会导致布隆过滤器或相关结构。

我们可以选择大小为32位或64位的布隆过滤器。在这种情况下,无需将大位向量的片段组合成单个哈希值。对于大小为32的经典布隆过滤器的情况,最优的哈希函数数量(或每个查找表值的非零位)为2。

如果我们选择经典布隆过滤器的“或”操作而不是“xor”,并且为查找表的每个值使用一半的非零位,那么我们就得到了Jim Balter提到的解决方案。

如果我们选择“+”而不是“或”操作,并为查找表的每个值使用大约一半的非零位,那么我们就得到了与Konrad Rudolph建议的类似的解决方案。


我不确定我理解你回答的第二部分。你是建议每个序列使用一个32位的布隆过滤器,并使用hash_combine将它们组合起来吗? - Arek' Fu
@Arek'Fu:不,使用每个序列的32位Bloom过滤器后,没有什么需要合并的了,我们已经有了单个32位哈希值。我只是列举了几种可能性来构建哈希函数,以满足您的要求(大小为32..300的位集,不同的设置/切换位的方式,并且仅在位集大于所需哈希大小时使用hash_combine)。至于选择哪种变体,64位或32位位集似乎是最快的,“xor”、“+”变体可能比“or”更好。 - Evgeny Kluev

4

将你的序列元素按数字顺序排序,然后将序列存储在 Trie 中。Trie 的每一层都是一种数据结构,在该层中搜索该元素...您可以根据其中的元素数量使用不同的数据结构...例如:链接列表、二叉搜索树或排序向量。

如果你想使用哈希表而不是 Trie,则仍然需要按数字顺序对元素进行排序,然后应用其中一个非交换哈希函数。你需要对元素进行排序以比较序列,因为你将会遇到哈希表碰撞。如果你不需要排序,则可以将每个元素乘以一个常数因子,将它们涂抹在 int 的位上(有理论可找到这样的因子,但你可以通过实验找到),然后对结果进行异或运算。或者你可以在表格中查找你的 ~300 个值,将它们映射到混合均匀的唯一值上,通过 XOR 操作(每个值都可以选择一个随机值,使其具有相等数量的 0 和 1 位 —— 每个异或操作都会翻转随机的一半位,这是最优的)。


今天我花了一些时间来尝试实现你的第二个建议,我认为这是目前为止最有前途的。我构建了300个随机的64位字符串,其中0和1的位数相等。我尝试使用XOR和求和两种策略混合映射值--两种策略都给出非常相似(而且非常好的)性能和冲突率。 - Arek' Fu
我在网上搜索了一下,得出的印象是,考虑到我需要处理的序列数量,使用trie可能有些过度。据我所知,对于大型数据集,trie的性能优于哈希表。我的序列数量变化很大——有时只有10个,但偶尔会达到10^6个。你能否推荐一个现有的简单C++ trie实现?如果我能运行一些简单的东西,那就可以给我一个性能提升的想法。 - Arek' Fu
令我惊讶的是,使用32位整数产生的碰撞率非常相似,而性能略微较差! - Arek' Fu
@Arek'Fu 你可能是对的,对于一个相对较小的序列数量,当哈希表冲突率较低时,使用trie树可能有点过度了。我无法提供除谷歌搜索结果之外的实现建议,例如:https://dev59.com/r3NA5IYBdhLWcg3wQ7Uw - Jim Balter
我决定接受这个答案,因为它最接近我最终采用的算法,我已经在一个独立的答案中发布了它。 - Arek' Fu

2
我接受了Jim Balter的答案,因为他最接近我最终编写的代码,但所有答案都因其有用而获得了我的+1。
这是我最终采用的算法。我编写了一个小型Python脚本,生成300个64位整数,使它们的二进制表示恰好包含32个真和32个假位。真位的位置是随机分布的。
import itertools
import random
import sys

def random_combination(iterable, r):
    "Random selection from itertools.combinations(iterable, r)"
    pool = tuple(iterable)
    n = len(pool)
    indices = sorted(random.sample(xrange(n), r))
    return tuple(pool[i] for i in indices)

mask_size = 64
mask_size_over_2 = mask_size/2

nmasks = 300

suffix='UL'

print 'HashType mask[' + str(nmasks) + '] = {'
for i in range(nmasks):
    combo = random_combination(xrange(mask_size),mask_size_over_2)
    mask = 0;
    for j in combo:
        mask |= (1<<j);
    if(i<nmasks-1):
        print '\t' + str(mask) + suffix + ','
    else:
        print '\t' + str(mask) + suffix + ' };'

脚本生成的C++数组用法如下:
typedef int_least64_t HashType;

const int maxTableSize = 300;

HashType mask[maxTableSize] = {
  // generated array goes here
};

inline HashType xorrer(HashType const &l, HashType const &r) {
  return l^mask[r];
}

HashType hashConfig(HashType *sequence, int n) {
  return std::accumulate(sequence, sequence+n, (HashType)0, xorrer);
}

这个算法是我试过的算法中最快的(这里这里使用立方体和这里使用大小为300的位集)。对于我的“典型”整数序列,碰撞率小于1E-7,这完全符合我的目的。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接