闭散列实现哈希表
在闭散列实现哈希表中,我们选择线性探测法来解决哈希冲突。在哈希表的简介部分,我们已经介绍过线性探测法啦!
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
基本结构定义
在哈希表的简介部分,我们知道闭散列中删除一个元素是通过标记的方式删除的,因此在实现闭散列中我们需要定义一个变量用来标记哈希表中每一个位置的状态。这种情况可以使用枚举(enum)来表示哈希表中每个位置的状态。
enum STATE
{
EXIST, //表示这个位置已经存放数据了
EMPTY, //表示这个位置没有存放数据
DELETE //表示这个位置的数据已经被删除
};
哈希表是 unordered_map
与 unordered_set
的底层数据结,HashTable
存储的数据依旧是 key-value
的形式,也就是 pair
啦!类比 map
和 set
就行啦!
template<class K, class V>
struct HashData
{
pair<K, V> _kv; //存储数据
STATE _state; //哈希表每一个位置的状态
};
哈希表就是一个数组,只不过具有特定的插入,删除数据的方式。比可以类比priority_queue
(堆),他的底层也是一个数组,但是也具有插入删除数据的规则。
template<class K, class V>
class HashTable
{
public:
private:
vector<HashData<K, V>> _table;
size_t _n = 0; //哈希表中有效元素的个数
};
这里为什么需要一个 _n
来存储哈希表中有效元素的个数呢?在哈希表的简介部分我们已经知道,闭散列解决哈希冲突的方式可能会发生数据堆叠,降低哈希表的效率。因此,我们需要维护 _n
来管理哈希表的扩容,以此来提高效率。具体的做法会在哈希表的 Insert
函数中讲解。
构造函数
我们可以一开始就给哈希表开一段空间,原因后面再讲。
HashTable()
{
_table.resize(10);
}
bool Insert(const pair<K, V>& kv)
在哈希表的简介部分我们了解到很多哈希函数,我们选择比较常见的除留余数法作为实现哈希表的哈希函数。当我们要插入一个新的元素时,我们就拿他的 key
对数组的 size
取模,这样就能得到新元素对应的下标位置,如果这个下标对应的标识符不是 EMPTY
就找下一个位置,直到找到一个标识符为 DELETE
或者 EMPTY
的位置,然后插入元素就可以啦。
不可以对数组的 capacity
取模哦!
为了方便,哈希表中的数组一般都是 size
和 capacity
一样大的,即使不一样大,对 size
取模都不会错。
为了确保 size
和 capacity
一样大,在 HashTable
的构造函数调用 vector
的 resize
函数就可以!
bool Insert(const pair<K, V>& kv)
{
size_t hashi = kv.first % _table.size();
//找到一个空位置
while (_table[hashi]._state != EXIST)
{
++hashi;
hashi %= _table.size();
}
//插入数据
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
++_n;
return true;
}
于是我们顺利写出了这样的代码,显然这是不够的,因为我们并没有考虑哈希表扩容的问题。那扩容逻辑应该怎么写呢?我们先不着急,来看看哈希表的载荷因子是个什么东东。
哈希表的载荷因子定义为: α \alpha α = 填入表中的元素个数 / 哈希表的长度
α \alpha α 是散列表装满程度的标志因子。由于表长是定值, α \alpha α 与 “填入表中的元素个数” 成正比,所以, α \alpha α 越大,表明填入表中的元素越多,产生冲突的可能性越大,但是空间利用率越大;反之, α \alpha α 越小,表明填入表中的元素越少,产生冲突的可能性就越小,但是空间利用率就变低了。实际上,哈希表的平均查找长度是载荷因子 α \alpha α 的函数,只是不同处理冲突的方法有不同的函数。
对于开放定址法,载荷因子是特别重要的因素,应严格限制在 0.7 - 0.8 以下。超多 0.8 查询时 CPU 缓存不明中按照指数曲线上升。因此,一些采用开放定址法的 hash 库,如 Java 的系统库限制了载荷因子为 0.75,超过这个值就要对哈希表进行扩容处理。
我们实现,选择载荷因子大于等于 0.7 之后扩容哈!假设我们一开始没有给哈希表初始化空间,在扩容判断的时候就比较麻烦,因为一开始哈希表的 size
为 0,就需要特殊判断了!
α
=
n
/
t
a
b
l
e
.
s
i
z
e
(
)
\alpha = n / table.size()
α=n/table.size(),n 为哈希表中有效元素的个数,显然 table.size()
作为除数是不能为 0 的,因此需要特殊判断。
在扩容之后,因为 HashTable
的大小发生改变了,原来哈希表中的元素都需要重新映射。
不可以直接 resize(newSize)
就完事儿了,必须重新映射!
在计算载荷因子时有如下计算方法:
1 : n ∗ 10 / t a b l e . s i z e ( ) ≥ 7 1:n * 10 / table.size() \geq 7 1:n∗10/table.size()≥7
2 : ( d o u b l e ) n / t a b l e . s i z e ( ) ≥ 0.7 2:(double)n / table.size() \geq 0.7 2:(double)n/table.size()≥0.7
看你喜欢哪一种了!
bool Insert(const pair<K, V>& kv)
{
if (_n * 10 / _table.size() >= 7)
{
size_t newSize = _table.size() * 2;
//遍历旧表,重新映射到新表
HashTable<K, V> newHT;
newHT._table.resize(newSize);
for (int i = 0; i < _table.size(); i++)
{
if (_table[i]._state == EXIST)
{
newHT.Insert(_table[i]._kv);
}
}
_table.swap(newHT._table);
}
size_t hashi = kv.first % _table.size();
//找到一个空位置
while (_table[hashi]._state != EXIST)
{
++hashi;
hashi %= _table.size();
}
//插入数据
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
++_n;
return true;
}
HashData<const K, V>* Find(const K& key)
查找比较简单,根据 key
算出下标,然后向后查找,如果找到和 key
值相等的元素并且该元素的标志位不是 DELETE
,返回即可;如果直到找到标志位为空的元素还没找到,那么返回 nullptr
就可以。
HashData<const K, V>* Find(const K& key)
{
size_t hashi = key % _table.size();
while (_table[hashi]._state != EMPTY)
{
if (_table[hashi]._state == EXIST && _table[hashi]._kv.first == key)
{
return (HashData<const K, V>*)&_table[hashi];
}
++hashi; //继续到下一个位置寻找
hashi %= _table.size();
}
return nullptr; //哈希表中找不到这个元素
}
bool Erase(const K& key)
这个函数就更简单了,如果说要删除的元素在哈希表中存在的话,将对应的标志位改为 DELETE
就行啦!
bool Erase(const K& key)
{
HashData<const K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_n;
return true;
}
return false;
}
解决 string 作为 key 的情况
像那些整形,指针啥的都可以直接取模,然后计算他在哈希表中的位置。但是对于字符串呢,他是无法取模的。因此,怎么解决这个问题呢?又得靠我们的仿函数了,当 key
值为 string
类型的时候,通过模板的特化,调用处理 string
的那个 operator()
就可以啦!
template<class K>
struct DefaultHashFunc
{
size_t operator()(const K& key)
{
return (size_t)key;
}
};
这是一个对于可以取模的 key 准备的 operator()
我们啥也不需要做,返回传入的 key
就行啦。返回值强转为 size_t
能够解决负数作为 key
值的问题。
模板特化的知识点已经讲过了,忘记了可以复习复习:21天学会C++:Day14----模板_姬如祎的博客-CSDN博客
这里还有一个问题就是,如何让字符串能够对整数取模呢?这里就涉及到字符串的哈希了!字符串的哈希算法有很多,你可以在网上搜索。我们这里选择一个比较常见的字符串哈希算法:BKDR Hash算法。
模板特化:
template<>
struct DefaultHashFunc<string>
{
size_t operator()(const string& str)
{
size_t hash = 0;
for (auto e : str)
{
hash *= 131;
hash += e;
}
return hash;
}
};
最终代码:
template<class K, class V, class HashFunc = DefaultHashFunc<K>>
class HashTable
{
public:
HashTable()
{
_table.resize(10);
}
bool Insert(const pair<K, V>& kv)
{
if (_n * 10 / _table.size() >= 7)
{
size_t newSize = _table.size() * 2;
//遍历旧表,重新映射到新表
HashTable<K, V> newHT;
newHT._table.resize(newSize);
for (int i = 0; i < _table.size(); i++)
{
if (_table[i]._state == EXIST)
{
newHT.Insert(_table[i]._kv);
}
}
_table.swap(newHT._table);
}
HashFunc hf;
size_t hashi = hf(kv.first) % _table.size();
//找到一个空位置
while (_table[hashi]._state != EXIST)
{
++hashi;
hashi %= _table.size();
}
//插入数据
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
++_n;
return true;
}
HashData<const K, V>* Find(const K& key)
{
HashFunc hf;
size_t hashi = hf(key) % _table.size();
while (_table[hashi]._state != EMPTY)
{
if (_table[hashi]._state == EXIST && _table[hashi]._kv.first == key)
{
return (HashData<const K, V>*)&_table[hashi];
}
++hashi; //继续到下一个位置寻找
hashi %= _table.size();
}
return nullptr; //哈希表中找不到这个元素
}
bool Erase(const K& key)
{
HashData<const K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_n;
return true;
}
return false;
}
private:
vector<HashData<K, V>> _table;
size_t _n = 0; //哈希表中有效元素的个数
};
好啦,这就是开散列实现哈希表的全部过程啦!下一讲我们会探索闭散列实现哈希表的方式。