Hash
哈希表(Hash Table),也被称为散列表,是数据库管理系统中一种常用的数据结构,用于快速存储和检索数据。哈希表基于哈希函数(Hash Function)将键(Key)映射到存储位置,从而实现高效的数据访问。
- 哈希函数(Hash Function):
哈希函数是哈希表的核心组成部分。它将输入的键(Key)映射为一个固定长度的索引值(通常是一个整数)。合适的哈希函数应该具备以下特点:
- 易于计算:哈希函数的计算应该是高效且快速的。
- 均匀分布:哈希函数应该将键均匀地分布到哈希表中,避免发生碰撞(Collision)。
- 一致性:同样的输入键应该始终映射到相同的索引值。
- 哈希表操作
哈希表支持以下基本操作:
- 插入(Insertion):将一个键值对(Key-Value Pair)插入哈希表中,哈希函数计算键的哈希值并确定存储位置。
- 查找(Lookup):根据给定的键,使用哈希函数找到对应的存储位置,并返回对应的值。
- 删除(Deletion):根据给定的键,找到存储位置并删除对应的键值对。
- 哈希碰撞
哈希碰撞是指两个不同的键映射到了相同的哈希值,导致它们被存储在了相同的位置。解决哈希碰撞的常见方法包括:
- 链接法(Chaining):每个哈希桶存储一个链表或其他数据结构,将具有相同哈希值的键值对连接在一起。
- 开放寻址法(Open Addressing):在发生碰撞时,继续在哈希表中探查下一个位置,直到找到一个空槽来存储数据。
- 哈希表的性能:
哈希表在理想情况下具有常数时间的平均复杂度,即 O(1),但在存在哈希碰撞的情况下,性能可能会下降。解决碰撞以及选择合适的哈希函数都是影响性能的重要因素。
- 应用场景:
哈希表在数据库管理系统中有广泛的应用,用于加速数据的存储和检索。常见的应用包括:
- 索引加速:数据库中的索引通常使用哈希表来快速定位数据行。
- 唯一性约束:用于确保数据库中的某一列具有唯一值。
- 缓存管理:用于存储频繁访问的数据,以减少对底层存储的访问次数。
哈希函数(Hash Function)
对于任何输入key,都会返回一个整数 的整数表示。
需要速度快、碰撞率低的 碰撞率。
静态哈希
静态哈希是一种哈希方法,其中哈希表的大小在创建后不会发生变化。在静态哈希中,哈希函数将键映射到预先定义的固定数量的槽位(slot)(哈希桶)。这意味着一旦分配了哈希表的大小,它将保持不变,无论插入或删除多少数据。
特点和优点:
- 哈希表的大小固定,不需要频繁地调整,因此在内存管理上较为简单。
- 插入和删除操作相对简单,不涉及重新哈希和数据迁移。
- 由于哈希表大小固定,可以预先计算好存储位置,使查找操作更加高效。
缺点:
- 如果数据量较少,可能会导致空间浪费,而如果数据量较大,可能会导致碰撞率较高。
- 当数据集增加或删除时,难以保持较低的碰撞率,可能需要重新设计和重新构建哈希表。
应用场景:
- 适用于数据量稳定且不经常变化的情况,如只读数据集、配置数据等。
- 适用于不需要频繁插入和删除操作的场景。
线性探测哈希(linear prob hash )
“Linear Probing Hash”(线性探测哈希)是一种开放寻址法(Open Addressing)的哈希碰撞解决策略,用于处理哈希表中的碰撞问题。在哈希表中,当两个不同的键映射到了相同的哈希值(即发生了碰撞),线性探测哈希会尝试在哈希表中找到下一个可用的位置来存储冲突的键值对,而不是通过链表等数据结构来处理碰撞。
具体来说,线性探测哈希的解决方法是:在发生碰撞时,继续在哈希表中的下一个位置(线性地向前移动一个位置)查找,直到找到一个空闲的槽位来存储数据。这个过程会持续进行,直到所有槽位都被占用或者找到合适的位置为止。当需要查找或删除某个键时,也会使用同样的线性探测方式来找到存储位置。
线性探测哈希的优点是简单且易于实现,不需要额外的链表或数据结构来存储冲突的键值对。然而,它也存在一些问题:
- 聚集问题(Clustering):由于线性探测可能导致连续的位置被占用,可能会导致聚集问题,即一些区域会变得密集,而其他区域则较为稀疏,这可能会影响性能。
- 删除问题:删除一个键值对可能会留下一个空槽,导致查找过程中的不连续性,进而影响性能。
线性探测哈希的执行过程
- 初始化哈希表:
首先,创建一个空的哈希表,其内部包含一些空槽位。每个槽位可以存储一个键值对 - 哈希函数:
定义一个哈希函数,它将键映射到哈希表的槽位。例如,我们可以使用简单的取模运算:哈希值 = 键 % 哈希表大小。 - 插入键值对:
- 计算 Key1 的哈希值:哈希值 = Key1 % 哈希表大小。
- 如果哈希表的哈希值位置为空,将 (Key1, Value1) 存储在这个位置。
- 如果哈希值位置已经被占用,执行线性探测,继续查找下一个位置,直到找到一个空位置为止。
- 查找键值对:
- 计算 Key 的哈希值:哈希值 = Key% 哈希表大小。
- 从哈希值位置开始,线性探测地查找下一个位置,直到找到 Key 或者遇到空位置为止。
- 删除键值对:
- 计算 Key 的哈希值:哈希值 = Key % 哈希表大小。
- 从哈希值位置开始,线性探测地查找下一个位置,直到找到 Key 或者遇到空位置为止。
- 如果找到了 Key,将对应的槽位标记为空。
执行例子
哈希表大小:10
索引: 0 1 2 3 4 5 6 7 8 9
值: - - - - - - - - - -
插入 (7, "apple"):
哈希值:7 % 10 = 7
在索引 7 处插入键值对 (7, "apple")。
哈希表变为:
索引: 0 1 2 3 4 5 6 7 8 9
值: - - - - - - - "apple" - -
插入 (17, "banana"):
哈希值:17 % 10 = 7(碰撞发生)
由于索引 7 处已经有值,线性探测下一个位置。
哈希值:(7 + 1) % 10 = 8
在索引 8 处插入键值对 (17, "banana")。
哈希表变为:
索引: 0 1 2 3 4 5 6 7 8 9
值: - - - - - - - "apple" "banana" -
插入 (28, "cherry"):
哈希值:28 % 10 = 8(碰撞发生)
由于索引 8 处已经有值,线性探测下一个位置。
哈希值:(8 + 1) % 10 = 9
在索引 9 处插入键值对 (28, "cherry")。
哈希表变为:
索引: 0 1 2 3 4 5 6 7 8 9
值: - - - - - - - "apple" "banana" "cherry"
查找 (17, "banana"):
哈希值:17 % 10 = 7
在索引 7 处找到值 "apple",但不是我们要找的。
继续线性探测下一个位置:
哈希值:(7 + 1) % 10 = 8
在索引 8 处找到值 "banana",与要查找的键匹配。
删除 (17, "banana"):
哈希值:17 % 10 = 7
在索引 7 处找到值 "apple",但不是我们要删除的。
继续线性探测下一个位置:
哈希值:(7 + 1) % 10 = 8
在索引 8 处找到值 "banana",与要删除的键匹配。
将索引 8 处的值标记为空。
哈希表变为:
索引: 0 1 2 3 4 5 6 7 8 9
值: - - - - - - - "apple" - "cherry"
解决删除问题
删除一个键值对可能会留下一个空槽,导致查找过程中的不连续性,进而影响性能。
-
Movement
重复keys,直到找到第一个空槽。 -
Tombstone
设置标记,表明slot中的条目已 逻辑删除。可以对标记的slot重复使用新keys,另外需要进行垃圾回收
Robin Hood Hash
Robin Hood Hashing 是一种哈希表的碰撞解决方法,属于开放寻址法的一种变种。它的核心思想是通过优化线性探测来减少冲突带来的性能影响。Robin Hood Hashing 试图使所有插入的键值对在哈希表中的位置尽量接近其本来的哈希值,从而降低查找时间。
工作原理
当发生哈希冲突时,它会尝试将冲突的键值对移到与其哈希值更接近的位置。如果冲突的键值对距离其本来的哈希位置(探测次数)较远,那么系统会尝试将其移到距离更近的位置,这样可以减少整体的查找时间。这个过程类似于 “打劫者”(Robin Hood)将资源从富人那里取回分配给穷人。
插入操作
- 计算键的哈希值。
- 尝试将键插入哈希表的哈希值位置。
- 如果发生碰撞,比较当前键的距离(探测次数)与原始哈希值的距离。
- 如果当前键的距离小于冲突键的距离,则交换它们的位置,使得距离更短的键更靠近其哈希值。
- 继续上述过程,直到找到一个空槽位为止。
查找操作
- 计算健的哈希值
- 从哈希值位置开始,线性探测地查找键,直到找到目标键或者遇到空槽位为止。
删除
- 计算键的哈希值。
- 从哈希值位置开始,线性探测地查找键,直到找到目标键或者遇到空槽位为止。
- 如果找到了目标键,将对应的槽位标记为空。
动态哈希
允许在插入或删除数据时动态地调整哈希表的大小。动态哈希适用于需要频繁插入和删除操作的场景,它可以自适应地处理数据的变化,以保持较低的碰撞率。
特点和优点:
- 哈希表的大小可以根据数据的变化动态调整,以保持较低的碰撞率,提高性能。
- 插入和删除操作可能涉及重新哈希和数据迁移,但可以保持较好的性能。
- 能够适应数据集的变化,保持较低的碰撞率,同时避免了静态哈希可能的浪费或溢出问题。
缺点:
- 动态哈希表的管理较为复杂,需要处理重新哈希和数据迁移的情况。
应用场景:
- 适用于需要频繁插入和删除操作的场景,如数据库、缓存等。
- 适用于数据量经常变化且需要保持较低碰撞率的情况。
Chained Hashing
Chained Hashing 属于开放寻址法的一种。在链式哈希中,每个哈希桶(槽位)不再只存储一个键值对,而是可以存储一个链表、链表的头指针或其他数据结构。这样,当发生哈希冲突时,冲突的键值对可以被添加到同一槽位的链表中,形成一个“链”,从而解决冲突问题。
插入操作:
- 计算键的哈希值。
- 根据哈希值找到对应的槽位。
- 如果该槽位为空,直接将键值对插入。
- 如果该槽位不为空,说明发生了哈希冲突,将新的键值对添加到链表中。
查找操作:
- 计算键的哈希值。
- 根据哈希值找到对应的槽位。
- 在槽位上的链表中进行线性搜索,查找目标键值对。
删除操作:
- 计算键的哈希值。
- 根据哈希值找到对应的槽位。
- 在槽位上的链表中进行线性搜索,找到目标键值对并删除。
特点和优点:
- 简单且易于实现,不需要重新哈希或者复杂的计算。
- 可以有效地处理哈希冲突,避免了大部分碰撞问题。
- 适用于不确定数据规模的情况,可以在哈希表大小固定的情况下灵活地处理数据变化。
缺点:
- 需要额外的空间来存储链表头指针,可能会导致内存浪费。
- 在链表长度较长的情况下,查找性能可能会下降,最坏情况下可能退化为线性查找。
应用场景:
- 适用于处理冲突较多的情况,能够有效地解决碰撞问题。
- 适用于不确定数据规模或需要频繁插入和删除操作的场景。
链式哈希是一种简单且可靠的哈希碰撞解决方法,它在处理冲突时能够提供良好的性能。然而,对于一些性能要求较高的场景,可能需要考虑其他更高级的哈希碰撞解决方法。
实现
#include <iostream>
#include <vector>
#include <list>
const int TABLE_SIZE = 10; // 哈希表大小
// 定义链式哈希表的数据结构
class ChainedHash {
private:
std::vector<std::list<std::pair<int, std::string>>> table;
public:
ChainedHash() : table(TABLE_SIZE) {}
// 哈希函数,计算键的哈希值
int hashFunction(int key) {
return key % TABLE_SIZE;
}
// 插入操作
void insert(int key, const std::string &value) {
int index = hashFunction(key); // 计算哈希值
table[index].emplace_back(key, value); // 将键值对添加到链表末尾
}
// 查找操作
bool find(int key, std::string &value) {
int index = hashFunction(key); // 计算哈希值
for (const auto &entry : table[index]) {
if (entry.first == key) {
value = entry.second; // 找到对应键的值
return true;
}
}
return false; // 未找到键
}
// 删除操作
bool remove(int key) {
int index = hashFunction(key); // 计算哈希值
for (auto it = table[index].begin(); it != table[index].end(); ++it) {
if (it->first == key) {
table[index].erase(it); // 从链表中删除键值对
return true;
}
}
return false; // 未找到键
}
};
int main() {
ChainedHash hashTable;
// 插入操作
hashTable.insert(28, "apple");
hashTable.insert(17, "banana");
// 查找操作
std::string value;
if (hashTable.find(17, value)) {
std::cout << "找到键 17,值为:" << value << std::endl;
} else {
std::cout << "未找到键 17" << std::endl;
}
// 删除操作
if (hashTable.remove(17)) {
std::cout << "已删除键 17" << std::endl;
} else {
std::cout << "未找到键 17,无法删除" << std::endl;
}
return 0;
}
Extendiable Hash
Extendible Hash(可扩展哈希)是一种动态哈希方法,用于解决在哈希表中发生冲突时的碰撞问题。它允许在数据集不断增长的情况下动态地调整哈希表的大小,以保持较低的碰撞率和较好的性能。Extendible Hash 是一种分裂桶的策略,它允许在需要时将哈希桶分割成更小的部分,从而提高哈希表的性能和效率。
核心思想
Extendible Hash 使用一个目录(Directory)和一组桶(Buckets)来组织数据。初始时,目录中包含一个指向单个桶的指针。当桶中的键值对数量达到一定阈值时,会触发桶的分裂,将一个桶分成两个,并更新目录中的指针,使其指向新的桶。这种方式可以动态地增加哈希表的大小,而无需重建整个哈希表。
插入操作:
-
计算哈希值: 首先,根据待插入的键,计算其哈希值。这个哈希值将被用来定位目录中的某个桶。
-
访问目录: 使用哈希值作为索引,访问目录中的相应位置。目录中存储着指向各个桶的指针。
-
访问桶: 跟随目录中的指针,访问到对应的桶。这个桶是 Extendible Hash 中的一个重要组成部分。
-
检查桶状态: 检查被访问的桶的状态,看是否满足插入条件。如果桶中的键值对数量未达到阈值,则可以直接插入。
-
局部插入(Local Insert): 如果桶未满,将新的键值对插入到桶中。操作完成,插入结束。
-
桶分裂检查: 如果桶已满,需要考虑是否进行桶的分裂操作。这是 Extendible Hash 最关键的部分。
-
全局指示器检查(Global Indicator Check): 在进行桶分裂之前,需要检查全局指示器。全局指示器指示了目录是否需要扩展。如果全局指示器为真,需要执行全局操作。
-
局部分裂(Local Split): 如果全局指示器为假,进行局部分裂操作。这涉及将一个桶分裂成两个,重新分配键值对。
-
全局分裂(Global Split): 如果全局指示器为真,需要执行全局分裂操作。这意味着扩展目录,增加目录的位数,以及重新分配桶和键值对。
Global 和 Local 概念:
-
全局(Global): 全局操作是指 Extendible Hash 在插入操作时,如果桶已满并且需要进行分裂,但当前目录中的位数不足以容纳更多的桶,那么就需要进行全局分裂操作。全局分裂会增加目录的位数,重新分配桶和键值对。全局操作可能涉及到更大的数据重排,因此会更耗时。
-
局部(Local): 局部操作是指 Extendible Hash 在插入操作时,如果桶已满并且当前目录中的位数足够容纳新的桶,那么就可以进行局部分裂操作。局部分裂只涉及到单个桶的分裂,重新分配桶中的键值对,相对来说开销较小。
查找操作
- 计算键的哈希值,得到目录中的指针。
- 跟随目录中的指针找到对应的桶。
- 在桶中进行线性搜索,查找目标键值对。
删除操作:
- 计算键的哈希值,得到目录中的指针。
- 跟随目录中的指针找到对应的桶。
- 在桶中进行线性搜索,找到目标键值对并删除。
#include <iostream>
#include <vector>
#include <list>
#include <cmath>
const int MAX_BUCKET_SIZE = 2; // 桶的最大容量
// 哈希函数,使用末尾二进制的方法
int hashFunction(int key, int bits) {
return key & ((1 << bits) - 1);
}
// 定义桶的数据结构
struct Bucket {
std::list<std::pair<int, std::string>> data;
};
// 定义 Extendible Hash 的数据结构
class ExtendibleHash {
private:
int globalDepth; // 全局深度
int bucketSize; // 桶的容量
int directorySize; // 目录的大小
std::vector<Bucket> directory; // 目录
public:
ExtendibleHash(int depth, int size) : globalDepth(depth), bucketSize(size) {
directorySize = 1 << globalDepth;
directory.resize(directorySize);
}
// 插入操作
void insert(int key, const std::string &value) {
int index = hashFunction(key, globalDepth); // 计算哈希值
Bucket &bucket = directory[index]; // 获取对应的桶
if (bucket.data.size() < bucketSize) {
// 桶未满,直接插入
bucket.data.emplace_back(key, value);
} else {
// 桶已满,进行局部分裂操作
splitBucket(index);
insert(key, value); // 重新插入
}
}
// 局部分裂操作
void splitBucket(int index) {
if (directory[index].data.size() >= bucketSize) {
int localDepth = log2(directorySize); // 当前桶的本地深度
if (localDepth == globalDepth) {
// 全局深度不足,进行全局分裂
doubleDirectory();
} else {
// 创建新桶并分配键值对
Bucket newBucket;
int mask = 1 << localDepth; // 用于检查桶内的键的末尾二进制位
for (auto it = directory[index].data.begin(); it != directory[index].data.end();) {
if (it->first & mask) {
newBucket.data.push_back(*it);
it = directory[index].data.erase(it);
} else {
++it;
}
}
// 更新目录中的指针
int newIndex = index ^ mask;
directory[newIndex] = newBucket;
}
}
}
// 全局分裂操作
void doubleDirectory() {
globalDepth++;
directorySize *= 2;
directory.resize(directorySize);
for (int i = 0; i < directorySize / 2; i++) {
directory[i + directorySize / 2] = directory[i];
}
}
// 查找操作
bool find(int key, std::string &value) {
int index = hashFunction(key, globalDepth); // 计算哈希值
Bucket &bucket = directory[index]; // 获取对应的桶
for (const auto &entry : bucket.data) {
if (entry.first == key) {
value = entry.second; // 找到对应键的值
return true;
}
}
return false; // 未找到键
}
};
int main() {
ExtendibleHash hashTable(1, MAX_BUCKET_SIZE);
// 插入操作
hashTable.insert(28, "apple");
hashTable.insert(17, "banana");
hashTable.insert(36, "cherry");
// 查找操作
std::string value;
if (hashTable.find(17, value)) {
std::cout << "找到键 17,值为:" << value << std::endl;
} else {
std::cout << "未找到键 17" << std::endl;
}
return 0;
}