哈希概述

哈希(hash)又称散列,其基本想法是,将存储的值与其存储位置建立某种映射,因此哈希的查找效率非常高,是一种支持常数平均时间查找的结构。与红黑树相比,哈希的效率表现是以统计为基础的,不需要依赖输入数据的随机性。

本文以C++泛型技法对哈希表进行实现,文章结构如下:

关联式数据结构_哈希表剖析 #C++_哈希碰撞

建立值-址映射

建立哈希结构的第一步是将“值”(数据)与“址”(存储位置,即下标)联系起来,便于利用“值”快速找到对应的元素。直接定址除留余数是两种常用的映射方式,为了按索引快速访问,这两种方法的底层存储都使用一个数组。

直接定址法

直接定址法适用于数据的分布范围集中时的情况。映射时,直接以数据的值作为 key,映射到数组中对应的位置。

关联式数据结构_哈希表剖析 #C++_哈希碰撞_02

这种方式的缺点显而易见:

  • 当数据之间的差值较大时,会有大量的空余空间,存在大量空间浪费现象,相对映射虽然能够缓解数据基数较大时产生的空间浪费,但对此处的问题也无能为力。
  • 当数据较大时(例如INT_MAX级别的数据),这种方式就需要申请数GB的内存空间,这种开销是不切实际的。

除留余数法

除留余数法适用于数据的分布范围较为分散的情况。对于数据 x ,假设数组大小为 tableSize,先进行x % tableSize得到一个范围在[0, tableSize)的数 index,将 index 作为 x 的索引。

关联式数据结构_哈希表剖析 #C++_开散列_03

除留余数法可以避免使用一个太大的数组,但是会带来一个问题:正如上图所示,可能会有不同的元素被映射到相同的位置,即拥有相同的索引。这种现象被称为哈希碰撞(hash collision)

哈希碰撞

哈希碰撞时无法避免的,因为元素数量往往会大于数组容量。解决哈希碰撞有很多种方式,例如线性探测(linear probing)二次探测(quadratic probing)和开链(separate chaining)等。使用以上方式解决哈希碰撞导出的效率与数组的填满程度有很大的关联。

负载因子(loading factor)描述了数组的填满程度,负载因子 = 有效数据个数 / 数组容量。负载因子越高,数组的填满程度越高,发生哈希碰撞的概率越高,空间利用率越高,反之同理。为了保证哈希表的效率,同时不至于浪费太多的内存空间,往往会将负载因子控制在一定范围内,这一点会在下文体现。

下文会对线性探测、二次探测做一概览性介绍,主要针对开链法做详细分析。

哈希函数

在详细介绍解决哈希碰撞的方式之前,我们需要意识到一个亟待解决的问题:当数据类型为正整数时,我们可以轻松通过上述方式进行直接定址或者取模定址,但当数据类型为其他类型,例如字符串类型时,将无法拿来作为数组的索引。解决方法是,将其他类型的数据通过一个函数映射为正整数类型,即对于非正整数类型,需要做两层映射

关联式数据结构_哈希表剖析 #C++_开散列_04

将数据转化并映射为一个大小可接受的索引,完成这样的工作的函数被称为哈希函数(hash function)。为了保证输出索引的合法性,同时尽量减小第一层映射后的冲突概率,哈希函数的设计有下面几个原则:

  • 哈希函数应尽可能简单;
  • 哈希函数的值域必须在哈希表格的范围之内;
  • 哈希函数的值域应尽可能均匀分布,即取每个位置应该是等概率的。

为了增加灵活性,一般将哈希函数设计为一个仿函数,便于用户可以在某些情况下自定义哈希函数。下面实现的分别是针对整型、浮点型的,以及针对字符串类型的两个哈希函数对象。

template<class Key>
struct HashFuncDefault
{
  //可以针对正负整型、浮点型
  size_t operator()(const Key& key)
  {
    //为了方便,不再在哈希函数内部进行取模运算
    return (size_t)key;
  }
};

