底层结构
unordered系统的关联式容器之所以效率比较高,是因为其底层使用了哈希结构。
哈希概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须经过关键码的多次比较。
- 顺序查找的时间复杂度为
- 平衡树中为树的高度,即
搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。
如果构造一种存储结构,通过某种函数HashFunc使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找是通过该函数可以很快找到该元素。
当向该结构中:
- 插入元素:
- 根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
- 搜索元素:
- 对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(HashTable)(或者称为散列列表)。
哈希思路实例
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash ( key ) = key % capacity;
capacity为存储元素底层空间总的大小。
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快。
但是这时就会有一些问题,按照上述的哈希方式,向集合插入元素44,会出现什么问题?
哈希冲突
对于两个数据元素的关键字,两个关键字不同a与b,但是Hash(a) == Hash(b) ,即:不同关键字通过相同哈希函数计算出相同的哈希地址,该现象称为哈希冲突或哈希碰撞。
把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
哈希函数
引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0——m-1之间;
- 哈希函数计算出来的地址能均匀分布在整个空间中;
- 哈希函数应该比较简单。
常见的哈希函数
直接定址法(常用)
- 取关键字的某个线性函数为散列地址:Hash ( Key ) = A*Key + B
- 优点:简单、均匀
- 缺点:需要事先知道关键字的分布情况
- 使用场景:适合查找比较小且连续的情况
除留余数法(常用)
- 设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash ( Key ) = Key % p ( p <= m ),将关键码转换为哈希地址。
平方取中法(了解)
- 假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;
- 关键字为4321,对它平方就是18671041,抽取中间的3位671(或者710)作为哈希地址。
- 平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况。
折叠法(了解)
- 折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
随机数法(了解)
- 选择一个随机函数,取关键字的随机函数为它的哈希地址,即H(Key) = random(key),其中random为随机函数。
- 通常应用于关键字长度不等时采用此法。
数学分析法(了解)
- 设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现在频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。
- 数学分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况。
- 哈希函数设计的越精妙,产生哈希冲突的可能性就越低但;
- 但是无法避免哈希冲突。
哈希冲突解决
解决哈希冲突两种常见的方法:闭散列和开散列。
闭散列
闭散列,也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个”空位置中去。
那如何寻找下一个空位置呢?
线性探测
比如,上述的示例,现在需要插入元素44,先通过哈希函数计算哈希地址,HashAddr为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
- 插入:
- 通过哈希函数获取待插入元素在哈希表中的位置;
- 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到了下一个空位置,插入新元素。
- 删除:
- 采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来就可能会受到影响。因此线性探测采用标记的伪删除法来删除一个元素。
// 哈希表每个空间给个标记 // EMPTY 空位置 // EXIST 此位置已有元素 // DELETE 元素已删除 enum State { EMPTY, EXIST, DELETE };
- 线性探测优点:实现非常简单。
- 线性探测的缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
二次探测
线性探测的缺陷是产生冲突的数据堆积在一起,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:
- Hash(i) = Hash(0) + i^2
- Hash(i-1) = Hash(0) + (i-1)^2
- Hash(i) = Hash(i-1) + (2i-1)
其中:i = 1,2,3……,Hash(0) 是通过散列函数 Hash(x) 对元素的关键码 key 进行计算得到的位置,m是表的大小。
对于上述的示例,如果要插入44,产生冲突,使用解决后的情况为:
研究表明:
- 当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。
- 在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。
闭散列最大的缺陷就是空间利用效率比较低,这也是哈希的缺陷。
开散列
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一个子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
可以从下图中看出,开散列中每个桶中放的都是发生哈希冲突的元素。
开散列与闭散列比较
- 应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上:
- 由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a<=0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省了存储空间。
模拟实现
哈希表闭散列线性探测——open_address
open_address的HashTable的结构体以及待实现的函数
namespace openress {
//枚举哈希数据的三种状态
enum State
{
EXIST,
DELETE,
EMPTY
};
//定义一个存储键值对&&状态为空的数据
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
HashTable();
bool Insert(const pair<K, V>& kv);
HashData<K, V>* Find(const K& key);
bool Erase(const K& key);
private:
vector<HashData<K, V>> _tables;
size_t _n = 0;//存储的数据的个数
};
}
构造函数HashTable()
先进行将哈希表初始化为,起初最多存储10个数据。
HashTable()
{
_tables.resize(10);
}
插入数据Insert()
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
// 扩容
if (_n * 10 / _tables.size() >= 7)
{
//vector<HashData<K, V>> newTables(_tables.size() * 2);
遍历旧表, 将所有数据映射到新表
...
//_tables.swap(newTables);
HashTable<K, V, Hash> newHT;
newHT._tables.resize(_tables.size() * 2);
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._state == EXIST)
{
newHT.Insert(_tables[i]._kv);
}
}
_tables.swap(newHT._tables);
}
Hash hs;
size_t hashi = hs(kv.first) % _tables.size();
while (_tables[hashi]._state == EXIST)
{
++hashi;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
查找数据Find()
HashData<K, V>* Find(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
while (_tables[hashi]._state != EMPTY)
{
if (_tables[hashi]._state == EXIST
&& _tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
++hashi;
hashi %= _tables.size();
}
return nullptr;
}
删除数据Erase()
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret == nullptr)
{
return false;
}
else
{
ret->_state = DELETE;
return true;
}
}
完整代码
//openress开散列定址法
namespace open_address
{
enum State
{
EXIST,
EMPTY,
DELETE
};
template<class K,class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
HashTable()
{
_tables.resize(10);
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
if (_n * 10 / _tables.size() >= 7)
{
HashTable<K, V> newHT;
newHT._tables.resize(_tables.size() * 2);
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._state == EXIST)
{
newHT.Insert(_tables[i]._kv);
}
}
_tables.swap(newHT._tables);
}
Hash hs;
size_t hashi = hs(kv.first) % _tables.size();
while (_tables[hashi]._state != EMPTY)
{
hashi++;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
_n++;
return true;
}
HashData<K, V>* Find(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
while (_tables[hashi]._state != EMPTY)
{
if (_tables[hashi]._kv.first == key
&& _tables[hashi]._state == EXIST)
{
return &_tables[hashi];
}
else
{
hashi++;
hashi %= _tables.size();
}
}
return nullptr;
}
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
return true;
}
else
{
return false;
}
}
private:
vector<HashData<K, V>> _tables;
size_t _n;
};
void Hash_open_address_Test1()
{
HashTable<int, int> ht;
int a[] = { 6,23,13,4,2,32,43,56,4,1 };
for (auto e : a)
{
ht.Insert({ e,e });
}
ht.Insert({ 9,9 });
for (auto e : a)
{
ht.Erase(e);
}
}
void Hash_open_address_Test2()
{
HashTable<int, int> ht;
int a[] = { 11,21,4,14,24,15,9 };
for (auto e : a)
{
ht.Insert({ e,e });
}
ht.Insert({ 34,34 });
cout << ht.Find(24) << endl;
cout << ht.Find(34) << endl;
}
void Hash_open_address_Test3()
{
HashTable<string, string> ht;
ht.Insert({ "crush","喜欢" });
ht.Insert({ "sheep","目前喜欢" });
ht.Insert({ "iredq","目前喜欢" });
ht.Insert({ "hsepe","目前喜欢" });
}
}
哈希表开散列哈希桶——hash_bucket
hash_bucket的HashTable的结构体以及待实现的函数
namespace hash_bucket
{
template<class K,class V>
struct HashNode
{
HashNode<K, V>* _next;
//存储的是为hashNode的节点
pair<K, V> _kv;
//就是存储的pair类型的数据
HashNode(const pair<K, V>& kv)
//哈希节点的构造函数,将这个生成为一个结构体
:_kv(kv)
,_next(nullptr)
{}
};
template<class K,class V,class Hash = HashFunc<K>>
//哈希表的实现
//此时的模版类型有三个,因为有一个是解决string的映射问题
class HashTable
{
typedef HashNode<K, V> Node;
//本来节点的类型应该为HashNode<K,V>,但是为了方便
//typedef重命名使其更为简单
public:
HashTable();//构造函数
~HashTable();
bool Insert(const pair<K, V>& kv);
Node* Find(const K& key);
bool Erase(const K& key);
private:
vector<Node*> _tables;
size_t _n;
};
}
构造函数HashTable()
HashTable()//构造函数
//将每个表初始化可存储数据的个数
{
_tables.resize(10, nullptr);
}
析构函数~HashTable()
~HashTable()
//析构函数
//无论何种的类型的析构函数都不会对内置类型做任何处理
//vector中存储的是自定义类型的对象,
//且这些自定义类型需要进行特定的资源清理操作,
//这些自定义类型需要提供自己的析构函数来确保资源的正确释放
{
//依次把每个桶释放
for (size_t i = 0; i < _tables.size(); i++)
//遍历哈希表中的每一个哈希桶
{
Node* cur = _tables[i];
while (cur)
//如果当前哈希表的当前哈希桶存储的节点的指针不为nullptr
//那么就进行遍历
{
//先存储下一个节点
Node* next = cur->_next;
//释放当前节点
delete cur;
//循环遍历下一个节点
cur = next;
}
//将下面的节点释放结束之后,不要忘记将哈希桶中存储的指针地址更改为nullptr
_tables[i] = nullptr;
}
}
插入数据Insert()
//插入数据pair键值对
bool Insert(const pair<K, V>& kv)
//键值对是不能修改的
{
Hash hs;
size_t hashi = hs(kv.first) % _tables.size();
if (_n == _tables.size())
{
//这里就不需要和开散列那样复用insert,
//因为可能会出现这种情况:
//假如需要很多的节点,那么就需要new很多节点
//此时会出现空间消耗浪费
//所以还是使用for循环遍历即可
vector<Node*> newtables(_tables.size() * 2);
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
Hash hs;
size_t hashi = hs(cur->_kv.first) % newtables.size();
cur->_next = newtables[hashi];
//这里一定要理解链表中节点的链接方式
//节点的链接方式的本质是没有箭头的
//我们只是根据节点中存储的第一个节点的地址
//意想为有箭头
newtables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newtables);
}
//先将需要插入的值,成为一个节点
Node* newnode = new Node(kv);
//此时newnode就有属性:pair<K,V> 与 Node* 存储下一个节点地址的属性
newnode->_next = _tables[hashi];
//这里注意整个类名是HashTable,但是vector中任然叫_tables
//而tables[i]内部也只是存储了Node*类型
_tables[hashi] = newnode;
++_n;
return true;
}
查找数据Find()
Node* Find(const K& key)
//根据key关键值找对应的键值对
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
//先进行映射,查看是否对应在映射的哪个位置
Node* cur = _tables[hashi];
//找到对应映射的哈希桶,进行遍历哈希桶
while (cur)
{
//一直遍历哈希桶,确定是否相等
//如果相等就直接返回当前节点
if (cur->_kv.first == key)
{
return cur;
}
//如果不相等的话就直接,判断下一个节点是否相等
else
{
cur = cur->_next;
}
}
//如果遍历一直到空,此时任然没有找到相同节点的值,那么说明并没有这个节点
//就返回空
return nullptr;
}
删除数据Erase()
//删除指定的节点
bool Erase(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
//找到当前节点对应的哈希桶
//要删除节点:单链表的某个节点,
// 那么一定是要当前节点cur的前一个节点prev的下一个节点指向当前节点的next
// 所以需要记录两个节点
Node* cur = _tables[hashi];
//从哈希桶从上往下进行遍历,此时注意前一个节点也不是哈希桶中,而是空
Node* prev = nullptr;
//一直找一直找节点:找到对应的值相同的节点
//每一次遍历,都要进行记录对应的节点的前一个节点并且如果不相等就让当前节点走下一个节点
//如果找到了对应的节点
//如果当前节点是头结点(前节点prev为空):
// 那么直接让哈希桶存储的节点的地址更新为cur的next的节点地址即可
//如果当前节点不是头结点(prev不为空):
// 那么此时就是直接prev的next指向cur的next即可
//然后对应的cur节点就直接进行释放,并且减少存储的数据的个数
while (cur)
{
if (cur->_kv.first == key)
{
if (prev == nullptr)
{
_tables[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
_n--;
delete cur;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
完整代码
//哈希桶
namespace hash_bucket
{
template<class K,class V>
struct HashNode
{
HashNode<K, V>* _next;
//存储的是为hashNode的节点
pair<K, V> _kv;
//就是存储的pair类型的数据
HashNode(const pair<K, V>& kv)
//哈希节点的构造函数,将这个生成为一个结构体
:_kv(kv)
,_next(nullptr)
{}
};
template<class K,class V,class Hash = HashFunc<K>>
//哈希表的实现
//此时的模版类型有三个,因为有一个是解决string的映射问题
class HashTable
{
typedef HashNode<K, V> Node;
//本来节点的类型应该为HashNode<K,V>,但是为了方便
//typedef重命名使其更为简单
public:
HashTable()//构造函数
//将每个表初始化可存储数据的个数
{
_tables.resize(10, nullptr);
}
~HashTable()
//析构函数
//无论何种的类型的析构函数都不会对内置类型做任何处理
//vector中存储的是自定义类型的对象,
//且这些自定义类型需要进行特定的资源清理操作,
//这些自定义类型需要提供自己的析构函数来确保资源的正确释放
{
//依次把每个桶释放
for (size_t i = 0; i < _tables.size(); i++)
//遍历哈希表中的每一个哈希桶
{
Node* cur = _tables[i];
while (cur)
//如果当前哈希表的当前哈希桶存储的节点的指针不为nullptr
//那么就进行遍历
{
//先存储下一个节点
Node* next = cur->_next;
//释放当前节点
delete cur;
//循环遍历下一个节点
cur = next;
}
//将下面的节点释放结束之后,不要忘记将哈希桶中存储的指针地址更改为nullptr
_tables[i] = nullptr;
}
}
//插入数据pair键值对
bool Insert(const pair<K, V>& kv)
//键值对是不能修改的
{
Hash hs;
size_t hashi = hs(kv.first) % _tables.size();
if (_n == _tables.size())
{
//这里就不需要和开散列那样复用insert,
//因为可能会出现这种情况:
//假如需要很多的节点,那么就需要new很多节点
//此时会出现空间消耗浪费
//所以还是使用for循环遍历即可
vector<Node*> newtables(_tables.size() * 2);
for (size_t i = 0; i < _tables.size(); i++)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
Hash hs;
size_t hashi = hs(cur->_kv.first) % newtables.size();
cur->_next = newtables[hashi];
//这里一定要理解链表中节点的链接方式
//节点的链接方式的本质是没有箭头的
//我们只是根据节点中存储的第一个节点的地址
//意想为有箭头
newtables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newtables);
}
//先将需要插入的值,成为一个节点
Node* newnode = new Node(kv);
//此时newnode就有属性:pair<K,V> 与 Node* 存储下一个节点地址的属性
newnode->_next = _tables[hashi];
//这里注意整个类名是HashTable,但是vector中任然叫_tables
//而tables[i]内部也只是存储了Node*类型
_tables[hashi] = newnode;
++_n;
return true;
}
Node* Find(const K& key)
//根据key关键值找对应的键值对
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
//先进行映射,查看是否对应在映射的哪个位置
Node* cur = _tables[hashi];
//找到对应映射的哈希桶,进行遍历哈希桶
while (cur)
{
//一直遍历哈希桶,确定是否相等
//如果相等就直接返回当前节点
if (cur->_kv.first == key)
{
return cur;
}
//如果不相等的话就直接,判断下一个节点是否相等
else
{
cur = cur->_next;
}
}
//如果遍历一直到空,此时任然没有找到相同节点的值,那么说明并没有这个节点
//就返回空
return nullptr;
}
//删除指定的节点
bool Erase(const K& key)
{
Hash hs;
size_t hashi = hs(key) % _tables.size();
//找到当前节点对应的哈希桶
//要删除节点:单链表的某个节点,
// 那么一定是要当前节点cur的前一个节点prev的下一个节点指向当前节点的next
// 所以需要记录两个节点
Node* cur = _tables[hashi];
//从哈希桶从上往下进行遍历,此时注意前一个节点也不是哈希桶中,而是空
Node* prev = nullptr;
//一直找一直找节点:找到对应的值相同的节点
//每一次遍历,都要进行记录对应的节点的前一个节点并且如果不相等就让当前节点走下一个节点
//如果找到了对应的节点
//如果当前节点是头结点(前节点prev为空):
// 那么直接让哈希桶存储的节点的地址更新为cur的next的节点地址即可
//如果当前节点不是头结点(prev不为空):
// 那么此时就是直接prev的next指向cur的next即可
//然后对应的cur节点就直接进行释放,并且减少存储的数据的个数
while (cur)
{
if (cur->_kv.first == key)
{
if (prev == nullptr)
{
_tables[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
_n--;
delete cur;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
private:
vector<Node*> _tables;
size_t _n;
};
void Hash_bucket_test1()
{
HashTable<int, int> ht;
int a[] = { 11,21,4,14,15,9,19,29,39 };
for (auto e : a)
{
ht.Insert({ e,e });
}
ht.Insert({ 6,6 });
for (auto e : a)
{
ht.Erase(e);
}
}
void Hash_open_address_Test3()
{
HashTable<string, string> ht;
ht.Insert({ "crush","喜欢" });
ht.Insert({ "sheep","目前喜欢" });
ht.Insert({ "iredq","目前喜欢" });
ht.Insert({ "hsepe","目前喜欢" });
}
}
解决存储的数据并非整形转整形进行取模计算
//某些类型是不能直接转为int类型的,
//就必须通过其强制类型转换为整形
template<class K>
//因为进行取模的是key关键值,所以只需要一个模版参数
//将这个函数方法封装为一个结构体
//使用的时候直接是构造一个结构体,然后进行使用操作符
struct HashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
//特化string
template<>
//如果说此时的数据类型是string,那么此时也不能使用强制转换
//特化:特殊情况特殊处理
struct HashFunc<string>
{
//模版参数直接为空,结构体名称后直接跟模版参数类型
size_t operator()(const string& key)
//形参中已经确定是string类型了
{
//此时初始化hash为0
size_t hash = 0;
for (auto e : key)
//依次遍历字符串中的字符
{
//为了尽量减少哈希冲突*31+字符串的值
hash *= 31;
hash += e;
}
return hash;
}
};