1. 哈希的引入
1.1. 哈希的概念
无论是在顺序结构还是在树形结构中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。搜索的效率取决于搜索过程中元素的比较次数,因此顺序结构中查找的时间复杂度一般为 O ( N ) O(N) O(N) ,而平衡树中查找的时间复杂度为树的高度 O ( l o g N ) O(logN) O(logN)。为了进一步提高查找效率,就有人提出了哈希的概念。
哈希简单来说就是通过构造一种存储结构,该结构能够通过某种函数使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时就能通过该函数很快找到该元素。该方式即为哈希(散列)方法, 哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(散列表)。
1.2. 构建哈希表
一般我们利用数组来构建哈希表,建立数组下标与数据元素之间的映射关系,那么在查找时就可以通过数组下标以 O ( 1 ) O(1) O(1)的时间复杂度找到该元素。简单来说可以分为以下两个步骤:
例如:
我们将集合{1,3,5,6}
中所有元素插入一个哈希表中,哈希函数设置为:hash(key)=key%capacity
,其中capacity
为存储元素底层空间的总大小,这里假设为8。
2. 哈希冲突
2.1. 哈希冲突的概念
哈希冲突,也称为散列冲突,是指在使用哈希函数进行数据存储或检索时,不同的输入数据经过哈希函数计算后得到了相同的哈希值。
比如我们继续以上面的例子为例,插入一个元素9会发生什么呢?
2.2. 哈希函数的设计
哈希冲突发生的一个重要的一个原因就是:哈希函数设计不合理。我们在设计哈希函数时应遵循以下规则:
常见的哈希函数有以下这几种:
3. 哈希冲突的解决
虽然哈希函数设计的越精确,发生哈希冲突的可能性就越低,但都无法彻底避免哈希冲突。所以为了解决这个问题,提出了两种方法:闭散列与开散列。
3.1. 闭散列
闭散列,也称为开放定址法,是一种解决哈希冲突的方法。在这种方法中,当发生哈希冲突时,通过在哈希表中寻找另一个空闲位置来存储冲突的数据。
具体思路为当使用哈希函数计算出一个关键字的哈希地址后,如果该地址已经被占用(发生了哈希冲突),则按照一定的探测方法在哈希表中寻找下一个空闲位置来存储该关键字。这个过程一直持续到找到一个空闲位置或者遍历完整个哈希表。
其中探测方法主要有以下两种:
继续借用上面的例子,我们插入元素9会发生哈希冲突,这时我们采用线性探测该元素会插入到2号位置。
如果我们继续插入元素11,采用二次探测的方式,该元素就会插入到4号位置。
通过上面观察我们知道当我们插入元素越多发生哈希冲突的可能性就越大,为了避免这种情况这时我们就需要扩容,而扩容需要我们设置一个参照条件判断当前是否需要扩容,这个条件在哈希表中被称为负载因子。
其中负载因子越大,产出冲突的概率越高,增删查改的效率越低。负载因子越小,产出冲突的概率越低,增删查改的效率越高。一般负载因子超过0.7就需要进行扩容。
3.2. 开散列
开散列又叫链地址法(开链法),也是一种解决哈希表的方法。具体来说就是对关键码集合用哈希函数计算哈希地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中,所以也叫哈希桶。
比如我们将集合{1,3,5,6,9,11,17}
中所有元素插入一个哈希表中,哈希函数设置为:hash(key)=key%capacity
,其中capacity
为假设为8。
当然开散列也需要设置负载因子,而且开散列的负载因子可以大于1,但是我们一般建议设置在0~1之间。并且开散列还存在一种极端情况那就是所有元素都冲突,被放在一个桶里。此时增删查改的效率就会劣化为 O ( N ) O(N) O(N)
为了解决这个情况,当每个桶中的元素超过一定长度之后,将单链表结构改为红黑树结构,将红黑树的根节点存放在哈希表中。这样保证即使是极端情况,查找效率也可以保持 O ( l o g N ) O(logN) O(logN)。
但是有些实现就没有这样的方式,因为随着数据的增多,负载因子就会增大最终就会导致扩容,一旦扩容哈希冲突的个数就会减少,所以不做任何处理也是可行的。
4. 哈希表的功能
哈希表的功能主要有以下三个:
5. 哈希表的结构
5.1. 闭散列的结构
在使用闭散列的哈希中,如果要删除某个数据我们为了保持原有的结构不可能采用的覆盖的形式,所以我只好采用一种伪删除的方式,即对删除节点进行标记。而标记State
的方式有三种:EMPTY(无数据)
,EXIST(存在数据)
,DELETE(删除)
,所以说每个哈希节点的结构如下:
enum State
{
EMPTY,//不存在数据
EXIST,//存在数据
DELETE//已删除
};
template<class K,class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
而在哈希表中,我们以数组的方式存放数据,并且为了方便计算负载因子,需要增加一个记录有效元素个数的变量_n
。其中为了方便描述我们可以使用typedef
简化。
template<class K,class V>
class HashTable
{
typedef HashData<K, V> Node;
public:
//具体实现
private:
vector<Node> _table;//哈希表
size_t _n = 0;//有效数据个数
};
5.2. 开散列的结构
在开散列中每一个的元素存放的都是一个链表,所以节点结构参照链表实现即可。
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)
{}
};
哈希表的结构就与闭散列的类似,都存在表示一个变量_n
表示有效元素的个数。并且数组元素存放的是一个地址。
template<class K,class V>
class HashTable
{
typedef HashData<K, V> Node;
public:
//具体实现
private:
vector<Node*> _table;//哈希表
size_t _n = 0;//有效数据个数
};
6. 哈希表的功能
6.1. 哈希表的插入
6.1.1. 闭散列
闭散列的插入首先得考虑扩容的情况,一般而言当负载因子大于0.7就需要扩容,而扩容就会影响原来哈希表的映射关系,所以这时就需要将原哈希表的元素重新通过哈希函数映射到新的哈希表中,这里我们可以采用类似递归的方式插入简化过程。最后通过交换使原哈希表出了作用域之后销毁。
最后插入过程只需要通过哈希函数找到对应的位置,如果发生哈希冲突则进行线性探测或者二次探测,最后再增加有效元素个数。
其中这里注意:因为闭散列的负载因子是控制以0.7以内的,所以一定能找到插入位置。
bool Insert(const pair<K, V>& kv)
{
// 查找是否已存在相同键的节点
Node* ret = Find(kv.first);
if (ret)
{
return false;
}
// 如果哈希表为空,则初始化大小为 10
if (_table.size() == 0)
{
_table.resize(10);
}
// 负载因子大于 0.7 则扩容
else if ((double)_n / _table.size() > 0.7)
{
HashTable<K, V> newHT;
// 新哈希表大小为原哈希表的两倍
newHT._table.resize(2 * _table.size());
for (auto& e : _table)
{
// 如果当前位置的状态为已存在,则将其插入新哈希表
if (e._state == EXIST)
{
// 用类似递归的方式复用插入操作
newHT.Insert(e._kv);
}
}
// 通过交换让原本哈希表自动回收,同时新哈希表成为当前使用的哈希表
_table.swap(newHT._table);
}
// 计算起始位置,capacity 中有些空间还未初始化,所以只能模 size
size_t start = kv.first % _table.size();
size_t index = start;
size_t hashi = 1;
// 找到空位置用于插入新节点
while (_table[index]._state == EXIST)
{
//线性探测
hashi++;
//二次探测
//index = start + hashi*hashi
}
// 将新的键值对插入到找到的空位置
_table[index]._kv = kv;
_table[index]._state = EXIST;
// 有效数据数量加一
_n++;
return true;
}
6.1.2. 开散列
开散列的插入也需要考虑扩容,但是开散列因为存放的是一个链表,所以空间利用率就比较高,这时我们的负载因子可以设为1。而同样我们也需要将原数据映射到新的哈希表中,这里我们可以通过遍历原哈希表对新的哈希表进行头插的形式,最后通过交换使原哈希表出了作用域之后销毁。
插入过程也十分简单,因为是开散列所以直接通过哈希函数映射到相应位置,然后进行头插,然后增加有效元素个数即可。
bool Insert(const pair<K, V>& kv)
{
// 查找是否已存在相同键的节点
Node* ret = Find(kv.first);
if (ret)
{
return false;
}
// 如果负载因子等于 1 进行扩容
if (_n == _table.size())
{
vector<Node*> newHT;
// 如果原哈希表大小为 0,则新哈希表大小为 10,否则为原大小的两倍
size_t newSize = _table.size() == 0 ? 10 : _table.size() * 2;
newHT.resize(newSize);
for (int i = 0; i < _table.size(); i++)
{
if (_table[i])
{
Node* cur = _table[i];
// 遍历链表进行头插更新节点进新的哈希表
while (cur)
{
// 记录下一个节点
Node* next = cur->_next;
size_t index = cur->_kv.first % newHT.size();
// 进行头插
cur->_next = newHT[index];
newHT[index] = cur;
cur = next;
}
// 将原哈希桶置空
_table[i] = nullptr;
}
}
// 通过交换让原本哈希表自动回收,同时新哈希表成为当前使用的哈希表
_table.swap(newHT);
}
// 计算插入位置
size_t index = kv.first % _table.size();
Node* newnode = new Node(kv);
// 进行头插
newnode->_next = _table[index];
_table[index] = newnode;
_n++;
return true;
}
6.2. 哈希表的查找
6.2.1. 闭散列
查找逻辑就比较简单了,直接通过哈希函数计算相应的位置,然后通过线性探测与二次探测一一比较,如果比对成功且该元素存在那么查找成功,如果查找到空节点那么查找失败。
Node* Find(const K& key)
{
if (_table.size() == 0)
{
return nullptr;
}
//capacity中有些空间还未初始化,所以只能模size
size_t start = key % _table.size();
size_t index = start;
size_t hashi = 1;
//闭散列中哈希表中一定有空位置,如果是EMPTY则未找到
while (_table[index]._state != EMPTY)
{
if (_table[index]._state == EXIST && _table[index]._kv.first == key)
{
return &_table[index];
}
//线性探测
index = start + hashi;
//二次探测
//index = start + i*i
index %= _table.size();
hashi++;
}
return nullptr;
}
6.2.2. 开散列
开散列的查找就更简单了,只需通过对应的哈希函数找到插入位置遍历链表查找即可。
Node* Find(const K& key)
{
if (_table.size() == 0)
{
return nullptr;
}
size_t index = key % _table.size();
Node* cur = _table[index];
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
6.3. 哈希表的删除
6.3.1. 闭散列
闭散列可以通过服用查找函数,如果找到了则将对应元素改为删除状态减少有效元素个数,否则返回nullptr
。
bool Erase(const K& key)
{
//找到删除目标
Node* ret = Find(key);
if (!ret)
{
return false;
}
//找到了进行伪删除
ret->_state = DELETE;
_n--;
return true;
}
6.3.2. 开散列
开散列就不能服用插入函数了,因为我们删除节点需要先找到该节点的前一个节点.,所以只能通过重新遍历查找,最后链接前后节点再使有效元素个数减少即可。
bool Erase(const K& key)
{
size_t index = key % _table.size();
//记录前一个节点方便链接
Node* prev = nullptr;
Node* cur = _table[index];
while (cur)
{
//找到
if (cur->_kv.first == key)
{
//如果为头节点
if (prev == nullptr)
{
_table[index] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
--_n;
return true;
}
//没找到的话就遍历下一个
prev = cur;
cur = cur->_next;
}
return false;
}
思考题:为什么除留余数法这种方法的模数建议为素数,并且又该如何实现呢?
实现方法如下:
//直接定一个倍数接近2的素数数组
const size_t primeList[PRIMECOUNT] =
{
53ul, 97ul, 193ul, 389ul, 769ul,
1543ul, 3079ul, 6151ul, 12289ul, 24593ul,
49157ul, 98317ul, 196613ul, 393241ul, 786433ul,
1572869ul, 3145739ul, 6291469ul, 12582917ul, 25165843ul,
50331653ul, 100663319ul, 201326611ul, 402653189ul, 805306457ul,
1610612741ul, 3221225473ul, 4294967291ul
};
//每次扩容时改用调用该函数即可
size_t GetNextPrime(size_t prime)
{
const int PRIMECOUNT = 28;
size_t i = 0;
for (i = 0; i < PRIMECOUNT; i++)
{
if (primeList[i] > prime)
return primeList[i];
}
return primeList[i];
}
思考题:为什么闭散列中的状态要由DELETE,删除之后设为EMPTY不也可以吗?
7. 源码
7.1. 闭散列
namespace open_address
{
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
{
typedef HashData<K, V> Node;
public:
//具体实现
bool Insert(const pair<K, V>& kv)
{
// 查找是否已存在相同键的节点
Node* ret = Find(kv.first);
if (ret)
{
return false;
}
// 如果哈希表为空,则初始化大小为 10
if (_table.size() == 0)
{
_table.resize(10);
}
// 负载因子大于 0.7 则扩容
else if ((double)_n / _table.size() > 0.7)
{
HashTable<K, V> newHT;
// 新哈希表大小为原哈希表的两倍
newHT._table.resize(2 * _table.size());
for (auto& e : _table)
{
// 如果当前位置的状态为已存在,则将其插入新哈希表
if (e._state == EXIST)
{
// 用类似递归的方式复用插入操作
newHT.Insert(e._kv);
}
}
// 通过交换让原本哈希表自动回收,同时新哈希表成为当前使用的哈希表
_table.swap(newHT._table);
}
// 计算起始位置,capacity 中有些空间还未初始化,所以只能模 size
size_t start = kv.first % _table.size();
size_t index = start;
size_t hashi = 1;
// 找到空位置用于插入新节点
while (_table[index]._state == EXIST)
{
index = start + hashi;
index %= _table.size();
//线性探测
hashi++;
//二次探测
//index = start + hashi*hashi
}
// 将新的键值对插入到找到的空位置
_table[index]._kv = kv;
_table[index]._state = EXIST;
// 有效数据数量加一
_n++;
return true;
}
Node* Find(const K& key)
{
if (_table.size() == 0)
{
return nullptr;
}
//capacity中有些空间还未初始化,所以只能模size
size_t start = key % _table.size();
size_t index = start;
size_t hashi = 1;
//闭散列中哈希表中一定有空位置,如果是EMPTY则未找到
while (_table[index]._state != EMPTY)
{
if (_table[index]._state == EXIST && _table[index]._kv.first == key)
{
return &_table[index];
}
//线性探测
index = start + hashi;
//二次探测
//index = start + i*i
index %= _table.size();
hashi++;
}
return nullptr;
}
bool Erase(const K& key)
{
//找到删除目标
Node* ret = Find(key);
if (!ret)
{
return false;
}
//找到了进行伪删除
ret->_state = DELETE;
_n--;
return true;
}
private:
vector<Node> _table;//哈希表
size_t _n = 0;//有效数据个数
};
}
7.2. 开散列
namespace hash_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)
{}
};
template<class K, class V>
class HashTable
{
typedef HashNode<K, V> Node;
public:
bool Insert(const pair<K, V>& kv)
{
// 查找是否已存在相同键的节点
Node* ret = Find(kv.first);
if (ret)
{
return false;
}
// 如果负载因子等于 1 进行扩容
if (_n == _table.size())
{
vector<Node*> newHT;
// 如果原哈希表大小为 0,则新哈希表大小为 10,否则为原大小的两倍
size_t newSize = _table.size() == 0 ? 10 : _table.size() * 2;
newHT.resize(newSize);
for (int i = 0; i < _table.size(); i++)
{
if (_table[i])
{
Node* cur = _table[i];
// 遍历链表进行头插更新节点进新的哈希表
while (cur)
{
// 记录下一个节点
Node* next = cur->_next;
size_t index = cur->_kv.first % newHT.size();
// 进行头插
cur->_next = newHT[index];
newHT[index] = cur;
cur = next;
}
// 将原哈希桶置空
_table[i] = nullptr;
}
}
// 通过交换让原本哈希表自动回收,同时新哈希表成为当前使用的哈希表
_table.swap(newHT);
}
// 计算插入位置
size_t index = kv.first % _table.size();
Node* newnode = new Node(kv);
// 进行头插
newnode->_next = _table[index];
_table[index] = newnode;
_n++;
return true;
}
Node* Find(const K& key)
{
if (_table.size() == 0)
{
return nullptr;
}
size_t index = key % _table.size();
Node* cur = _table[index];
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
bool Erase(const K& key)
{
size_t index = key % _table.size();
//记录前一个节点方便链接
Node* prev = nullptr;
Node* cur = _table[index];
while (cur)
{
//找到
if (cur->_kv.first == key)
{
//如果为头节点
if (prev == nullptr)
{
_table[index] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
--_n;
return true;
}
//没找到的话就遍历下一个
prev = cur;
cur = cur->_next;
}
return false;
}
private:
vector<Node*> _table;//哈希表
size_t _n = 0 ;//有效数据个数
};
}
ile (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
bool Erase(const K& key)
{
size_t index = key % _table.size();
//记录前一个节点方便链接
Node* prev = nullptr;
Node* cur = _table[index];
while (cur)
{
//找到
if (cur->_kv.first == key)
{
//如果为头节点
if (prev == nullptr)
{
_table[index] = cur->_next;
}
else
{
prev->_next = cur->_next;
}
delete cur;
--_n;
return true;
}
//没找到的话就遍历下一个
prev = cur;
cur = cur->_next;
}
return false;
}
private:
vector<Node*> _table;//哈希表
size_t _n = 0 ;//有效数据个数
};
}