template<>
struct HashFuncDefault<std::string>
{
//针对字符串类型
size_t operator()(const string& str)
{
		size_t hash = 0;
    //使用BKDR算法,尽量减小转化后的冲突概率
  	for (auto& ch : str) {
    	hash = hash * 131 + ch;
    }
    return hash;
}

使用开放定址法的闭散列结构

线性探测和二次探测

当计算出某个元素的插入位置,而该位置上的空间已不再可用时,线性探测的策略是循序往下一一寻找,如果到达尾端就绕道头部继续寻找,直到找到一个可用空间(空位置)为止。

关联式数据结构_哈希表剖析 #C++_开散列_05

通过这种方式,只要哈希表格够大,就一定能够找到一个安放位置,但是要花多少时间就与哈希表格中的数据分布情况有关了。

进行搜索操作时,原理与插入类似,如果计算出的索引位置上的数据与目标不符,就循序向下寻找,直到找到吻合的数据,或者找到空位置结束。进行删除操作时,因为在闭散列中,哈希表中的每一个元素不仅表述它自己,也关系到其它元素的排列,所以只对删除目标标记删除符号,而不是真正删除,即进行惰性删除(lazy deletion)

关联式数据结构_哈希表剖析 #C++_哈希函数_06

线性探测看似非常好地解决了哈希碰撞,但是通过下图就能很容易发现问题:

关联式数据结构_哈希表剖析 #C++_哈希函数_07

接续上图所示的状态,0、1、2、3 索引位置都已经被占用,无论新插入元素的位置是 0、1、2 还是 3,都会继续向后探测至 4 索引位置并插入,除非新元素经过计算后的索引恰好落在 4~7,否则位置 4~7 就不会被直接使用。这清楚地凸显了一个问题:平均插入成本的增长幅度远高于负载因子的成长,这样的现象被称为主集团(primary clustering)。此时数组中有的是一大团紧凑的、被占用过的位置,新插入元素很可能在主集群中不断解决碰撞问题,最后又增加了主集群的面积。

二次探测(quadratic probing)可以用来解决主集群问题。在线性探测中,如果遇到冲突位置,就向后走一步进行检查,而在二次探测中,如果遇到冲突位置,向后走的步长为 i2 。如果计算出的索引为 h,而对应位置已被占用,那么就尝试位置 h + 12, h + 22, h + 32, …, h + i2,而不是像线性探测那样尝试 h + i 。

关联式数据结构_哈希表剖析 #C++_开散列_08

二次探测可以解决主集团问题,缓解数据的拥堵踩踏,却有可能造成次集团(secondary clustering)。消除次集团的方法及后续问题在这里不做探讨。

闭散列的元素定义

对于闭散列,本文只做简单实现,不对迭代器和封装进行讨论。闭散列的元素定义中包含两部分:有效数据和状态信息,其中有效数据以 Key-Value 模型表现,每个数据位置有三个状态:存在(EXIST)、空(EMPTY)和删除(DELETE),其中空状态和删除状态都属于空闲状态,初始时数据位置为空状态。

//位置状态
enum STATU
{
  EXIST,
  EMPTY,
  DELETE
};

template<class Key, class Value>
struct HashData
{
  pair<Key, Value> _data;
  STATU _statu;

  HashData()
    :_statu(EMPTY)
    { }
};

闭散列的完整代码

下满是闭散列的代码,当负载因子大于0.75时,要对表格进行扩容,并重新计算元素的索引位置重新插入。将负载因子控制在 0.75 ,可以同时兼顾查找插入效率和空间使用状况。

enum STATU
{
  EXIST,
  EMPTY,
  DELETE
};
//哈希函数#1
template<class Key>
struct HashFuncDefault
{
  size_t operator()(const Key& key)
  {
    return (size_t)key;
  }
};
//哈希函数#2,针对字符串类型
template<>
struct HashFuncDefault<std::string>
{
	size_t operator()(const string& str)
	{
    size_t hashNum = 0;
    for (const auto& e : str) {
      hashNum = hashNum * 113 + e;
  	}
  	return hashNum;
  }
};
//元素定义
template<class Key, class Value>
struct HashData
{
  pair<Key, Value> _data;
  STATU _statu;

  HashData()
    :_statu(EMPTY)
    { }
};

template<class Key, class Value, class HashFunc = HashFuncDefault<Key>>
class hash_table
{
private:
  typedef HashData<Key, Value> dataType;

public:
  //初始时为_table开10个空间
  hash_table()
    :_size(0)
    {
      _table.resize(10);
    }

