C++ 哈希表
目录
(图像由AI生成)
0.前言
在之前的博客中,我们探讨了AVL树和红黑树,这些平衡二叉树在保持数据有序方面表现出色。然而,随着数据量的增加和查询速度的要求提升,我们需要一种更高效的查找数据结构——哈希表。本篇博客将详细介绍C++中用于实现哈希表的unordered
系列关联式容器,并深入探讨哈希的概念、哈希冲突及其解决方法。
1.unordered系列关联式容器(C++11)
C++11引入了unordered
系列关联式容器,这些容器基于哈希表实现,提供了高效的元素查找、插入和删除操作。这些容器包括unordered_map
、unordered_set
及其多重版本(如unordered_multimap
和unordered_multiset
)。在本节中,我们将重点介绍unordered_map
。
1.1unordered_map
1.1.1文档介绍
unordered_map
是一种基于哈希表实现的关联容器,用于存储键值对(key-value pairs)。每个键值对中的键是唯一的,通过键可以快速查找到对应的值。与有序容器不同,unordered_map
中的元素没有特定的顺序,其主要特点包括:
- 快速查找:平均时间复杂度为O(1)。
- 键唯一:每个键在容器中是唯一的。
- 动态扩展:容器会根据需要动态扩展以保持高效的操作性能。
主要成员函数包括:
operator[]
:通过键访问元素,如果键不存在则插入一个默认值。at
:通过键访问元素,如果键不存在则抛出异常。insert
:插入新的键值对。erase
:通过键或迭代器删除元素。find
:查找键对应的元素,返回一个迭代器。size
:返回容器中元素的数量。empty
:检查容器是否为空。
1.1.2使用示例
以下是一个详细的代码示例,展示了如何使用unordered_map
进行各种操作:
#include <iostream>
#include <unordered_map>
#include <string>
int main() {
// 创建一个unordered_map,键为std::string,值为int
std::unordered_map<std::string, int> umap;
// 插入键值对
umap["apple"] = 1;
umap["banana"] = 2;
umap["cherry"] = 3;
// 使用operator[]访问元素
std::cout << "apple: " << umap["apple"] << std::endl;
// 使用at方法访问元素
try {
std::cout << "banana: " << umap.at("banana") << std::endl;
std::cout << "orange: " << umap.at("orange") << std::endl; // 此行将抛出异常
} catch (const std::out_of_range& e) {
std::cout << "Exception: " << e.what() << std::endl;
}
// 查找元素
auto search = umap.find("cherry");
if (search != umap.end()) {
std::cout << "Found: " << search->first << " -> " << search->second << '\n';
} else {
std::cout << "Not found\n";
}
// 删除元素
umap.erase("banana");
// 遍历所有元素
for (const auto& pair : umap) {
std::cout << pair.first << " -> " << pair.second << '\n';
}
// 检查容器大小和是否为空
std::cout << "Size: " << umap.size() << std::endl;
std::cout << "Is empty: " << (umap.empty() ? "Yes" : "No") << std::endl;
return 0;
}
输出结果:
apple: 1
banana: 2
orange: Exception: _Map_base::at
Found: cherry -> 3
cherry -> 3
apple -> 1
Size: 2
Is empty: No
代码解释:
- 创建
unordered_map
:定义一个键为std::string
,值为int
的unordered_map
。 - 插入键值对:使用
operator[]
插入键值对。 - 访问元素:
- 通过
operator[]
访问键为"apple"
的值。 - 通过
at
方法访问键为"banana"
的值,如果键不存在则抛出异常。
- 通过
- 查找元素:使用
find
方法查找键为"cherry"
的元素,如果找到则输出其键值对。 - 删除元素:使用
erase
方法删除键为"banana"
的元素。 - 遍历元素:使用范围for循环遍历所有键值对并输出。
- 检查容器状态:输出容器的大小并检查是否为空。
1.2unordered_set
1.2.1文档介绍
unordered_set
是C++11标准库中提供的基于哈希表实现的集合容器。与unordered_map
不同,unordered_set
仅存储唯一的值(elements),没有键值对的概念。其主要特点包括:
- 快速查找:平均时间复杂度为O(1)。
- 唯一性:集合中的每个元素都是唯一的,不允许重复。
- 无序存储:元素的存储顺序不固定。
- 动态扩展:容器会根据需要动态扩展以保持高效的操作性能。
主要成员函数包括:
insert
:插入元素。如果元素已存在,则不会插入。erase
:通过值或迭代器删除元素。find
:查找元素,返回一个迭代器。count
:检查元素是否存在,返回1或0。size
:返回容器中元素的数量。empty
:检查容器是否为空。
1.2.2使用示例
以下是一个详细的代码示例,展示了如何使用unordered_set
进行各种操作:
#include <iostream>
#include <unordered_set>
#include <string>
int main() {
// 创建一个unordered_set,元素类型为std::string
std::unordered_set<std::string> uset;
// 插入元素
uset.insert("apple");
uset.insert("banana");
uset.insert("cherry");
// 尝试插入一个已存在的元素
if (!uset.insert("apple").second) {
std::cout << "\"apple\" already exists in the set.\n";
}
// 查找元素
auto search = uset.find("banana");
if (search != uset.end()) {
std::cout << "Found: " << *search << '\n';
} else {
std::cout << "Not found\n";
}
// 检查元素是否存在
if (uset.count("cherry") > 0) {
std::cout << "\"cherry\" is in the set.\n";
} else {
std::cout << "\"cherry\" is not in the set.\n";
}
// 删除元素
uset.erase("banana");
// 遍历所有元素
for (const auto& elem : uset) {
std::cout << elem << '\n';
}
// 检查容器大小和是否为空
std::cout << "Size: " << uset.size() << std::endl;
std::cout << "Is empty: " << (uset.empty() ? "Yes" : "No") << std::endl;
return 0;
}
输出结果:
"apple" already exists in the set.
Found: banana
"cherry" is in the set.
cherry
apple
Size: 2
Is empty: No
代码解释:
- 创建
unordered_set
:定义一个元素类型为std::string
的unordered_set
。 - 插入元素:使用
insert
方法插入元素。如果插入重复元素,insert
方法返回的pair
的second
为false
。 - 查找元素:使用
find
方法查找元素,如果找到则输出该元素。 - 检查元素是否存在:使用
count
方法检查元素是否存在,返回1(存在)或0(不存在)。 - 删除元素:使用
erase
方法删除指定元素。 - 遍历元素:使用范围for循环遍历所有元素并输出。
- 检查容器状态:输出容器的大小并检查是否为空。
2.哈希的概念
在顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须遍历关键码的多次比较。顺序查找的时间复杂度为O(N),平衡树的查找时间复杂度为。这些方法的效率取决于查找过程中元素的比较次数。
哈希表(Hash Table)是一种通过哈希函数(Hash Function)将关键码直接映射到存储位置的数据结构。其基本思想是:可以不经过任何比较,一次直接从表中得到所需的元素。如果两个关键码不同,通过其对应的哈希函数值也不同。哈希表中的关键码和存储位置之间有确定的映射关系,使得通过计算哈希函数可以快速找到相应的元素。
2.1 插入元素
哈希表的插入元素过程如下:
- 根据插入元素的关键码,计算其存储位置的哈希值。
- 根据计算得到的存储位置,将元素存储在相应的位置。
2.2 搜索元素
哈希表的搜索元素过程如下:
- 对元素的关键码进行哈希计算,将得到的哈希值作为存储位置。
- 直接从该存储位置取元素进行比较,如果关键码相等,则搜索成功。
2.3 哈希函数
哈希表中使用的转换函数称为哈希函数(散列函数)。哈希函数将关键码转换为存储位置的索引。一个常见的哈希函数是取模运算:
hash(key)=key%capacityhash(key)=key%capacity
其中,capacity是哈希表的存储层空间总大小。举例来说,数据集合{1, 7, 6, 4, 5, 9}在容量为10的哈希表中的哈希值计算如下:
- hash(1) = 1 % 10 = 1
- hash(7) = 7 % 10 = 7
- hash(6) = 6 % 10 = 6
- hash(4) = 4 % 10 = 4
- hash(5) = 5 % 10 = 5
- hash(9) = 9 % 10 = 9
这些计算结果将元素分别存储在哈希表的相应位置上,如下表所示:
位置 | 元素 |
---|---|
0 | |
1 | 1 |
2 | |
3 | |
4 | 4 |
5 | 5 |
6 | 6 |
7 | 7 |
8 | |
9 | 9 |
2.4 哈希表的优势
哈希表的主要优势在于其高效的查找性能。使用哈希表进行查找时,不需要进行多次关键码的比较,因此查找的时间复杂度接近于O(1)。插入和删除操作也同样高效,使得哈希表在实际应用中非常广泛。
3.哈希冲突
哈希冲突是指在使用哈希函数计算元素存储位置时,不同的关键码得到了相同的哈希值,从而导致它们被映射到同一个存储位置。哈希冲突是哈希表设计中必须解决的问题,因为它直接影响到哈希表的性能和效率。
假设我们有一个容量为10的哈希表,并使用简单的取模哈希函数hash(key) = key % 10
。我们需要插入以下元素:1, 11, 21。这些元素的哈希值计算如下:
- hash(1) = 1 % 10 = 1
- hash(11) = 11 % 10 = 1
- hash(21) = 21 % 10 = 1
可以看到,1、11和21这三个元素的哈希值都是1,因此它们被映射到了哈希表的同一个存储位置1。这就是一个典型的哈希冲突示例。
哈希冲突会导致多个元素被映射到同一个位置,进而需要额外的机制来区分和存储这些冲突的元素。频繁的哈希冲突会降低哈希表的查找、插入和删除操作的效率。因此,设计良好的哈希函数和有效的冲突解决策略对于哈希表的性能至关重要。
4.哈希函数的设计
哈希函数是哈希表的核心,它将关键码映射到存储位置。如果哈希函数设计不合理,容易导致大量的哈希冲突,从而影响哈希表的性能。因此,设计一个好的哈希函数是确保哈希表高效运行的关键。
4.1设计原则
一个好的哈希函数应满足以下设计原则:
- 确定性:相同的输入必须产生相同的输出。
- 均匀性:哈希值应尽可能均匀分布,以减少冲突。
- 高效性:计算哈希值的过程应尽可能高效。
- 适应性:适用于不同的数据分布情况,能够处理各种可能的输入。
4.2常见哈希函数
1. 直接定址法
直接定址法是最简单的一种哈希函数,它将关键码线性转换为地址。其形式为:
Hash(Key)=A×Key+B
- 优点:简单、均匀
- 缺点:需要预先知道关键码的分布情况
- 适用场景:适合有连续关键码的情况
例如,如果关键码是连续的整数,直接定址法能够非常高效地将其转换为哈希地址。
2. 除留余数法
除留余数法是最常用的哈希函数之一,它通过取模运算将关键码转换为哈希地址。其形式为:
Hash(Key)=Key%p
其中,p是一个不大于m(哈希表容量)的质数。
- 优点:实现简单,分布较均匀
- 缺点:如果选择的p不合适,容易产生冲突
- 适用场景:广泛适用于各种关键码的分布
例如,对于关键码集合{1, 7, 6, 4, 5, 9},如果哈希表容量为10,我们选择p=7,则哈希值计算如下:
- hash(1) = 1 % 7 = 1
- hash(7) = 7 % 7 = 0
- hash(6) = 6 % 7 = 6
- hash(4) = 4 % 7 = 4
- hash(5) = 5 % 7 = 5
- hash(9) = 9 % 7 = 2
这些计算结果将元素均匀地分布在哈希表中,减少了冲突的可能性。
3. 平方取中法
平方取中法是另一种哈希函数,它首先将关键码平方,然后取平方结果的中间几位作为哈希值。
- 优点:不依赖于关键码的分布
- 缺点:平方运算相对较慢
- 适用场景:关键码的位数较多且分布不规则的情况
4. 折叠法
折叠法将关键码分割成几部分,然后将这些部分相加,最后取结果的模。
- 优点:不需要知道关键码的分布
- 缺点:分割和相加的操作可能较慢
- 适用场景:关键码的位数较多且没有明显规律的情况
5. 随机数法
随机数法选择一个随机数生成器,将关键码映射为一个随机数作为哈希值。
- 优点:理论上能够均匀分布
- 缺点:随机数生成器的质量直接影响哈希值的分布
- 适用场景:适用于关键码长度不等的情况
6. 数字分析法
数字分析法根据关键码的数值特征,将其映射为哈希值。
- 优点:能够处理数值型关键码
- 缺点:需要分析关键码的数值特征
- 适用场景:适用于数值型关键码的情况
5.哈希冲突的解决
哈希冲突是指不同的关键码通过哈希函数计算后得到相同的哈希值,导致它们被映射到同一个存储位置。为了解决哈希冲突,常用的方法有闭散列(开放定址法)和开散列(拉链法)。
5.1闭散列(开放定址法)
开放定址法的基本思想是,当发生哈希冲突时,按一定规则探测其他空闲位置,直到找到一个空闲位置为止。线性探测法是开放定址法中最简单的一种。
线性探测
线性探测法的步骤如下:
- 当发生哈希冲突时,从冲突位置开始,以固定步长依次检查下一个位置,直到找到空闲位置。
- 如果到达哈希表末尾,则从表头重新开始探测。
线性探测法的公式为: hash(key,i)=(hash(key)+i)%capacity 其中,i是探测次数。
代码示例
以下是线性探测法解决哈希冲突的代码示例:
#include <iostream>
#include <vector>
#include <string>
class HashTable {
private:
std::vector<std::string> table;
int capacity;
std::string DELETED;
public:
HashTable(int size) : capacity(size), table(size, ""), DELETED("<DELETED>") {}
int hash(const std::string& key) {
int hash_value = 0;
for (char c : key) {
hash_value = (hash_value * 31 + c) % capacity;
}
return hash_value;
}
void insert(const std::string& key) {
int index = hash(key);
int i = 0;
while (!table[(index + i) % capacity].empty() && table[(index + i) % capacity] != DELETED) {
i++;
}
table[(index + i) % capacity] = key;
}
bool search(const std::string& key) {
int index = hash(key);
int i = 0;
while (!table[(index + i) % capacity].empty()) {
if (table[(index + i) % capacity] == key) {
return true;
}
i++;
}
return false;
}
void remove(const std::string& key) {
int index = hash(key);
int i = 0;
while (!table[(index + i) % capacity].empty()) {
if (table[(index + i) % capacity] == key) {
table[(index + i) % capacity] = DELETED;
return;
}
i++;
}
}
void display() {
for (int i = 0; i < capacity; ++i) {
if (table[i] != "" && table[i] != DELETED) {
std::cout << i << " : " << table[i] << std::endl;
} else {
std::cout << i << " : " << "<EMPTY>" << std::endl;
}
}
}
};
int main() {
HashTable ht(10);
ht.insert("apple");
ht.insert("banana");
ht.insert("cherry");
ht.insert("date");
ht.insert("elderberry");
ht.display();
std::cout << "Searching for 'banana': " << (ht.search("banana") ? "Found" : "Not Found") << std::endl;
ht.remove("banana");
std::cout << "Searching for 'banana' after deletion: " << (ht.search("banana") ? "Found" : "Not Found") << std::endl;
ht.display();
return 0;
}
输出结果:
0 : apple
1 : <EMPTY>
2 : elderberry
3 : cherry
4 : date
5 : <EMPTY>
6 : <EMPTY>
7 : <EMPTY>
8 : <EMPTY>
9 : banana
Searching for 'banana': Found
Searching for 'banana' after deletion: Not Found
0 : apple
1 : <EMPTY>
2 : elderberry
3 : cherry
4 : date
5 : <EMPTY>
6 : <EMPTY>
7 : <EMPTY>
8 : <EMPTY>
9 : <EMPTY>
5.2开散列(拉链法)
拉链法是解决哈希冲突的一种有效方法。当发生哈希冲突时,拉链法通过在每个哈希表桶(bucket)中存储一个链表来解决冲突。这样,每个桶可以包含多个元素,这些元素都具有相同的哈希值。
原理
- 哈希函数:首先使用哈希函数将关键码映射到哈希表的一个桶中。
- 链表存储:如果多个关键码映射到同一个桶中,则这些元素通过链表的形式存储在该桶中。
- 插入操作:新元素根据哈希值找到相应的桶,并插入到该桶的链表中。
- 查找操作:根据哈希值找到相应的桶,然后在链表中顺序查找元素。
- 删除操作:根据哈希值找到相应的桶,然后在链表中删除元素。
优点
- 避免聚集:拉链法不会像线性探测法那样产生一次聚集问题,适用于负载因子较高的情况。
- 动态扩展:链表的长度可以动态增加,不需要预先分配大量的空间。
缺点
- 额外开销:需要额外的存储空间来维护链表。
代码示例
#include <iostream>
#include <list>
#include <vector>
// 定义哈希表类
class HashTable {
private:
std::vector<std::list<int>> table; // 哈希表,每个桶存储一个链表
int capacity; // 哈希表容量
public:
// 构造函数,初始化哈希表容量和表结构
HashTable(int size) : capacity(size), table(size) {}
// 哈希函数,将整数转换为哈希值
int hash(int key) {
return key % capacity;
}
// 插入元素到哈希表
void insert(int key) {
int index = hash(key); // 计算哈希值
table[index].push_back(key); // 将元素插入到相应桶的链表中
}
// 查找元素是否存在于哈希表中
bool search(int key) {
int index = hash(key); // 计算哈希值
for (const auto& element : table[index]) { // 遍历链表
if (element == key) {
return true;
}
}
return false;
}
// 删除哈希表中的指定元素
void remove(int key) {
int index = hash(key); // 计算哈希值
table[index].remove(key); // 在链表中删除元素
}
// 显示哈希表的内容
void display() {
for (int i = 0; i < capacity; ++i) {
std::cout << i << " : ";
for (const auto& element : table[i]) {
std::cout << element << " -> ";
}
std::cout << "NULL" << std::endl;
}
}
};
int main() {
HashTable ht(10);
// 插入元素
ht.insert(10);
ht.insert(20);
ht.insert(15);
ht.insert(30);
ht.insert(25);
// 插入导致哈希冲突的元素
ht.insert(40); // 假设 10 和 40 产生相同的哈希值
// 显示哈希表
ht.display();
// 查找元素
std::cout << "Searching for 20: " << (ht.search(20) ? "Found" : "Not Found") << std::endl;
ht.remove(20);
std::cout << "Searching for 20 after deletion: " << (ht.search(20) ? "Found" : "Not Found") << std::endl;
// 显示哈希表
ht.display();
return 0;
}
输出结果:
0 : 10 -> 20 -> 30 -> 40 -> NULL
1 : NULL
2 : NULL
3 : NULL
4 : NULL
5 : 15 -> 25 -> NULL
6 : NULL
7 : NULL
8 : NULL
9 : NULL
Searching for 20: Found
Searching for 20 after deletion: Not Found
0 : 10 -> 30 -> 40 -> NULL
1 : NULL
2 : NULL
3 : NULL
4 : NULL
5 : 15 -> 25 -> NULL
6 : NULL
7 : NULL
8 : NULL
9 : NULL
6.结语
通过本篇博客,我们深入探讨了C++中的哈希表及其实现,包括unordered_map
和unordered_set
的基本使用、哈希的概念、哈希冲突的解决方法以及哈希函数的设计原则。哈希表凭借其高效的查找、插入和删除操作,成为现代编程中不可或缺的工具。理解和掌握哈希表的原理和应用,不仅能提高程序的性能,还能为处理大量数据提供强有力的支持。希望本篇博客能帮助读者更好地理解和应用哈希表,在实际项目中发挥其优势。
原文地址:https://blog.csdn.net/wxk2227814847/article/details/140729354
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!