【问题标题】:Robin Hood hashing in CC语言中的罗宾汉散列
【发布时间】:2018-05-27 14:26:28
【问题描述】:

我正在实现一个 Hashtable 来处理与 robin hood 散列的冲突。但是,以前我使用的是链接,插入近 100 万个密钥的过程几乎是瞬间完成的。罗宾汉散列不会发生同样的情况,我觉得这很奇怪,因为我觉得它更快。所以我想问的是我的插入功能是否正确实现。代码如下:

typedef struct hashNode{
    char *word;
    int freq; //not utilized in the insertion
    int probe;// distance from the calculated index to the actual index it was inserted.
    struct hashNode* next; //not utilized in the insertion
    struct hashNode* base; //not utilized in the insertion
}hashNode;
typedef hashNode* hash_ptr;

hash_ptr hashTable[NUM_WORDS] = {NULL}; // NUM_WORDS = 1000000
                                        // Number of actual entries = 994707

hash_ptr swap(hash_ptr node, int index){
    hash_ptr temp = hashTable[index];
    hashTable[index] = node;
    return temp;
}

static void insertion(hash_ptr node,int index){
    while(hashTable[index]){
        if(node->probe > hashTable[index]->probe){
            node = swap(node,index);
        }
        node->probe++;
        index++;
        if(index > NUM_WORDS) index = 0;

    }
    hashTable[index] = node;
}

将所有内容上下文化:

  • 节点参数是新条目。
  • index 参数是新条目所在的位置,如果它没有被占用的话。

【问题讨论】:

  • 您在这两种情况下看到的时间是什么?你能把你正在使用的测试和测量代码也放上去吗?
  • @HoriaComan 我没有使用任何实际测量值。我所拥有的是,我知道两个哈希表何时完成。在链接中大约需要 1 秒。在罗宾汉中,它持续了 20 分钟,但仍未完成
  • 对我来说听起来像是一个无限循环。使用调试器。打印出一些东西,就像每次你成功地向哈希表中添加一些东西一样。如果您有一段时间没有看到打印,请闯入并单步执行代码。
  • @so 我照你说的做了,我似乎没有陷入困境。它只是非常缓慢地添加条目。我知道这可能取决于很多因素,但是,robin hood 散列应该比链接更快吗?或者可能是代码中的问题?谢谢
  • 您的insertion 方法看起来与Sebastian 发布的insert_helper 方法明显不同,Sebastian 引用了 Pedro Celis 1986 年的论文。

标签: c algorithm hashtable probing


【解决方案1】:

Robin Hood 算法非常聪明,但它与任何其他开放式散列技术一样依赖于良好的散列函数。

作为最坏的情况,考虑最坏的哈希函数:

int hash(const char* key) { return 0; }

由于这会将每个项目映射到同一个槽,很容易看出探测的总数是条目数的二次方:第一个插入在第一个探测上成功;第二个插入需要两个探针;第三一三探头;依此类推,导致总共有 n(n+1)/2 探针用于 n 插入。无论您使用简单的线性探测还是罗宾汉探测,都是如此。

有趣的是,如果 - 这是一个非常大的 if - 没有尝试验证插入的元素是独特的。 (在您提供的代码中就是这种情况,这并非完全不合理;很有可能将哈希表构建为固定查找表,并且已知要添加的条目是唯一的。有关此的更多信息稍后指出。)

在链式哈希实现中,非验证插入函数可能如下所示:

void insert(hashNode *node, int index) {
  node->next = hashTable[index];
  hashTable[index] = node;
}

请注意,没有充分的理由为哈希链使用双向链表,即使您计划实施删除。额外的链接只是浪费内存和循环。

您可以在(实际上)任何时间构建链式哈希表这一事实并不意味着该算法已经构建了一个好的哈希表。当需要在表中查找一个值时,就会发现问题:查找元素的平均探针数将是表中元素数的一半。 Robin Hood(或线性)开放寻址哈希表具有完全相同的性能,因为所有搜索都从表的开头开始。与使用该表的成本相比,开放地址哈希表的构建速度也很慢这一事实可能几乎无关紧要。

我们不需要像“始终使用 0”函数那样糟糕的哈希函数来产生二次函数。散列函数具有极小范围的可能值就足够了(与散列表的大小相比)。如果可能值的可能性相同,则链式哈希仍然是二次的,但平均链长将除以可能值的数量。但是,对于线性/R.Hood 探测散列,情况并非如此,特别是如果所有可能的散列值都集中在一个小范围内。例如假设哈希函数是

int hash(const char* key) {
  unsigned char h = 0;
  while (*key) h += *key++;
  return h;
}

这里,hash 的范围被限制在 [0, 255)。如果表大小远大于 256,这将迅速减少到与常量散列函数相同的情况。很快,哈希表中的前 256 个条目将被填充,并且在该点之后的每次插入(或查找)都需要在表开头的线性增加的紧凑范围内进行线性搜索。所以性能将与具有恒定哈希函数的表的性能没有区别。

所有这些都不是为了鼓励使用链式哈希表。相反,它指出了使用良好哈希函数的重要性。 (从某种意义上说,散列密钥的结果均匀分布在可能的节点位置的整个范围内是好的。)尽管如此,通常情况下,聪明的开放寻址方案比简单的链接对坏散列函数更敏感。