  bool insert(const pair<Key, Value>& data)
  {
    //判断负载因子,决定是否扩容
    if ((double)_size / _table.size() > 0.75)
    {
      //扩容
      size_t newSize = _table.size() * 2;
      //直接开一个临时哈希表,复用insert对元素索引进行重新计算,重新插入元素
      hash_table* newHash = new hash_table;
      newHash->_table.resize(newSize);
      //重新计算元素的哈希索引并放入新表
      for (int i = 0; i < _table.size(); ++i)
      {
        if (_table[i]._statu == EXIST) {
          newHash->insert(_table[i]._data);
        }
      }
      //将新表交换给_table
      _table.swap(newHash->_table);
    }

    //计算哈希索引
    HashFunc hf;
    int hashi = hf(data.first) % _table.size();

    //线性探测插入
    //找到一个空闲位置(没有有效数据)插入
    while (_table[hashi]._statu == EXIST)
    {
      //对应的Key已经存在
      if (_table[hashi]._data.first == data.first) {
        return false;
      }
      hashi %= _table.size();
    }
    _table[hashi]._data = data;
    _table[hashi]._statu = EXIST; //修改数据位置的状态
    ++_size; //修改有效数据个数
    return true;
  }

  bool erase(const Key& key)
  {
    HashFunc hf;
    int hashi = hf(key) % _table.size();
    //直到找到空位置结束寻找
    while (_table[hashi]._statu != EMPTY)
    {
      //找到键值为key的有效数据,进行删除
      if (_table[hashi]._statu != DELETE &&
          _table[hashi]._data.first == key)
      {
        _table[hashi]._statu = DELETE; //修改状态和_size
        --_size;
        return true;
      }
      ++hashi;
      hashi %= _table.size();
    }
    return false;
  }

  pair<Key, Value>* find(const Key& key)
  {
    HashFunc hf;
    int hashi = hf(key) % _table.size();
    //直到找到空位置结束寻找
    while (_table[hashi]._statu != EMPTY)
    {
      if (_table[hashi]._statu != DELETE &&
          _table[hashi]._data.first == key)
      {
        return &(_table[hashi]._data);
      }
      ++hashi;
      hashi %= _table.size();
    }
    return nullptr;
  }

private:
  vector<dataType> _table; //使用vector作为底层存储容器
  size_t _size; //记录有效数据个数
};

使用开链法的开散列结构

与开放定址法不同的是,当遇到哈希冲突后,开链法不向后寻找并占用表中的其他位置,而是开出一个单向链表,将数据链到对应位置的“下面”。这种方式类似于将数据挂到一个数组上,被挂起来的单链表被称为哈希桶(hash bucket),在同一个桶中,每个节点数据的索引相同。

关联式数据结构_哈希表剖析 #C++_开散列_09

在开链法中,数组中的每个位置维护一个单链表,当进行插入时,不管对应位置是否被占用,直接将新数据的节点头插入桶中;查找时,先找到索引位置,再在桶中依次寻找指定值;删除时,直接找到元素位置删除对应节点即可。

在开散列中,负载因子可能会大于 1。虽然在哈希桶中的操作只能是一种线性操作,但如果 list 够短,速度还是够快,为了保证 list 够短,需要将负载因子控制在 1 以内,即保持平均每一个桶中只挂一个数据。

哈希桶的节点定义

哈希桶的节点与单链表的节点相同,包含一个后继指针和一个有效数据。使用单链表而非双链表,是因为单链表完全可以满足这里的需求,并且尽可能减少了哈希桶的开销。

template<class T>
struct HashNode
{
private:
  typedef HashNode<T> Node;

public:
  T _data; //有效数据
  Node* _next; //后继指针
	//节点的构造函数
  HashNode(const T& data)
    :_data(data),
  _next(nullptr)
  { }
};

开散列中对哈希的定义与闭散列类似,不同的是数组存的是节点指针,并且在开散列的析构中,需要逐个遍历哈希桶完成资源的释放。

/*
这里有4个模板参数,第4个模板参数为哈希仿函数,缺省值为默认哈希函数
与红黑树类似,T为哈希表真正存储的有效数据类型,当T为pair<>时,Key和KeyOfT是为了
便于拿到pair<>中的第一个成员(Ke)
*/
template<class Key, class T, class KeyOfT, class HashFunc = HashFuncDefault<Key>>
class hash_table
{
private:
  typedef HashNode<T> Node;

public:
  //初始化时给数组10个空间
  hash_table()
    :_size(0)
  {
      _table.resize(10);
  }
  
