unordered_map 对哈希函数的过度调用

Ami*_*rsh 17 c++ unordered-map

以下代码导致对哈希函数的无法解释的调用:

namespace foo {
    using Position = tuple <int, int, int>;
    
    std::ostream& operator<<(std::ostream& out, const Position& pos) noexcept{
        return out << get<0>(pos) << ", " << get<1>(pos) << ", " << get<2>(pos);
    }

    struct hashFunc{
        std::size_t operator()(const Position& pos) const noexcept{
            int res = get<0>(pos) * 17 ^ get<1>(pos) * 11 ^ get<2>(pos);
            cout << "@@@ hash function called for key: " << pos 
                 << ", hash: " << res << endl;
            return res;
        }
    };

    template<typename T>
    void print_buckets(T&& map) {
        auto num_buckets = map.bucket_count();
        cout << "------------------------------" << endl;
        cout << "NUM BUCKETS: " << num_buckets << endl;
        for(size_t i=0; i<num_buckets; ++i) {
            auto bucket_size = map.bucket_size(i);
            if(bucket_size) {
                cout << "BUCKET " << i << " size: " << bucket_size << endl;        
            }
        }
        cout << "------------------------------" << endl;
    }
}
Run Code Online (Sandbox Code Playgroud)

主要的:

using namespace foo;

int main() {
    // note: bucket_count specified
    unordered_map <Position, std::string, hashFunc> test(10); 
    
    auto x = tuple{1,0,0};
    auto z = tuple{0,1,0};
    auto w = tuple{0,0,1};
            
    cout << "==================================" << endl;
    cout << "about to insert: " << x << endl;
    test[x] =  "hello";
    print_buckets(test);
    cout << "after insert of: " << x << endl;
    
    cout << "==================================" << endl;
    cout << "about to insert: " << z << endl;
    test[z] = "hey";
    print_buckets(test);
    cout << "after insert of: " << z << endl;
    
    cout << "==================================" << endl;
    cout << "about to insert: " << w << endl;
    test.insert({w, "hello"});
    print_buckets(test);
    cout << "after insert of: " << w << endl;    
    cout << "==================================" << endl;
}
Run Code Online (Sandbox Code Playgroud)

输出:

==================================
about to insert: 1, 0, 0
@@@ hash function called for key: 1, 0, 0, hash: 17
------------------------------
NUM BUCKETS: 11
BUCKET 6 size: 1
------------------------------
after insert of: 1, 0, 0
==================================
about to insert: 0, 1, 0
@@@ hash function called for key: 0, 1, 0, hash: 11
@@@ hash function called for key: 1, 0, 0, hash: 17   <= why?
------------------------------
NUM BUCKETS: 11
@@@ hash function called for key: 1, 0, 0, hash: 17   <= why?
BUCKET 0 size: 1
BUCKET 6 size: 1
------------------------------
after insert of: 0, 1, 0
==================================
about to insert: 0, 0, 1
@@@ hash function called for key: 0, 0, 1, hash: 1
@@@ hash function called for key: 0, 1, 0, hash: 11   <= why?
------------------------------
NUM BUCKETS: 11
@@@ hash function called for key: 1, 0, 0, hash: 17   <= why?
BUCKET 0 size: 1
@@@ hash function called for key: 0, 1, 0, hash: 11   <= why?
BUCKET 1 size: 1
BUCKET 6 size: 1
------------------------------
after insert of: 0, 0, 1
==================================
Run Code Online (Sandbox Code Playgroud)

代码 (gcc 和 clang 的行为相同)


笔记:

1.bucket_count在构造函数不带参数的情况下尝试相同的方法,由于rehash,对hash函数的调用变得更加过度。但在上面的场景中,似乎没有重新哈希,也没有冲突。

2. 相关,但特别是在 MSVC 上:插入到 std::unordered_map 在 MSVC++ 的 STL 中两次调用散列函数,糟糕的设计还是特殊原因?

Use*_*ess 5

首先,有几个观察:

  • 无序映射既是哈希表,又是单向链表。

    这里begin回报的iterator哪些车型LegacyForwardIterator

  • 将条目插入映射需要更新哈希表和链表。