开放寻址方案绝对具有吸引力,尤其是在静态查找表的情况下。它们在静态查找表的情况下更有吸引力,因为删除确实很痛苦,因此不必实现键删除消除了巨大的复杂性。最常见的删除解决方案是将已删除的元素替换为 DELETED 标记元素。查找探针仍必须跳过 DELETED 标记,但如果查找之后将插入插入,则可以在查找扫描期间记住第一个 DELETED 标记,如果找不到键,则由插入的节点覆盖。这可以接受,但负载因子必须使用预期的 DELETED 标记数来计算,如果使用模式有时会连续删除大量元素,则表的实际负载因子会显着下降。

但是,在删除不是问题的情况下,开放地址哈希表具有一些重要的优势。特别是,在有效负载(键和相关值,如果有的话)很小的情况下,它们的开销要低得多。在链式哈希表的情况下,每个节点都必须包含一个next 指针,并且哈希表索引必须是指向节点链的指针向量。对于key只占用一个指针空间的hash表,开销是100%,也就是说负载因子为50%的linear-probed open-addressed hash table比索引的chained table占用的空间少一点vector 已被完全占用,其节点按需分配。

线性探测表不仅存储效率更高,它还提供了更好的参考局部性,这意味着 CPU 的 RAM 缓存将得到更大的利用。使用线性探测,可以使用单个高速缓存行(因此只有一个慢速内存引用)进行八次探测,这几乎是通过随机分配的表条目的链接列表进行探测的八倍。 (实际上,速度不会这么快,但它可能会快两倍以上。)对于性能真正重要的字符串键,您可能会考虑存储长度和/或前几个字符哈希条目本身中的键,因此指向完整字符串的指针大多只使用一次,以验证探测成功。

但是开放寻址的空间和时间优势都取决于哈希表是一个条目的数组,而不是你的实现中指向条目的指针数组。将条目直接放入哈希索引可以避免每个条目(或至少每个链)的指针可能显着的开销,并允许有效使用内存缓存。所以这可能是您在最终实现中需要考虑的问题。

最后,开放寻址不一定会使删除变得复杂。在杜鹃哈希(以及它近年来启发的各种算法)中,删除并不比链式哈希中的删除更难,甚至可能更容易。在杜鹃散列中,任何给定的键只能位于表中的两个位置之一(或者,在某些变体中,某个小常量 k 的位置之一)并且查找操作只需要检查这两个位置地方。 (插入可能需要更长的时间,但对于小于 50% 的负载因子,仍然需要 O(1)。)因此,您只需将条目从其所在位置删除即可删除它;这对查找/插入速度没有明显影响,并且插槽将被透明地重用,而无需任何进一步的干预。 (不利的一面是,一个节点的两个可能位置并不相邻,它们很可能位于不同的缓存行上。但是对于给定的查找,它们中只有两个位置。一些变体具有更好的参考局部性。)


关于您的 Robin Hood 实现的最后几个 cmet:

  1. 我并不完全相信 99.5% 的负载率是合理的。也许没关系,但99%和99.5%的差距太小了,没有明显的理由去诱惑命运。此外,通过将表的大小设为 2 的幂(在本例中为 1,048,576)并使用位掩码计算余数,可以消除散列计算期间相当慢的余数运算。最终结果可能会明显更快。

  2. 在哈希条目中缓存探测计数确实有效(尽管我之前有过怀疑),但我仍然相信建议的缓存哈希值的方法更胜一筹。您可以轻松计算探测距离;它是搜索循环中的当前索引与从缓存的哈希值(或缓存的起始索引位置本身,如果您选择缓存的话)计算的索引之间的差异。该计算不需要对哈希表条目进行任何修改,因此它对缓存更友好且速度稍快,并且不需要更多空间。 (但无论哪种方式,都会产生存储开销,这也会降低缓存的友好性。)

  3. 最后,正如评论中所指出的,您的环绕代码中有一个错误;应该是

    if(index >= NUM_WORDS) index = 0;
    

    使用严格的大于测试,您的下一次迭代将尝试使用索引 NUM_WORDS 处的条目,该条目超出范围。

【讨论】:

    【解决方案2】:

    只是把它留在这里:99% 的填充率是合理的。下界是 95%,也不是 90%。我知道他们在报纸上说的,他们错了。非常错误。使用 60%-80% 像你应该的开放地址

    Robin Hood 散列在您插入时不会改变数量或冲突,平均(和总)冲突数保持不变。只有它们的分布发生了变化:罗宾汉改进了最坏的情况。但对于平均值,它与线性、二次或双重哈希相同。

    • 在 75% 的情况下,您在命中前会发生大约 1.5 次碰撞
    • 大约 2 次碰撞达到 80%
    • 在 90% 时大约 4.5 次碰撞
    • 大约 9 次碰撞达到 95%
    • 大约 30 次碰撞达到 99%

    我在 10000 个元素的随机表上进行了测试。罗宾汉散列不能改变这个平均值,但它将 1% 最坏情况下的碰撞次数从 150-250 次(在 95% 的填充率下)提高到大约 30-40 次。

    【讨论】:

      猜你喜欢
      • 2023-03-23
      • 2011-08-12
      • 1970-01-01
      • 2010-10-15
      • 2013-11-16
      • 2021-03-11
      • 2023-03-21
      • 2019-05-02
      • 1970-01-01
      相关资源
      最近更新 更多