无序关联容器 包括 unordered_set, unordered_map内部 都是基于哈希表实现
1、HashTable 原理分析
1、哈希表是一种 通过哈希函数将键映射到索引 的数据结构
哈希函数 负责将任意大小的输入 映射到固定大小的输出,即哈希值。这个哈希值 用作在数组中存储键值对的索引
2、为了避免哈希表中链表过长 导致性能下降,会在需要时进行扩容
扩容过程 涉及到 重新计算所有元素的哈希值,并将它们 分布到新的更大的哈希表中。这一过程称为 rehashing
2、HashTable 代码实现
cpp
#include <algorithm> // 不加find就会出问题
#include <iostream>
#include <sstream>
#include <functional>
#include <list>
#include <cstddef>
#include <utility>
#include <vector>
#include <string>
template <typename Key, typename Value, typename Hash = std::hash<Key>> // 创建了一个 std::hash<Key> 类型的临时对象
// std::hash<Key> 是标准库中为基本类型和部分标准库类型(如 int, std::string 等)定义的默认哈希函数对象
// 它接受一个 Key 类型的值,并返回一个 std::size_t 类型的哈希值
// std::hash<Key> 是一个模板类,提供了一个用于生成哈希值的仿函数。标准库为许多常见类型(如 int、std::string、char 等)提供了专门化版本的 std::hash
// 这个仿函数的主要目的是将一个 Key 类型的值转化为 std::size_t 类型的哈希值,这个哈希值通常用在哈希表(如 std::unordered_map)中
// 它的主要作用是将 Key 类型的对象映射为一个哈希值。具体来说,std::hash<Key>() 构造出的对象本身并没有返回值,但你可以通过调用该对象的 operator() 来生成哈希值
// std::hash<int> hash_fn;
// std::size_t hash_value = hash_fn(42); // 计算整数 42 的哈希值
// 或
// std::size_t hash_value = std::hash<int>()(42);
class HashTable {
class HashNode {
public:
Key key;
Value value;
// 直接调用默认构造函数 value() explicit HashNode(const Key& key_) : key(key_), value() {}
explicit HashNode(const Key& key_) : key(key_), value(Value{}) {}
HashNode(const Key& key_, const Value& value_) : key(key_), value(value_) {}
bool operator==(const HashNode& hn) const { return key == hn.key; }
bool operator!=(const HashNode& hn) const { return key != hn.key; }
bool operator<(const HashNode& hn) const { return key < hn.key; }
bool operator>(const HashNode& hn) const { return key > hn.key; }
bool operator==(const Key& key_) const { return key == key_; }
// std::find(bucket.begin(), bucket.end(), key) == bucket.end()需要使用bool operator==(const Key& key_)
void print() const {
std::cout << key << " " << value << " ";
}
};
private:
using Bucket = std::list<HashNode>;
std::vector<Bucket> buckets;
Hash hashFunction;
size_t numBucket;
size_t numNode;
double maxLoadFactor = 0.75; // 默认的最大负载因子
size_t hash(const Key& key) {
return hashFunction(key) % numBucket;
}
void reHash(size_t newSize) {
std::vector<Bucket> newBuckets(newSize);
for (Bucket& bucket : buckets) {
for (HashNode& hn : bucket) { // 链表也可以遍历
size_t i = hashFunction(hn.key) % newSize; // hashFunction(hn.key)
newBuckets[i].push_back(hn); // 直接push_back(hn)
}
}
buckets = std::move(newBuckets);
numBucket = newSize;
}
public:
HashTable(size_t numBucket_ = 10, Hash hashFunction_ = Hash()) // 临时变量不能使用引用
: buckets(numBucket_), hashFunction(hashFunction_), numBucket(numBucket_), numNode(0) {}
// buckets(numBucket_)直接调用buckets(numBucket_)构造函数初始化
void insert(const Key& key, const Value& value) {
// if ((numNode + 1) / numBucket > maxLoadFactor) { 不行,如果numBucket为0就会数组越界
if ((numNode + 1) > maxLoadFactor * numBucket) {
reHash(numBucket == 0 ? 1 : 2 * numBucket);
}
size_t i = hash(key);
if (std::find(buckets[i].begin(), buckets[i].end(), key) == buckets[i].end()) {// 前提键要表里没有,不是值
buckets[i].push_back(HashNode(key, value));
numNode++;
}
}
void erase(const Key& key) {
// 移除链表中全部元素
size_t i = hash(key);
// 迭代器不能使用引用赋值的原因主要与迭代器的本质和行为特性有关
// 迭代器是一个对象或指针,可以通过迭代器的操作符来遍历容器。迭代器的实现可以是指针(如原生数组的迭代器)或更复杂的类类型(如 std::vector、std::list 等 STL 容器的迭代器)。当迭代器指向不同的元素时,它的内部状态会改变
// 引用本质上是一个别名,不能被重新赋值以指向不同的对象
auto it = std::find(buckets[i].begin(), buckets[i].end(), key);
if (it != buckets[i].end()) {
// 一个链表上可能有多个不同的key,一个key只有唯一一个表中元素
buckets[i].erase(it); // 别忘了加bucket[i].
numNode--;
}
}
// 查找键是否存在于哈希表中,返回指向值的指针
Value* find(const Key& key) {
size_t i = hash(key);
auto it = std::find(buckets[i].begin(), buckets[i].end(), key);
if (it == buckets[i].end())
return nullptr;
else
return &it->value;
}
size_t getNum() {
return numNode;
}
void print() {
for (Bucket& bucket : buckets) {
for (HashNode& hn : bucket) {
hn.print();
}
}
if (numNode == 0) {
std::cout << "empty";
}
std::cout << std::endl;
}
void clear() {
buckets.clear(); // vector.clear()
numNode = 0;
numBucket = 0;
}
};
int main() {
int N;
std::cin >> N;
getchar();
HashTable<int, int> hashTable;
while (N--) {
std::string line;
std::getline(std::cin, line);
std::istringstream iss(line);
std::string command;
iss >> command;
if (command == "insert") {
int k, v;
iss >> k >> v;
hashTable.insert(k, v);
}
else if (command == "erase") {
int k;
iss >> k;
if (hashTable.getNum() == 0) // 别忘了,不然可能数组越界
continue;
hashTable.erase(k);
}
else if (command == "find") {
int k;
iss >> k;
if (hashTable.getNum() == 0) { // 别忘了,不然可能数组越界
std::cout << "not exist" << std::endl;
continue;
}
int* ans = hashTable.find(k);
if (ans == nullptr) {
std::cout << "not exist" << std::endl;
}
else
std::cout << *ans << std::endl;
}
else if (command == "size") {
std::cout << hashTable.getNum() << std::endl;
}
else if (command == "print") {
hashTable.print();
}
else if (command == "clear") {
hashTable.clear();
}
}
return 0;
}
1、std::move 是一种将对象的所有权 从一个变量转移到另一个变量的操作。当 执行 buckets = std::move(newBuckets);
时,实际上将 newBuckets 中的数据移动到 buckets 中,buckets 接管了 newBuckets 的内容,而 newBuckets 本身 将处于有效但未指定的状态。这意味着 newBuckets 之后可以继续存在,但不能再假定它的内容或大小等属性了
2、void insertKey(const Key &key) { insert(key, Value{}); }
:
Value{} 和 Value() 都是用于默认构造一个对象
1)对于大多数类类型,Value{} 和 Value() 的行为是一致的,都会调用默认构造函数
然而,Value{} 可以处理更加复杂的初始化情况,例如聚合类型(结构体、数组等)的初始化,也可以避免一些类型转换问题。相比之下,Value() 更加传统,只调用默认构造函数
2)在某些情况下,Value() 可能会引发"最具争议的解析"问题。例如,在声明一个函数时,如果你写 Value(),编译器可能会误认为这是一个函数声明,而不是对象初始化。而 Value{} 则没有这个问题,因为花括号语法不会被解析为函数声明
虽然在大多数常见场景中,Value{} 和 Value() 是等价的,都会默认构造一个 Value 类型的对象,但在一些特定情况下(如聚合类型、避免括号引发的语法歧义时),Value{} 可能是更好的选择
3、#include <cstddef>
是一个 C++ 标准库头文件,包括 std::size_t,NULL
4、#include <utility>
包括 std::move,std::swap,std::pair,std::make_pair
3、与标准库的区别
实现的哈希表 是一个简化版的哈希表,它使用链表来处理哈希冲突。这种方法 也被称为分离链接法
-
模板参数:
我们的实现 只接受键类型 Key 和哈希函数 Hash 作为模板参数
STL 的 std::unordered_set 和 std::unordered_map 有更多模板参数,如键的类型、值的类型、哈希函数、键的相等函数、分配器等
-
负载因子和自动重哈希:
我们的实现 在负载因子超过 0.75 时自动重哈希 并且只能增加到当前大小的两倍
STL的哈希表容器 提供更多灵活性,可以调整负载因子,并且有更复杂的重哈希策略
-
内存分配:
我们的实现使用 std::list 来管理冲突,这会导致多次单独的内存分配
STL 通常使用 更高效的内存分配策略,比如预分配内存池 来减少分配次数和提升性能
-
迭代器支持:
我们的实现没有提供迭代器支持
STL提供了完整的迭代器支持,允许用户方便地遍历容器中的元素
-
功能丰富性:
我们的哈希表实现了基础的插入、删除和查找功能
STL的哈希表容器提供了更丰富的接口,如 emplace, count, bucket, bucket_count, bucket_size, load_factor, max_load_factor, rehash, reserve等
-
异常安全性:
我们的实现 没有显示地处理异常安全性问题
STL 的实现 通常保证基本的异常安全性,并在某些操作中提供强异常安全性保证
-
优化:
我们的实现是一个基本的哈希表,可能没有针对性能进行优化
STL的实现被高度优化以提供良好的性能,特别是在大数据量下
-
平台兼容性和移植性
-
接口一致性:
4、HashTable 常见面试题
1、什么是哈希表?它是如何工作的?
哈希表 是一种使用哈希函数组织数据,以便 快速插入和搜索的数据结构。它通过 将键映射到表中的位置来存储键值对。哈希函数 将每个键转换为哈希表中的索引,该索引 决定了 键值对在表中的存储位置。如果 两个键映射到同一个索引,就会产生冲突,这通常 通过链表或开放寻址法来解决
2、开放寻址法 通过在表中寻找其他可用位置来解决冲突,而不是 将冲突的元素存储在链表(如链地址法)中
当插入一个新元素时,如果计算出的哈希值对应的位置已经被占用(发生了冲突),则根据一定的规则寻找下一个可用的位置进行插入。这些规则称为探查策略:
-
线性探查法:
从冲突位置开始,按顺序检查下一个位置(即索引值加1),直到找到一个空闲位置
哈希函数的形式:h(k, i) = (h'(k) + i) % m
其中,h'(k) 是原始哈希函数,i 是探查次数,m 是哈希表的大小
-
二次探查法:
使用二次函数的形式进行探查,即探查的步长是 i^2^
哈希函数的形式:h(k, i) = (h'(k) + c1i + c2 i^2^) % m
c1 和 c2 是常数。二次探查法 可以在一定程度上减少线性探查法中出现的"聚集"问题
-
双重哈希法:
在发生冲突时,使用第二个哈希函数生成探查步长,避免线性或二次探查的潜在问题
哈希函数的形式:h(k, i) = (h1(k) + i * h2(k)) % m
h1(k) 是原始哈希函数,h2(k) 是第二个哈希函数
开放寻址法中的删除操作 稍显复杂,因为 简单地将一个位置标记为空 可能会破坏查找路径,导致一些元素不可达。通常,删除时 会标记该位置为"已删除"状态,这样查找操作 在遇到这个位置时,仍会继续探查
开放寻址法适用于对内存占用敏感、数据量较小且负载因子较低的场景,如编译器符号表、缓存系统等
3、哈希冲突是什么?如何处理哈希冲突?
哈希冲突 发生在 不同的键通过哈希函数映射到哈希表的同一位置时。处理哈希冲突的方法有:
1)链表法(分离链接法):在每个哈希表索引上维护一个链表,所有映射到该索引的元素 都会被存储在链表中
2)开放寻址法:如果发生冲突,就会寻找下一个空闲的哈希表索引
4、一个好的哈希函数应该满足以下条件:
快速计算
哈希值均匀分布,以减少冲突
一致性:相同的输入总是产生相同的输出
不同的输入应尽可能映射到不同的输出
5、什么是负载因子?对哈希表有什么影响?
负载因子 是哈希表中已存储元素数量与位置总数的比率。它是衡量 哈希表满程度的指标。当负载因子过高时,冲突的可能性增加,这会降低哈希表的性能。因此,通常在 负载因子达到一定阈值时,哈希表会进行扩容(即重哈希)来增加存储位置,从而减少冲突和维护操作的效率
重哈希 是在哈希表的负载因子 超过预定的阈值时,增加哈希表容量 并重新分配现有元素的过程。这个过程 需要计算每个元素的新哈希值,并将它们移动到 新表中的正确位置。重哈希可以帮助 减少冲突和维持操作的快速性能
6、在哈希表中插入、删除和搜索操作的复杂度是多少?
理想情况下,即没有发生冲突 或 冲突非常少时,插入、删除和搜索操作的时间复杂度为 O(1)。但是,在最坏的情况下,如果所有的键 都映射到同一索引,则这些操作的时间复杂度 会退化到 O(n),其中 n 是哈希表中元素的数量。使用良好的哈希函数和冲突解决策略 可以帮助保持操作的平均时间复杂度为接近 O(1)
7、如何解决哈希表的扩容问题?
扩容通常发生在哈希表的负载因子 超过预定阈值时
解决方案通常包括:
创建一个更大的哈希表:创建一个容量更大的新哈希表
重新哈希所有元素:将所有现有的元素 重新计算哈希值 并插入到新的哈希表中
逐步迁移:在某些实现中,可以逐步迁移元素到新表,分摊重哈希的成本 到多次插入操作中
传统的哈希表 在扩展时通常会 一次性将所有元素重新哈希到一个更大的新表中,这会导致一次性很大的计算开销,可能导致程序在这段时间内暂停响应,尤其是 在实时或性能敏感的应用中
8、逐步迁移
逐步迁移的思想是 将重新哈希的成本分摊到多个插入操作中,使得每次插入的成本上升平缓,从而避免 一次性重哈希带来的性能尖峰
分步重哈希:
当新的插入操作到来时,除了执行正常的插入操作外,还会顺便 将旧表中的一些元素迁移到新表中
具体来说,系统可能在 每次插入时从旧表中取出一个或少量元素,重新计算它们的哈希值 并将它们插入到新表中
这种逐步迁移继续进行,直到旧表的所有元素都迁移到新表中为止
查询操作:
在迁移过程中,查询操作需要检查两个表
首先在新表中查找,如果未找到,再去旧表中查找
如果在旧表中找到了元素,可以立即将其迁移到新表中(懒惰迁移),这进一步加快了迁移进度
完成迁移:
当旧表中的所有元素都已经迁移到新表时,旧表可以被销毁,此时哈希表的扩展过程正式结束
逐步迁移的实现细节与优化
迁移批量大小:通常选择一个折衷的批量大小,以平衡迁移速度与插入开销
延迟迁移:有些实现中,迁移操作并不一定在插入时触发,而是在插入时 只记录迁移进度,实际迁移操作 可能放在闲时或通过后台线程来完成
懒惰迁移:在查询操作中,如果一个元素在旧表中找到,可以立即将其迁移到新表。这种方法利用了查询操作分摊迁移成本,进一步优化了迁移效率
双表管理:在迁移过程中,必须同时管理旧表和新表。如何高效地在两个表中查找和插入元素也是一个关键点
9、如何确保哈希表的线程安全?
确保哈希表的线程安全 可以通过以下方式之一:
互斥锁:使用互斥锁 来同步对哈希表的访问。每次一个线程访问哈希表时,它都需要先获取锁
读写锁:如果读操作远多于写操作,使用读写锁 可以提高性能,因为它允许多个线程同时读取,但写入时需要排他访问
原子操作:对于简单的操作,可以使用原子操作来避免使用锁
细粒度锁:而不是对整个哈希表加锁,可以对哈希表的一部分(例如单个桶或链表)加锁,以减少锁的粒度
10、常见的哈希表实现问题包括:
内存使用不当:如果哈希表过大或者存在许多空桶,可能会导致内存浪费
冲突处理不佳:如果冲突没有得到有效处理,会严重影响哈希表的性能
哈希函数选择不当:一个不好的哈希函数可能会导致频繁的冲突
扩容代价高:重哈希是一个代价很高的操作,如果发生得太频繁,可能会严重影响性能
https://kamacoder.com/ 手写简单版本STL,内容在此基础上整理补充