目录
在哈希之前的搜索:例如顺序结构以及平衡树中,查找一个元素时,必须要经过关键码的多次比
较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即o(logN),搜索的效率取决于搜索过
程中元素的比较次数。平衡树相对于顺序结构来说效率已经有了很大的提升,但是现在想要一种
更加高效的搜索:可以不经过任何比较,一次直接从表中得到要搜索的元素。这就是哈希,下面
我们来介绍与哈希有关的概念,并且代码实现。
一、哈希的概念
(一)基本概念
1.哈希是一种映射关系:通过一个函数计算出一个元素要存储的位置
2.哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(散列表)
(二)哈希函数
这部分将介绍哈希函数的选择:主要介绍两种最常用的①直接定制法 ②除留余数法
当然,哈希函数不止这两种,还有:平方取中法、折叠法、随机数法、数学分析法等等
1.直接定制法
优点:没有哈希冲突,每个值都映射了一个唯一位置
缺点:如果数据分布不均匀,那么我们还给每个值映射一个位置,可能会存在巨大的空间浪费
2.除留余数法
优点:不再是给每个值映射一个位置,而是在限定大小的空间中将我们的值映射进去。
这种方式避免了出现巨大的空间浪费。除留余数法的哈希函数:index = key % 空间大小。
缺点:不同的值可能会映射到相同位置上去,导致哈希沖突。哈希冲突越多效率就越低。
(三)哈希冲突
这部分将介绍哈希冲突:不同的值映射到相同位置,这就产生了哈希冲突。
解决哈希冲突的方法有很多,两种常见的方法是:闭散列和开散列,我们主要介绍这两种。
1.闭散列(开放定址法)
当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存
放到冲突位置中的“下一个”空位置中去。我们只要能找到“下一个”空位置就可以解决哈希冲突,
所以现在的问题就是去找“下一个”空位置,介绍两种方法:①线性探测 ②二次探测
(1)线性探测
线性探测:挨着往后找,直到找到空位置。
线性探测优点:代码实现非常简单。
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,导致搜索效率
降低。如何缓解呢?这就引出了二次探测:线性探测找空位置的方式就是挨着往后逐个去找,而
二次探测是跳跃着找,因此二次探测为了避免该问题,一定程度上解决了数据“堆积”的问题。
(2)二次探测
二次探测:按i^2跳跃着往后找,直到找到空位置。
使用二次探测的好处就是一定程度上解决了数据“堆积”的问题,但这还不是最好的方案,这里就
又引出了拉链法解决哈希冲突,这是一个更优的方案。
2.开散列(拉链法)
首先利用哈希函数计算数据要存放的地址,具有相同地址的数据归于同一子集合,每一个子集合
称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
引入哈希桶:当我产生哈希冲突了,我自力更生解决,不占用别人的位置,我自己挂起来。
二、代码实现
通过上面的讲解,我相信大家已经对哈希有了正确的认识。现在我们来实现代码,但是我首先要
说明一点:我不会很详细的去讲解这部分代码是如何实现的,大家看代码上的注释一定可以看懂
细节是如何实现的。我把重点放在实现带迭代器的哈希桶上,重点学习他的代码结构。
(一)线性探测
哈希函数选择除留余数法,解决哈希冲突的方法为闭散列中的线性探测。
线性探测法代码的大框架
//开放定制法
//用于标记哈希表每个位置的状态
enum State
{
EMPTY,
EXITS,
DELETE,
};
template<class T>
struct HashData
{
T _data;
State _state;
};
//unordered_set<K> -> HashTable<K, K>
//unordered_map<K, V> -> HashTable<K, pair<K, V>>
template<class K, class T, class KeyOfValue>
class HashTable
{
public:
HashTable()
:_num(0)
{}
bool Insert(const T& data);//插入
HashData<T>* Find(const K& key);//查找
bool Erase(const K& key);//删除
private:
vector<HashData<T>> _table;
size_t _num;//哈希表中存了几个有效数据
};
解读HashTable的模版参数:
①class K:插入元素的first。key模型,就是key的类型;key/value模型,还是key的类型。
②class T:插入的元素。key模型,就是key的类型;key/value模型,就是pair<key, value>类型
③class KeyOfValue:仿函数,作用是取出key。把键值对中的key取出来。
1.插入
bool Insert(const T& data);
bool Insert(const T& data)
{
KeyOfValue kofv;
//负载因子 =表中数据/表的大小 衡量哈希表满的程度
//表越接近满,插入数据越容易冲突,冲突越多,效率越低
//哈希表并不是满了才增容,在开放定址法中,一般负载因子到了0.7左右就开始增容
//负载因子越小,冲突概率越低,整体效率越高,但是负载因子越小,浪费的空问越大,所以负载因子一般取一个折中值
if(_table.size() == 0 || _num * 10 / _table.size() >= 7)
{
//增容
//1.开2倍大小的新表
vector<HashData<T>> newtable;
size_t newsize = (_table.size() == 0) ? 10 : _table.size() * 2;
newtable.resize(newsize);
//2.遍历旧表的数据,重新计算在新表中位置
for(size_t i=0; i<_table.size(); i++)
{
if(_table[i]._state == EXITS)
{
//计算在新表中的位置
size_t newindex = kofv(_table[i]._data) % newtable.size();
//处理哈希冲突
while(newtable[newindex]._state == EXITS)
{
newindex++;
//当下标走到哈希表的结尾,就把下标绕回来
if(newindex == newtable.size())
newindex = 0;
}
//将元素放进新表
newtable[newindex] = _table[i];
}
}
//3.释放旧表:现在已经将把旧表复制到新表中了,所以需要释放旧表
_table.swap(newtable);
}
//计算data中的key在表中映射的位置
size_t index = kofv(data) % _table.size();
//解决哈希冲突:利用线性探测法
while(_table[index]._state == EXITS)
{
//不可以插入重复的元素
if(kofv(_table[index]._data) == kofv(data))
return false;
//线性探测:挨着往后走,所以是++
index++;
//当下标走到哈希表的结尾,就把下标绕回来
if(index == _table.size())
index = 0;
}
//插入数据
_table[index]._data = data;
_table[index]._state = EXITS;
_num++;
return true;
}
插入的实现大体上分成如下四步:
①通过负载因子来判断是否需要对哈希表进行扩容。
②除留余数法计算出映射到的位置。
③解决哈希冲突。
④插入数据。
2.查找
HashData<T>* Find(const K& key);(结合代码中的注释,一定可以看懂的!😁)
HashData<T>* Find(const K& key)
{
KeyOfValue kofv;
//计算data中的key在表中映射的位置
size_t index = key % _table.size();
//如果计算出的位置不是EMPTY
while(_table[index]._state != EMPTY)
{
//如果index的值是要查找的值
if(kofv(_table[index]._data) == key)
{
//如果这个位置是EXITS,就返回这个位置的地址
if(_table[index]._state == EXITS)
return &_table[index];
//如果这个位置是DELETE,直接返回空
else if(_table[index]._state == DELETE)
return nullptr;
}
//如果index的值不是要查找的值,index++
index++;
//当下标走到哈希表的结尾,就把下标绕回来
if(index == _table.size())
index = 0;
}
//如果计算出的位置是EMPTY,直接返回空
return nullptr;
}
3.删除
bool Erase(const K& key);(结合代码中的注释,一定可以看懂的!😁)
bool Erase(const K& key)
{
HashData<T>* ret = Find(key);
//找到了,直接删除
if(ret)
{
ret->_state = DELETE;
_num--;
return true;
}
//没找到,返回false
else
return false;
}
(二)哈希桶
哈希函数选择除留余数法,解决哈希冲突的方法为开散列中的拉链法(哈希桶)。
哈希桶代码的大框架
//哈希桶
//节点定义
template<class T>
struct HashNode
{
HashNode(const T& data)
:_data(data)
,_next(nullptr)
{}
T _data;
HashNode<T>* _next;
};
//前置声明:因为这里需要提前用到哈希表用来实现++找到下一个桶
template<class K, class T, class KeyofT, class Hash>
class HashTable;
//迭代器
//解释为什么要加上class Hash,因为HashFunc需要使用
//HashFunc作用:将不能取模的数据转化成可以可以取模的整数
template<class K, class T, class KeyOfT, class Hash>
struct __HashTableIterator
{
//复杂类型重命名
typedef __HashTableIterator<K, T, KeyOfT, Hash> Self;
typedef HashTable<K, T, KeyOfT, Hash> HT;
typedef HashNode<T> Node;
//成员变量
Node* _node;//指向节点的指针
HT* _pht;//指向哈希表的指针
//成员函数
__HashTableIterator(Node* node, HT* pht)
:_node(node)
,_pht(pht)
{}
T& operator*(){return _node->_data;}
T* operator->(){return &(_node->_data);}
Self& operator++();//后面单独讲解
bool operator!=(const Self& s){return _node != s._node;}
};
//哈希表
template<class K, class T, class KeyofT, class Hash>
class HashTable
{
//声明迭代器是哈希表的友元,在迭代器里面可以访问哈希表的私有成员
friend struct __HashTableIterator<K, T, KeyofT, Hash>;
public:
typedef HashNode<T> Node;
typedef __HashTableIterator<K, T, KeyofT, Hash> iterator;
public:
//构造函数与析构函数
HashTable()
:_num(0)
{}
~HashTable(){Clear();}
void Clear();
//迭代器
iterator begin();
iterator end(){return iterator(nullptr, this);}
//研究表明:哈希表的大小是一个素数的时候,发生哈希冲突的概率会降低
size_t GetNextPrime(size_t prime);
//将key转化成能取模的数
size_t HashFunc(const K& key);
pair<iterator, bool> Insert(const T& data);
Node* Find(const K& key);
bool Erase(const K& key);
private:
vector<Node*> _table;
size_t _num;//记录表中数据个数
};
解读__HashTableIterator/HashTable的模版参数:
①class K:插入元素的first。key模型,就是key的类型;key/value模型,还是key的类型。
②class T:插入的元素。key模型,就是key的类型;key/value模型,就是pair<key, value>类型
③class KeyOfT:仿函数,作用是取出key。把键值对中的key取出来。
④class Hash:仿函数,作用是将不能取模的数据转化成可以可以取模的整数。
1.迭代器中的重载++
Self& operator++()
{
//如果当前的桶还没有走完
if(_node->_next)
_node = _node->_next;
//如果当前的桶已经走完了,就去找到下一个桶继续遍历
else
{
//因为这里是迭代器,没有HashTable,所以我们需要把他传过来
KeyOfT koft;
//计算出当前元素映射到的位置
size_t index = _pht->HashFunc(koft(_node->_data)) % _pht->_table.size();
index++;
for(;index<_pht->_table.size() ;index++)
{
Node* cur = _pht->_table[index];
if(cur)
{
_node = cur;
return *this;
}
}
//如果没找到,就是空
_node = nullptr;
}
return *this;
}
(1)如果当前的桶还没有走完:直接走向下一个
(2)如果当前的桶已经走完了:就去找到下一个桶继续遍历
①++it,当一个桶走完了,迭代器中如何找到下一个桶进行遍历?这是实现迭代器的关键问题!
要想找到下一个桶在哪里,就一定要有哈希表。可是++是要在迭代器中实现的,但是迭代器中没
有哈希表,怎么办?那只能把哈希表传过来了!这就是 HT* _pht 存在的原因!!!
解释迭代器上面的前置声明:因为要提前使用HashTable,所以需要前置声明HashTable。
②++的实现还访问了 _pht 的私有成员变量,怎么办?把迭代器设计成哈希表的友元。
2.哈希表中的构造/析构函数
①构造函数的实现非常简单,只是将计数用的_num设置成0就可以了
②析构函数借助 void Clear(){} 函数来实现,Clear()函数的作用是将挂着的桶全部清除。
void Clear()
{
//清除所有元素
for(size_t i=0; i<_table.size(); i++)
{
Node* cur = _table[i];
while(cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_table[i] = nullptr;
}
}
3.哈希表中迭代器的实现
(1)begin
//迭代器
iterator begin()
{
for(size_t i=0; i<_table.size(); i++)
{
//遍历整个哈希表,找到第一个元素
if(_table[i] != nullptr)
return iterator(_table[i], this);
}
return end();
}
(2)end
直接返回一个nullptr构造的迭代器作为end
iterator end(){return iterator(nullptr, this);}
4.将key转化成能取模的数
在实现线性探测法的哈希表时,我们默认了一件事情:所有存储的数据都是可以直接通过 % 来
取模的(也就是说默认存储的数据全都是整数)。但是实际上,存储的数据一定都是整数吗?
很明显不一定都是整数,比如我们会存储字符串,字符串本身不能直接进行取模,所以就有了
size_t HashFunc(const K& key),用于把不能直接取模的key转换成能取模的整数。
//将key转化成能取模的数
size_t HashFunc(const K& key)
{
//利用Hash这个仿函数
//Hash仿函数将对应的key转成可以取模的整形(因为有些类型不可以直接取模)
Hash hash;
return hash(key);
}
拓展知识:介绍一种常用的 字符串转化成整数的算法(BKDR算法)
//将string转成可以取模的仿函数
struct _HashString
{
size_t operator()(const string& key)
{
//BKDR算法
size_t hash = 0;
for(size_t i=0; i<key.size(); i++)
{
hash *= 131;//乘上131后效果最好
hash += key[i];
}
return hash;
}
};
5.插入
(1)前置函数:size_t GetNextPrime(size_t prime);
作用:在插入之前,首先需要判断哈希表是否需要扩容。在上面线性探测实现的时候,我们选择
的是直接扩大二倍,但是这样是最好的选择吗?答:不是!!!研究表明:哈希表的大小是一个
素数的时候,发生哈希冲突的概率会降低。所以就有了GetNextPrime()函数来控制哈希表大小。
//研究表明:哈希表的大小是一个素数的时候,发生哈希冲突的概率会降低
size_t GetNextPrime(size_t prime)
{
const int PRIMECOUNT = 28;
//数据来源:STL源码
static const size_t primeList[PRIMECOUNT] ={
53ul, 97ul, 193ul, 389ul, 769ul,
1543ul, 3079ul, 6151ul, 12289ul, 24593ul,
49157ul, 98317ul, 196613ul, 393241ul, 786433ul,
1572869ul, 3145739ul, 6291469ul, 12582917ul,
25165843ul,50331653ul, 100663319ul, 201326611ul, 402653189ul,
805306457ul,1610612741ul, 3221225473ul, 4294967291ul};
for(size_t i=0; i<PRIMECOUNT; i++)
{
if(primeList[i] > prime)
return primeList[i];
}
return primeList[PRIMECOUNT - 1];
}
(2)插入函数:pair<iterator, bool> Insert(const T& data);
pair<iterator, bool> Insert(const T& data)
{
KeyofT koft;
//如果负载因子等于1,则增容,避免大量的哈希沖突
if(_table.size() == _num)
{
//1.开2倍空间
vector<Node*> newtable;
//size_t newsize = (_table.size() == 0) ? 10 : _table.size() * 2;
size_t newsize = GetNextPrime(_table.size());
newtable.resize(newsize);
//2.把旧表数据放进新表
for(size_t i=0; i<_table.size(); i++)
{
Node* cur = _table[i];//指向第i个桶的开始
//遍历第i个桶
while(cur)
{
Node* next = cur->_next;//指向cur的下一个
//计算在新表中的位置
size_t newindex = HashFunc(koft(cur->_data)) % newtable.size();
//把cur取下来头插到新表中
// newtable[newindex](新表头) node node
//cur
cur->_next = newtable[newindex];
newtable[newindex] = cur;
cur = next;
}
_table[i] = nullptr;
}
_table.swap(newtable);
}
//计算data中的key在表中映射的位置
size_t index = HashFunc(koft(data)) % _table.size();
//1.先查找这个值在不在表中
Node* cur = _table[index];
while(cur)
{
//遇到重复的不插入
if(HashFunc(koft(data)) == HashFunc(koft(cur->_data)))
return make_pair(iterator(cur, this), false);
else
cur = cur->_next;
}
//2.把数据挂到链表上(选择头插)
Node* newnode = new Node(data);
newnode->_next = _table[index];
_table[index] = newnode;
_num++;
return make_pair(iterator(newnode, this), true);
}
插入的实现大体上分成如下三步:
①通过负载因子来判断是否需要对哈希表进行扩容。
②除留余数法计算出映射到的位置。
③插入(先判断是否有重复,再利用头插法把新数据插入)。
6.查找
Node* Find(const K& key);(结合代码中的注释,一定可以看懂的!😁)
Node* Find(const K& key)
{
KeyofT koft;
size_t index = HashFunc(key) % _table.size();
Node* cur = _table[index];//指向桶的第一个节点
while(cur)
{
if(HashFunc(koft(cur->_data)) == key)
return cur;
else
cur = cur->_next;
}
//走到这里就是把整个桶都走了一遍,还没找到
return nullptr;
}
7.删除
bool Erase(const K& key);(结合代码中的注释,一定可以看懂的!😁)
bool Erase(const K& key)
{
KeyofT koft;
size_t index = HashFunc(key) % _table.size();
Node* cur = _table[index];//指向桶的第一个节点
Node* prev = nullptr;//记录cur的前一个节点
while(cur)
{
if(HashFunc(koft(cur->_data)) == HashFunc(key))
{
//prev cur(删) cur->next
//连接
if(cur == _table[index])
_table[index] = cur->next;//头删
else
prev->_next = cur->_next;//非头删
delete cur;
_num--;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
//走到这里就是把整个桶都走了一遍,还没找到
return false;
}
8.总结
上面介绍了哈希桶的实现,其中涉及到了复杂的模版参数。最后结合实际使用场景梳理一下各个
模版参数之间的关系,以及如何使用仿函数。(用图表示)