目录
unordered_set(map) 和 set(map) 的差异
unordered_multiset / unordered_multimap
unordered_set和unordered_map
在 C++ 中,unordered_set 和 unordered_map 是两种基于哈希表(Hash Table)的容器,它们是 C++11 标准模板库的一部分,提供了高效的元素存储和访问。
unordered_set(map)的介绍
unordered_set 是一个无序集合,它存储唯一的元素,并且不允许重复。unordered_set 提供了平均常数时间复杂度为 O(1) 的插入、删除和查找操作。unordered_set 维护元素的唯一性,如果尝试插入一个已存在的元素,它不会被添加到集合中。
unordered_set 的声明如下
template < class Key, // unordered_set::key_type/value_type
class Hash = hash<Key>, // unordered_set::hasher
class Pred = equal_to<Key>, // unordered_set::key_equal
class Alloc = allocator<Key> // unordered_set::allocator_type
> class unordered_set;
- 第一个模板参数 Key:Key 是 unordered_set 底层关键字类型。
- 第二个模板参数 Hash(仿函数):unordered_set 默认要求Key支持转换成整形,如果Key不支持或者想按自己的需求将Key转换成整数,可以自己实现并传入。
- 第三个模板参数 Pred(仿函数):unordered_set 默认要求Key支持比较相等,不需要支持比较大小,这是因为 unordered_set 的底层是哈希表(哈希桶), 其实现不需要比较Key的大小,但其Find函数和Erase函数需要比较Key是否相等。如果 Key 支持或者想按自己的需求来,可以自己实现并传入。
- 第四个模板参数 Alloc:空间配置器,unordered_set 底层存储数据的内存是从空间配置器申请的,如果需要可以自己实现内存池并传入。
unordered_map 的声明如下
template < class Key, // unordered_map::key_type
class T, // unordered_map::mapped_type
class Hash = hash<Key>, // unordered_map::hasher
class Pred = equal_to<Key>, // unordered_map::key_equal
class Alloc = allocator< pair<const Key,T> > // unordered_map::allocator_type
> class unordered_map;
unordered_map 和 unordered_set 除了参数多了T,其余都是一样的,而这个 T 就是 Key-Value 中的value类型。
unordered_set(map) 和 set(map) 的差异
unordered_set 和 set 的增删查操作是一摸一样的,不作演示了。
- unordered_set 和 set的第一个差异是对Key的要求不同,set 要求支持小于比较, 因为底层是红黑树(二叉搜索树),unordered_set 要求支持转换成整型且支持等于比较。
- unordered_set 和 set的第二个差异是迭代器的不同,set 是双向迭代器,而 unordered_set 是单向迭代器。set 底层是红黑树,中序遍历是有序的,迭代器遍历是有序的,而 unordered_set 底层是哈希表,迭代器遍历是无序的。
- unordered_set 和 set的第三个差异是性能上的差异,unordered_set 的增删查操作是 O (1) 的时间复杂度,而set 的 增删查操作是 O(logN) 的时间复杂度,unordered_set 的速度较快些。
unordered_map 和 map 的差异跟 unordered_set 和 set 的差异基本上是一模一样的,不过多解释了。
unordered_multiset / unordered_multimap
- unordered_multiset / unordered_multimap 跟 multiset / multimap 功能完全一样,支持Key冗余。
- unordered_multiset / unordered_multimap 和 multiset / multimap 的差异也是 key 要求的差异、iterator 及其遍历顺序的差异和性能的差异。
介绍哈希表
哈希概念
哈希(hash)又称散列,本质是通过哈希函数把关键字 Key 跟存储位置建立一个映射关系,查找时通过这个哈希函数计算出Key存储的位置,进行快速查找。
- 说简单点就是,将关键字 Key 转换成地址编号,当你需要 Key 或者 Key-Value 中的 Value ,直接根据地址编号就能找到,这样就不用经过任何的 Key 比较,搜索效率有了极大地提高。
其中哈希函数的作用是将关键字 Key 跟存储位置建立映射关系,其实就是将 Key 转换成整数。
一个好的哈希函数应该让 N 个关键字被等概率的均匀的分布在哈希表的 M 个空间,实际上很难做到,但是要可能往这方面设计。
例如:数据集合{1,4,5,6}
哈希函数设置为:hash(key) = key % capacity,capacity为存储空间的总大小。
直接定址法
直接定址法适合关键字的范围比较集中时实现哈希表,比如一组关键字在 [0, 49] 之间,那么我们开个大小为50个数的数组,每个关键字直接对应着数组下标,再比如一组关键字在 [a, z] 的小写字母,那就开个26个数的数组,每个关键字的 ASCII 码就是数组下标。这个方法可以在解决 OJ 上的一些问题。
387. 字符串中的第一个唯一字符 - 力扣(LeetCode)
class Solution {
public:
int firstUniqChar(string s) {
vector<int> cnt(26,0);
int n = s.size();
for(auto ch : s)
{
cnt[ch - 'a']++;
}
for(int i = 0; i < n; ++i)
{
if(cnt[s[i] - 'a'] == 1) return i;
}
return -1;
}
};
直接定址法也有缺点,那就是当数据范围比较分散且上下限差距较大时,开的空间比较大,会浪费内存。
哈希冲突
回到一开始举的例子
假如往哈希表里插入元素 44 ,会发生什么事情?hash(44) = 44 % 10 = 4 ,此时两个不同的Key通过同一个哈希函数映射到了同一位置上去。
这时就出现了哈希冲突,解决方法就是设计一个更好的哈希函数以减少冲突,但实际上,无论什么哈希函数,哈希冲突都是不可避免的。
负载因子
- 假设哈希表存储了 N 个值,哈希表的大小为 M ,其负载因子 = N / M。
负载因子越大,哈希冲突的概率也越高,空间利用率也越高。
负载因子越小,哈希冲突的概率也越低,空间利用率也越低。
常见哈希函数
哈希函数的设计尽可能的减小哈希冲突发生的概率,一下是一些常见的哈希函数。
除法散列法(重点)
假设哈希表的大小为 M ,通过 key 除以 M 的余数作为映射位置的下标。
哈希函数为:hash(key) = key % M。
M 也是有要求的,尽可能避免取2的幂(2 ^ x,x是正整数)、10的幂等整数,尽可能取不接近2的幂的质数。
为什么不取2的幂、10的幂等整数?
因为 key % 2 ^ x 相当于保留 key 二进制数的后 x 位,这样引发哈希冲突的概率会大些,比如,M = 16 = 2 ^ 4,63 和 31 的二进制数的后 4 位都是相同的 1111, 而模上 16 后得到的都是 15,引发了哈希冲突。
话虽如此,但 Java 的 HashMap 采用除法散列就是用2的整数幂作为哈希表的大小 M ,这样就不用取模,可以直接位运算。比如,M 是 2 ^ 16 ,那就是取后 16 位,key ^= key >> 16 ,其结果作为哈希值,映射的值还是在 [0, M) 范围内,但是让 key 所有的位都参与计算,这样映射出的哈希更均匀一下。
乘法散列法
乘法散列法对哈希大小 M 没有要求,其实现首先对 key 乘以常数 A(0 < A < 1),,并抽取 key * A的小数部分,然后用 M 乘以 key * A的小数部分,再舍去小数向下取整。
hash(key) = floor(M * ((A * key) % 1.0)) ,floor 表示向下取整。那问题又来了,A 的值如何设定,一般将 A 取黄金分割点比较好,A = 0.6180339887... 。
假设 M 为1024,key 为 1234 ,A = 0.6180339887,A * key = 762.6539420558,取小数部分为0.6539420558, M * ((A × key) % 1.0) = 0.6539420558 * 1024= 669.6366651392,那么hash(1234) = 669 。
其实还有其他哈希函数,比如全域散列法、平方取中法、折叠法、随机数法和数学分析法,这里就不过多讲解了。
哈希表的实现
实际上实现哈希表用的最多的除法散列法,但是无论什么哈希函数都无法避免哈希冲突的问题,接下来就是两种解决哈希冲突的方法,也是两种不同形式的哈希表实现。
开发定址法(闭散列)
该方法将所有元素放到哈希表中,当出现哈希冲突时,按照某种方法(线性探测、二次探测和双重探测)寻找下一个没有存储数据的位置进行存储,开放定址法中负载因子一定小于 1 。
线性探测:当 key 取模得到的 hashi 位置已经有了元素后,那么 hashi++ 向后寻找空位置,若 hashi > 哈希表的空间大小 M,让 hashi 回到哈希表头。
线性探测也有其缺点,若一个位置发生连续冲突时,会发生群集/堆积现象,这样又会带来一个问题,删除某个元素后,可能会影响后面冲突的查找,这时又需要给每个元素增加状态标识。
这里我们用开放定址实现Key-Value的哈希表。
整体框架
//状态标识,防止元素删除后对查找后面冲突值的影响
enum State
{
EXIST,
EMPTY,
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 size_t(key);
}
};
//string做哈希表的key很常见,对哈希函数进行string特化
template<>
struct HashFunc<string>
{
// 字符串转换成整形,可以把字符asci码相加即可
// 但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的
// 这⾥⽤上次的计算结果去乘以⼀个质数去解决,这个质数⼀般取
// 31, 131等效果会⽐较好
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;
hash *= 131;
}
return hash;
}
};
//找比n大,离n最近的质数
inline unsigned long __stl_next_prime(unsigned long n)
{
//质数数组
static const int __stl_num_primes = 28;
static const unsigned long __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
};
const unsigned long* first = __stl_prime_list;
const unsigned long* last = __stl_prime_list + __stl_num_primes;
//在first和last找大于等于n位置的指针
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
//哈希表
template<class K, class V, class Hash = HashFunc<K>>
class HashTable
{
public:
//构造函数
HashTable()
:_tables(__stl_next_prime(0))
,_n(0)
{}
//......
private:
vector<HashData<K, V>> _tables; //用vector来存储元素
size_t _n; //记录有效元素的个数
};
哈希表的插入
1.扩容函数:当负载因子大于等于0.7时需要扩容,如果采用旧表映射到新表的方法,这样的逻辑跟Insert函数太重复了,有点浪费,我们可以直接在函数栈帧建立一个新表复用Insert函数,最后交换两个tables就可以完成扩容了。
2.线性探测:通过 key 计算得到 hashi 后,还不能直接往其插入,得判断元素状态。如果 hashi 位置的状态为 EXIST ,则不能往该位置插入,因为被其他元素占有了,得线性探测往后寻找空位置。
void CheckCapacity()
{
//当负载因子>=0.7时,扩容
if (_n * 10 / _tables.size() >= 7)
{
//方法一:旧表映射到新表
/*vector<HashData<K, V>> newtables(_tables.size() * 2);
for (auto& data : _tables)
{
if (data._state == EXIST)
{
size_t newhashi = HashFunc(data._kv.first) % newtables.size();
newtables[newhashi]._kv = data._kv;
newtables[newhashi]._state = EXIST;
}
}
_tables.swap(newtables);*/
//方法二:利用新哈希表
HashTable<K, V> newht;
//newht._tables.resize(_tables.size() * 2);
newht._tables.resize(__stl_next_prime(_tables.size() + 1));
for (auto& data : _tables)
{
if (data._state == EXIST)
{
newht.Insert(data._kv);
}
}
_tables.swap(newht._tables);
}
}
bool Insert(const pair<K, V>& kv)
{
//如果找到了重复元素,直接返回false
if (Find(kv.first))
return false;
//检查负载因子是否需要扩容
CheckCapacity();
//哈希函数
Hash hashfunc;
size_t hashi = hashfunc(kv.first) % _tables.size();
//线性探测
while (_tables[hashi]._state == EXIST)
{
++hashi;
hashi %= _tables.size();
}
_tables[hashi]._kv = kv;
_tables[hashi]._state = EXIST;
++_n;
return true;
}
哈希表的查找
哈希表的查找也要线性探测,当前元素状态不为空还不行,还需要该元素状态是存在且 key 相等,如果元素状态是空了,那说明在哈希表中真的找不到该元素了。
HashData<K, V>* Find(const K& key)
{
Hash hashfunc;
size_t hashi = hashfunc(key) % _tables.size();
while (_tables[hashi]._state != EMPTY)
{
if (_tables[hashi]._state == EXIST
&& _tables[hashi]._kv.first == key)
{
return &_tables[hashi];
}
hashi++;
hashi %= _tables.size();
}
return nullptr;
}
哈希表的删除
直接复用 Find 函数即可,找到了将其元素状态置删除,没找到就返回 false 即可。
bool Erase(const K& key)
{
HashData<K, V>* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
return true;
}
else
{
return false;
}
}
测试开放定址法实现的哈希表
void test_open_hashtable2()
{
srand((unsigned int)time(0));
int N = 100000;
vector<int> v(N);
for (int i = 0; i < N; ++i)
{
v[i] = rand() + i;
}
open_address::HashTable<int, int> ht;
for (auto e : v)
{
ht.Insert({ e, e });
}
auto ret = ht.Find(19);
if (ret)
cout << "找到了" << endl;
else
cout << "没找到" << endl;
}
链地址法(开散列)(重点)
开放地址法将所有元素放入哈希表中,链地址法就不是这样。链地址法中哈希表中存储一个指针,没有数据映射这个位置时,这个指针为空,有多个数据映射到这个位置时,把冲突的数据链接成一个链表,挂在哈希表下面,这种方法又叫哈希桶。
整体框架
//哈希函数
template<class K>
struct HashFunc
{
size_t operator()(const K& key)
{
return size_t(key);
}
};
//string做哈希表的key很常见,对哈希函数进行string特化
template<>
struct HashFunc<string>
{
// 字符串转换成整形,可以把字符asci码相加即可
// 但是直接相加的话,类似"abcd"和"bcad"这样的字符串计算出是相同的
// 这⾥⽤上次的计算结果去乘以⼀个质数去解决,这个质数⼀般取
// 31, 131等效果会⽐较好
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;
hash *= 131;
}
return hash;
}
};
//找比n大,离n最近的质数
inline unsigned long __stl_next_prime(unsigned long n)
{
// Note: assumes long is at least 32 bits.
static const int __stl_num_primes = 28;
static const unsigned long __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
};
const unsigned long* first = __stl_prime_list;
const unsigned long* last = __stl_prime_list + __stl_num_primes;
//在first和last找大于等于n位置的指针
const unsigned long* pos = lower_bound(first, last, n);
return pos == last ? *(last - 1) : *pos;
}
//哈希表里存节点的指针
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;
public:
HashTable()
: _tables(__stl_next_prime(0))
,_n(0)
{}
//...
private:
vector<Node*> _tables;
size_t _n = 0;
};
哈希表的插入
void CheckCapacity()
{
//当哈希表里节点的数量与哈希表大小相等时扩容
if (_n == _tables.size())
{
//把哈希桶里的链表每个节点拆下来插入newht效率太低了
/*HashTable<K, V> newht;
newht._tables.resize(__stl_next_prime(_tables.size() + 1));
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
while (cur)
{
newht.Insert(cur->_kv);
cur = cur->_next;
}
}
_tables.swap(newht._tables);*/
hash hashfunc;
vector<Node*> newtables(__stl_next_prime(_tables.size() + 1));
for (size_t i = 0; i < _tables.size(); ++i)
{
Node* cur = _tables[i];
while (cur)
{
Node* next = cur->_next;
size_t hashi = hashfunc(cur->_kv.first) % newtables.size();
//头插
cur->_next = newtables[hashi];
newtables[hashi] = cur;
cur = next;
}
_tables[i] = nullptr;
}
_tables.swap(newtables);
}
}
bool Insert(const pair<K, V>& kv)
{
//不允许重复插入
if (Find(kv.first))
return false;
//检查扩容
CheckCapacity();
hash hashfunc;
size_t hashi = hashfunc(kv.first) % _tables.size();
//头插
Node* newnode = new Node(kv);
newnode->_next = _tables[hashi];
_tables[hashi] = newnode;
++_n;
return true;
}
这里如果利用新哈希表复用 Insert 函数来完成扩容的话,效率太低了。因为这种方式本质是将原本哈希表的数据复制到新哈希表里,需要 new 出新节点;当将新哈希表与旧哈希表交换后,此时新哈希表挂着原来的链表数据,由于新哈希表是局部对象,出了作用域会调用哈希表的析构函数(当然这里还没写),又 new 节点 ,又析构,这样消耗太大了,导致效率很低。
所以这里选择直接将旧节点拿下放入新 tables 中,而且必须要一个一个拿,不能直接拿下头节点指针然后存入新 tables 。因为扩容后原本的冲突的数据大概率就不冲突了,在新哈希表的位置就不一样了。
哈希表的查找
查找很简单,不过多叙述了。
Node* Find(const K& key)
{
hash hashfunc;
size_t hashi = hashfunc(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)
{
hash hashfunc;
size_t hashi = hashfunc(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;
--_n;
return true;
}
else
{
prev = cur;
cur = cur->_next;
}
}
return false;
}
测试链地址法实现的哈希表
void test_bucket_hashtable1()
{
int a[] = { 19,30,5,36,13,20,21,12,24,96, 19};
hash_bucket::HashTable<int, int> ht;
for (auto e : a)
{
ht.Insert({ e, e });
}
auto ret = ht.Find(12);
if (ret)
cout << "找到了" << endl;
else
cout << "没找到" << endl;
ht.Erase(12);
ret = ht.Find(12);
if (ret)
cout << "找到了" << endl;
else
cout << "没找到" << endl;
}
void test_bucket_hashtable2()
{
srand((unsigned int)time(0));
int N = 100000;
vector<int> v(N);
for (int i = 0; i < N; ++i)
{
v[i] = rand() + i;
}
hash_bucket::HashTable<int, int> ht;
for (auto& e : v)
{
ht.Insert({ e, e });
}
auto ret = ht.Find(12);
}
拜拜,下期再见😏
摸鱼ing😴✨🎞