其次,关于这些容器的实现决策的几点说明:

  • 对于单链表,通常有一个不包含任何数据的哨兵节点(对于诸如 之类的东西Node<T>,它仍然会有一个T,只是默认初始化)。我们只需要它的next指针,因为它有助于保持列表操作的规则性(即,我们不必将insert-at-the-headinsert-after-node作为不同的特殊情况编写)。

  • 对于哈希表(假设链表存储桶,因为标准要求它),我们可以使用Node table[N](因此每个存储桶都有自己的预分配的哨兵)或Node* table[N].

    在这种情况下,由于我们实际上正在使用Node<T>并且不知道 的大小T,因此为每个存储桶存储一个指针似乎是合理的。

  • 对于一个哈希表,其是单链接列表,是有意义的使用每桶列表作为(部分)的所有元素列表。否则我们需要为每个节点存储两个指针,next_in_bucket并且next_in_list.

    这意味着一个bucket指向的“哨兵”(one-before-the-beginning)节点实际上是一个bucket的最后一个节点……除了列表前面的bucket,当它真的是总名单哨兵。

    代码中的注释说

      /* ...
      *  The non-empty buckets contain the node before the first node in the
      *  bucket. This design makes it possible to implement something like a
      *  std::forward_list::insert_after on container insertion and
      *  std::forward_list::erase_after on container erase
      *  calls. _M_before_begin is equivalent to
      *  std::forward_list::before_begin. Empty buckets contain
      *  nullptr.  Note that one of the non-empty buckets contains
      *  &_M_before_begin which is not a dereferenceable node so the
      *  node pointer in a bucket shall never be dereferenced, only its
      *  next node can be.
    
    Run Code Online (Sandbox Code Playgroud)

    (哨兵_M_before_begin在这段代码中)

所以,当我们向一个已经填充的桶中添加一个元素时,步骤大致是

void insert_to_non_empty_bucket(Node *n, Key k) {
  Node *sentinel = table[k];
  n->next = sentinel->next;
  sentinel->next = n;
}
Run Code Online (Sandbox Code Playgroud)

再次注意,我们不知道也不关心这里的哨兵是前一个桶的最后一个元素,还是整个列表哨兵。无论哪种方式,代码都是相同的(这是首先使用哨兵的原因之一)。

然而,当我们将第一个元素添加到一个空桶(它不是唯一的非空桶)时,我们有一个额外的步骤:我们需要更新下一个桶的哨兵指针,以指向我们的新节点。否则我们将有两个桶都指向列表哨兵。

void insert_to_empty_bucket(Node *n, Key k) {
  Node *sentinel = &list_sentinel; // ie, &_M_before_begin
  n->next = sentinel->next;
  sentinel->next = n;

  // update the *next* bucket in the table
  table[n->next->key] = n;
}
Run Code Online (Sandbox Code Playgroud)

最后:在这个实现中,Node 不缓存 key,所以没有n->next->key. 实际上有一个 trait 控制这个,但在这种情况下它显然是错误的,这意味着最后一行必须重新计算哈希才能更新下一个存储桶。


注意。只是为了澄清,当我说上一个桶下一个桶时,我只是在谈论列表中的位置,其中桶的出现顺序与它们变为非空时的顺序相反。它与表中的位置没有任何关系,也没有暗示任何内在顺序。


Dan*_*ica 5

正如其他人指出的那样,无序映射只是哈希表的一种形式,在 libstdc++ 中基本上实现为单个(“全局”)链表。另外,还有一个指向此列表的存储桶数组。重要的是,存储的指针bucket[i] 并不指向属于该桶的第一个节点(根据哈希函数映射),而是指向其在全局链表中的前一个节点。原因很明显 \xe2\x80\x94 当您将一个项目添加到单链表中时,您需要更新其前任。这里,当需要向某个桶中插入元素时,需要更新该桶的第一个节点的前驱节点。

\n

然而,全局链表的第一个节点没有任何前驱。为了使事情统一,有一个哨兵节点扮演这个角色。在libstdc++中,它是一个成员变量_M_before_begin

\n

假设我们有一个哈希表,其中包含属于 的键A和属于 的键。例如,它可能如下所示:Bbucket[0]Cbucket[1]

\n
global linked list          buckets[]\n------------------          ---------\n\n_M_before_begin  <--------  bucket[0]\n       |\n       v\nnode_with_key_A \n       |\n       v\nnode_with_key_B  <--------  bucket[1]\n       |\n       v\nnode_with_key_C\n       |\n       x\n
Run Code Online (Sandbox Code Playgroud)\n

现在,当一个新键(例如)D被添加到一个空存储桶(例如)中时bucket[2],libstdc++ 会将其插入到全局链表的开头

\n

因此,这次插入后的情况如下:

\n
global linked list          buckets[]\n------------------          ---------\n\n_M_before_begin  <--------  bucket[2]\n       |\n       v\nnode_with_key_D  <--------  bucket[0]\n       |\n       v\nnode_with_key_A \n       |\n       v\nnode_with_key_B  <--------  bucket[1]\n       |\n       v\nnode_with_key_C\n       |\n       x\n
Run Code Online (Sandbox Code Playgroud)\n

请注意,bucket[0]与 所node_with_key_A指向的相对应_M_before_begin 需要更新。而且,正如其他人再次指出的那样,libstdc++ 默认情况下不缓存哈希值,因此唯一的选择是如何找到存储桶索引node_with_key_A是触发哈希函数。

\n

请注意,基本上我只是说与其他人相同,但想添加一些可能有帮助的插图。

\n
\n

这种方法的另一个结果是在查找过程中可能会调用哈希函数: https: //godbolt.org/z/K6qhWc。原因是某些存储桶的第一个元素已知,但最后一个元素未知。因此,需要解析节点键的哈希函数,以在链表遍历过程中找出节点是否仍然属于实际的桶。

\n