  //析构
  ~hash_table()
  {
    //逐个释放哈希桶
    for (int i = 0; i < _table.size(); ++i)
    {
      Node* cur = _table[i];
      while (cur)
      {
        Node* nextNode = cur->_next;
        delete cur;
        cur = nextNode;
      }
      _table[i] = nullptr;
    }
  }

private:
  vector<Node*> _table;
  size_t _size; //有效数据个数
};

迭代器

哈希表的迭代器是对桶节点指针的封装,除此之外,哈希表的迭代器必须一直维系着与 buctets vector 的联系,因为在进行前进操作时,如果当前节点是哈希桶的最后一个位置,则需要向后寻找下一个桶

/*
此处HashNode和hash_table的声明是为了解决__hash_table_iterator类与
hash_table类互相依赖的问题,__hash_table_iterator的成员包含了hash_table,
且hash_table中使用了__hash_table_iterator
*/
template<class T>
struct HashNode;

template<class Key, class T, class KeyOfT, class HashFunc = HashFuncDefault<Key>>
class hash_table;

template<class Key, class T, class Ref, class Ptr, class KeyOfT, class HashFunc>
struct __hash_table_iterator
{
  private:
  typedef HashNode<T> Node;
  typedef __hash_table_iterator<Key, T, T&, T*, KeyOfT, HashFunc> iterator;
  typedef __hash_table_iterator<Key, T, Ref, Ptr, KeyOfT, HashFunc> Self;
  typedef shr::HashBucket::hash_table<Key, T, KeyOfT, HashFunc> HashTable;

  public:
  Node* _node; //桶节点指针
  //hash_table的迭代器必须维系着与buctets vector的联系,以便于寻找下一个桶
  const HashTable* _hash_table; 

  __hash_table_iterator(Node* node, const HashTable* hash_table)
    :_node(node), 
  _hash_table(hash_table)
  { }

  //对于iterator,这个函数是拷贝构造
  //对于const_iterator,这个函数是构造
  //使用iterator构造const_iterator
  __hash_table_iterator(const iterator& it)
    :_node(it._node),
  _hash_table(it._hash_table)
  { }

  Ref operator*()
  {
    return _node->_data;
  }

  Ptr operator->()
  {
    return &(_node->_data);
  }

  Self& operator++()
  {
    KeyOfT kot;
    HashFunc hf;
    //下一个节点不为空,直接向后走一步即可
    if (_node->_next) {
      _node = _node->_next;
    }
    else //下一个节点为空
    {
      //寻找下一个不为空的哈希桶
      int hashi = hf(kot(_node->_data)) % _hash_table->_table.size();
      for (int i = hashi + 1; i < _hash_table->_table.size(); ++i)
      {
        if ((_hash_table->_table)[i])
        {
          _node = (_hash_table->_table)[i];
          return *this;
        }
      }
      _node = nullptr;//未找到有数据的哈希桶,赋值为空
    }
    return *this;
  }

  Self operator++(int)
  {
    Self tmpIt = *this;
    operator++();
    return tmpIt;
  }

  bool operator==(const Self& it)
  {
    return _node == it._node;
  }

  bool operator!=(const Self& it)
  {
    return _node != it._node;
  }
};

template<class Key, class T, class KeyOfT, class HashFunc>
class hash_table
{
  //因为在迭代器中要访问hash_table的私有成员vector<Node*>,
  //所以将迭代器类型声明为hash_table的友元
  template<class Key, class T, class Ref, class Ptr, class KeyOfT, class HashFunc>
  friend struct __hash_table_iterator;

  public:
  typedef __hash_table_iterator<Key, T, T&, T*, KeyOfT, HashFunc> iterator;
  typedef __hash_table_iterator<Key, T, const T&, const T*, KeyOfT, HashFunc> const_iterator;
  
  /*…………*/
  
  iterator begin()
  {
    //找到第一个不为空的哈希桶的头结点
    for (int i = 0; i < _size; ++i)
    {
      if (_table[i]) {
        return iterator(_table[i], this);
      }
    }
    return iterator(nullptr, this);
  }

