文章目录
- 1. 哈希概念
- 2. 如何解决哈希冲突?
- 3. 哈希函数概念
- 4. 闭散列(目前淘汰的方法,了解)
- **4.1.线性探测**
- **4.2. 二次探测**
- 4.3 闭散列查找与删除
- 5. 开散列
- 6. 开散列与闭散列比较
1. 哈希概念
搜索树需要多次的关键码比较来搜索到结果,哈希就是能让值与存储位置建立映射关系,可以不经过任何比较,一次直接从表中得到要搜索的元素。
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立
一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中:
插入元素
- 根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放。
搜索元素
- 对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置
取元素比较,若关键码相等,则搜索成功。
构造哈希主要有两个元素:
- 哈希表 - - 作为存储结构
- 哈希函数 - - 作为转换函数
例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快
问题:按照上述哈希方式,向集合中插入元素44,会出现什么问题?
如下图当我们插入17时,hash(17)与hash(7) 相同 ,导致哈希冲突了。
2. 如何解决哈希冲突?
哈希冲突概念:
- 不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
引起哈希冲突的原因:
- 哈希函数设计不够合理。
- 哈希函数如果能设计合理,能够较大层度减少冲突次数。
- 解决冲突需要时间消耗。
哈希冲突吃的解决方法:
- 闭散列。
- 开散列。
3. 哈希函数概念
哈希函数设计原则:
- 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值
域必须在0到m-1之间。 - 哈希函数计算出来的地址能均匀分布在整个空间中。
- 哈希函数应该比较简单。
常见的哈希函数:
1.直接定址法–(常用)
取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况
使用场景:适合查找比较小且连续的情况
面试题:字符串中第一个只出现一次字符
例如:表的位置等于数值,A=1,B=0。
2.除留余数法–(我们使用该方法)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,
按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。
简单且可以很好的减少冲突。STL库就是采用此方法。
其他哈希函数:
3.平方取中法–(了解)
4.折叠法–(了解)
5.随机数法–(了解)
6.数学分析法–(了解)
4. 闭散列(目前淘汰的方法,了解)
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有
空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。
那如何寻找下一个空位置呢?
寻找下一个空位置的方式:
- 线性探测法
- 二次探测法
4.1.线性探测
比如2.1中的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,hashAddr为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
- 插入
-
- 过哈希函数获取待插入元素在哈希表中的位置
-
- 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素
-
图2.1
- 删除
-
- 采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。
-
- 比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。
// 哈希表每个空间给个标记
// EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
enum State{EMPTY, EXIST, DELETE};
线性探测代码实现:
4.1.1 初始化哈希
步骤:
我们按照2倍的速度增长空间,因为采用除留余数法,因此我们将空间的大小设置为质数。
我们初始一组28位的质数数组,元素两两之间约等于两倍。
注意:为了减少增容,我们可以预先开辟一个大空间,前提是知道实际数据数量。
#pragma once
#include<vector>
#include<string>
#include<utility>
#include<iostream>
using namespace std;
namespace H
{
// 哈希表每个空间给个标记
// EMPTY此位置空, EXIST此位置已经有元素, DELETE元素已经删除
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 Hash=HashFunc<K>>
class HashTable
{
public:
HashTable(size_t capacity = 0)
: _tables(__stl_next_prime(capacity)), _size(0)
{
for (size_t i = 0; i < capacity; ++i)
_tables[i]._state = EMPTY;
}
private:
// 获取下一个质数
inline size_t __stl_next_prime(size_t n)
{
static const size_t __stl_num_primes = 28;
static const size_t __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
};
for (size_t i = 0; i < __stl_num_primes; ++i)
{
if (__stl_prime_list[i] > n)
{
return __stl_prime_list[i];
}
}
return -1;
}
private:
vector<HashData<K,V>> _tables;
size_t _size=0;
size_t loadFactor = 7;// 负载因子
};
}
4.1.2 插入数据
插入步骤
- 检测扩容。
- 过哈希函数获取待插入元素在哈希表中的位置。
- 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。
bool Insert(const pair<K, V>& kv)
{
// 检测哈希表底层空间是否充足
_CheckCapacity();
Hash HashFunc;
size_t hashAddr = HashFunc(kv.first);
size_t startAddr = hashAddr;
while (_tables[hashAddr]._state != EMPTY)
{
//数据重复了
if (_tables[hashAddr]._state == EXIST && _tables[hashAddr]._kv.first
== kv.first)
return false;
hashAddr++;
if (hashAddr == _tables.size())
hashAddr = 0;
/*
// 转一圈也没有找到,注意:动态哈希表,该种情况可以不用考虑,哈希表中元
素个数到达一定的数量,哈希冲突概率会增大,需要扩容来降低哈希冲突,因此哈希表中元素是
不会存满的
*/
//bug 处理极端情况下,线性探测的过程中,探测的节点都是 state==DELETE,会导致死循环
if (hashAddr == startAddr)
return false;
}
// 插入元素
_tables[hashAddr]._state = EXIST;
_tables[hashAddr]._kv = kv;
_size++;
return true;
}
思考:哈希表什么情况下进行扩容?如何扩容?
散列表的载荷因子定义为: a = 填入表中的元素个数 /散列表的长度
a是散列表装满程度的标志因子。由于表长是定值,a与“填入表中的元素个数”成正比,所以,a越大,表明填入表中的元素占比越多,产生冲突的可能性就越大:反之,a越小,标明填入表中的元素占比越少,产生冲突的可能性就越小。
实际上,散列表的平均查找长度是载荷因子a的函数,只是不同处理冲突的方法有不同的函数。
对于开放定址法,荷载因子是特别重要因素,应严格限制在0.7-0.8以下。超过0.8,查表时的CPU缓存不命中(cachemissing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的系统库限制了荷载因子为0.75,超过此值将resize散列表(扩容)。
void _CheckCapacity()
{
if (_tables.size() == 0 || _size * 10 / _tables.size() >= loadFactor)
{
//扩容
size_t newSize = __stl_next_prime(_tables.size());
HashTable<K, V, Hash> newHT;
newHT._tables.resize(newSize);
for (size_t i = 0; i < _tables.size(); i++)
{
if (_tables[i]._state == EXIST)
{
newHT.Insert(_tables[i]._kv);
}
}
_tables.swap(newHT._tables);
}
}
哈希函数
- 我们模拟STL实现一个模板仿函数。
- STL库里我们也可以自己写一个仿函数传递给
hash
,让hash
使用我们特定的仿函数。 - STL库默认将我们的key强制转换位
size_t
- 因此std::string经常使用,string不能够强制转化位
size_t
,因此我们特化一份string
类型的仿函数。 - 如何写
string
类型的hash仿函数? - 将一个string类型转换位hash值其实有多种方法的,该链接详细介绍字符串哈希算法
- 我们使用第一种方法,简单高效。
template<class K>
struct HashFunc
{
size_t operator()(const K& k)
{
return size_t(k);
}
};
template<>
struct HashFunc<std::string>
{
size_t operator()(const std::string& str)
{
size_t ret = 0;
for (auto e : str)
{
ret *= 131;
ret += e;
}
return ret;
}
};
4.2. 二次探测
线性探测优点:实现非常简单,
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同
关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降
低。如何缓解呢?
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位
置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法
为:
H
i
H_i
Hi = (
H
0
H_0
H0 +
i
2
i^2
i2 )% m, 或者:
H
i
H_i
Hi = (
H
0
H_0
H0 -
i
2
i^2
i2 )% m。其中:i =1,2,3…,
H
0
H_0
H0是通过散列函数Hash(x)对元素的关键码 key 进行计算得到的位置,m是表
的大小。
研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任
何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在
搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出
必须考虑增容。
bool Insert(const pair<K, V>& kv)
{
// 检测哈希表底层空间是否充足
_CheckCapacity();
Hash HashFunc;
size_t hashAddr = HashFunc(kv.first)%_tables.size();
size_t startAddr = hashAddr;
size_t i = 0;
while (_tables[hashAddr]._state == EXIST)
{
//数据重复了
if (_tables[hashAddr]._kv.first== kv.first)
return false;
i++;
hashAddr = (hashAddr + i*i) % _tables.size();
}
// 插入元素
_tables[hashAddr]._state = EXIST;
_tables[hashAddr]._kv = kv;
_size++;
return true;
}
4.3 闭散列查找与删除
HashData<K, V>* Find(const K& key)
{
size_t hashAddr = HashFunc(key) % _tables.size();
size_t startAddr = hashAddr;
size_t i = 0;
while (_tables[hashAddr]._state != EMPTY)
{
if (_tables[hashAddr]._state == EXIST && _tables[hashAddr]._kv.first
== key)
return &_tables[hashAddr];
//hashAddr++;
i++;
hashAddr = (hashAddr + i * i) % _tables.size();
if (hashAddr == startAddr)
return nullptr;
}
return nullptr;
}
bool Erase(const K & key)
{
HashData<K, V>* indexP = Find(key);
if (nullptr != indexP)
{
indexP->_state = DELETE;
_size++;
return true;
}
return false;
}
5. 开散列
5.1 开散列概念
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地
址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链
接起来,各链表的头结点存储在哈希表中。
注意:如果桶中数据量大,有些库实现会采用把桶换成红黑树,一个线性,一个非线性,数据量大时搜索起来肯定要快很多。我们这里采用线性的方式实现。
如下图插入 1,4,5,6,7,9,44
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
5.2 开散列的实现
- 开散列与闭散列的区别
- 存储结构不一样
- 都需要考虑增容问题
5.3 初始化实现
namespace Bucket
{
template<class K, class V>
struct HashNode
{
pair<K, V> _kv;
HashNode<K, V>* _next;
HashNode(const pair<K, V>& kv)
:_kv(kv)
, _next(nullptr)
{}
};
// 休息:20
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
public:
HashTable(size_t capacity = 0)
: _tables(__stl_next_prime(capacity),nullptr), _size(0)
{
}
inline size_t __stl_next_prime(size_t n)
{
static const size_t __stl_num_primes = 28;
static const size_t __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
};
for (size_t i = 0; i < __stl_num_primes; ++i)
{
if (__stl_prime_list[i] > n)
{
return __stl_prime_list[i];
}
}
return -1;
}
private:
vector<Node*> _tables;
size_t _size = 0; // 存储有效数据个数
};
}
5.3 开散列插入
步骤:
- 检查死否需要增容。
- 插入元素求出hash值,向对应单链表进行头插。
开散列增容:
- 桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可
能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希
表进行增容,那该条件怎么确认呢? - 开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容。
增容步骤:
- 开辟一个新的哈希表。
- 将旧的哈希表的所有节点进行重新计算在新的哈希表里的哈希值。
- 节点由堆开辟,有对应的地址,我们只需要将节点移动到新哈希表里即可,不需要节点拷贝。
bool Insert(const pair<K, V>& kv)
{
// 去重
if (Find(kv.first))
{
return false;
}
Hash hash;
// 负载因子到1就扩容
if (_size == _tables.size())
{
vector<Node*> newTables;
newTables.resize(__stl_next_prime(_tables.size()), nullptr);
// 旧表中节点移动映射新表
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
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[i] = nullptr;
}
_tables.swap(newTables);
}
size_t hashi = hash(kv.first) % _tables.size();
// 头插
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_size;
return true;
}
5.4 开散列查找
查找步骤:
- 求出查找元素的hash值。
- 向对应链表遍历查找。
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;
}
5.5 开散列删除
删除步骤:
- 求出删除元素的hash值。
- 向对应链表遍历查找元素,然后删除。
- 注意:考虑头部元素删除 还是 中间元素删除。原因是删除两者删除的逻辑不一样。
bool Erase(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
Hash hash;
size_t hashi = hash(key) % _tables.size();
Node* prev = nullptr;
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
// 1、头删
// 2、中间删
if (prev == nullptr)
{
_tables[hashi] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
--_size;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
5.6 开散列Size()
// 获取有效元素大小
size_t Size()
{
return _size;
}
// 表的总长度
size_t TablesSize()
{
return _tables.size();
}
// 桶的个数
size_t BucketSize()
{
size_t num = 0;
for (size_t i = 0; i < _tables.size(); ++i)
{
if (_tables[i])
{
++num;
}
}
return num;
}
// 有效桶的个数
size_t MaxBucketLenth()
{
size_t maxLen = 0;
for (size_t i = 0; i < _tables.size(); ++i)
{
size_t len = 0;
Node* cur = _tables[i];
while (cur)
{
++len;
cur = cur->_next;
}
//if (len > 0)
//printf("[%d]号桶长度:%d\n", i, len);
if (len > maxLen)
{
maxLen = len;
}
}
return maxLen;
}
6. 开散列与闭散列比较
闭散列:
- 为了保证搜索效率,负载因子需要控制在 <=0.7~0.8 之间。
- 负载因子越大搜索效率越低,空间利用率越高。
- 负载因子越小搜索效率越高,空间利用率越低
- 优点:
- 实现非常简单。
- 缺点:
- 线性探测,不同的关键码占用别人的空位置,导致数据粘连,需要多次比较,搜索效率降低。
- 二次探测,让关键码分散,解决大部门数据粘连。
- 因此,开散列最大的缺陷是哈希表里存在大量空闲空间。
开散列:
- 闭散列和开散列最坏情况时间复杂度为 O(N);
- 开散列最好的情况是每个桶都挂一个节点,搜索时间O(1)。
- 闭散列最后的情况是每个关键码能对应其存放位置,搜索时间O(1)。
- 增容比较:
- 两者最好的情况是预先开辟空间,无需增容。
- 闭散列和开散列增容时时间都是O(N).
- 但是两者的增容过程是不一样的。
- 闭散列采用需要拷贝数据。开散列采用移到节点,不需要拷贝。
- 因此,增容时开散列更优秀。
- 空间比较:
- 开散列/链式法 有指针消耗,较少的空闲空间浪费,但是相比闭散列,表项比指针占用更多的空间。
- 因此,开散列空间利用率比闭散列好。