哈希表实现
- 一,哈希概念
- 哈希概念
- 常见哈希函数
- 哈希冲突
- 哈希冲突的解决
- 二,闭散列实现
- 闭散列的结构
- 插入
- 查找
- 删除
- 闭散列总结
- 三,哈希桶实现
- 哈希桶的结构
- 插入
- 查找
- 删除
- 析构
- 拷贝构造
- 赋值运算符重载
- 四,哈希表总结
- 开散列与闭散列的比较
- 哈希表的增删查改时间复杂度
一,哈希概念
哈希概念
🚀理想的搜索方法:不经过任何的比较,一次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数使元素的存储位置与其关键码之间能够建立起一一映射的关系,那么在查找的时候就能通过此函数快速的找到该元素。
🚀向该结构中插入元素:根据该元素的关键码,以及哈希函数,计算出该元素的存储位置并按该存储位置存放即可。
🚀在该结构中查询元素:根据元素的关键码以及哈希函数计算出该元素的存储位置,然后将元素的值与该存储位置的值进行比较,如果相等则查询成功。
🚀上面的方式就是一种哈希方法,哈希方法中使用的函数叫做哈希函数,构造出来的结构叫做哈希表。
🚀哈希本质就是一种映射,建立元素与存储位置的映射关系。
常见哈希函数
1,直接定址法
取关键字的某个线性函数作为哈希地址:Hash(key) = A * key + B;
其优点就是简单均匀,缺点是要实现知道数据的分布。
使用场景就是数据连续紧凑的情况。
2,除留余数法
设哈希表的长度为m,通常m素数,因为数据模一个素数后得到的余数更加均匀。关键字key模m得到的余数就是关键字对应的哈希地址。Hash(key) = key % m;
除留余数法是最常用的哈希函数。
3,平方取中法
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;
再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址。
平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况
4, 折叠法
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况。
5,随机数法
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) =random(key),其中random为随机数函数。
通常应用于关键字长度不等时采用此法。
6,数学分析法
数学分析法是比较灵活多变的,例如使用手机号作为关键字对其散列,尝试告诉我们电话号码的前几位的相似度是很高的,所以我们可以取后四位,但是后四位依然可能出现冲突,所以可以对后四位进行反转(3276->6723),右环移位(3276->6327),等等的方法。
哈希冲突
🚀无论选取哪种哈希函数,将不同关键字通过哈希函数映射出的哈希地址有可能相同,这种现象称之为哈希冲突。
🚀设计更加精妙的哈希函数只等降低哈希冲突出现的概率,但是不能避免哈希冲突,哈希冲突是必然存在的。
哈希冲突的解决
1,闭散列
也叫开放定址法,当发生哈希冲突的时候,如果哈希表未被填满,说明在哈希表中必然还有空位置,那么就把key存在冲突位置的下一个位置去。
那么如何去寻找下一个位置呢?
(1)线性探测:从冲突的位置,一次向后探测,直到寻找到一个空位置。
(2)二次探测:从冲突的位置,新的hashi = 老的hashi + i*i,每次探测后i++,直到寻找到一个空位置。
2,开散列
也叫拉链法,哈希桶,首先对关键字通过哈希函数计算出哈希地址,具有相同地址的关键字归属于同一集合,每一个集合叫做一个桶,每个桶的元素通常通过一个单链表连接起来,各个链表的头节点存放在哈希表中。
对于开散列,发生哈希冲突时,不会去占用下一个数据的位置,相比于闭散列降低了冲突的概率。
二,闭散列实现
闭散列的结构
🚀闭散列在实现前要考虑一个问题,闭散列数据的删除不同于顺序表(移动覆盖),而是给每个位置都设置一个标记位,标记的状态有EMPTY,EXIST,DELETE。
typedef enum
{
EMPTY,
EXIST,
DELETE
}state;
template<typename K,typename V>
struct HashNode
{
pair<K, V> _kv;
state _state = EMPTY;
};
template<typename K,typename V,typename Hash = BKDRhash<K>>
class HashTable
{
public:
bool insert(const pair<K, V>& kv)
{}
HashNode<K, V>* find(const K& key)
{}
bool erase(const K& key)
{}
private:
vector<HashNode<K,V>> _table;
size_t _n = 0;//记录哈希表中数据个数
};
🚀实现哈希表时常用的哈希函数就是除留余数法,但并不是所有的数据类型都能够直接通过模哈希表的大小来获取映射的位置。所以要通过一个仿函数来控制,仿函数的作用就是将各种类型的数据经过特定的算法返回一个整数。在实现哈希表的时候仿函数默认支持的类型有整型和字符串类型。
template<typename T>
struct BKDRhash
{
size_t operator()(const T& t)
{
return t;
}
};
template<>
struct BKDRhash<string>
{
size_t sum = 0;
size_t operator()(const string& t)
{
for (auto e : t) { sum += e; sum *= 13; }
return sum;
}
};
🚀将字符串转化为整型的方法有很多,本文采用的BKDRhash的方法。如果需要更多方法请参考字符串哈希算法
插入
🚀首先要计算插入数据在hash表中的位置(hashi),因为可能存在哈希冲突,当发生冲突时要继续向后迭代,直到某个位置的状态标志位为EMPTY或DELETE时就插入数据,并且将标记位改为EXIST,_n++。
🚀为了更好的维持哈希表的效率,引入了负载因子这一概念,负载因子是指插入到哈希表中的数据占哈希表总体大小的百分比,可见这个负载因子不宜过大,过大意味着在插入新数据的时候发生哈希冲突的概率变大。同样负载因子的值也不宜过小,虽然负载因子较小的时候,发生哈希冲突的概率低,但是空间利用率也低。综合考虑,负载因子的值控制在0.7-0.8之间是比较合适的。
🚀插入数据之前,要先判断是否需要扩容,扩容的情况分为两种:
第一种是,刚开始时哈希表的size为0要进行扩容。
第二种是,当负载因子超过设定的值的时候要扩容。
bool insert(const pair<K, V>& kv)
{
if (find(kv.first) != nullptr) { return false; }
if (_table.size() == 0 || _n * 10 / _table.size() >= 7)
{
//扩容...
size_t newsize = _table.size() == 0 ? 10 : _table.size() * 2;
HashTable<K, V,Hash>* newhash = new HashTable<K, V, Hash>;
newhash->_table.resize(newsize);
for (auto& e : _table)
{
if(e._state == EXIST)
{
newhash->insert(e._kv);
}
}
_table.swap(newhash->_table);
}
size_t hashi = Hash()(kv.first) % _table.size();
size_t i = 1;
size_t index = hashi;
while (_table[index]._state == EXIST)
{
index = hashi + i;
index %= _table.size();
i++;
}
_table[index]._kv = kv;
_table[index]._state = EXIST;
_n++;
return true;
}
查找
HashNode<K, V>* find(const K& key)
{
if (_table.size() == 0) { return nullptr; }
size_t hashi = Hash()(key) % _table.size();
size_t i = 1;
size_t index = hashi;
while (_table[index]._state != EMPTY)
{
if (_table[index]._state == EXIST && _table[index]._kv.first == key) { return &_table[index]; }
index = hashi + i;
index %= _table.size();
i++;
if (index == hashi) { return nullptr; }//找了一圈还没找到
}
return nullptr;
}
🚀注意: 查找的过程类似于插入,但也有不同之处,在插入时计算出hashi之后,因为可能发生哈希冲突,所以要将hashi位置向后迭代直到某位置的标记位为EMPTY或者DELETE时即可插入。但是,查找的时候算出hashi之后,要迭代到某位置的标记位为EMPTY的时候停止。这种情况下可能会出现的bug就是查找的数据不在哈希表中,并且哈希表的所有位置的标记位不存在EMPTY的情况,会造成死循环。解决的办法就是如果查找了一圈还没找到就直接返回nullptr。
特殊情况图示:
🚀在上图中的哈希表中每个位置的标记位不是EXIST就是DELETE,不存在EMPTY的情况。
删除
🚀删除的逻辑十分简单,先进行查找,如果返回值为nullptr,证明哈希表中不存在此数据直接返回flase,否则进行数据的删除(就是将此位置的标记位修改为DELETE)。
bool erase(const K& key)
{
HashNode<K, V>* ret = find(key);
if (ret == nullptr) { return false; }
//不是空那么直接修改标志位
ret->_state = DELETE;
--_n;
return true;
}
闭散列总结
🚀当插入的某组数据中存在局部集中一些数据时,哈希冲突是很严重的,一个数据占据了另一个数据的位置,另一个位置就要去占据一个其他数据的位置,这样一些数据较为集中的时候,哈希冲突是很严重的导致效率较低。
🚀下面实现这开散列的形式是更为优秀的,其哈希冲突的概率于闭散列相比是更低的。
三,哈希桶实现
哈希桶的结构
🚀哈希桶的实现是顺序表+单链表的结构,当发生哈希冲突的时候,不会去占用其他数据的位置,而是挂接到当前位置的单链表中。这样相比于闭散列的形式哈希冲突的概率降低。
template<typename K,typename V>
struct HashNode
{
HashNode<K, V>* _next = nullptr;
pair<K, V> _kv ;
struct HashNode<K,V>(const pair<K,V>& kv)
:_next(nullptr),_kv(kv)
{}
};
template<typename T>
struct BKDRhash
{
size_t operator()(const T& t)
{
return t;
}
};
template<>
struct BKDRhash<string>
{
size_t sum = 0;
size_t operator()(const string& t)
{
for (auto e : t) { sum += e; sum *= 13; }
return sum;
}
};
template<typename K,typename V,typename Hash = BKDRhash<K>>
struct HashTable
{
public:
HashTable() = default;
HashTable(const HashTable& ht)
{}
HashTable& operator=(HashTable ht)
{}
~HashTable()
{}
bool insert(const pair<K, V>& kv)
{}
HashNode<K, V>* find(const K& key)
{}
bool erase(const K& key)
{}
void swap(HashTable<K,V,Hash>& ht)
{}
private:
size_t GetNextNum(size_t CurSize)
{}
private:
vector<HashNode<K,V>*> _table;
size_t _n = 0;
};
插入
🚀首先计算出数据映射的位置(hashi),在hashi位置完成单链表的头插即可。
🚀开散列的形式实现哈希表的时候,负载因子的值是可以超过1的,因为哈希桶中的数据是存储在挂在哈希表每个位置的单链表中的。
负载因子越大,空间利用率越高,效率越低。
负载因子越小,空间利用率越低,效率越高。
综合考虑,当负载因子为1时扩容是比较合理的,理想情况下就是每个位置的单链表都挂着一个结点。
🚀插入之前,要先判断是否需要扩容:
1,哈希表的size为0
2,负载因子达到1
扩容时的处理方法与闭散列是不同的,对于哈希桶而言如果像闭散列那样处理的话效率是不够高的,因为完全可以重复利用单链表的结点,而不是创建新的节点插入,再将原结点释放。
bool insert(const pair<K, V>& kv)
{
if (_n == _table.size()) //哈希表为空或者负载因子为1的时候扩容
{
if (find(kv.first) != nullptr) return false;
//扩容...
size_t newsize = GetNextNum(_table.size());
vector<HashNode<K, V>*> newtable(newsize,nullptr);
for (auto& cur : _table)
{
while (cur)
{
HashNode<K, V>* next = cur->_next;
size_t hashi = Hash()(cur->_kv.first) % newtable.size();
//头插
cur->_next = _table[hashi];
newtable[hashi] = cur;
cur = next;
}
}
_table.swap(newtable);
}
size_t hashi = Hash()(kv.first) % _table.size();
//头插
HashNode<K, V>* node = new HashNode<K, V>(kv);
node->_next = _table[hashi];
_table[hashi] = node;
++_n;
return true;
}
🚀通常哈希表的长度最好为素数,因为大量数据模一个素数所得到的余数会较为均匀的分布。
static const int __stl_num_primes = 28;
static const unsigned long __stl_prime_list[__stl_num_primes] =
{
53, 97, 193, 389, 769,
1543, 3079, 6151, 12289, 24593,
49157, 98317, 196613, 393241, 786433,
1572869, 3145739, 6291469, 12582917, 25165843,
50331653, 100663319, 201326611, 402653189, 805306457,
1610612741, 3221225473, 4294967291
};
size_t GetNextNum(size_t CurSize)
{
if (CurSize == 0) return __stl_prime_list[0];
size_t i = 0;
for (; i < __stl_num_primes; i++)
{
if (__stl_prime_list[i] == CurSize) break;
}
return __stl_prime_list[i + 1];
}
查找
🚀对于哈希桶的查找也十分简单,直接遍历哈希表,找到相应数据返回其地址即可,如果没有找到返回nullptr。
HashNode<K, V>* find(const K& key)
{
if (_table.size() == 0) return nullptr;
for (auto& cur : _table)
{
while (cur)
{
if (cur->_kv.first == key) return cur;
}
}
return nullptr;
}
删除
🚀首先计算出hashi,然后在hashi位置处的单链表中找到要删除的结点和其前置结点。对于一般情况,将其前置结点的next指针指向要删除结点的next指针然后delete掉删除结点即可。当前前置结点为空的时候,将要删除结点的下一个结点的指针存储在哈希表的hashi处,delete掉要删除结点即可。如果整个链表中没有要删除的数据,那么就返回false;
bool erase(const K& key)
{
if (_table.size() == 0) return false;
size_t hashi = Hash()(key) % _table.size();
HashNode<K, V>* prev = nullptr;
HashNode<K, V>* cur = _table[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
if (prev == nullptr) _table[hashi] = cur->_next;
else prev->_next = cur->_next;
delete cur;
--_n;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
析构
🚀由于底层使用的是vector,所以vector的空间不用手动释放,但是对于每个链表的空间是要手动释放的,否则会发生内存泄漏。
~HashTable()
{
for (auto& cur : _table)
{
while (cur)
{
HashNode<K, V>* next = cur->_next;
delete cur;
cur = next;
}
}
_n = 0;
}
拷贝构造
🚀哈希桶的拷贝是深拷贝,值得注意的一点是在拷贝链表结点的时候应该采用尾插的形式,因为拷贝出来的哈希桶要和原哈希桶的结构保持相同。
HashTable(const HashTable& ht)
{
_table.resize(ht._table.size());
for (size_t i = 0; i < ht._table.size(); i++) // 拷贝每个单链表结点
{
HashNode<K,V>* cur = ht._table[i];
HashNode<K, V>* tail = nullptr;
while (cur)
{
HashNode<K,V>* newnode = new HashNode<K,V>(cur->_kv);
if (_table[i] == nullptr)
{
_table[i] = newnode;
}
else
{
if (tail)
tail->_next = newnode;
}
tail = newnode;
cur = cur->_next;
}
}
}
赋值运算符重载
🚀对于赋值运算符重载可以直接复用拷贝构造。
HashTable& operator=(HashTable ht)
{
swap(ht);
return *this;
}
四,哈希表总结
开散列与闭散列的比较
🚀应用开散列,对于每个结点要增设链接的指针,似乎增加了空间的开销。事实上,由于开放定址法必须持有大量的空闲空间以确保搜索效率,对于线性探测要保证负载因子的大小要在0.7-0.8之间,而表项所占的空间要比指针大的多,所以开散列反而比闭散列更省空间。
哈希表的增删查改时间复杂度
🚀哈希表的时间复杂度为O(1),这里使用的是平均的时间复杂度,其最坏的时间复杂度为O(N),就是当N个元素都在一个桶内的情况,这种情况出现的概率是极低的,并且哈希表的扩容就会重新映射,改变这一状态。还有一种解决方法就是,当某个桶的长度超过某个值的时候,采用红黑树代替来链表。哈希表中存储的内容也要做改变。
template<typename K,typename V,typename Hash>
struct HashNodePtr
{
typedef union ptr
{
链表结点的指针
红黑树结点指针
}ptr;
size_t _size;
};