unordered_map 对哈希函数的过多调用

unordered_map excess calls to hash function

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

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;
    }
}

主要:

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;
}

输出:

==================================
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
==================================

Code (gcc 和 clang 的行为相同)


备注:

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

2。相关,但专门针对 MSVC:Inserting to std::unordered_map calls hash function twice in MSVC++'s STL, bad design or special reason?

我无法解释为什么这样做,但它不适合发表评论,所以我将其留在答案部分。插入元素后,stdlib (10.1.0) 中有两个部分:

__hash_code __code = __h->_M_hash_code(__k);

计算要插入的元素的哈希值__k.

后面这部分代码:

    {
      // The bucket is empty, the new node is inserted at the
      // beginning of the singly-linked list and the bucket will
      // contain _M_before_begin pointer.
      __node->_M_nxt = _M_before_begin._M_nxt;
      _M_before_begin._M_nxt = __node;
      if (__node->_M_nxt)
        // We must update former begin bucket that is pointing to
        // _M_before_begin.
        _M_buckets[_M_bucket_index(__node->_M_next())] = __node;
      _M_buckets[__bkt] = &_M_before_begin;
    }

其中_M_bucket_index__node->_M_next()计算散列,__node指的是为__k创建的节点。

也许这有助于您或其他人进一步解释它。

可能是std::unordered_map的实现。 hash_value 没有存储到每个节点中。因此,当插入新元素或计算桶大小时,它将re-hash下一个桶中的第一个元素。

您可以尝试使用<tr1/unordered_map>来避免这个问题。示例:

#include <tr1/unordered_map>
using std::tr1::unordered_map;

注意:我不知道 tr1/unordered_mapunordered_map 哪个更好。

首先,有几点观察:

  • 无序映射既是散列 table,又是 singly-linked 列表。

    参见 here begin returns 一个 iterator 模型 LegacyForwardIterator.

  • 向映射中插入条目需要同时更新哈希 table 和链表。

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

  • 对于singly-linked列表,通常有一个不包含任何数据的哨兵节点(对于Node<T>,它仍然会有一个T,只是 default-initialized)。我们只需要它的 next 指针,因为它有助于保持列表操作的规律性(即,我们不必编写 insert-at-the-headinsert-after-node 作为不同的特殊情况)。

  • 对于散列 tables(假设 linked-list 个桶,因为它是标准所要求的)我们可以使用 Node table[N](所以每个桶都有自己的哨兵预分配)或 Node* table[N].

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

  • 对于散列 table 这是 也是 一个 singly-linked 列表,使用 per-bucket 是有意义的列为 all-elements 列表(的一部分)。否则我们需要为每个节点存储两个指针,next_in_bucketnext_in_list.

    这意味着一个桶指向的“哨兵”(one-before-the-beginning)节点实际上是前一个桶的last节点...除了bucket在列表的最前面,当它真的是整体列表哨兵时。

    code中的评论说

      /* ...
      *  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.
    

    (这段代码中的哨兵是_M_before_begin

所以,当我们向already-populated桶中添加一个元素时,步骤大致是

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

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

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

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;
}

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


注意。澄清一下,当我说 上一个桶 下一个桶 时,我只是在谈论列表中的位置,桶以相反的顺序出现他们成为 non-empty 的时间。它与 table 中的位置没有任何关系,也不暗示任何内在顺序。

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

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

假设我们有一个散列 table,其中键 AB 属于 bucket[0],键 C 属于 bucket[1]。例如,它可能如下所示:

global linked list          buckets[]
------------------          ---------

_M_before_begin  <--------  bucket[0]
       |
       v
node_with_key_A 
       |
       v
node_with_key_B  <--------  bucket[1]
       |
       v
node_with_key_C
       |
       x

现在,当一个新密钥,比如 D,被添加到一个空桶,比如 bucket[2],libstdc++ 将它插入 at the beginning of the global linked list

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

global linked list          buckets[]
------------------          ---------

_M_before_begin  <--------  bucket[2]
       |
       v
node_with_key_D  <--------  bucket[0]
       |
       v
node_with_key_A 
       |
       v
node_with_key_B  <--------  bucket[1]
       |
       v
node_with_key_C
       |
       x

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

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


这种方法的另一个结果是在查找过程中可能会调用散列函数:https://godbolt.org/z/K6qhWc。原因是某些桶的第一个元素是已知的,但不知道最后一个。因此需要解析节点key的hash函数,在链表遍历时判断节点是否还属于实际的bucket。