写 leetcode 时发现很多时候高效的答案会用到 unordered_map 和 unordered_set. 从使用角度看,unordered 的 stl 容器与正常的 map,set 相比,在插入和查询的效率上更高,《boost::unordered_map 和 std::map 的对比》中用 5000 万条数据放入两个容器中进行了对比。这是因为实现上,unordered 的 stl 容器使用的是 hashtable, 而 map,set 使用的是红黑树。当然 map,set 插入后天然有序,需要按 key 排序时会比较方便.

正好面试要求让我写 hashtable 的实现,今天就研究一下 c++ 中 unordered_map 的实现,主要参照的是 linux 下的源码:/usr/include/c++/4.8.2/profile/unordered_map 和 hashtable (hashtable 我在我的 c++ 源码里没找到,可能在编译好的库中了,所以找的网上的代码).

# 1. 源代码

# i. 模板定义

以下是 map 和 unordered_map 的模板定义:

template<typename _Key, typename _Tp, typename _Compare = std::less<_Key>,
	 typename _Allocator = std::allocator<std::pair<const _Key, _Tp> > >
class map
template<typename _Key, typename _Tp,
	 typename _Hash = std::hash<_Key>,
	 typename _Pred = std::equal_to<_Key>,
	 typename _Alloc = std::allocator<std::pair<const _Key, _Tp> > >
class unordered_map

可以看到 map 需要 key 实现比较函数,默认使用 std 的 less 函数,所以可以通过重载 operator<() 实现,unordered_map 需要 key 实现 hash 函数和 equal_to 函数,hash 可以注入 std,equal_to 可以重载 operator==(). 从这个模板的定义也可以大概了解实现的数据结构.

# ii.operator[]

template<typename K, typename Pair, typename Hashtable>
    typename map_base<K, Pair, extract1st<Pair>, true, Hashtable>::mapped_type&
    map_base<K, Pair, extract1st<Pair>, true, Hashtable>::
    operator[](const K& k)
    {
      Hashtable* h = static_cast<Hashtable*>(this);
      typename Hashtable::hash_code_t code = h->m_hash_code(k);
      std::size_t n = h->bucket_index(k, code, h->bucket_count());
 
      typename Hashtable::node* p = h->m_find_node(h->m_buckets[n], k, code);
      if (!p)
	return h->m_insert_bucket(std::make_pair(k, mapped_type()),
				  n, code)->second;
      return (p->m_v).second;
    }

可以看到主要还是依靠 Hashtable 实现的.

步骤是:

  1. 计算 key 的 hash_code

  2. 找到对应的 bucket

  3. 在 bucket 中寻找 key 值对应的 node

  4. 若存在对应 node, 则返回 node 对应值,若不存在,则插入一个新节点,返回新节点的值 (对应类型默认值)

# iii.hash

hashtable 一个很重要的影响性能的因素是冲突率,即一个桶中的元素个数。而决定冲突率的则是 hash 函数和桶的个数,所以 hash 函数的定义和 rehash 的时机格外重要.

inline std::pair<bool, std::size_t>
  prime_rehash_policy::
  need_rehash(std::size_t n_bkt, std::size_t n_elt, std::size_t n_ins) const
  {
    if (n_elt + n_ins > m_next_resize)
      {
	float min_bkts = (float(n_ins) + float(n_elt)) / m_max_load_factor;
	if (min_bkts > n_bkt)
	  {
	    min_bkts = std::max(min_bkts, m_growth_factor * n_bkt);
	    const unsigned long* const last = X<>::primes + X<>::n_primes;
	    const unsigned long* p = std::lower_bound(X<>::primes, last,
						      min_bkts, lt());
	    m_next_resize =
	      static_cast<std::size_t>(std::ceil(*p * m_max_load_factor));
	    return std::make_pair(true, *p);
	  }
	else
	  {
	    m_next_resize =
	      static_cast<std::size_t>(std::ceil(n_bkt * m_max_load_factor));
	    return std::make_pair(false, 0);
	  }
      }
    else
      return std::make_pair(false, 0);
  }

以上是 need_rehash 函数,判断是否需要 rehash 的主要是依靠 m_max_load_factor, 这是元素个数和桶的个数的比例,默认为 1.0, 若桶数目过少 hashtable 便会进行一次 rehash.

template<typename K, typename V,
	   typename A, typename Ex, typename Eq,
	   typename H1, typename H2, typename H, typename RP,
	   bool c, bool ci, bool u>
    void
    hashtable<K, V, A, Ex, Eq, H1, H2, H, RP, c, ci, u>::
    m_rehash(size_type n)
    {
      node** new_array = m_allocate_buckets(n);
      try
	{
	  for (size_type i = 0; i < m_bucket_count; ++i)
	    while (node* p = m_buckets[i])
	      {
		size_type new_index = this->bucket_index(p, n);
		m_buckets[i] = p->m_next;
		p->m_next = new_array[new_index];
		new_array[new_index] = p;
	      }
	  m_deallocate_buckets(m_buckets, m_bucket_count);
	  m_bucket_count = n;
	  m_buckets = new_array;
	}
      catch(...)
	{
	  // A failure here means that a hash function threw an exception.
	  // We can't restore the previous state without calling the hash
	  // function again, so the only sensible recovery is to delete
	  // everything.
	  m_deallocate_nodes(new_array, n);
	  m_deallocate_buckets(new_array, n);
	  m_deallocate_nodes(m_buckets, m_bucket_count);
	  m_element_count = 0;
	  __throw_exception_again;
	}
    }

接下来是 rehash 函数,rehash 其实就是新开辟一块空间,重进计算 hash 将元素放入,然后释放原来的空间,很好理解.

template<>
    struct Fnv_hash<8>
    {
      static std::size_t
      hash(const char* first, std::size_t length)
      {
	std::size_t result = static_cast<std::size_t>(14695981039346656037ULL);
	for (; length > 0; --length)
	  {
	    result ^= (std::size_t)*first++;
	    result *= 1099511628211ULL;
	  }
	return result;
      }
    };

最后是 hash 函数,这里的 hash 函数是 std::string 的.

# 2. 总结

c++ 底层实现的 hashtable 和基础的数据结构还是很一致的,但细节上很多是经过考量的,比如 hash 函数,有很多版本。网上看到:

FNV 有分版本,例如 FNV-1 和 FNV-1a,区别其实就是先异或再乘,或者先乘在异或,这里用的是 FNV-1a,为什么呢,维基里面说,The small change in order leads to much better avalanche characteristics,什么叫 avalanche characteristics 呢,这个是个密码学术语,叫雪崩效应,意思是说输入的一个非常微小的改动,也会使最终的 hash 结果发生非常巨大的变化,这样的哈希效果被认为是更好的。

所以光知道数据结构,在实现时不注重细节处理也很容易造成很多问题.

更新于 阅读次数

请我喝[茶]~( ̄▽ ̄)~*

Nirvana 支付宝

支付宝