💓博主CSDN主页:麻辣韭菜💓
⏩专栏分类:C++知识分享⏪
🚚代码仓库:C++高阶🚚
🌹关注我🫵带你学习更多C++知识
🔝🔝
前言
如果你会用map和set,那么你就会用哈希表这种数据结构底层实现的unordered_map 和unordered_set。看名字unordered无序,而map和set是有序的。数据结构也是不同,map和set是搜索二叉树,而unordered_map 和unordered_set是哈希表(哈希桶)。
1. unordered系列关联式容器
1.1 unordered_map
1.1.1 unordered_map的文档介绍
1.1.2 unordered_map的接口说明
插入与访问元素
-
operator[]
- 通过键访问或插入元素,并返回对应的值
- 如果键存在,则返回对应值的引用;如果不存在,则插入新元素并返回默认构造的值
std::unordered_map<std::string, int> myMap; myMap["one"] = 1; // 插入键值对 int value = myMap["one"]; // 访问键对应的值
-
insert
- 插入指定键值对
- 返回一个 pair 对象,其
.second
成员指示插入是否成功,.first
指向已存在的元素(如果有)
std::unordered_map<std::string, int> myMap; auto result = myMap.insert(std::make_pair("two", 2)); // 插入并获取结果 if (result.second) { std::cout << "Insertion successful!" << std::endl; }
删除与查找元素
-
erase
- 删除指定键对应的元素
std::unordered_map<std::string, int> myMap = {{"one", 1}, {"two", 2}}; myMap.erase("two"); // 删除键为 "two" 的元素
-
find
- 查找指定键的元素,返回指向该元素的迭代器
- 如果未找到,则返回指向
end()
的迭代器
std::unordered_map<std::string, int> myMap = {{"one", 1}, {"two", 2}}; auto it = myMap.find("one"); if (it != myMap.end()) { std::cout << "Found: " << it->second << std::endl; }
其他操作
-
clear
- 清空哈希表,移除所有元素
std::unordered_map<std::string, int> myMap = {{"one", 1}, {"two", 2}}; myMap.clear(); // 清空哈希表
-
size
- 返回哈希表中元素的数量
std::unordered_map<std::string, int> myMap = {{"one", 1}, {"two", 2}}; std::cout << "Size: " << myMap.size() << std::endl;
默认构造函数
如果我们创建一个没有指定显式构造函数参数的 std::unordered_map
对象,那么编译器会为其生成默认构造函数。这个默认构造函数会创建一个空的哈希表。
std::unordered_map<int, std::string> myMap; // 调用默认构造函数创建空的哈希表
默认析构函数
当 std::unordered_map
对象超出其作用域,或者通过 delete
运算符显式销毁时,编译器会为其生成默认析构函数。这个默认析构函数会释放哈希表占用的内存空间。
{
std::unordered_map<int, std::string> myMap; // 对象超出作用域,会调用默认析构函数自动释放内存
} // myMap 被销毁
默认拷贝和移动构造函数,以及赋值运算符
std::unordered_map
也会涉及到默认的拷贝和移动构造函数,以及拷贝和移动赋值运算符。这些默认实现会对键值对进行浅复制或移动操作。
std::unordered_map<int, std::string> myMap1 = {{1, "one"}, {2, "two"}};
std::unordered_map<int, std::string> myMap2 = myMap1; // 调用默认的拷贝构造函数
std::unordered_map<int, std::string> myMap3 = std::move(myMap1); // 调用默认的移动构造函数
myMap3 = myMap2; // 调用默认的拷贝赋值运算符
myMap3 = std::move(myMap2); // 调用默认的移动赋值运算符
迭代器类型
-
iterator
- 用于遍历可修改
std::unordered_map
中的元素
- 用于遍历可修改
-
const_iterator
- 用于遍历
const
修饰的std::unordered_map
,其指向的元素不可被修改
- 用于遍历
迭代器获取
std::unordered_map<int, std::string> myMap = {{1, "one"}, {2, "two"}, {3, "three"}};
// 获取起始迭代器
auto it = myMap.begin(); // 返回指向第一个元素的迭代器
auto cit = myMap.cbegin(); // 返回指向第一个元素的 const 迭代器
// 获取结束迭代器
auto end = myMap.end(); // 返回指向最后一个元素之后位置的迭代器
auto cend = myMap.cend(); // 返回指向最后一个元素之后位置的 const 迭代器
迭代器使用示例
使用迭代器可以遍历 std::unordered_map
中的元素,并访问每个元素的键和值。
std::unordered_map<int, std::string> myMap = {{1, "one"}, {2, "two"}, {3, "three"}};
// 遍历并打印键值对
for (auto it = myMap.begin(); it != myMap.end(); ++it) {
std::cout << "Key: " << it->first << ", Value: " << it->second << std::endl;
}
1.2 unordered_set
插入与访问元素
-
insert
- 将新元素插入到无序集合中
- 返回一个 pair 对象,包含一个迭代器指向新元素的位置以及一个 bool 值,指示是否插入成功
std::unordered_set<int> mySet; auto result = mySet.insert(42); if (result.second) { std::cout << "Insertion successful!" << std::endl; }
-
emplace
- 在集合中构造一个新元素
- 返回一个 pair 对象,其中
.first
是迭代器指向新元素的位置,.second
是指示是否插入成功的 bool 值
std::unordered_set<std::string> mySet; auto result = mySet.emplace("hello"); if (result.second) { std::cout << "Insertion successful!" << std::endl; }
-
find
- 查找集合中是否存在指定的元素
- 返回指向匹配元素位置的迭代器,如果没找到则返回指向
end()
的迭代器
std::unordered_set<int> mySet = {1, 2, 3}; auto it = mySet.find(2); if (it != mySet.end()) { std::cout << "Found: " << *it << std::endl; }
删除元素
-
erase
- 从集合中移除指定值或指定位置的元素,或者指定范围的元素
std::unordered_set<int> mySet = {1, 2, 3}; mySet.erase(2); // 移除值为 2 的元素
其他操作
-
clear
- 清空集合,移除所有元素
std::unordered_set<int> mySet = {1, 2, 3}; mySet.clear(); // 清空集合
-
size
- 返回集合中元素的数量
std::unordered_set<int> mySet = {1, 2, 3}; std::cout << "Size: " << mySet.size() << std::endl;
默认构造函数
std::unordered_set<T> mySet;
这是 std::unordered_set
的默认构造函数,创建一个空的无序集合。
列表初始化构造函数
std::unordered_set<T> mySet = {val1, val2, ...};
使用大括号进行列表初始化,可以在创建无序集合的同时插入元素。
区间构造函数
std::unordered_set<T> mySet(otherSet.begin(), otherSet.end());
使用另一个无序集合的迭代器范围进行构造。复制范围内的元素到新的无序集合。
拷贝构造函数
std::unordered_set<T> mySet(otherSet);
通过另一个无序集合进行拷贝构造,复制另一个无序集合的内容到新的无序集合。
移动构造函数
std::unordered_set<T> mySet(std::move(otherSet));
通过移动语义实现的构造函数,将另一个无序集合的内容移动到新的无序集合中,另一个无序集合会变为空。
迭代器类型
-
iterator
- 用于遍历可修改
std::unordered_set
中的元素
- 用于遍历可修改
-
const_iterator
- 用于遍历
const
修饰的std::unordered_set
,其指向的元素不可被修改
- 用于遍历
迭代器获取
std::unordered_set<int> mySet = {1, 2, 3, 4};
// 获取起始迭代器
auto it = mySet.begin(); // 返回指向第一个元素的迭代器
auto cit = mySet.cbegin(); // 返回指向第一个元素的 const 迭代器
// 获取结束迭代器
auto end = mySet.end(); // 返回指向最后一个元素之后位置的迭代器
auto cend = mySet.cend(); // 返回指向最后一个元素之后位置的 const 迭代器
迭代器使用示例
使用迭代器可以遍历 std::unordered_set
中的元素。
std::unordered_set<int> mySet = {1, 2, 3};
// 遍历并打印元素
for (auto it = mySet.begin(); it != mySet.end(); ++it) {
std::cout << "Element: " << *it << std::endl;
}
注意事项
在 C++11 及以上版本,也可以使用范围-based for 循环来遍历 std::unordered_set
:
for (const auto& element : mySet) {
std::cout << "Element: " << element << std::endl;
}
2. 底层结构
2.1 哈希概念
2.2 哈希冲突
2.3 哈希函数
1. 直接定址法--(常用)
前面说的如果取模的余数和之前已经插入的数的取模余数是相等的,那么会出现哈希冲突,解决哈希冲突两种常用的方法:闭散列和开散列我们先用闭散列
闭散列
闭散列 代码实现
哈希结构
#pragma once
#include <vector>
enum State
{
MEPTY, //空
EXIST, //存在
DELETE //删除
};
template <class K, class V>
struct hashData
{
pair<K, V> _kv;
State _state = MPETY;
};
template <class K, class V>
class hashTable
{
typedef hashData<K, V> Node;
private:
vector<Node> _tables;
size_t n;//记录元素个数
};
插入函数
bool Insert(const pair<K, V>& kv)
{
if (Find(kv.first))
return false;
//当负载因子为大于0.7时就扩容
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;
}
这里解释一下关于扩容后为什么要新创建哈希桶,因为扩容后,映射位置变了,假设以前的size为10 扩容后为20,那之前的插入的值就找不到了,所以我们需要重新创建一个哈希桶,然后根据映射位置重新插入到新的哈希桶中。最后再交换。
查找函数
Node* Find(const K& key)
{
if (_tables.size() == 0)
{
return false;
}
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)
{
Node* ret = Find(key);
if (ret)
{
ret->_state = DELETE;
--_n;
return true;
}
else
{
return false;
}
}
这里删除函数并不是真正意义上的删除,如果真的删除了,那么其他没有被删除的数都会受到影响,所以我们标记这个映射位置为DELETE,等下次插入的数映射位置和这个位置一样时,直接覆盖。
开散列
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。
开散列代码实现
namespace HashBucket
{
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;
}
// 负载因因子==1时扩容
if (_n == _tables.size())
{
/*size_t newsize = _tables.size() == 0 ? 10 : _tables.size()*2;
HashTable<K, V> newht;
newht.resize(newsize);
for (auto cur : _tables)
{
while (cur)
{
newht.Insert(cur->_kv);
cur = cur->_next;
}
}
_tables.swap(newht._tables);*/
size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
vector<Node*> newtables(newsize, nullptr);
//for (Node*& cur : _tables)
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 _CheckCapacity()
{
size_t bucketCount = BucketCount();
if(_size == bucketCount)
{
HashBucket<V, HF> newHt(bucketCount);
for(size_t bucketIdx = 0; bucketIdx < bucketCount; ++bucketIdx)
{
PNode pCur = _ht[bucketIdx];
while(pCur)
{
// 将该节点从原哈希表中拆出来
_ht[bucketIdx] = pCur->_pNext;
// 将该节点插入到新哈希表中
size_t bucketNo = newHt.HashFunc(pCur->_data);
pCur->_pNext = newHt._ht[bucketNo];
newHt._ht[bucketNo] = pCur;
pCur = _ht[bucketIdx];
}
}
newHt._size = _size;
this->Swap(newHt);
}
}
开散列的思考
只能存储key为整形的元素,其他类型怎么解决?
// 哈希函数采用处理余数法,被模的key必须要为整形才可以处理,此处提供将key转化为
//整形的方法
// 整形数据不需要转化
template<class T>
class DefHashF
{
public:
size_t operator()(const T& val)
{
return val;
}
};
// key为字符串类型,需要将其转化为整形
class Str2Int
{
public:
size_t operator()(const string& s)
{
const char* str = s.c_str();
unsigned int seed = 131; // 31 131 1313 13131 131313
unsigned int hash = 0;
while (*str)
{
hash = hash * seed + (*str++);
}
return (hash & 0x7FFFFFFF);
}
};
// 为了实现简单,此哈希表中我们将比较直接与元素绑定在一起
template<class V, class HF>
class HashBucket
{
// ……
private:
size_t HashFunc(const V& data)
{
return HF()(data.first)%_ht.capacity();
}
};
除留余数法,最好模一个素数,如何每次快速取一个类似两倍关系的素数?
size_t GetNextPrime(size_t prime)
{
const int PRIMECOUNT = 28;
static const size_t primeList[PRIMECOUNT] =
{
53ul, 97ul, 193ul, 389ul, 769ul,
1543ul, 3079ul, 6151ul, 12289ul, 24593ul,
49157ul, 98317ul, 196613ul, 393241ul, 786433ul,
1572869ul, 3145739ul, 6291469ul, 12582917ul,
25165843ul,
50331653ul, 100663319ul, 201326611ul, 402653189ul,
805306457ul,
1610612741ul, 3221225473ul, 4294967291ul
};
size_t i = 0;
for (; i < PRIMECOUNT; ++i)
{
if (primeList[i] > prime)
return primeList[i];
}
开散列与闭散列比较
应用链地址法处理溢出,需要增设链接指针,似乎增加了存储开销 。事实上:由于开地址法必须保持大量的空闲空间以确保搜索效率,如二次探查法要求装载因子 a <=0.7 ,而表项所占空间又比指针大的多,所以使用链地址法反而比开地址法节省存储空间。
3.哈希的应用
3.1 位图
3.1.1 位图概念
位图的实现
#pragma once
#include <vector>
#include <string>
#include <time.h>
template<size_t N>
class bitset
{
public:
bitset()
{
_bits.resize(N/8 + 1, 0);
}
void set(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] |= (1 << j);
}
void reset(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
_bits[i] &= ~(1 << j);
}
bool test(size_t x)
{
size_t i = x / 8;
size_t j = x % 8;
return _bits[i] & (1 << j);
}
private:
vector<char> _bits;
};
void test_bitset1()
{
bitset<100> bs;
bs.set(10);
bs.set(11);
bs.set(15);
cout << bs.test(10) << endl;
cout << bs.test(15) << endl;
bs.reset(10);
cout << bs.test(10) << endl;
cout << bs.test(15) << endl;
bs.reset(10);
bs.reset(15);
cout << bs.test(10) << endl;
cout << bs.test(15) << endl;
}
3.1.3 位图的应用
3.2 布隆过滤器
3.2.1布隆过滤器提出
3.2.2布隆过滤器概念
布隆过滤器代码实现
struct BKDRHash
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (auto ch : s)
{
hash += ch;
hash *= 31;
}
return hash;
}
};
struct APHash
{
size_t operator()(const string& s)
{
size_t hash = 0;
for (long i = 0; i < s.size(); i++)
{
size_t ch = s[i];
if ((i & 1) == 0)
{
hash ^= ((hash << 7) ^ ch ^ (hash >> 3));
}
else
{
hash ^= (~((hash << 11) ^ ch ^ (hash >> 5)));
}
}
return hash;
}
};
struct DJBHash
{
size_t operator()(const string& s)
{
size_t hash = 5381;
for (auto ch : s)
{
hash += (hash << 5) + ch;
}
return hash;
}
};
// N最多会插入key数据的个数
template<size_t N,
class K = string,
class Hash1 = BKDRHash,
class Hash2 = APHash,
class Hash3 = DJBHash>
class BloomFilter
{
public:
void set(const K& key)
{
size_t len = N*_X;
size_t hash1 = Hash1()(key) % len;
_bs.set(hash1);
size_t hash2 = Hash2()(key) % len;
_bs.set(hash2);
size_t hash3 = Hash3()(key) % len;
_bs.set(hash3);
//cout << hash1 << " " << hash2 << " " << hash3 << " " << endl << endl;
}
bool test(const K& key)
{
size_t len = N*_X;
size_t hash1 = Hash1()(key) % len;
if (!_bs.test(hash1))
{
return false;
}
size_t hash2 = Hash2()(key) % len;
if (!_bs.test(hash2))
{
return false;
}
size_t hash3 = Hash3()(key) % len;
if (!_bs.test(hash3))
{
return false;
}
// 在 不准确的,存在误判
// 不在 准确的
return true;
}
private:
static const size_t _X = 6;
bitset<N*_X> _bs;
};
3.2.4 布隆过滤器的查找
3.2.5 布隆过滤器删除
3.2.6 布隆过滤器优点
3.2.7 布隆过滤器缺陷
4. 海量数据面试题