前言:
前面我们一同学习了二叉搜索树,以及特殊版本的平衡二叉搜索树,这些容器让我们查找数据的效率提高到了O(log^2 N)。虽然效率提高了很多,但是有没有一种理想的方法使得我们能提高到O(1)呢?其实在C语言数据结构中,我们接触过哈希表,他可以使效率提高到O(1)。
哈希表作为STL中我们所必须学习和了解的容器,是一种一一映射的存储方式,其次它在日常生活中的应用范围也是很广的,例如位图,海量数据筛选中用到的布隆过滤器等等......
下面我们就来先学习一下STL中的应用哈希表的两个容器,再了解一下底层结构 (两个关联式容器unordered_map和unordered_set,unordered系列的关联式容器之所以效率比较高,是因为其底层使用了哈希结构),最后再来模拟实现一下。
目录
(一)STL中底层应用哈希的两个容器
在STL中对应的容器分别是unordered_map和unordered_set这两个关联式容器。、
我们会用map和set,其实就会用unordered_map和unordered_set这两个容器,但是这两类容器是有区别的!
我们一一分析:
1、unordered_set
文档链接->unordered_set文档
我们使用一下unordered_set的接口函数:
void test_unordered_set1()
{
unordered_set<int> s1;
s1.insert(1);
s1.insert(2);
s1.insert(9);
s1.insert(2);
s1.insert(3);
s1.insert(3);
s1.insert(4);
s1.insert(5);
unordered_set<int>::iterator it = s1.begin();
while (it != s1.end())
{
cout << *it << " ";
++it;
}
cout << endl;
for (auto e : s1)
{
cout << e << " ";
}
}
结果是实现了存储+去重,但是是无序的。
由上图和查阅资料得知:
- map和set: 去重 + 排序
- unordered_map和unordered_set: 只有去重
这里主要原因是底层实现不同,map和set底层是红黑树,unordered_set和unordered_map底层是红黑树。
其余函数接口和之前所学的容器使用起来大致相同,不再一一赘述。
unordered_map和unordered_set都是单向迭代器:
值得注意的是unordered_map和unordered_set的迭代器都是单向迭代器,而我们之前学的map和set则是双向迭代器(所以迭代器可以++也可以--)。
unordered_set和set的性能对比:
int main()
{
const size_t N = 100000;
unordered_set<int> us;
set<int> s;
vector<int> v;
v.reserve(N);
srand(time(0));
for (size_t i = 0; i < N; i++)
{
v.push_back(rand());
//v.push_back(rand()+i);
//v.push_back(i);
}
size_t begin1 = clock();
for (auto e : v)
{
s.insert(e);
}
size_t end1 = clock();
cout << "set insert:" << end1 - begin1 << endl;
size_t begin2 = clock();
for (auto e : v)
{
us.insert(e);
}
size_t end2 = clock();
cout << "unordered_set insert:" << end2 - begin2 << endl;
size_t begin3 = clock();
for (auto e : v)
{
s.find(e);
}
size_t end3 = clock();
cout << "set find:" << end3 - begin3 << endl;
size_t begin4 = clock();
for (auto e : v)
{
us.find(e);
}
size_t end4 = clock();
cout << "unordered_set find:" << end4 - begin4 << endl << endl;
size_t begin5 = clock();
for (auto e : v)
{
s.erase(e);
}
size_t end5 = clock();
cout << "set erase:" << end5 - begin5 << endl;
size_t begin6 = clock();
for (auto e : v)
{
us.erase(e);
}
size_t end6 = clock();
cout << "unordered_set erase:" << end6 - begin6 << endl << endl;
return 0;
}
数据随机但有重复: 数据随机但重复少
数据连续无重复:
总结:
总的来说unordered_map和unordered_set要比map和set的性能要好的,但是也并不是一定的,当数据量很大的时候,扩容重新哈希是有消耗的。
2、unordered_map
文档链接->unordered_map文档
我们使用unordered1_map的接口函数:
void test_unordered_map()
{
string arr[] = { "梨","梨","苹果","梨","西瓜","西瓜" };
unordered_map<string, int> m;
for (auto& e : arr)
{
m[e]++;
}
for (auto& kv : m)
{
cout << kv.first << ":" << kv.second << endl;
}
}
int main()
{
test_unordered_map();
return 0;
}
总体来说,unordered_map和map的用法差不多,但是他们的效率有所不同。
(二)常见的查找性能对比
- 暴力查找: 时间复杂度〇(N)
- 二分查找: 时间复杂度〇(logN) ,缺点 --- 有序、数组结构
- 搜索二叉树: 时间复杂度〇(N),缺点 --- 极端场景退化成单支
- 平衡二叉搜索树: 时间复杂度〇(logN)
- AVLTree: 左右子树高度差不超过1
- 红黑树:最长路径不超过最短路径的2倍
- 哈希
- B树系列: 多叉平衡搜索树 --- 数据库原理
- 跳表
ps:
红黑树高度略高一些,但是跟AVL树是同一数量级,对于现代计算机没有差别但是红黑树相对而言近似平衡,旋转少。
(三)哈希表的概念及模拟实现
1、哈希的概念
我们已学过的查找 :
顺序结构以及平衡树 中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素
时,必须要经过关键码的多次比较 。顺序查找时间复杂度为O(N),平衡树中为树的高度,即
O(log_2 N),搜索的效率取决于搜索过程中元素的比较次数。
理想的查找方法:
可以不经过任何比较,一次直接从表中得到要搜索的元素。
如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立
一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
- 该中存储结构可以实现:
-
- 插入元素时:
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放。
- 插入元素时:
-
- 查找元素时:
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功。
++该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称++
++为哈希表(Hash Table)(或者称散列表)++
- 查找元素时:
2、哈希函数
我们如何一一将键值转换为对应的关键码值,并映射到对应序号的存储位置呢?
直接建立映射关系问题:
- 1、数据范围分布很广、不集中(可能存在空间浪费严重的问题)
- 2、key的数据不是整数,是字符串怎么办?是自定义类型对象怎么办?
此时我们就需要一个函数对特殊非整数类型的数据进行处理,使其返回一个特定的整数,这个函数我们叫做 ------ 哈希函数。
常见的哈希函数:
1、直接定址法(常用)
- 取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B
- 优点:简单、均匀
- 缺点:需要事先知道关键字的分布情况
- 使用场景:适合查找比较小且连续的情况
2、除留余数法(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,
按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址
3、其余常见但不常用的还有 平方取中法、折叠法、随机数法、数学分析法等。
字符串也有自己类型的哈希函数**----->参考文献**(了解即可)
3、哈希冲突
不同关键字通过相同哈希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。
按照上述哈希函数计算出键值对应的关键码值,但是算出来的这些码值当中,有很大的可能会出现关键码值相同的情况,这种情况就叫作:哈希冲突。
- 哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突。
- 解决哈希冲突两种常见的方法是:闭散列和开散列
4、闭散列------开放定址法
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有
**空位置,那么可以把key存放到冲突位置中的"下一个" 空位置中去。**那如何寻找下一个空位置呢?
这就用到了我们的------ 线性探测:(依次去找空位置)
如下图中的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,hashAddr为4,
因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。
++线性探测:++
++从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。++
插入:
- 通过哈希函数获取待插入元素在哈希表中的位置
- 线性探测找到空位置将值插入
查找:
- 挨个遍历哈希表,直到找到空为止
删除:
- 过关键码值再用线性探测找到该值直接删除
- 注意:
- 删除要是直接删除的话有可能会影响查找的准确性
- 如图删除了4,要去找44就会找不到。
那怎么办呢?
- ++所以我们给每个键值提供一个状态,采取伪删除 的方法++
++即通过对每一个数据加上一个标识状态即可:++
线性探测的缺点:
- 一旦发生哈希冲突,所有的冲突连在一起,容易产生数据"堆积",即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
++所以我们引入了二次探测 :跳跃着找空位置,是相 对上面方法的优化,使得数据可能****不那么拥堵。++
所以经过上面的介绍,我们想自己实现一个闭散列需要注意以下的几点:
- 哈希函数的选择
- 哈希冲突的应对
- 如何应对"堆积"导致效率低下的情况
- 如何扩容表
第一点和第二点我们已经在上文介绍过了,这里我们应用的就是开放定址法和线性探测。
- 第三点:如何应对"堆积"导致效率低下的情况?
查询资料:
首先根据上文我们知道闭散列哈希表并不能太满:
- 太满就会导致线性探测时,找不到位置
- 更不能放满,那样探测就会陷入死循环
- 所以要控制一下存储的数据
- 我们引入了一个变量
n
来记录存储数据的个数
散列表的载荷因子定义为: a = 填入表中的元素个数 / 散列表的长度
所以我们要控制一下负载因子:
- 第四点:负载因子超了如何扩容?
有人会说直接resize扩容就行了啊。但是,你没注意到一个问题:
我在刚刚那个表中又插入了13,这时按理说应该扩容了防止效率变低。
假如我扩容到20格,我想找13的时候根据哈希函数,13不应该在编号是13的格子中吗?但是我是存储在3中啊,这就矛盾了...
所以扩容时我们不能直接将原来的数据拷贝过去:
- 因为哈希是映射的关系,关键码值是通过数据和表的大小计算出来的
- 如果直接拷贝的话全都乱套了
- 这时我们需要重新映射
如图所示,也不是特别麻烦
直接建立一个新表,然后遍历旧表一次映射到新表中
不过扩容时会有不少的消耗
补充:
- 映射的时候取模
- 应该是对表的size()取模,而不是capacity()
- 因为对capacity取模的话,可能取到超出size的位置
- operator[]会对超出size的检查(不过有的也不检查,根据不同版本的库里定)
5、开散列------链地址法(开链法),哈希桶
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地
址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链
接起来,各链表的头结点存储在哈希表中。
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
很显然,哈希桶中每个元素是个地址,所以哈希桶的底层原理就是一个指针数组,每个结点再挂着一个单链表,这样冲突就很容易解决了。
还是老问题,
- 如何应对"堆积"导致效率低下的情况
- 如何扩容
- 如何应对"堆积"导致效率低下的情况?
这里我们还是选择适时扩容,那什么情况扩容呢?
桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可
能会导致一个桶中链表节点非常多,会影响的哈希表的性能,因此在一定条件下需要对哈希
表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点,
再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可
以给哈希表增容。
- 如何扩容?
方案一:
方案二:
很显然我们更倾向于方案二:
方案一写法更简单,但是不断递归开销更大。
注:哈希桶结点插入,我们一般采用头插的方法,因为对于每一个链表,如果尾插,需要先找到尾,增加了时间消耗,头插的话消耗更低。
++因为开散列是一个指针数组,涉及到空间的开辟,所以析构函数我们要自己完善:++
~HashTable()
{
for (auto& cur : _tables)
{
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
cur = nullptr;
}
}
(四)详细代码
namespace OpenAddress
{
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
{
public:
HashData<K, V>* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
size_t hashi = 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;
}
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
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 = kv.first % _tables.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;
}
private:
vector<HashData<K, V>> _tables;
size_t _n = 0;//存储的数据个数
};
void TestHashTable2()
{
HashTable<int, int> ht;
int arr[] = { 1,2,2,3,3,3,4,4,4,4,5,9,2,3 };
for (auto& e : arr)
{
ht.Insert(make_pair(e, e));
}
}
void TestHashTable1()
{
int a[] = { 3, 33, 2, 13, 5, 12, 1002 };
HashTable<int, int> ht;
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
ht.Insert(make_pair(15, 15));
if (ht.Find(13))
{
cout << "13在" << endl;
}
else
{
cout << "13不在" << endl;
}
ht.Erase(13);
if (ht.Find(13))
{
cout << "13在" << endl;
}
else
{
cout << "13不在" << endl;
}
}
}
namespace HashBacket
{
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,class V>
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;
}
}
Node* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
size_t hashi = key % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (cur->_kv.first == key)
{
return cur;
}
cur = cur->_next;
}
return nullptr;
}
bool Erase(const K& key)
{
size_t hashi = 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;
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
{
return false;
}
if (_n == _tables.size())
{
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
vector<Node*> newtables(newsize, nullptr);
for (auto& cur : _tables)
{
while (cur)
{
Node* next = cur->_next;
size_t hashi = cur->_kv.first % newtables.size();
//头插到新表
cur->_next = newtables[hashi];
newtables[hashi] = cur;
cur = next;
}
}
_tables.swap(newtables);
}
size_t hashi = kv.first % _tables.size();
// 头插
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
private:
vector<Node*> _tables;
size_t _n = 0;
};
void TestHashTable1()
{
int a[] = { 3, 33, 2, 13, 5, 12, 1002 };
HashTable<int, int> ht;
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
ht.Insert(make_pair(15, 15));
ht.Insert(make_pair(25, 25));
ht.Insert(make_pair(35, 35));
ht.Insert(make_pair(45, 45));
}
void TestHashTable2()
{
int a[] = { 3, 33, 2, 13, 5, 12, 1002 };
HashTable<int, int> ht;
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
ht.Erase(12);
ht.Erase(3);
ht.Erase(33);
}
}
感谢你的阅读!