在C++的世界中,哈希表是一种高效、独特的数据结构,被广泛应用于需要快速查找、插入、删除的场景。通过哈希函数将数据映射到表中,它不仅提高了操作效率,还在解决冲突时展现了精巧的设计。哈希表在算法的应用中尤为重要,无论是缓存、字典处理还是集合操作,都离不开它的身影。本篇博客将带你一步步深入理解哈希表的实现原理及其应用,让你在编码之路上更上一层楼。
在 C++ 标准库中,unordered
系列容器(如 unordered_map
和 unordered_set
)是一类基于哈希表实现的关联式容器。与传统的 map
和 set
容器不同,unordered
容器通过哈希函数实现了无序存储,能够在均摊
时间复杂度内完成插入、查找和删除操作。这使得 unordered
系列容器在处理需要快速查找的场景中非常高效。
unordered
容器概述unordered
容器包含以下几种类型,主要用于不同类型的键值对或集合操作:
unordered_set
:一个不包含重复元素的集合,存储的是唯一的键。unordered_multiset
:与 unordered_set
相似,但允许包含重复元素。unordered_map
:一个键值对的容器,每个键只能出现一次,键是唯一的。unordered_multimap
:一个键值对的容器,允许键重复。与传统的有序容器(如 map
和 set
)不同,unordered
容器中的元素没有特定的顺序。因此,unordered_map
和 unordered_set
更加适用于不关心元素顺序、仅关注快速访问的场景。
unordered
容器中的实现原理unordered
容器的核心数据结构是哈希表,它利用哈希函数将键映射到表中的位置。在哈希表中,unordered
容器采用了一种**桶(bucket)**的机制来存储数据:
unordered
容器的特点。
unordered
容器不维护元素的顺序,元素顺序与插入顺序无关。unordered_set
和 unordered_map
的基本操作unordered_set
的基本用法unordered_set
是一个集合,用于存储唯一的元素,元素的顺序是无序的。主要用在需要快速查找元素的场景中。
#include <unordered_set>
#include <iostream>
int main() {
std::unordered_set<int> uset = {1, 2, 3, 4, 5}; // 初始化
// 遍历输出元素
for (const int& num : uset) {
std::cout << num << " ";
}
std::cout << std::endl;
return 0;
}
使用 insert
方法插入元素。如果元素已存在,insert
不会插入重复元素。
uset.insert(6); // 插入元素6
uset.insert(3); // 3已存在,不插入
使用 find
方法查找元素,若找到则返回元素的迭代器,否则返回 end()
。
if (uset.find(3) != uset.end()) {
std::cout << "3 存在于集合中" << std::endl;
}
使用 erase
方法删除指定元素,可以传入元素值或迭代器。
uset.erase(4); // 删除元素4
size()
获取集合的大小。clear()
清空集合中的所有元素。std::cout << "集合大小: " << uset.size() << std::endl;
uset.clear();
unordered_map
的基本用法unordered_map
是一种键值对关联容器,使用哈希表实现。每个键是唯一的,可以通过键快速访问对应的值。
#include <unordered_map>
#include <iostream>
#include <string>
int main() {
std::unordered_map<std::string, int> umap = {{"apple", 3}, {"banana", 5}, {"orange", 2}}; // 初始化
// 遍历键值对
for (const auto& kv : umap) {
std::cout << kv.first << " -> " << kv.second << std::endl;
}
return 0;
}
使用 insert
或 operator[]
插入键值对。operator[]
如果键不存在,会插入新键并初始化值为默认值。
umap["grape"] = 10; // 插入或更新键为 "grape" 的值
umap.insert({"melon", 8}); // 插入键值对 {"melon", 8}
使用 find
方法查找元素,若找到则返回指向键值对的迭代器,否则返回 end()
。
auto it = umap.find("banana");
if (it != umap.end()) {
std::cout << "香蕉数量: " << it->second << std::endl;
}
使用 erase
方法删除指定键的元素。
umap.erase("orange"); // 删除键 "orange"
size()
获取 unordered_map
的键值对数量。clear()
清空所有元素。std::cout << "键值对数量: " << umap.size() << std::endl;
umap.clear();
哈希表是一种基于哈希函数的数据结构,用于快速存储和查找数据。它的核心思想是将键(Key)通过哈希函数转换为数组索引,从而实现快速的数据访问。哈希表被广泛用于字典、缓存、集合等需要高效查找的场景,是以上所介绍容器的底层结构。
哈希表包含以下几个核心概念:
负载因子 = 元素个数 / 桶的数量
表示。负载因子过高会影响性能,通常哈希表会在负载因子达到一定值时扩容。哈希表的操作,如插入、删除、查找,在理想情况下的时间复杂度为
。这是因为哈希表可以通过哈希函数将键快速映射到对应的存储位置。
哈希函数是哈希表性能的核心,其目的是将键均匀地分布在哈希表的桶中,减少冲突的发生。好的哈希函数具有以下特性:
在 C++ 中,标准库提供了许多内置类型的哈希函数,如 std::hash<int>
、std::hash<std::string>
等。此外,用户也可以为自定义类型定义自己的哈希函数。
开放地址法是在哈希表中找一个新的空位来存储冲突元素。当发生冲突时,通过探测寻找下一个可用位置存放新元素。开放地址法常见的探测方式有:
在发生冲突时,线性探测通过固定步长(通常为 1)逐步查找下一个空位,直到找到可用位置。
hash(key) + i
,其中 i
是冲突次数。int linearProbing(int hashValue, int i, int tableSize) {
return (hashValue + i) % tableSize;
}
二次探测通过平方增长的步长来查找下一个位置,避免线性探测的“聚集”问题。
hash(key) + i^2
。int quadraticProbing(int hashValue, int i, int tableSize) {
return (hashValue + i * i) % tableSize;
}
双重哈希采用两个哈希函数,在发生冲突时,根据第二个哈希函数的值决定步长。
hash1(key) + i * hash2(key)
。int doubleHashing(int hashValue, int i, int tableSize, int hash2) {
return (hashValue + i * hash2) % tableSize;
}
拉链法将每个哈希表位置(桶)设计为一个链表,所有被哈希到同一位置的元素都存储在该链表中。插入新元素时,将其添加到链表中,查找时则遍历该链表。
。
DefaultHashFunc
哈希函数类DefaultHashFunc
是一个通用的哈希函数模板类,用于将键转换为哈希值。代码对整型和字符串类型分别进行了不同的哈希计算:
size_t
类型。string
类型,定义了一个专门的特化版本,使用一种常见的哈希算法,将字符串中的每个字符逐步加入到哈希值中,并乘以一个质数(131)来增加分布的均匀性。template<class K>
class DefaultHashFunc {
public:
size_t operator()(const K& key) {
return (size_t)key;
}
};
template<>
class DefaultHashFunc<string> {
public:
size_t operator()(const string& str) {
size_t hash = 0;
for (auto ch : str) {
hash *= 131;
hash += ch;
}
return hash;
}
};
HashData
类HashData
类表示哈希表中的一个数据节点,包含一个键值对 _kv
和一个 STATE
状态 _state
。STATE
枚举类定义了三个状态:
这种状态管理方便在开放地址法中处理删除操作。
enum STATE {
EXIST,
EMPTY,
DELETE
};
template<class K, class V>
class HashData {
public:
pair<K, V> _kv;
STATE _state = EMPTY;
};
HashTable
类HashTable
是哈希表的主体类,支持以下操作:
public:
HashTable() {
_table.resize(10);
}
Insert
:向哈希表插入一个键值对 kv
。如果负载因子达到 0.7,进行扩容操作(即 resize
)。 bool Insert(const pair<K, V>& kv) {
// 扩容,扩容之后映射关系变了,需要重新映射
if ((double)_n / _table.size() >= 0.7) {
size_t newSize = _table.size() * 2;
// 遍历旧表,重新映射到新表
HashTable<K, V, HashFunc> newHT;
newHT._table.resize(newSize);
for (size_t i = 0; i < _table.size(); ++i) {
newHT.Insert(_table[i]._kv);
}
_table.swap(newHT._table);
}
// 线性探测
// 起始位置,处理空间大小,是capacity还是size
HashFunc hf;
size_t hashi = hf(kv.first) % _table.size();
while (_table[hashi]._state == EXIST) {
++hashi;
hashi %= _table.size();
}
_table[hashi]._kv = kv;
_table[hashi]._state = EXIST;
++_n;
return true;
}
Find
:通过哈希函数定位键在哈希表中的位置,若发生冲突,则使用线性探测逐步查找直到找到匹配的键或遇到空位置。HashData<const K, V>* Find(const K& key) {
// 线性探测
HashFunc hf;
size_t hashi = hf(key) % _table.size();
while (_table[hashi]._state != EMPTY) {
if (_table[hashi]._state == EXIST && _table[hashi]._kv.first == key) {
return (HashData<const K, V>*) & _table[hashi];
}
++hashi;
hashi %= _table.size();
}
return nullptr;
}
Erase
:先通过 Find
方法查找键的位置,如果找到则将该位置的状态标记为 DELETE
,并减少元素计数 _n
。bool Erase(const K& key) {
HashData<const K, V>* ret = Find(key);
if (ret) {
ret->_state = DELETE;
--_n;
return true;
}
return false;
}
_table
:存储 HashData
的向量,作为哈希表的实际存储容器。_n
:存储有效数据的个数,用于计算负载因子并触发扩容操作。private:
vector<HashData<K, V>> _table;
size_t _n = 0; // 存储有效数据的个数
};
DefaultHashFunc
,支持不同类型的键。这里我们继续沿用以上的DefaultHashFunc
哈希函数类。
HashNode
类HashNode
类表示哈希表中的一个节点,包含一个键值对 _kv
和一个指向下一个节点的指针 _next
。该类用于构成链表,以解决哈希冲突。
template<class K, class V>
class HashNode {
public:
pair<K, V> _kv;
HashNode<K, V>* _next;
HashNode(const pair<K, V>& kv)
: _kv(kv), _next(nullptr) {}
};
_kv
:存储键值对。_next
:指向链表中的下一个节点。HashTable
类HashTable
使用拉链法来处理哈希冲突。每个桶存储一个链表的头节点,当多个键映射到相同的哈希位置时,通过链表存储多个节点。
_table
:std::vector
的每个位置存储一个链表头节点指针,表示一个桶。_n
:存储哈希表中当前有效数据的数量。private:
vector<Node*> _table; // 指针数组
size_t _n = 0; // 存储了多少有效数据
HashTable() {
_table.resize(10, nullptr);
}
~HashTable() {
for (size_t i = 0; i < _table.size(); ++i) {
Node* cur = _table[i];
while (cur) {
Node* next = cur->_next;
delete cur;
cur = next;
}
_table[i] = nullptr;
}
}
Insert
插入操作首先检查键是否已存在,若存在则返回 false
,避免重复插入。若键不存在,执行以下步骤:
HashFunc
计算哈希值。bool Insert(const pair<K, V>& kv) {
if (Find(kv.first)) {
return false;
}
HashFunc hf;
if (_n == _table.size()) {
size_t newSize = _table.size() * 2;
vector<Node*> newTable;
newTable.resize(newSize, nullptr);
for (size_t i = 0; i < _table.size(); ++i) {
Node* cur = _table[i];
while (cur) {
Node* next = cur->_next;
size_t hashi = hf(cur->_kv.first) % newSize;
cur->_next = newTable[hashi];
newTable[hashi] = cur;
cur = next;
}
_table[i] = nullptr;
}
_table.swap(newTable);
}
size_t hashi = hf(kv.first) % _table.size();
Node* newnode = new Node(kv);
newnode->_next = _table[hashi];
_table[hashi] = newnode;
++_n;
return true;
}
Find
查找操作使用哈希函数计算键的位置,遍历该位置的链表,查找对应键的节点。如果找到则返回节点指针,否则返回 nullptr
。
Node* Find(const K& key) {
HashFunc hf;
size_t hashi = hf(key) % _table.size();
Node* cur = _table[hashi];
while (cur) {
if (cur->_kv.first == key) {
return cur;
}
cur = cur->_next;
}
return nullptr;
}
Erase
删除操作与查找类似,通过哈希值定位到对应的链表,找到目标节点后将其从链表中移除。
bool Erase(const K& key) {
HashFunc hf;
size_t hashi = hf(key) % _table.size();
Node* prev = nullptr;
Node* cur = _table[hashi];
while (cur) {
if (cur->_kv.first == key) {
if (prev == nullptr) {
_table[hashi] = cur->_next;
} else {
prev->_next = cur->_next;
}
--_n;
delete cur;
return true;
}
prev = cur;
cur = cur->_next;
}
return false;
}
Print
打印哈希表中的内容,展示每个桶中的链表结构,用于观察哈希表的分布情况。
void Print() {
for (size_t i = 0; i < _table.size(); ++i) {
printf("[%d]->", i);
Node* cur = _table[i];
while (cur) {
cout << cur->_kv.first << ":" << cur->_kv.second << "->";
cur = cur->_next;
}
printf("NULL\n");
}
cout << endl;
}
HashFunc
计算键的哈希值,映射到对应桶位置。哈希表不仅仅是一个工具,更是一种思想。它带给我们的不仅是高效的算法,更是对数据结构和算法优化的深刻理解。掌握了哈希表的设计与实现,你将拥有在数据密集型任务中快速处理信息的能力。希望本篇博客能帮助你在C++的学习道路上更进一步,感受到编程的魅力。 今天的分享到这里就结束啦!如果觉得文章还不错的话,可以三连支持一下,17的主页还有很多有趣的文章,欢迎小伙伴们前去点评,您的支持就是17前进的动力!