目录
一、哈希概念
二、哈希实现
1、闭散列
1.1、线性探测
1.2、二次探测
2、开散列
2.1、开散列的概念
2.2、开散列的结构
2.3、开散列的查找
2.4、开散列的插入
2.5、开散列的删除
3、性能分析
一、哈希概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O(logN),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数 (hashFunc) 使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
- 插入元素:
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放。 - 搜索元素:
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功。
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)。
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity 为存储元素底层空间总的大小。
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快。但是可能会造成哈希冲突。
哈希整体代码结构:
enum State
{
EMPTY,
EXIST,
DELETE
};
template<class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
template<class K>
struct HashFunc
{
//key本身可以进行隐式类型转换
size_t operator()(const K& key)
{
return key;
}
};
template<>
struct HashFunc<string>
{
//BKDR哈希
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;
hash *= 31; //通过这种方式减少不同字符串的ascll码值相同造成的冲突问题
//这个值可以取31、131、1313、131313等等
}
return hash;
}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
bool Insert(const pair<K, V>& kv)
{}
private:
vector<HashData<K, V>> _tables;
size_t _n = 0; //存储的数据个数
};
模板参数中,Hash是一个仿函数,用于将key值转换成整型。 如果key是一个字符串类型,则使用特化,通过BKDR的方式转换成整型。
二、哈希实现
对于两个数据元素的关键字 k_i 和 k_j(i != j),有 k_i != k_j,但有:Hash(k_i) ==Hash(k_j),即:不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
产生哈希冲突的原因是哈希函数设计不够合理。哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间。
- 哈希函数计算出来的地址能均匀分布在整个空间中。
- 哈希函数应该比较简单。
常见哈希函数:
- 直接定址法--(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B。
优点:简单、均匀。
缺点:需要事先知道关键字的分布情况。
使用场景:适合查找比较小且连续的情况。 - 除留余数法--(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。
哈希冲突的解决主要有两种方法:闭散列和开散列。
1、闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?
1.1、线性探测
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
插入:
- 通过哈希函数获取待插入元素在哈希表中的位置。
- 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。
插入代码:
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
Hash hash;
size_t hashi = hash(kv.first) % _tables.size(); //这里是size,而不是capacity
//因为 [] 无法访问 size 外的数值
//线性检测
size_t i = 1;
size_t index = hashi;
while (_tables[index]._state == EXIST)
{
index = hashi + i;
index %= _tables.size();
++i;
}
_tables[index]._kv = kv;
_tables[index]._state = EXIST;
_n++;
return true;
}
因为可能出现 size 为 0 ,或者容量不够的情况,因此需要扩容操作:
//size为0,或者负载因子超过 0.7 就扩容
if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
{
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
vector<HashData> newtables(newsize); //创建一个新的vector对象
//遍历旧表,重新映射到新表
for (auto& data : _tables)
{
if (data._state == EXIST)
{
//重新算在新表中的位置
size_t hashi = hash(data.kv.first) % newtables.size();
size_t i = 1;
size_t index = hashi;
while (newtables[index]._state == EXIST)
{
index = hashi + i;
index %= newtables.size();
++i;
}
newtables[index]._kv = data.kv;
newtables[index]._state = EXIST;
}
}
_tables.swap(newtables);
}
需要注意的是,在扩容时,需要重新开辟一个 vector 对象,所有的数据都要重新插入一遍。而不能在原有的 vector 对象上扩容,因为这样做的话,扩容后,映射位置关系就变了,原来不冲突的值可能冲突了,原来冲突的值可能不冲突了。
因为以上写法存在代码的冗余,所以可以采用如下写法简化代码:
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
Hash hash;
//负载因子超过 0.7 就扩容
if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
{
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V> newht; //创建一个新的哈希表对象
newht._tables.resize(newsize); //扩容
//遍历旧表,重新映射到新表
for (auto& data : _tables)
{
if (data._state == EXIST)
{
newht.Insert(data._kv);
}
}
_tables.swap(newht._tables);
}
size_t hashi = hash(kv.first) % _tables.size(); //这里是size,而不是capacity
//线性检测
size_t i = 1;
size_t index = hashi;
while (_tables[index]._state == EXIST)
{
index = hashi + i;
index %= _tables.size();
++i;
}
_tables[index]._kv = kv;
_tables[index]._state = EXIST;
_n++;
return true;
}
删除:
采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
删除代码:
HashData<K, V>* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
Hash hash;
size_t hashi = hash(key) % _tables.size();
size_t i = 1;
size_t index = hashi;
while (_tables[index]._state != EMPTY)
{
if (_tables[index]._state == EXIST && _tables[index]._kv.first == key)
{
return &_tables[index];
}
index = hashi + i;
index %= _tables.size();
++i;
//如果找了一圈,那么说明全是存在或删除
if (index == hashi)
{
break;
}
}
return nullptr;
}
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_n;
return true;
}
else
{
return false;
}
}
需要注意的是,为了防止哈希表中只有存在与删除而造成的死循环问题,在函数中需要增加一次判断,限制查找次数。
线性探测优点:实现非常简单,
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
1.2、二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:H_i = (H_0 + i^2) % m,或者:H_i = (H_0 - i^2) % m。其中:i = 1,2,3…, H_0 是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表的大小。
研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。因此:比散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。
2、开散列
2.1、开散列的概念
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
2.2、开散列的结构
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>
struct HashFunc
{
//key本身可以进行隐式类型转换
size_t operator()(const K& key)
{
return key;
}
};
template<>
struct HashFunc<string>
{
//BKDR哈希
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;
hash *= 31; //通过这种方式减少不同字符串的ascll码值相同造成的冲突问题
//这个值可以取31、131、1313、131313等等
}
return hash;
}
};
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
~HashTable()
{
for (auto& cur : _tables)
{
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
cur = nullptr;
}
}
bool Insert(const pair<K, V>& kv)
{}
private:
vector<Node*> _tables;
size_t _n = 0;
};
2.3、开散列的查找
Node* Find(const K& key)
{
if (_tables.size() == 0)
return nullptr;
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;
}
2.4、开散列的插入
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
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;
}
因为有可能出现哈希表的 size 为 0 ,或者需要扩容的情况。所以需要给哈希表扩容。负载因子越大,冲突的概率越高,查找的效率就越低,同时空间利用率越高。
因为原表中的节点都是自定义类型的,所以不会被自动析构。我们只需要把原表中的节点重新计算位置,挪动到新表就可以了。
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
Hash hash;
//负载因子为1时,扩容
if (_n == _tables.size())
{
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
vector<Node*> newtables(newsize, nullptr);
for (Node*& cur : _tables)
{
while (cur)
{
Node* next = cur->_next;
size_t hashi = hash(cur->_kv.first) % newtables.size();
//头插到新表
cur->_next = newtables[hashi];
newtables[hashi] = cur;
cur = next;
}
}
_tables.swap(newtables);
}
size_t hashi = hash(kv.first) % _tables.size();
//头插
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
2.5、开散列的删除
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;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上:由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子a <=0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。
3、性能分析
对于开散列的哈希来说,增删查改的时间复杂度是 O(1) ,虽然在最坏的情况下(所有的值都挂在同一个下标上,即在同一个桶中),时间复杂度是 O(N),但是因为扩容操作的存在,这种最坏的情况几乎不可能出现。
如果真的出现了极端情况,导致所有的数据都在一个桶中。则可以采取当单个桶超过一定的长度,就把这个桶改挂成红黑树的方式:把哈希数据类型设置为结构体,结构体中包括链表指针、桶长度以及树的指针,如果桶的长度超出指定数值,就使用树的指针,反之则使用链表指针。
关于哈希底层结构的内容就讲到这里,希望同学们多多支持,如果有不对的地方欢迎大佬指正,谢谢!