【问题标题】:unordered_map excess calls to hash functionunordered_map 对哈希函数的过度调用
【发布时间】:2020-11-12 10:06:29
【问题描述】:

以下代码导致对哈希函数的不明调用:

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 构造函数参数的情况下尝试相同的方法,由于重新散列,对散列函数的调用变得更加过度。但是在上面的场景中,似乎没有rehash,也没有碰撞。

2。相关,但特别是在 MSVC 上:Inserting to std::unordered_map calls hash function twice in MSVC++'s STL, bad design or special reason?

【问题讨论】:

  • 你是在release还是-O2编译的
  • @Surt 具有上述行为的 -O2 和 -O3 结果
  • 仅供参考:std::endl 只是"\n" 的(有时)较慢的版本。它在输出"\n" 之后执行flush,这通常不是必需的。
  • @HTNW 在尝试分析事件序列的代码中,flush 可能是必要的,尤其是在某些事情失败并且程序终止的情况下。
  • 请注意,只有在使用 libstdc++ 时,Clang 的行为才相同。使用 libc++ 就没有这样的问题:godbolt.org/z/z56EeE.

标签: c++ unordered-map


【解决方案1】:

我无法解释为什么要这样做,但它不适合评论,所以我把它留在答案部分。插入元素后,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-&gt;_M_next() 的哈希值,__node 指的是为__k 创建的节点。

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

【讨论】:

    【解决方案2】:

    也许是std::unordered_map的实现。 hash_value 不会存储到每个节点中。因此,它会在插入新元素或计算桶大小时重新哈希下一个桶中的第一个元素。

    您可以尝试使用&lt;tr1/unordered_map&gt; 来避免此问题。示例:

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

    注意:我不知道tr1/unordered_map 还是unordered_map 更好。

    【讨论】:

      【解决方案3】:

      首先,有几点观察:

      • 无序映射既是一个哈希表,也是一个单链表。

        看到herebegin 返回一个iterator,它模拟LegacyForwardIterator

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

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

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

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

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

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

        这意味着一个bucket所指向的“sentinel”(one-before-the-beginning)节点实际上是前一个bucket的last节点……除了位于列表的最前面,当它确实是整个列表的哨兵时。

        code里的cmets说

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

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

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

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

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

      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不缓存密钥,所以没有n-&gt;next-&gt;key。实际上有一个 trait 控制了这一点,但在这种情况下显然是错误的,这意味着最后一行必须重新计算哈希才能更新下一个桶。


      注意。澄清一下,当我说 previous bucketnext bucket 时,我只是在谈论列表中的位置,其中存储桶的出现顺序与它们成为非空的。它与表格中的位置没有任何关系,也没有暗示任何内在顺序。

      【讨论】:

      • 我认为每个桶没有一个哨兵节点。 每个哈希表只有一个,即_M_before_begin
      • 是的,没错。我以为我是这么说的,但是试图将设计讨论融入答案是很棘手的,我可能会站出来澄清它。
      【解决方案4】:

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

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

      假设我们有一个哈希表,其中包含属于 bucket[0] 的键 AB 以及属于 bucket[1] 的键 C。例如,它可能如下所示:

      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。原因是某个桶的第一个元素是已知的,但不是最后一个。因此,在链表遍历过程中,需要对节点键的哈希函数进行解析,以确定节点是否仍然属于实际的桶。

      【讨论】:

      • ASCII 艺术值一千字!
      • ¯\___(ツ)___/¯
      猜你喜欢
      • 2011-11-05
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2016-06-17
      • 2013-03-13
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多