赞
踩
之前学习的红黑树,增删查改都为O(logN),但是今天学习的哈希表,理论上可以达到增删查改都为O(1),让我们来看看是什么结构这么神奇吧~
在线性结构和树形结构中,元素键值key与其存储位置之间没有对应关系,因此在查找指定元素时,要经过key的多次对比。
时间复杂度:顺序查找为O(N),二叉搜索平衡树查找为O(logN)。
理想的查找方式:不经过任何比较,直接通过key获取其存储位置。
这就是哈希的本质,通过某种函数(称之为哈希函数)构建key与其存储位置的一一映射关系,从而达到查找为O(1)。而这种结构也称为哈希表(Hash Table),又称散列表。
哈希函数设计原则:
那么,下面介绍两种常见的哈希函数:
优点:简单、均匀
缺点:需要事先知道key的分布情况
优点:不需要事先知道key的分布情况
缺点:会产生哈希冲突
选择除数为素数的原因:减少哈希冲突
如果选择的除数包含多个正因数,那么哈希地址可能会集中在某些特定的值上,从而导致冲突概率增加。
哈希冲突,又称哈希碰撞,即为不同key通过相同哈希函数计算出相同的哈希地址。
数学表达:对于两个数据元素的关键字 k i k_i ki和 k j k_j kj(i != j),有 k i k_i ki != k j k_j kj,有:Hash( k i k_i ki) == Hash( k j k_j kj)
面对陌生数据,我们一般比较常用的除留余数法会产生哈希冲突,而哈希冲突则是影响哈希表效率的关键因素。
那么,如何解决哈希冲突呢?这里有两种方法:闭散列和开散列
闭散列,又称开放定址法。
当哈希冲突发生时,开放定址法尝试在哈希表内部找到一个空闲的单元来存放冲突的元素。这个空闲的单元被称为开放单元或空白单元。
enum State
{
EMPTY,
EXIST,
DELETE
};
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
细节:
template<class K, class V>
class HashTable
{
public:
protected:
vector<HashData<K, V>> _tables;
size_t _n = 0;//有效数据个数
};
细节:
HashTable()
{
_tables.resize(10);
}
细节:这里vector提前开空间,可以避免后续为空的讨论
HashData<K, V>* Find(const K& key) { size_t hashi = key % _tables.size(); size_t pos = hashi; size_t i = 1; while (_tables[pos]._state != EMPTY) { if (_tables[pos]._state == EXIST && _tables[pos]._kv.first == key) { return &_tables[pos]; } pos = hashi + i; if (pos >= _tables.size()) { return nullptr; } ++i; } return nullptr; }
细节:
bool Insert(const pair<K, V>& kv) { if (Find(kv.first))//保持key唯一 { return false; } //... size_t hashi = kv.first % _tables.size(); size_t pos = hashi; size_t i = 1; while (_tables[pos]._state == EXIST) { pos = hashi + i;//线性探测 if (pos >= _tables.size()) { return false; } ++i; } _tables[pos]._kv = kv; _tables[pos]._state = EXIST; ++_n; return true; }
细节:
但是,上述情况是哈希表未满时,如果满了如何扩容?还有,一定要满了才扩容吗?
这里,我们引入负载因子的概念:α = 有效数据个数 / 哈希表长度
当负载因子越大,哈希冲突的概率就越大,同时发生哈希踩踏的概率也越大,对于开放定址法,应该控制负载因子小于0.7,超过将扩容。
if (_n * 10 / _tables.size() >= 7)//负载因子大于等于0.7, 扩容 { size_t newsize = _tables.size() * 2; vector<HashData<K, V>> newtables(newsize); for (auto& cur : _tables) { size_t hashi = cur._kv.first % newsize; size_t pos = hashi; size_t i = 1; while (newtables[pos]._state == EXIST) { pos = hashi + i;//线性探测 ++i; } newtables[pos]._kv = kv; _tables[pos]._state = EXIST; } _tables.swap(newtables); }
细节:
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret)
{
ret._state = DELETE;
--_n;
return true;
}
return false;
}
细节:
以上讲解的查找和插入,它们所用的探测方法是线性探测(一个一个往后找),这种探测方法可能会造成大量的哈希冲突。
那么,有没有什么探测方法能缓解哈希冲突呢?有,那就是二次探测!
改法也很简单,以一小段代码举例:
while (newtables[pos]._state == EXIST)
{
pos = hashi + i*i;//二次探测
++i;
}
这样就是每次跨越 i 的二次方向后探测,中间间隔大,哈希冲突就可以得到缓解。
但是,闭散列(开放定址法)有一个致命的缺陷,那就是空间利用率低!它必须保留相当一部分的开放空间,才能不断插入。
所以,实际上,我们更常用另一种方式来实现哈希表——闭散列,又称为开链法。
在开链法中,哈希表的每个槽位(bucket),又称为哈希桶,通过一个单链表来存储所有散列到该槽位的元素。这意味着即使不同的key经过哈希函数映射到同一个槽位,它们也可以被存储在同一个单链表上,从而避免了冲突。
template<class K, class V>
struct HashNode
{
HashNode<K, V>* _next;
pair<K, V> _kv;
HashNode(const pair<K, V>& kv)
: _next(nullptr)
, _kv(kv)
{}
};
细节:
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
protected:
typedef HashNode<K, V> Node;
public:
protected:
vector<Node*> _tables;
size_t _n = 0;//有效数据个数
};
细节:
HashTable()
{
_tables.resize(10);
}
细节:这里vector提前开空间,可以避免后续为空的讨论
~HashTable()
{
for (auto& cur : _tables)
{
while (cur)
{
Node* del = cur;
cur = cur->_next;
delete del;
}
}
}
细节:因为涉及链表结点空间的动态开辟,所以要手动释放
Node* Find(const K& key)
{
Hash hash;
size_t hashi = hash(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
细节:
bool Insert(const pair<K, V>& kv) { if (Find(kv.first))//保持key唯一 { return false; } Hash hash; //... size_t hashi = hash(kv.first) % _tables.size(); Node* newnode = new Node(kv); //头插 newnode->_next = _tables[hashi]; _tables[hashi] = newnode; ++_n; return true; }
细节:
运用开链法后,虽然没有哈希冲突了,但是链表长度过长也会影响效率。所以,哈希表也需要通过扩容来使链表长度变短,理想的状态是负载因子为1时扩容。
悄悄说一句:链表过长,还有另一种解决方法,那就是在该哈希桶下改挂一棵红黑树~
if (_n == _tables.size())//负载因子为1时,扩容 { size_t newsize = _tables.size() * 2; vector<Node*> newtables(newsize); for (auto& cur : _tables) { while (cur) { Node* next = cur->_next; //将旧表结点重新映射到新表上 size_t hashi = hash(cur->_kv.first) % newsize; cur->_next = newtables[hashi]; newtables[hashi] = cur; //跳回旧表的下一结点 cur = next; } } _tables.swap(newtables); }
细节:
bool Erase(const K& key) { Hash hash; size_t hashi = hash(key) % _tables.size(); Node* prev = nullptr; Node* cur = _tables[hashi]; while (cur) { if (cur->_kv.first == key) { if (prev == nullptr) { _tables[hashi] = cur->_next; } else { prev->_next = cur->_next; } delete cur; --_n; return true; } prev = cur; cur = cur->_next; } return false; }
细节:
由于除留余数法涉及到取模运算,而只有整型才能取模。所以针对非整型的数据,需要将其转化为整型,这一过程称为哈希化。
template<class K> struct HashFunc { size_t operator()(const K& key) { return key; } }; template<> struct HashFunc<string> { size_t operator()(const string& s) { size_t hash = 0; for (auto& ch : s) { hash = hash * 31 + ch; } return hash; } };
细节:
相比闭散列,开散列看似增加了存储指针的空间开销,实际上闭散列要保证大量的空闲单元以降低哈希冲突,所以开散列反而更加节省空间,其空间利用率更高!
哈希表与红黑树的对比:
数据有序性:哈希表无序,而红黑树有序
适用场景:哈希表适合单点查找,红黑树适合范围查找
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。