哈希概述

哈希(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 = (hashi + 1) % _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 + 1) % _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 + 1) % _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 const_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);
}