什么是哈希?
哈希是一种将任意长度的数据映射到固定长度的数据的方法。哈希的目的是为了快速的比较,查找或者验证数据的完整性,其通常用于数据结构,加密,签名等领域中。哈希有多种算法,如MD5,SHA等
什么是unordered_map?
在C++11之前,STL标准库中的关联式容器只有map和set,还有它们的键值冗余版本multimap和multiset,对于关联式容器,其存储的数据是一对键值对,主要功能除了存储还有查找。而实现map和set的底层结构——红黑树,查找key的时间复杂度为O(logN),最差的情况下会达到树的高度次,如果其存储的数据量较大,查找数据的效率可能就不会很理想。为了达到高效的查找,C++11带来了unordered_map和unordered_set这两个容器,当然还有它们的键值冗余版本unordered_multimap和unordered_multiset。与map和set有什么区别呢?两者的底层实现容器不同,unordered容器底层使用了哈希,直接映射是哈希的一种实现方式,它可以通过key值直接的锁定数据(将关键字直接作为哈希表的索引),完成查找。
比如统计小写字母的个数,a ~ z的26个字母被映射到0 ~ 25中,将其作为数组下标,查找某一字母的出现次数,只要将字母对应下标在数组中的值返回即可,可以说哈希结构的查找效率达到了O(1),无论数据量多大,我们都能很快的查找某一key值。与红黑树的查找有什么不同呢?红黑树需要key值之间进行比较,根据比较结果选择进入哪颗左右子树,然后接着比较,而哈希呢,只要对key值做一个转换就能直接锁定需要查找的数据存储的位置,相比于红黑树的不断比较,哈希的一次转换效率显然更高。
闭散列的线性探测
我们知道在哈希结构中存储数据的位置有限,但是数据是无限的,有没有可能出现两个不同的数据映射到同一位置的情况呢(哈希冲突)?这样的情况很常见,比如一个长度为10的int数组,数组下标就是0 ~ 9了,当存储数据时,我们将数据模10,得到数据在数组中的存储位置。现在要存储11这个数据,模10后得到1,那么我们就将11放到数组下标为1的位置,然后我们还要存储1,1模10还是1,那么1也要放到下标为1的位置,但这块位置已经被使用了,当1无法存储到自己应该存储的位置时,我们称这时发生了哈希冲突,或者说哈希碰撞。这是哈希结构O(1)的查找效率背后的代价——频繁出现的哈希冲突,开散列与闭散列是解决哈希冲突的两种形式,这篇文章主讲闭散列。
闭散列也称开放定址法,具体的算法是线性探测,如果插入数据时数组还有剩余空间,发生哈希冲突后将数据存储到未存储数据的位置,通常是从当前下标往后查找空位置,一旦找到空位置就把数据存进去。这样的算法看起来解决了哈希冲突,但会带来“我占用你的空间,你占用他的空间”这样的情况,进而使查找效率降低。除了线性探测,二次探测也是一种闭散列实现方式,线性探测是从当前下标一个一个地向后找,而二次探测就是跳跃地找,你+了1,二次探测+了1的平方,你+了2,二次探测+了2的平方…其与线性探测没有本质上的区别,它们都会带来“我占用你的空间,你占用他的空间”这样的情况,无非就是二次探测出现的概率降低
闭散列的模拟实现
整体结构的交代
首先我们需要一个数组存储键值对,并且还需要表征数组某个位置的情况(空,被使用,被删除),所以该数组需要存储键值对和表征状态的数据
enum State
{
EMPTY,
DELETE,
EXIST
};
// 哈希表数组所存储的数据
template <class K, class V>
struct HashData
{
State _state = EMPTY; // 状态的表征
pair<K, V> _kv; // 键值对
};
那么哈希表的结构就很明显了,一个数组,当然还需要一个记录存储数据的多少的变量(用来实现负载均衡,用更多的空间减少哈希冲突)
template <class K, class V>
class HashTable
{
typedef HashData<K, V> Data;
private:
vector<Data> _table; // 哈希表
size_t _n; // 负载因子
};
最后,对于哈希表肯定的操作是增删查改了,这里我们实现插入,删除和查找功能
template <class K, class V>
class HashTable
{
typedef HashData<K, V> Data;
typedef pair<K, V> Type;
public:
bool Insert(const Type& kv); // 增
bool Erase(const K& key); // 删
Data* Find(const K& key); // 查
private:
vector<Data> _table; // 哈希表
size_t _n = 0; // 负载因子
};
查找接口的实现
根据用户传入的key值,将其转换成哈希值,映射到数组_table上,如果该位置被使用(状态为EXIT)并且两者的key值相同,将该位置的地址返回,如果key不同继续向后查找,直到位置的状态是EMPTY(遇到状态是DELETE也要继续向后查找,比如10,20,30将它们依次插入哈希表,当表的长度为10的时候,它们都冲突了,所以它们会依次向后排列,当20被删除,要查找30时,如果遇到20的DELETE就停止,则无法查找到30)
// Find函数的类外实现
template <class K, class V>
typename HashTable<K, V>::Data* HashTable<K, V>::Find(const K& key)
{
// 数组长度为0直接返回
if (_table.size() == 0)
{
return nullptr;
}
// 计算key对应的哈希值
size_t hashi = key % _table.size();
// 从对应哈希值处向后遍历,查找对应key值
// 当该位置不为空时,判断该位置上的数据的key是否是想要的
while (_table[hashi]._state != EMPTY)
{
// 注意如果该位置的状态是删除,不需要往后判断
if (_table[hashi]._state != DELETE && _table[hashi]._kv.first == key)
{
return &_table[hashi];
}
hashi++;
// 注意查找时不要越界
hashi %= _table.size();
}
// 找不到返回空
return nullptr;
}
插入接口的实现
由于我们默认哈希结构不允许键值冗余的情况存在,所以在插入数据前先判断key值是否已经存在,这里调用刚才实现的Find就可以。如果没有键值冗余的情况存在,我们需要将key转换成哈希值,将其存储到数组_table的对应位置,如果发生冲突就进行线性探测。
当然了,在插入数据前为了降低发生哈希冲突的概率,我们需要判断哈希表的负载因子是否大于指定的值,如果是则扩容(也就是当哈希表中的数据达到一定数量时,需要扩容,负载因子就是哈希表中的数据个数除以哈希表总大小,其范围在0~1)
// Insert函数的类外实现
template <class K, class V>
bool HashTable<K, V>::Insert(const Type& kv)
{
if (Find(kv.first)) // 键值冗余发生,不插入
{
return false;
}
// 扩容检查
if (_table.size() == 0 || _n * 10 / _table.size() >= 7) // 当负载因子大于0.7,对哈希表扩容
{
// 先算出扩容后的内存
size_t new_size = _table.size() == 0 ? 10 : 2 * _table.size();
// 创建一个新的哈希表,将旧哈希表的数据插入的新哈希表,最后交换两者
HashTable<K, V> new_table;
// 开辟新表的空间
new_table._table.resize(new_size);
for (int i = 0; i < _table.size(); ++i)
{
// 将旧表中的键值对插入到新表中,复用Insert函数,此时肯定不会再次扩容
if (_table[i]._state == EXIST)
{
new_table.Insert(_table[i]._kv);
}
}
// 最后要交换旧表和新表
_table.swap(new_table._table);
}
// 计算对应的哈希值
size_t hashi = kv.first % _table.size();
// 找到一个没有存储数据的位置
while (_table[hashi]._state == EXIST)
{
hashi++;
}
// 将数据存储到表中
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
// 记得_n的维护
_n++;
return true;
}
删除接口的实现
最后是哈希表的删除接口,很简单直接复用Find接口,查找要删除的数据是否存在,如果存在将其状态改为EXIST并且修改_n维护负载因子。如果不存在直接返回false
// Erase函数的类外实现
template <class K, class V>
bool HashTable<K, V>::Erase(const K& key)
{
// 调用Find并接收其返回值
Data* result = Find(key);
if (result)
{
result->_state = EXIST;
--_n;
return true;
}
return false;
}
至于为什么这里不需要些构造函数与析构函数,首先是HashTable的成员_n不涉及内存管理,涉及内存管理的_table也因为vector实现了构造与析构,HashTable的默认构造和析构会调用vector的构造与析构,至于拷贝构造和拷贝赋值也是因为vector已经实现深拷贝,而其他内置成员也不涉及内存管理,所以我们使用默认函数即可