  iterator end()
  {
    return iterator(nullptr, this);
  }

  const_iterator begin() const
  {
    //找到第一个不为空的哈希桶的头结点
    for (int i = 0; i < _size; ++i)
    {
      if (_table[i]) {
        return iterator(_table[i], this);
      }
    }
    return const_iterator(nullptr, this);
  }

  const_iterator end() const
  {
    return const_iterator(nullptr, this);
  }
  
  /*…………*/

在开散列的迭代器中,要额外定义一个构造函数,如上面代码 23 行所示。这是因为在以 hash table 对 unordered_set 进行封装时,某些接口的返回值会发生迭代器类型不兼容的情况,此时需要主动进行构造以解决问题,详细会在对 unordered 系列容器的封装中解释。

元素操作

对哈希表中的数据的操作,其实是对哈希桶中数据的操作,本质是对单向链表的操作。

以键值 key 查找元素时,首先用哈希函数计算出元素索引,再在对应的哈希桶中寻找。

iterator find(const Key& key) const
{
  HashFunc hf;
  KeyOfT kot;
  int hashi = hf(key) % _table.size(); //找到目标节点的索引
  Node* cur = _table[hashi];
  //遍历哈希桶寻找
  while (cur)
  {
    if (kot(cur->_data) == key) {
      return iterator(cur, this);
    }
    cur = cur->_next;
  }
  return iterator(nullptr, this); //未找到,返回nullptr作为end迭代器
}

插入新元素时,先根据新元素的 key 计算出哈希索引,再创建新节点头插到哈希桶中。为了保证效率,当负载因子为 1 时对 buckets vector 进行扩容,即保持平均每个桶中仅有一个元素。扩容后重新整理旧元素的位置时,不建议像闭散列那样直接复用 insert 接口,因为旧节点的空间已经存在,没有必要再申请空间创建新节点、销毁旧节点,而是直接将旧节点重新链入新表即可。

pair<iterator, bool> insert(const T& data)
{
  HashFunc hf;
  KeyOfT kot;

  iterator retFind = find(kot(data));
  if (retFind != end()) {
    return make_pair(retFind, false);
  }

  //当负载因子为 1 时进行扩容以保证查找效率
  if (_size == _table.size())
  {
    //扩容
    size_t newSize = _table.size() * 2;
    vector<Node*> newTable; //创建一个新的临时哈希表
    newTable.resize(newSize);
    //将旧节点链入新表
    for (int i = 0; i < _table.size(); ++i)
    {
      Node* cur = _table[i];
      while (cur)
      {
        Node* nextNode = cur->_next;
        //计算新的索引
        int hashi = hf(kot(cur->_data)) % newSize;
        //将节点重新插入新表中
        cur->_next = newTable[hashi];
        newTable[hashi] = cur;
        cur = nextNode; //继续向后遍历
      }
    }
    //将新表交换给hash_table
    _table.swap(newTable);
  }
    
    //找到索引位置
    size_t hashi = hf(kot(data)) % _table.size();
    //将新节点头插入哈希桶中
    Node* newNode = new Node(data);
    newNode->_next = _table[hashi];
    _table[hashi] = newNode;
    ++_size; //更新有效数据个数
  
    return make_pair(iterator(newNode, this), true);
}

删除元素时,先计算出目标元素的哈希索引,再在对应的桶中寻找目标,进行单向链表节点的删除即可。

iterator erase(const Key& key)
{
  HashFunc hf;
  KeyOfT kot;
  int hashi = hf(kot(key)) % _table.size(); //找到目标节点的索引
  Node* cur = _table[hashi];
  Node* preNode = nullptr; //记录前驱节点,便于删除
  //遍历哈希桶找到目标值
  while (cur)
  {
    if (kot(cur->_data) == key)
    {
      Node* nextNode = cur->_next;
      //删除哈希桶的头结点
      if (preNode == nullptr) {
        _table[hashi] = nextNode;
      }
      else { //删除单链表的中间节点
        preNode->_next = nextNode;
      }
      iterator retIt = ++iterator(cur, this);
      delete cur;
      --_size;
      return retIt;
    }
    preNode = cur;
    cur = cur->_next; //向后遍历
  }
  return iterator(nullptr, this);
}