🌠 作者:@阿亮joy.
🎆专栏:《吃透西嘎嘎》
🎇 座右铭:每个优秀的人都有一段沉默的时光,那段时光是付出了很多努力却得不到结果的日子,我们把它叫做扎根
目录
- 👉unordered系列关联式容器👈
- unordered_map
- 1. unordered_map 的介绍
- 2. unordered_map 的桶操作
- 3. unordered_map 的使用
- 👉底层结构👈
- 哈希概念
- 哈希冲突
- 哈希冲突解决
- 1. 闭散列
- 2. 开散列
- 3. 开散列与闭散列比较
- 👉开散列实现 unordered_map 和 unordered_set👈
- 👉总结👈
👉unordered系列关联式容器👈
在 C++98 中,STL 提供了底层为红黑树结构的一系列关联式容器,在查询时效率可达到 O( l o g 2 N log_2N log2N),即最差情况下需要比较红黑树的高度次,当树中的节点非常多时,查询效率也不理想。最好的查询是进行很少的比较次数就能够将元素找到。因此,在 C++11 中,STL 又提供了 4 个 unordered 系列的关联式容器,这 4 个容器与红黑树结构的关联式容器使用方式基本类似,只是其底层结构不同。本文中只对 unordered_map 和 unordered_set 进行介绍。
unordered_map
1. unordered_map 的介绍
- unordered_map 是存储 <key, value> 键值对的关联式容器,其允许通过 key 快速的索引到与其对应的 value。
- 在 unordered_map 中,键值通常用于唯一地标识元素,而映射值是一个对象,其内容与此键值关联。键值和映射值的类型可能不同。
- 在内部 unordered_map 没有对 <kye, value> 按照任何特定的顺序排序, 为了能在常数范围内找到 key 所对应的value,unordered_map 将相同哈希值的键值对放在相同的桶中。
- unordered_map 容器通过 key 访问单个元素要比 map 快,但它通常在遍历元素子集的范围迭代方面效率较低。
- unordered_map 实现了
operator[]
,它允许使用 key 作为参数直接访问 value。- 它的迭代器至少是前向迭代器。
2. unordered_map 的桶操作
函数声明 | 功能介绍 |
---|---|
size_t bucket_count()const | 返回哈希桶中桶的总个数 |
size_t bucket_size(size_t n)const | 返回 n 号桶中有效元素的总个数 |
size_t bucket(const K& key) | 返回元素 key 所在的桶号 |
size_type max_bucket_count() const | 返回哈希表最能用于多少个桶 |
float load_factor() const | 返回哈希表的负载因子 |
float max_load_factor() const / void max_load_factor ( float z ) | 第一个接口是返回哈希表的最大负载因子,默认最大负载因子是 1;第二个接口可以设置哈希表的最大负载因子 |
rehash / reserve | 扩容,注:rehash 可能会缩容。 |
3. unordered_map 的使用
unordered_map、unordered_set 和map、set 的用法都是差不多的,现在我们来简单地使用一下 unordered_map。
在长度 2N 的数组中找出重复 N 次的元素
给你一个整数数组 nums ,该数组具有以下属性:
- nums.length == 2 * n.
- nums 包含 n + 1 个 不同的元素
- nums 中恰有一个元素重复 n 次
- 找出并返回重复了 n 次的那个元素。
思路:先用 unordered_map 统计数字出现的次数,然后就能找出出现 N 次的数字了。
class Solution
{
public:
int repeatedNTimes(vector<int>& nums)
{
unordered_map<int, int> countMap;
for(auto e : nums)
++countMap[e];
for(auto& kv : countMap)
{
if(kv.second == nums.size() / 2)
return kv.first;
}
return -1;
}
};
unordered 系列的容器中的数据是无序的
void SetTest()
{
unordered_set<int> s;
s.insert(2);
s.insert(3);
s.insert(1);
s.insert(5);
s.insert(2);
s.insert(6);
for (auto e : s)
{
cout << e << " ";
}
cout << endl;
}
map / set 和 unordered 系列的对比
#include <unordered_map>
#include <unordered_set>
#include <map>
#include <set>
#include <iostream>
#include <vector>
using namespace std;
void SetTest()
{
unordered_set<int> s;
s.insert(2);
s.insert(3);
s.insert(1);
s.insert(5);
s.insert(2);
s.insert(6);
for (auto e : s)
{
cout << e << " ";
}
cout << endl;
}
void Test()
{
int n = 10000000;
vector<int> v;
v.reserve(n);
srand(time(0));
for (int i = 0; i < n; ++i)
{
//v.push_back(i);
v.push_back(rand() + i); // 重复少
//v.push_back(rand()); // 重复多
}
size_t begin1 = clock();
set<int> s;
for (auto e : v)
{
s.insert(e);
}
size_t end1 = clock();
size_t begin2 = clock();
unordered_set<int> us;
for (auto e : v)
{
us.insert(e);
}
size_t end2 = clock();
cout << "size:" << s.size() << endl;
cout << "set insert:" << end1 - begin1 << endl;
cout << "unordered_set insert:" << end2 - begin2 << endl;
size_t begin3 = clock();
for (auto e : v)
{
s.find(e);
}
size_t end3 = clock();
size_t begin4 = clock();
for (auto e : v)
{
us.find(e);
}
size_t end4 = clock();
cout << "set find:" << end3 - begin3 << endl;
cout << "unordered_set find:" << end4 - begin4 << endl;
size_t begin5 = clock();
for (auto e : v)
{
s.erase(e);
}
size_t end5 = clock();
size_t begin6 = clock();
for (auto e : v)
{
us.erase(e);
}
size_t end6 = clock();
cout << "set erase:" << end5 - begin5 << endl;
cout << "unordered_set erase:" << end6 - begin6 << endl;
}
int main()
{
Test();
return 0;
}
👉底层结构👈
哈希概念
顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为 O(N),平衡树中为树的高度,即 O( l o g 2 N log_2 N log2N),搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
当向该结构中插入元素时,根据待插入元素的关键码以此函数计算出该元素的存储位置并按此位置进行存放。当搜索元素时,对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较。若关键码相等,则搜索成功。
该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)。
关于哈希的思想,其实我们在字符串中的第一个唯一字符这道题目里早就用到过了。
字符串中的第一个唯一字符中用到的是直接定址法,这种方法只能解决一些简单的场景。好比如:当数据的间隔较大时,就会很浪费空间了。
那为了解决上面的问题,我们可以除流余数法。
哈希冲突
对于两个数据元素的关键字 k i k_i ki 和 k j k_j kj(i != j),有 k i k_i ki != k j k_j kj,但有:Hash( k i k_i ki) ==Hash( k j k_j kj),即:不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。
哈希冲突解决
解决哈希冲突两种常见的方法是:闭散列和开散列(拉链法 / 哈希桶)。
1. 闭散列
闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的下一个空位置中去。那如何寻找下一个空位置呢?
线性探测
线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。插入元素时,通过哈希函数获取待插入元素在哈希表中的位置。如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。
删除元素时,采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素 4,如果直接删除掉,44 查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素,并没有将该元素真正的删除掉,而是采用标记的方式处理,但是不能直接将该位置标记为空,否则会影响从该位置产生冲突的元素的查找。。哈希表每个空间给个标记:EMPTY 表示此位置空,EXIST 表示此位置已经有元素,DELETE 表示元素已经删除。
当
vector
快要满时,此时的哈希冲突已经出现比较多了,存在你占我的位置,我占用别人的位置的情况了。那么这时候哈希表就要扩容了。那什么时候要扩容呢?为了解决扩容问题,有大佬提出了负载因子(载荷因子)的概念。哈希表的负载因子等于填入表中的元素个数除以哈希表的长度。负载因子越小,哈希冲突的概率越小;负载因子越大,哈希冲突的概率越大。当负载因子到达一个基准值时,哈希表就需要扩容。基准越大,冲突越多,效率越低,空间利用率越高。哈希表扩容的代价比vector
扩容的代价还有大,因为原来存在哈希冲突的数据,有可能就不冲突了,需要重新映射,并不能直接将数据拷贝到原来的位置上。
#pragma once
// 标识状态
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 start = hashi;
while (_tables[hashi]._state != EMPTY)
{
// 状态不是删除才能找到,否则会有BUG
if (_tables[hashi]._state != DELETE && _tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
++hashi;
hashi %= _tables.size();
// 找了一圈都没找到
if (hashi == start) // 防止插入又删除的场景
break;
}
return nullptr;
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
// 负载因子到了就要扩容
if (_tables.size() == 0 || 10 * _size / _tables.size() >= 7)
{
size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V> newHT;
newHT._tables.resize(newSize);
// 旧表的数据映射到新表
for (auto& e : _tables)
{
if (e._state == EXIST)
{
newHT.Insert(e._kv);
}
}
_tables.swap(newHT._tables);
}
size_t hashi = kv.first % _tables.size(); // 注意模除的是_table.size()
// 线性探测
while (_tables[hashi]._state == EXIST)
{
++hashi;
hashi %= _tables.size();
}
// 找到空位置就插入元素
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_size;
return true;
}
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret) // 元素存在
{
ret->_state = DELETE;
--_size;
return true;
}
else
return false;
}
void Print()
{
for (size_t i = 0; i < _tables.size(); ++i)
{
if (_tables[i]._state == EXIST)
{
printf("[%d:%d] ", i, _tables[i]._kv.first);
}
else
{
printf("[%d:*] ", i);
}
}
cout << endl;
}
private:
vector<HashData<K, V>> _tables;
size_t _size = 0; // 有效数据的个数
};
void TestHT1()
{
//int a[] = { 1, 11, 4, 15, 26, 7, 44, 9 };
int a[] = { 1, 11, 4, 15, 26, 7, 44 };
HashTable<int, int> ht;
for (auto e : a)
{
ht.Insert(make_pair(e, e));
}
ht.Print();
ht.Erase(4);
ht.Print();
ht.Insert(make_pair(-2, -2)); //负数也可以存在表中,整型提升
ht.Print();
cout << ht.Find(-2) << endl;
}
现在代码已经写得差不多了,那如果我们想用上面的代码统计出现次数可以吗?很明显不可以,因为字符串不能够取模。那么我们可以给HashTable
增加一个仿函数Hash
,其可以将不能取模的类型转成可以取模的类型。
template <class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return key;
}
};
// 特化
template <>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val += ch;
}
return val;
}
};
注:要求 hashi 的地方都需要用仿函数Hash
来求,也就是哈希函数。
上面写的仿函数HashFunc<string>
写得并不是很好,因为其面对一些不相同的字符串,求出来的哈希值却是相同的,这样哈希冲突的概率就会上升了。
那我们参考该博客:字符串哈希算法改进一下。
// BKDRHash
template <>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val += 131 * val + ch;
}
return val;
}
};
以上就是闭散列的线性探测的内容,那我们来总结一下线性探测的优缺点。
线性探测优点:实现非常简单。
线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据堆积,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。
为了缓解这个问题,二次探测就登场了。(注:它也无法完全解决哈希冲突的问题)
二次探测
线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为: 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 是表的大小。
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
// 负载因子到了就要扩容
if (_tables.size() == 0 || 10 * _size / _tables.size() >= 7)
{
size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V, HashFunc<K>> newHT;
newHT._tables.resize(newSize);
// 旧表的数据映射到新表
for (auto& e : _tables)
{
if (e._state == EXIST)
{
newHT.Insert(e._kv);
}
}
_tables.swap(newHT._tables);
}
// 二次探测
Hash hash;
size_t i = 0;
size_t start = hash(kv.first) % _tables.size();
size_t hashi = start;
while (_tables[hashi]._state == EXIST)
{
++i;
hashi = start + i * i;
hashi %= _tables.size();
}
// 找到空位置就插入元素
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_size;
return true;
}
闭散列的完整代码
namespace CloseHash
{
// 标识状态
enum State
{
EMPTY,
EXIST,
DELETE
};
template <class K, class V>
struct HashData
{
pair<K, V> _kv;
State _state = EMPTY;
};
template <class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return key;
}
};
// 特化
template <>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val += 131 * val + ch;
}
return val;
}
};
template <class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
HashData<K, V>* Find(const K& key)
{
if (_tables.size() == 0)
{
return nullptr;
}
Hash hash;
size_t hashi = hash(key) % _tables.size();
size_t start = hashi;
while (_tables[hashi]._state != EMPTY)
{
// 状态不是删除才能找到,否则会有BUG
if (_tables[hashi]._state != DELETE && _tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
++hashi;
hashi %= _tables.size();
// 找了一圈都没找到
if (hashi == start) // 防止插入又删除的场景
break;
}
return nullptr;
}
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
// 负载因子到了就要扩容
if (_tables.size() == 0 || 10 * _size / _tables.size() >= 7)
{
size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
HashTable<K, V, HashFunc<K>> newHT;
newHT._tables.resize(newSize);
// 旧表的数据映射到新表
for (auto& e : _tables)
{
if (e._state == EXIST)
{
newHT.Insert(e._kv);
}
}
_tables.swap(newHT._tables);
}
//Hash hash;
//size_t hashi = hash(kv.first) % _tables.size(); // 注意模除的是_table.size()
线性探测
//while (_tables[hashi]._state == EXIST)
//{
// ++hashi;
// hashi %= _tables.size();
//}
找到空位置就插入元素
//_tables[hashi]._kv = kv;
//_tables[hashi]._state = EXIST;
//++_size;
// 二次探测
Hash hash;
size_t i = 0;
size_t start = hash(kv.first) % _tables.size();
size_t hashi = start;
while (_tables[hashi]._state == EXIST)
{
++i;
hashi = start + i * i;
hashi %= _tables.size();
}
// 找到空位置就插入元素
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_size;
return true;
}
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret) // 元素存在
{
ret->_state = DELETE;
--_size;
return true;
}
else
return false;
}
void Print()
{
for (size_t i = 0; i < _tables.size(); ++i)
{
if (_tables[i]._state == EXIST)
{
printf("[%d:%d] ", i, _tables[i]._kv.first);
}
else
{
printf("[%d:*] ", i);
}
}
cout << endl;
}
private:
vector<HashData<K, V>> _tables;
size_t _size = 0; // 有效数据的个数
};
}
线性探测和二次探测都没有从本质上解决哈希冲突占用位置的问题,这时候就需要开散列的拉链法(哈希桶)
2. 开散列
开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合。每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。
注:拉链法并不会出现所有元素都在同一个同中的情况,因为有负载因子的存在,也就不会出现O(N)
的查找效率问题。如果还想进一步提高效率,桶中也可以挂红黑树。但是本人模拟实现的哈希桶是挂单向链表的,因为单向链表也够用了,相比双向链表更节省空间。
开散列增容
桶的个数是一定的,随着元素的不断插入,每个桶中元素的个数不断增多,极端情况下,可能会导致一个桶中链表节点非常多,会影响的哈希表的性能。因此在一定条件下需要对哈希表进行增容,那该条件怎么确认呢?开散列最好的情况是:每个哈希桶中刚好挂一个节点,再继续插入元素时,每一次都会发生哈希冲突,因此,在元素个数刚好等于桶的个数时,可以给哈希表增容。由于素数作为哈希表的长度可以产生最分散的余数,从而尽可能减小哈希冲突。所以,我们可以提前生成一个素数表就能知道下一次扩容的大小了。
namespace HashBucket
{
// 哈希函数
template <class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return key;
}
};
// 特化
template <>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val += 131 * val + ch;
}
return val;
}
};
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 Hash = HashFunc<K>>
class HashTable
{
typedef HashNode<K, V> Node;
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;
}
public:
Node* Find(const K& key)
{
if (_tables.size() == 0)
return nullptr;
// 在对应的桶查找
Hash hash;
//size_t hashi = Hash()(key) % _tables.size();
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;
}
bool Insert(const pair<K, V>& kv)
{
// 去重
if (Find(kv.first))
return false;
// 负载因子到1扩容
if (_size == _tables.size())
{
//size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
vector<Node*> newTables(__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;
Hash hash;
size_t hashi = hash(cur->_kv.first) % newTables.size();
cur->_next = newTables[hashi];
newTables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newTables);
}
// 头插
Hash hash;
size_t hashi = hash(kv.first) % _tables.size();
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_size;
return true;
}
bool Erase(const K& key)
{
if (_tables.size() == 0)
return false;
Hash hash;
size_t hashi = hash(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;
--_size;
return true;
}
prev = cur;
cur = cur->_next;
}
return false; // 表中没有key
}
HashTable() = default; // 强制生成默认构造函数
// 拷贝构造采用的是尾插
HashTable(const HashTable& ht)
{
_tables.resize(ht._tables.size(), nullptr);
_size = ht._size;
// 深拷贝
for (size_t i = 0; i < ht._tables.size(); ++i)
{
Node* tail = nullptr;
Node* cur = ht._tables[i];
Node* next = nullptr;
while (cur)
{
next = cur->_next;
Node* newnode = new Node(cur->_kv);
if (tail == nullptr)
_tables[i] = newnode;
else
tail->_next = newnode;
tail = newnode;
cur = next;
}
}
}
// 析构函数
~HashTable()
{
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_tables[i] = nullptr;
}
}
// 元素的个数
size_t Size()
{
return _size;
}
// 表的长度
size_t TablesSize()
{
return _tables.size();
}
// 桶的个数
size_t BucketNum()
{
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;
}
private:
vector<Node*> _tables;
size_t _size = 0; // 有效数据的个数
};
void TestHT1()
{
int n = 1000000;
vector<int> v;
v.reserve(n);
srand(time(0));
for (int i = 0; i < n; ++i)
{
//v.push_back(i);
v.push_back(rand() + i); // 重复少
//v.push_back(rand()); // 重复多
}
size_t begin1 = clock();
HashTable<int, int> ht;
for (auto e : v)
{
ht.Insert(make_pair(e, e));
}
size_t end1 = clock();
cout << "数据个数:" << ht.Size() << endl;
cout << "表的长度:" << ht.TablesSize() << endl;
cout << "桶的个数:" << ht.BucketNum() << endl;
cout << "平均每个桶的长度:" << (double)ht.Size() / (double)ht.BucketNum() << endl;
cout << "最长的桶的长度:" << ht.MaxBucketLenth() << endl;
cout << "负载因子:" << (double)ht.Size() / (double)ht.TablesSize() << endl;
}
}
3. 开散列与闭散列比较
应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销。事实上:由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子 a <= 0.7,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。
哈希表的插入只要是扩容和重新映射位置带来的消耗,而 set 是红黑树中节点的变色和旋转。如果提前知道哈希表的长度,我们可以通过 resize 或者 reserve 接口提前开好空间,减小扩容带来的消耗。
👉开散列实现 unordered_map 和 unordered_set👈
想要开散列实现 unordered_map 和 unordered_set,需要改造开散列Find
和Insert
函数,再加上一个迭代器和取得类型的仿函数。
#pragma once
namespace Joy
{
// 哈希函数
template <class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return key;
}
};
// 特化
template <>
struct HashFunc<string>
{
size_t operator()(const string& key)
{
size_t val = 0;
for (auto ch : key)
{
val += 131 * val + ch;
}
return val;
}
};
// 节点的改造
template <class T>
struct HashNode
{
T _data;
HashNode<T>* _next;
HashNode(const T& data)
: _data(data)
, _next(nullptr)
{}
};
// 前置声明:因为__HashIterator需要使用HashTable*
template <class K, class T, class Hash, class KeyOfT>
class HashTable;
// K是关键字key的的类型,T是节点数据的类型,Hash是哈希函数,KeyOfT是取出key值大小的仿函数
template <class K, class T, class Hash, class KeyOfT>
struct __HashIterator
{
typedef HashNode<T> Node;
typedef HashTable<K, T, Hash, KeyOfT> HT;
typedef __HashIterator<K, T, Hash, KeyOfT> Self;
Node* _node; // 节点指针
HT* _pht; // 指向哈希表的指针
__HashIterator(Node* node, HT* pht)
: _node(node)
, _pht(pht)
{}
T& operator*()
{
return _node->_data;
}
T* operator->()
{
return &_node->_data;
}
Self& operator++()
{
if (_node->_next)
{
// 在当前桶中迭代
_node = _node->_next;
}
else
{
// 找下一个桶
Hash hash;
KeyOfT kot;
size_t i = hash(kot(_node->_data)) % _pht->_tables.size();
++i;
for (; i < _pht->_tables.size(); ++i)
{
// 找到第一个不为空的桶就break
if (_pht->_tables[i])
{
_node = _pht->_tables[i];
break;
}
}
// 说明后面没有有数据的桶了
if (i == _pht->_tables.size())
_node = nullptr;
}
return *this;
}
bool operator!=(const Self& s) const
{
return _node != s._node;
}
bool operator==(const Self& s) const
{
return _node == s._node;
}
};
template <class K, class T, class Hash, class KeyOfT>
class HashTable
{
typedef HashNode<T> Node;
friend struct __HashIterator<K, T, Hash, KeyOfT>;
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;
}
public:
typedef __HashIterator<K, T, Hash, KeyOfT> iterator;
iterator begin()
{
for (size_t i = 0; i < _tables.size(); ++i)
{
// 第一个不为空的桶就是begin()
if (_tables[i])
return iterator(_tables[i], this);
}
return end();
}
iterator end()
{
return iterator(nullptr, this);
}
iterator Find(const K& key)
{
if (_tables.size() == 0)
return end();
// 在对应的桶查找
Hash hash;
KeyOfT kot;
//size_t hashi = Hash()(key) % _tables.size();
size_t hashi = hash(key) % _tables.size();
Node* cur = _tables[hashi];
while (cur)
{
if (kot(cur->_data) == key)
return iterator(cur, this);
cur = cur->_next;
}
return end(); // key不在哈希表中
}
pair<iterator, bool> Insert(const T& data)
{
Hash hash;
KeyOfT kot;
// 去重
iterator ret = Find(kot(data));
if (ret != end())
return make_pair(ret, false);
// 负载因子到1扩容
if (_size == _tables.size())
{
//size_t newSize = _tables.size() == 0 ? 10 : _tables.size() * 2;
vector<Node*> newTables(__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(kot(cur->_data)) % newTables.size();
cur->_next = newTables[hashi];
newTables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newTables);
}
// 头插
size_t hashi = hash(kot(data)) % _tables.size();
Node* newnode = new Node(data);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_size;
return make_pair(iterator(newnode, this), true);
}
bool Erase(const K& key)
{
if (_tables.size() == 0)
return false;
Hash hash;
size_t hashi = hash(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;
--_size;
return true;
}
prev = cur;
cur = cur->_next;
}
return false; // 表中没有key
}
HashTable() = default; // 强制生成默认构造函数
// 拷贝构造采用的是尾插
HashTable(const HashTable& ht)
{
_tables.resize(ht._tables.size(), nullptr);
_size = ht._size;
// 深拷贝
for (size_t i = 0; i < ht._tables.size(); ++i)
{
Node* tail = nullptr;
Node* cur = ht._tables[i];
Node* next = nullptr;
while (cur)
{
next = cur->_next;
Node* newnode = new Node(cur->_data);
if (tail == nullptr)
_tables[i] = newnode;
else
tail->_next = newnode;
tail = newnode;
cur = next;
}
}
}
// 析构函数
~HashTable()
{
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
delete cur;
cur = next;
}
_tables[i] = nullptr;
}
}
size_t Size()
{
return _size;
}
// 表的长度
size_t TablesSize()
{
return _tables.size();
}
// 桶的个数
size_t BucketNum()
{
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;
}
private:
vector<Node*> _tables;
size_t _size = 0; // 有效数据的个数
};
}
哈希表的迭代器中有节点的指针和指向哈希表的指针,因为迭代器是用来遍历的,所以需要哈希表才能找到下一个节点的指针。因为哈希表是在迭代器后面实现的,所以要在前面加一个哈希表的前置声明。注意:因为哈希表是私有的,所以可以将迭代器弄成哈希表的友元类,友元声明时也需要将模板参数带上。还需要注意的是,哈希表的 const 迭代器不能复用普通迭代器的代码。
实现 unordered_map 和 unordered_set
#pragma once
#include "HashTable.h"
namespace Joy
{
template <class K, class V, class Hash = HashFunc<K>>
class unordered_map
{
struct MapKeyOfT
{
const K& operator()(const pair<K, V>& kv)
{
return kv.first;
}
};
public:
// 加上typename告诉编译器这是类型声明
typedef typename HashTable<K, pair<K, V>, Hash, MapKeyOfT>::iterator iterator;
iterator begin()
{
return _ht.begin();
}
iterator end()
{
return _ht.end();
}
pair<iterator, bool> insert(const pair<K, V>& kv)
{
return _ht.Insert(kv);
}
V& operator[](const K& key)
{
pair<iterator, bool> ret = _ht.Insert(make_pair(key, V()));
return ret.first->second;
}
unordered_map() = default;
// 拷贝构造
unordered_map(const unordered_map& m)
: _ht(m._ht)
{}
private:
HashTable<K, pair<K, V>, Hash, MapKeyOfT> _ht;
};
void MapTest()
{
string arr[] = { "苹果", "西瓜", "苹果", "西瓜", "苹果", "苹果", "西瓜", "苹果", "香蕉", "苹果", "香蕉" };
unordered_map<string, int> countMap;
for (auto& str : arr)
{
++countMap[str];
}
for (auto& kv : countMap)
{
cout << kv.first << ":" << kv.second << endl;
}
cout << endl;
}
}
#pragma once
#include "HashTable.h"
namespace Joy
{
template <class K, class Hash = HashFunc<K>>
class unordered_set
{
struct SetKeyOfT
{
const K& operator()(const K& key)
{
return key;
}
};
public:
// 类型声明
typedef typename HashTable<K, K, Hash, SetKeyOfT>::iterator iterator;
iterator begin()
{
return _ht.begin();
}
iterator end()
{
return _ht.end();
}
pair<iterator, bool> insert(const K& key)
{
return _ht.Insert(key);
}
unordered_set() = default;
// 拷贝构造
unordered_set(const unordered_set& s)
: _ht(s._ht)
{}
private:
HashTable<K, K, Hash, SetKeyOfT> _ht;
};
void SetTest()
{
unordered_set<int> s;
s.insert(2);
s.insert(1);
s.insert(3);
s.insert(5);
s.insert(4);
for (auto e : s)
{
cout << e << " ";
}
cout << endl;
}
}
unordered_map 和 unordered_set 的接口并没有全部实现,主要是理解它们实现的原理。
一道小小的面试题:一个类型K去做set和 unordered_set 的模板参数要什么要求?set 要求该类型能够支持小于比较或者显示提供比较的仿函数,unordered_set 要求该类型对象能够转换成整型或者提供转换成整型的仿函数,还要求该类型对象可以支持等于比较或者提供等于比较的仿函数(判断哈希表中是否已经存在该对象)。
👉总结👈
本篇博客主要介绍什么是哈希表、哈希表的使用、哈希冲突、闭散列和开散列的实现以及 unordered_map 和 unordered_set 的模拟实现等等。那么以上就是本篇博客的全部内容了,如果大家觉得有收获的话,可以点个三连支持一下!谢谢大家!💖💝❣️