Lec5 Hashing
Lec5 Hashing
搜索问题及方法
1. 搜索问题的定义
我们有一个记录集合 $ L $,包含 $ n $ 条记录,形式为: $ (k_1, I_1), (k_2, I_2), , (k_n, I_n) $ 其中:
- $ k_j $ 是记录 $ j $ 的关键字;
- $ I_j $ 是与关键字 $ k_j $ 相关联的信息。
目标:
给定一个关键字 $ K $,定位集合 $ L $ 中满足 $ k_j = K $ 的记录 $ (k_j,
I_j) $。如果没有找到,则说明该记录不存在。
2. 搜索的分类
成功搜索(Successful Search):
找到与关键字 $ K $ 匹配的记录 $ (k_j, I_j) $。失败搜索(Unsuccessful Search):
没有找到关键字 $ K $ 的匹配记录,且集合中不存在这样的记录。精确匹配查询(Exact-Match Query):
搜索关键字值等于 $ K $ 的记录。范围查询(Range Query):
搜索所有关键字值位于给定范围内的记录。
3. 常见的搜索方法
A. 顺序和链表方法(Sequential and List Methods)
- 适用场景: 数据存储在内存(RAM)中。
- 特点:
- 按顺序逐一检查记录;
- 实现简单但效率较低;
- 适合小规模数据或无序数据的场景。
B. 直接访问(哈希方法, Hashing)
- 适用场景: 数据存储在内存(RAM)或磁盘中。
- 特点:
- 根据关键字计算哈希值,通过哈希值直接定位记录;
- 查询效率高,接近 $ O(1) $;
- 适用于快速定位,但不适合范围查询。
C. 树型索引方法(Tree Indexing Methods)
- 适用场景: 数据主要存储在磁盘中。
- 特点:
- 数据以树的形式组织(如 B 树、B+ 树);
- 查询效率为 $ O(n) $;
- 适合范围查询和大规模数据的管理。
4. 搜索方法的选择
- 顺序和链表方法:
- 数据量小,存储在内存中;
- 数据无序或需要按顺序遍历。
- 直接访问(哈希):
- 快速查找指定记录;
- 不适合范围查询。
- 树型索引:
- 适合存储在磁盘中的大规模数据;
- 支持范围查询和动态更新。
顺序搜索和高级搜索方法
1. 顺序搜索(Sequential Search)
适用场景: 无序列表中的关键字查找。
时间复杂度:
- 最坏情况下需要 $ (n) $ 的时间。
2. 顺序搜索的平均成本
定义:
- $ p_i $:关键字 $ K $ 在列表 $ L $ 中第 $ i $ 个位置的概率,$ i $;
- $ p_n $:关键字 $ K $ 不在列表 $ L $ 中的概率。
搜索成本:
- 当 $ K $ 位于位置 $ i $ 时,需要 $ (i+1) $ 次比较;
- 当 $ K $ 不在列表中时,需要 $ n $ 次比较。
平均搜索成本公式:
$ T(n) = n p_n + _{i=0}^{n-1} (i+1)p_i $
**假设 $ p_i $ 均等(即 $ n p + p_n = 1 \():**\) T(n) = n p_n + _{i=0}^{n-1} (i+1)p $ 代入 $ p = \(,得到:\) T(n) = $
范围:
$ T(n) n $
3. 排序数组中的搜索
- 优势:
- 单次比较可能排除多个元素。
- 比较元素 $ i $ 和 $ K $ 时,可排除 $ [0, i-1] $ 或 $ [i+1, n] $ 的所有元素。
4. 跳跃搜索(Jump Search)
方法:
- 给定一个步长 $ j $,检查列表中的第 $ j \(、\) 2j \(、\) 3j $ ... 等位置;
- 如果 $ K > L[mj] $,继续跳跃;
- 如果 $ L[mj] < K < L[(m+1)j] $,在区间 $ (L[mj], L[(m+1)j]) $ 内做顺序搜索。
总比较次数:
$ T(n, j) = n/j + j - 1 $
最优步长: 当 $ j = $ 时,搜索成本最小。
5. 二分搜索(Binary Search)
基本原理: 分而治之
- 每次选择列表的中间值;
- 根据 $ K $ 的值确定搜索范围(左半部分或右半部分)。
时间复杂度:
- 最坏情况下为 $ O(n) $。
适用条件:
- 列表已排序。
6. 字典搜索(Dictionary Search)
优化: 如果了解关键字分布,可以采用“计算二分搜索”(Computed Binary Search)。
- 假设要查找首字母为 “S” 的单词,可以直接跳到字典的 $ /4 $ 处开始搜索。
自组织列表(Self-Organizing Lists)
1. 定义
自组织列表通过访问频率而非关键字值对记录进行排序。
假设 $ p_i $ 是关键字 $ k_i $ 的记录被请求的概率:
- 访问频率最高的记录排在列表最前面;
- 其次频率的记录依次排列。
在这样的列表中,顺序搜索从第一个位置开始执行。
2. 特点
- 动态调整:
数据记录的访问频率可能随时间变化,自组织列表采用启发式策略动态调整列表顺序。 - 实际情况:
在大多数应用中,事先无法预知数据记录的访问频率。
3. 启发式策略(Heuristic Strategies)
自组织列表采用以下策略决定如何重新排序列表:
3.1 计数法(Count)
- 原理:
为每个记录维护一个访问计数器,根据访问计数对记录排序; - 特点:
访问次数越多的记录排在列表前面。
3.2 移至前端法(Move-to-Front)
- 原理:
每次查找到一个记录后,将其移动到列表的前端; - 特点:
无需维护计数器,更适合访问频率变化较大的情况。
3.3 交换法(Transpose)
- 原理:
每次查找到一个记录后,将其与前一个记录交换位置; - 特点:
调整较为温和,适合频率变化较小但有局部热点的情况。
策略 | 优点 | 缺点 |
---|---|---|
计数法 | 精确排序,适合稳定访问模式 | 需要维护计数器,计算开销大 |
移至前端法 | 实现简单,适合频率变化大的场景 | 排序可能不够稳定 |
交换法 | 调整温和,适合局部热点 | 调整效率较低,收敛速度慢 |
应用场景:
- 数据访问频率变化较大的情况下,推荐使用“移至前端法”;
- 频率较为固定的情况,可以考虑“计数法”或“交换法”。
提高速度的需求(The Need for Speed)---哈希
1. 数据结构回顾
我们所讨论的数据结构大多依赖于比较操作来查找元素,例如:
- 查找(Find) 和 插入(Insert) 需要的时间是 $ O(N) $。
- 在实际应用中,$ N $ 通常在 100 到 100,000 之间(甚至更多)。
- 对应的 $ N $ 大约在 6.6 到 16.6 之间。
2. 哈希表(Hash Tables)的优势
哈希表是一种抽象数据类型,旨在实现 O(1) 的查找和插入操作,即常数时间复杂度,极大地提高了效率。
3. 更少的功能、更快的操作(Fewer Functions Faster)
通过限制我们能执行的操作,可以提高剩余操作的性能。例如:
3.1 列表与栈的比较
- 在列表中,插入操作是
insert(L, X)
,而在栈中,插入操作是push(S, X)
。 - 列表提供了更高的灵活性,但栈由于功能受限,操作通常更快。
3.2 树与哈希表的比较
- 树提供了对所有元素的已知顺序,因此操作时可以保证有序性。
- 哈希表则仅提供快速的元素查找,不关心元素的顺序。
4. 有限的哈希操作(Limited Set of Hash Operations)
对于许多应用场景,哈希表只需要支持 插入(Insert)、查找(Find) 和 删除(Delete) 这几个基本操作。
- 这些操作满足大多数应用需求,但并不要求元素之间有任何顺序。
- 因为哈希表没有序列性要求,所以可以更专注于提高查找和插入的速度。
数据结构 | 主要特点 | 操作时间复杂度 | 用途 |
---|---|---|---|
列表 | 可插入任何位置的元素 | 插入:$ O(n) $ | 适用于需要动态增删的场景 |
栈 | 限制操作,后进先出 | 插入:$ O(1) $ | 适用于需要按顺序处理元素的场景 |
树 | 保持元素的顺序 | 查找:$ O(N) $ | 适用于需要有序存储的场景 |
哈希表 | 快速查找与插入 | 查找:$ O(1) $ | 适用于需要快速查找元素的场景 |
直接寻址法
1. 直接寻址概述
直接寻址使用数组来存储元素,这种方法非常快速,适用于特定条件:
假设条件:
- 键值是整数,属于集合 $ U = { 0, 1, , m-1 } $,其中 $ m $ 是一个较小的常数。
- 每个元素的键值是唯一的,即没有两个元素具有相同的键。
2. 操作方法
删除操作(Delete):
1
2
3Delete(Table T, ElementType x) {
T[key[x]] = NULL; // 将元素 x 删除,将数组中对应位置置为空
}- 删除操作是将数组中对应键的位置设为
NULL
,其时间复杂度为 $ O(1) $。
- 删除操作是将数组中对应键的位置设为
插入操作(Insert):
1
2
3Insert(Table T, ElementType x) {
T[key[x]] = x; // 将元素 x 插入到数组中对应键的位置
}- 插入操作是将元素直接存储在与其键值对应的数组位置,时间复杂度为 $ O(1) $。
查找操作(Find):
1
2
3Find(Table T, Key k) {
return T[k]; // 直接返回数组中与键 k 对应的位置的元素
}- 查找操作通过直接访问数组中的位置来完成,时间复杂度为 $ O(1) $。
3. 适用情况
- 当 键值范围 $ m $ 较小 且
所有键值都被使用 时,直接寻址非常高效。
- 查找、插入和删除 都能在常数时间内完成 $ O(1) $。
4. 空间浪费
- 当键值范围 $ m $
很大,而实际存储的元素数量较少时,数组会非常稀疏,导致大量空间浪费。
- 如果最大键值 $ m $ 远大于实际存储的元素数量 $ |K| $,数组中很多位置可能为空,这会导致浪费大量内存。
- 在最坏情况下,数组可能会太大,无法完全加载到内存中。
5. 改进方法
- 当大部分键值没有被使用时,需要通过映射将键值集合 $ U $ 映射到一个更小的集合,使得该集合的大小更接近实际存储元素的数量 $ |K| $,避免不必要的空间浪费。
哈希方案
1. 哈希表概述
我们希望将 $ N $ 个元素存储到一个大小为 $ M $ 的表中,位置由键 $ K $ 计算得出(键 $ K $ 可能并非数字)。这就需要使用哈希函数来计算索引,并且需要一个碰撞解决策略来处理两个键映射到相同索引的情况。
2. 哈希函数
定义: 哈希函数是一种通过键来计算表中位置(索引)的方法。它将元素的键(可能是字符串或数字)映射为一个整数(哈希值),这个哈希值用于确定元素在数组中的位置。
目标:
- 哈希函数的输出必须始终小于数组的大小。
- 哈希值应该尽可能均匀分布,以减少碰撞的概率。
3. 碰撞问题
碰撞定义: 当两个不同的键通过哈希函数映射到相同的数组索引时,就发生了碰撞。
解决策略: 需要一种碰撞解决策略来处理这种情况。一些常见的碰撞解决方法包括:
- 链式哈希(Chaining):在每个数组位置上使用链表来存储具有相同哈希值的元素。
- 开放地址法(Open Addressing):当发生碰撞时,尝试在表内寻找下一个空位置。
4. 数据存储示例
假设有一个数组 A,其中存储了以下课程信息:
1 | A[0] = {“CHEM 110”, Size 89} |
若要查找课程 CSE 373
的班级人数,可以通过以下方式:
- 线性查找:最坏情况下,时间复杂度为 $ O(N) $。
- 二分查找:最坏情况下,时间复杂度为 $ O(N) $。
5. 哈希表的应用
如果我们能够使用键直接索引数组,查找会更高效:
1 | A[“CSE 373”] = {Size 85} |
哈希表的核心思想是:
- 使用数据的某个属性(例如课程名称)作为键,直接索引数组。
这将使得访问记录的时间复杂度降到 $ O(1) $,即常数时间,极大地提高了查询效率。
6. 哈希函数的要求
为了实现哈希表的高效访问,需要设计一个快速的哈希函数,将元素键(如字符串或数字)映射为一个整数(哈希值)。
例如:
Hash(“CSE 373”) = 157
Hash(“CSE 143”) = 101
7. 哈希函数的输出要求
- 哈希函数的输出必须小于数组的大小(即哈希值要在数组的有效索引范围内)。
- 哈希函数应该尽可能地将哈希值均匀分布,以避免某些索引位置存储过多的元素,减少碰撞发生的概率。
哈希函数的性质
1. 哈希函数的目标
在设计哈希函数时,我们期望其具有以下几个特性:
- 哈希值分布均匀: 哈希值应该随机分布,以最小化碰撞(即不同的键映射到相同的哈希值)。这意味着哈希函数的输出应该没有任何系统性的规律,以避免出现大量的碰撞。
- 考虑整个键的内容: 哈希值应该依赖于键的所有值及其位置,以确保不同的键能映射到不同的哈希值。
2. 哈希函数与键集合的关系
- 键集合的实际内容非常重要: 哈希函数的效果与键集合 $ K $ 的实际内容密切相关,特别是当 $ K $ 是一个受限子集时,可能导致哈希函数的效果不如预期。比如,键可能是变量名、英语单词、保留关键字、电话号码等,这些集合往往有特殊的规律。
3. 简单哈希函数的应用
在某些情况下,如果我们可以确定键的分布,可以使用非常简单的哈希函数。例如,假设我们知道所有的键 $ s $ 是在 $ [0, 1) $ 区间内均匀分布的实数。那么,一个简单且高效的哈希函数就是:
$ (s) = (s m) $
其中,$ m $ 是哈希表的大小。
示例: 如果 $ m = 10 $,并且 $ s = 0.75 $,则:
$ (0.75) = (0.75 ) = 7 $
这种方法将 $ [0, 1) $ 区间的值映射到 $ 0 $ 到 $ m-1 $ 之间的整数,哈希值分布相对均匀,虽然会有碰撞,但这是哈希表中的常见问题,后续可以处理。
4. 完美哈希(Perfect Hashing)
完美哈希的定义: 在某些情况下,我们可以将已知的键集唯一地映射到哈希表的索引值。为了实现完美哈希,必须事先知道所有的键,并且能够构造一个一一映射的哈希函数。
- 限制: 完美哈希要求所有的键集合在构造哈希表时是已知的,并且映射是完美的——即没有碰撞。
5. 模块运算与哈希函数
对于一个较不受限制的键集合,我们可以使用 模运算 来设计哈希函数。模运算的形式为:
$ (a) = a $
其中,
a
是要哈希的键,size
是哈希表的大小。比如:如果哈希表大小为 251,那么:
$ 408 = 157 $ $ 352 = 101 $
映射分析:
- 模运算将整数映射到 $ 0 $ 到 $ m-1 $ 之间。
- 一一映射(one-to-one): 并非总是能做到一一映射,尤其是当键的集合和表的大小不匹配时。
- 覆盖映射(onto): 在哈希表足够大的情况下,模运算通常能够覆盖整个哈希表的所有位置。
哈希函数的应用与问题
1. 基本哈希函数
当键是整数时,常见的哈希函数为:
$ = $
然而,这种方法会遇到以下问题:
2. 问题 1:重复数字的情况
如果 TableSize
是
11,且所有的键都是重复的两位数字(例如:22, 33, 44
等),那么所有的键都将被映射到同一个哈希表位置。这样就会发生
碰撞(即多个键映射到同一个位置),从而影响哈希表的性能。
- 解决方法: 选择适当的
TableSize
,通常推荐选择一个 质数 作为哈希表的大小,这样可以有效地分散键的分布,减少碰撞的发生。
3. 示例 1:简单的模运算哈希函数
1 | int h(int x) { |
- 问题: 这个哈希函数依赖于键值的 最低有效4位,这些位数的分布可能不均匀,导致哈希表中存在大量碰撞。
4. 示例 2:中间平方法(Mid-Square Method)
该方法是将键值平方,并取中间的 $ r $ 位作为哈希值。
过程: 将键值平方,得到的结果在 0 到 $ 2^r - 1 $ 之间。中间的位数有助于避免依赖于某些特定的低位,从而改进哈希值的分布。
示例:
- 设 $ r = 2 $,键值 $ K = 4567 $,则平方后的结果是 $ 4567^2 = 20857489 $。
- 取中间的两位数(即 57)作为哈希值。
5. 问题 2:字符串哈希的挑战
如果键是 字符串,我们可以通过将字符串中的字符转换为 ASCII 码 并求和来生成哈希值。然而,这种方法会面临以下问题:
- 问题: 如果哈希表的大小是 10,000,且所有的字符串键长度都不超过 8 个字符,那么每个字符的 ASCII 值介于 0 到 127 之间。因此,字符串的总和将落在 0 到 $ 8 = 1016 $ 之间,导致哈希值集中在较小的范围内,从而浪费了大量空间。
6. 问题与改进:字符键的哈希问题
问题: 通过将字符的 ASCII 值直接求和来生成哈希值,对于较短的字符串,哈希值会集中在哈希表的某些位置,导致分布不均,且不同的字符组合可能映射到相同的哈希值。例如:
- “abc”、"bca" 和 "cab" 的 ASCII 值总和是相同的,这会导致碰撞。
7. 改进方法:基于基数 256 的哈希法
- 思路: 我们可以将字符串视为一个 基数 256 的数。例如,字符串 $ c_1c_2…c_n $ 可以被看作是一个数:
$ (c_1c_2…c_n) = 256^{n-1} c_1 + 256^{n-2} c_2 + + 256^0 c_n $
使用霍纳法则(Horner’s Rule)来计算哈希值:
- 霍纳法则是一种递归计算多项式的方法,它可以高效地计算上面的表达式。
- 算法如下:
1
2
3r = 0;
for i = 1 to n do
r := (c[i] + 256 * r) mod TableSize- 解释:
- 每次计算时,将当前字符的值加到前一步结果的 256
倍上,再取模哈希表大小
TableSize
,直到处理完所有字符。 - 这样,字符串的每个字符的值都会参与到最终的哈希计算中,避免了单纯求和的问题。
- 每次计算时,将当前字符的值加到前一步结果的 256
倍上,再取模哈希表大小
哈希碰撞与解决方法
1. 哈希碰撞(Collisions)
哈希碰撞发生在两个不同的键值哈希到相同的哈希值时。例如:
- 假设哈希表的大小
TableSize
为 17,键值 18 和 35 经过哈希函数mod 17
后都得到相同的哈希值: $ 18 = 1 = 1 $ - 由于两个键映射到相同的哈希表位置,碰撞发生了,无法在相同的哈希表槽位存储两个不同的数据记录。
2. 解决碰撞的方法
有两种主要的解决碰撞的方法:分离链接法(Separate Chaining) 和 开放寻址法(Open Addressing)。
3. 分离链接法(Separate Chaining)
在分离链接法中,每个哈希表的槽位保存一个指向链表的指针,链表用于存储那些哈希到同一个位置的多个元素。
- 碰撞处理: 当发生碰撞时,将新的元素插入到该槽位对应的链表中。
- 查找操作: 计算哈希值后,遍历对应槽位的链表进行查找。
- 优缺点:
- 可能会有多达
TableSize
个链表(每个槽位对应一个链表)。 - 查找、插入和删除的时间复杂度为 O(N),其中
N
是链表中元素的数量。
- 可能会有多达
4. 链表作为数据结构的优点
- 链表结构可以有效地存储多个哈希到同一槽位的元素。
- 使用链表的优点是结构简单,易于实现。
5. 二叉搜索树(BST)替代链表
在一些情况下,可以使用 二叉搜索树(BST) 来代替链表,以提高查找效率。
- 时间复杂度: 查找操作的时间复杂度为 O(log
N),其中
N
是链表中的元素数量。 - 适用场景: 当链表中的元素较多时,使用二叉搜索树比链表更高效。
- 缺点: 如果链表中的元素数量较少,使用二叉搜索树的开销可能不值得,因为创建和维护二叉树的成本较高。
6. 开放寻址法(Open Addressing)
开放寻址法是另一种解决哈希碰撞的策略。与分离链接法不同,开放寻址法不使用链表存储碰撞的元素,而是通过查找空槽来解决碰撞。
- 插入操作: 当发生碰撞时,使用一个二次哈希函数或探查策略寻找下一个空槽,并将元素插入第一个空槽。
- 查找操作: 在碰撞发生后,查找元素时会顺序扫描哈希表,直到找到目标元素或空槽。
- 时间复杂度: 查找、插入和删除的时间复杂度为 O(1)(在理想情况下),但在哈希表负载较高时,可能会退化为 O(N)。
7. 总结与对比
- 分离链接法: 适合哈希表中存储的元素较多或碰撞较多的情况。虽然查找、插入和删除的时间复杂度是 O(N),但如果链表较短,实际性能仍然较好。
- 二叉搜索树: 用于解决链表过长的问题,能将查找时间优化到 O(log N),但对于小规模数据,二叉树的维护开销可能不划算。
- 开放寻址法: 适合负载因子较低的哈希表,查找、插入和删除的时间复杂度通常较低。但在高负载情况下,可能需要重新调整哈希表的大小。
哈希表的负载系数与开放寻址法
1. 负载系数(Load Factor)
负载系数表示哈希表中存储的数据项数量与哈希表大小之间的关系。它定义为:
$ , = $
其中:
- $ N $ 是要存储的元素数量。
- $ $ 是哈希表的大小。
示例:
- 如果哈希表的大小为 101,存储 505 个元素,那么负载系数为: $ = = 5 $
- 如果哈希表的大小为 101,存储 10 个元素,那么负载系数为: $ = = 0.1 $
负载系数的影响:
- 链接法(Separate Chaining)下,平均链表长度为负载系数 \(\lambda\),因此访问一个元素的平均时间复杂度为 $ O(1) + O() $。
- 理想情况下,我们希望负载系数 $ $ 小于 1,但接近 1,这样可以在哈希表中均匀分布元素。
- 对于链接法:当负载系数 $ > 1 $ 时,哈希表依然可以正常工作,虽然效率会降低。
2. 开放寻址法(Open Addressing)解决碰撞
开放寻址法是一种没有使用链表的哈希碰撞解决方案。所有的元素都存储在哈希表中。
操作过程:
- 插入操作: 当插入一个元素时,首先计算它的“主位置” $ h_0(X) $(哈希值),如果该位置已被占用,则依次检查接下来的槽位,直到找到一个空槽为止。
- 查找操作: 查找元素时,按照插入时使用的相同探查序列进行搜索,直到找到目标元素或发现空槽。
优势:
- 减少了链表结构的开销,节省了空间。
- 当表格填充较少时,性能较好。
缺点:
- 当哈希表开始填满时,可能会出现“聚集”(clustering)现象,导致搜索效率下降。
3. 探查序列(Probe Sequence)
开放寻址法的实现方式取决于如何确定下一个探查位置。常见的几种探查序列有:
线性探查(Linear Probing): $ p(X,i) = i $ 即每次探查时,依次检查当前位置的下一个槽位,直到找到空槽或目标元素。
二次探查(Quadratic Probing): $ p(X,i) = i^2 $ 即每次探查时,探查位置为 $ h(X) + i^2 $,通过二次方增加步长来减少聚集现象。
双重哈希(Double Hashing): $ p(X,i) = i (X) $ 通过使用第二个哈希函数(例如,Hash2)来确定步长。每次探查位置是通过主哈希值加上第二哈希值乘以探查次数来计算的。
伪随机探查(Pseudo-random Probing): $ p(X,i) = i $ 这是线性探查的一种变体,在哈希表中查找位置时,探查的顺序是伪随机的。这种方法在哈希表非常稀疏时,效果类似于分离链接法;但当哈希表开始填充时,会出现聚集现象,但仍能保持常数平均查找时间。
4. 聚集与性能
- 聚集(Clustering): 当哈希表的某些区域填充过多时,可能会导致探查路径变长,影响查找效率。线性探查、二次探查和双重哈希等方法在不同程度上尝试解决这个问题。
- 稀疏表格: 当哈希表的负载系数较低时,开放寻址法的效率较高,几乎与分离链接法相似。
5. 总结
- 负载系数 $ $: 影响哈希表的性能,较小的负载系数能减少碰撞,提高效率。理想情况下,负载系数接近但小于 1。
- 开放寻址法: 无需链表,所有元素存储在哈希表中,但可能会导致聚集。探查序列的选择(如线性探查、二次探查、双重哈希等)会影响性能。
- 聚集: 探查序列不当时可能导致聚集,从而影响性能,因此需要合理选择探查方法。
哈希表中的聚集问题与探查方法
1. 聚集问题(Clustering)
当哈希表中出现几个连续的占用位置时,这些位置就成为后续碰撞的“目标”,并且随着聚集的增长,可能会形成更大的聚集区域。聚集现象会影响哈希表的性能,特别是搜索和插入操作的效率。
初级聚集(Primary Clustering)
- 初级聚集是指,即使哈希值不同的元素也会因为探查路径相同而聚集在一起。这样,后续的元素在探查时也可能会进入同样的槽位,导致更多的碰撞。
示例: 假设哈希表的大小 $ M = 10 $,哈希函数为 $ h(K) = K $,探查函数为 $ p(K,i) = i $(即线性探查)。
当一个新键值的主位置是 7 时,它将依次检查槽位 7、8、9、0、1、2,最终会被插入到槽位 2。
在这种情况下,插入一个新元素到哈希表的槽位 2 的概率是: $ $ 因为这 6 个位置已经被占用了(7、8、9、0、1、2)。
如果下一个插入的元素哈希到槽位 3、4、5 或 6,那么它最终会插入其中一个空槽的位置,概率为: $ $
2. 线性探查(Linear Probing)与常数跳跃
为了解决聚集问题,线性探查可以通过在探查时跳过一些槽位来进行优化。具体来说,可以使用一个常数 $ c $ 来代替固定的增量 1。
探查函数: $ p(K, i) = c i $ 这样,探查序列就变为: $ = (h(K) + c i) M $ 其中,$ c $ 是跳跃常数,$ i $ 是探查次数。
选择合适的常数 $ c $
- 选择一个较好的 $ c $ 值可以确保探查序列会遍历所有哈希表的槽位,直到回到主位置。
- 为了确保探查序列遍历所有槽位,$ c $ 应该和哈希表大小 $ M $ 互质(即它们的最大公约数为 1)。
示例:
- 如果 $ M = 10 $,那么 $ c $ 可以选择 1、3、7 或 9。
- 如果 $ M = 11 $,那么 $ c $ 可以是从 1 到 10 之间的任意数值。
线性探查的局限性
问题未完全解决: 即使使用了 $ c > 1 $,也未能完全解决初级聚集问题。比如当 $ c = 2 $ 时,探查序列的例子为:
- 如果 $ h(K_1) = 3 $,那么探查序列为:3、5、7、9、...。
- 如果 $ h(K_2) = 5 $,那么探查序列为:5、7、9、...。
可以看出,尽管跳跃了一个槽位,但两个不同的哈希值 $ h(K_1) $ 和 $ h(K_2) $ 仍然会聚集在一起,导致更多的碰撞,无法有效避免初级聚集。
3. 伪随机探查(Pseudo-random Probing)
伪随机探查是一种通过使用预先定义的排列(permutation)来进行探查的方法,从而避免初级聚集。
示例: 假设哈希表的大小 $ M = 101 \(,并且给定一个排列数组:\) [0] = 5, [1] = 2, [2] = 32, $
- 假设 $ h(k_1) = 30 $ 和 $ h(k_2) = 35 $,那么它们的探查序列如下:
- 对于 $ k_1 $,探查序列为:30、35、32、62。
- 对于 $ k_2 $,探查序列为:35、40、37、67。
通过伪随机探查,每次探查的槽位不再是固定模式,而是根据预定义的排列进行选择,从而有效减少聚集现象,并提高查找和插入的效率。
4. 总结
- 初级聚集: 当哈希表中出现连续占用的槽位时,后续的元素也可能会碰撞到这些槽位,导致更多的碰撞。
- 线性探查: 使用常数 $ c $ 跳跃槽位来解决聚集问题,但仍无法完全避免初级聚集,特别是当 $ c $ 与哈希表大小 $ M $ 不互质时。
- 伪随机探查: 使用预定义的排列来随机选择探查路径,从而有效减少聚集现象,改善性能。
二次探查与次级聚集
1. 二次探查(Quadratic Probing)
在哈希表中,当插入元素时,如果主位置发生碰撞,使用二次探查来解决碰撞问题。与线性探查不同,二次探查的探查序列是根据平方函数变化的,而不是简单的增加常数。
探查函数:
二次探查的探查函数一般表示为: $ p(K, i) = c_1 i^2 + c_2 i + c_3 $ 其中,$ c_1, c_2, c_3 $ 为常数,$ i $ 为探查的次数。
例子: 如果使用 $ p(K, i) = i^2 $ 作为探查函数,则探查序列为: $ (h(K) + i^2) M $ 这意味着,当碰撞发生时,第 $ i $ 次探查会跳过 $ i^2 $ 个槽位。
探查序列示例:
假设哈希表大小 $ M = 50 $,并且给定以下情况:
- 对于 $ h(K_1) = 30 $,探查序列是:30, 31, 34, 39, 46, ...
- 对于 $ h(K_2) = 29 $,探查序列是:29, 30, 33, 38, 45, ...
通过二次探查,两个不同的键值 $ h(K_1) $ 和 $ h(K_2) $ 会有不同的探查序列,这避免了初级聚集的问题。
2. 二次探查的优点与限制
消除初级聚集
- 初级聚集: 当多个键值通过相同的探查序列碰撞时,它们会聚集在一起,导致性能下降。二次探查通过引入平方增量来避免这种现象,即使多个键值发生碰撞,它们也会有不同的探查序列。
消除初级聚集:
- 二次探查能有效地消除初级聚集现象(即键值发生碰撞时,探查路径产生相同的情况),避免了在相同位置的碰撞造成性能降低。
次级聚集:
- 次级聚集(Secondary Clustering): 如果两个键值的哈希值相同,它们在探查时会遵循相同的探查序列。这种情况称为次级聚集。虽然二次探查消除了初级聚集,但次级聚集仍然存在。
- 次级聚集的问题表现在,如果两个键值的哈希值相同,它们将始终走相同的探查路径,导致再次发生碰撞。
表格大小与插入条件:
- 如果哈希表大小是质数,并且表格至少有一半为空,那么二次探查可以确保新元素总是能够插入,即使哈希表已部分填满。
3. 总结
- 二次探查: 使用平方增量的探查序列,可以避免初级聚集现象。通过改变增量(例如 $ i^2 $),键值的探查路径会有所不同,降低了碰撞发生的概率。
- 次级聚集: 虽然二次探查消除了初级聚集,但仍然可能存在次级聚集,尤其是当多个键值哈希到同一个位置时,它们的探查序列将会重叠。
- 适用条件: 如果哈希表的大小是质数,并且表格的填充率较低(二次探查适用于表格至少有一半为空),则可以保证新元素能够成功插入。
双重哈希(Double Hashing)
1. 双重哈希简介
双重哈希是一种解决哈希冲突的方法,通过使用两个哈希函数来生成探查序列。与线性探查和二次探查不同,双重哈希在每次探查时不仅使用第一个哈希函数,还使用第二个哈希函数来计算步长,从而产生更分散的探查序列。
探查过程:
在双重哈希中,探查序列由两个哈希函数决定。对于给定的键 \(X\),探查的顺序是:
$ h_1(X), h_1(X) + h_2(X), h_1(X) + 2 h_2(X), h_1(X) + 3 h_2(X), $
其中:
- $ h_1(X) $ 是第一个哈希函数,决定键值的初始位置。
- $ h_2(X) $ 是第二个哈希函数,决定步长。
- $ p(K, i) = i h_2(K) $ 是探查序列的计算方法。
探查过程示例:
假设哈希表大小 $ M = 101 $,且有以下情况:
- $ h(k_1) = 30 $, $ h(k_2) = 28 $, $ h(k_3) = 30 $
- $ h_2(k_1) = 2 $, $ h_2(k_2) = 5 $, $ h_2(k_3) = 5 $
那么对于每个键,探查序列将会是:
- 对于 $ k_1 $,探查序列为:30, 32, 34, 36, 38, ...
- 对于 $ k_2 $,探查序列为:28, 33, 38, 43, 48, ...
- 对于 $ k_3 $,探查序列为:30, 35, 40, 45, 50, ...
2. 双重哈希的优缺点
优点:
- 空间效率: 双重哈希不会产生次级聚集,因为步长是由第二个哈希函数决定的,这使得探查序列更加分散,减少了聚集的可能性。
- 速度较快: 双重哈希能够同时计算初始哈希值和增量哈希值,因此比线性探查和二次探查更高效,尤其是在表格稀疏时。
- 避免聚集: 通过第二个哈希函数,双重哈希能够有效地避免初级和次级聚集。
缺点:
- 需要小心实现: 双重哈希要求第二个哈希函数 $ h_2(X) $ 不能为 0,且不能是哈希表大小 $ M $ 的约数,否则会导致探查序列无效(即会陷入死循环)。因此需要小心选择第二个哈希函数,确保其满足这些条件。
空闲位置与冲突处理:
- 双重哈希是一种高效的空间利用方法,但其实现较为复杂,因为需要两个哈希函数,并且要确保第二个哈希函数的值不会导致冲突。
- 如果表格空间足够大并且负载因子较低,双重哈希能够保持良好的性能,避免空间浪费。
3. 规则与建议(Rules of Thumb)
- 第二个哈希函数 $ h_2(X) $ 不能为 0。
- 第二个哈希函数 $ h_2(X) $ 不能是哈希表大小 $ M $ 的约数,否则会导致探查序列无法遍历所有槽位,导致性能下降。
- 哈希表的负载因子应尽量保持较低,尤其是在使用双重哈希时,以避免频繁的碰撞和探查。
- 哈希表大小 $ M $ 应选择为质数,这样可以确保探查序列的均匀分布。
4. 总结
- 双重哈希:通过使用两个哈希函数来生成探查序列,可以有效解决碰撞问题,尤其是在处理较大哈希表时,其性能优势更加明显。
- 注意事项:要确保第二个哈希函数不会产生 0 或与哈希表大小的约数关系,否则可能会导致性能下降或死循环问题。
重新哈希(Rehashing)
1. 重新哈希概述
重新哈希是当哈希表的负载因子变得过高时进行的一种操作,目的是通过扩展哈希表的大小并重新计算每个元素的哈希值来保持哈希表的高效性。这有助于避免哈希冲突过多,提升查询和插入操作的性能。
- 懒删除(Lazy Deletion):在删除元素时,不立即将其从表中移除,而是将槽标记为已删除,这样在插入新元素时仍然考虑到这个槽位,以保持哈希表的负载因子不变。
- 如果表格变得过于拥挤,或许已经有很多删除操作,插入可能变得缓慢,甚至失败。
2. 重新哈希的触发条件
当哈希表达到一定负载因子(通常是接近 1)时,或者发生了许多删除操作,性能会开始下降,插入操作也会变得更加复杂。
为了避免这种情况,需要通过重新哈希来扩展表格的大小。一般情况下,当哈希表的负载因子超过某个阈值时,执行重新哈希操作。
何时进行重新哈希:
- 负载因子过高(λ接近1):当哈希表中的元素数接近哈希表的容量时(例如负载因子接近 1),重新哈希是必要的。
- 删除过多:如果表中进行过多删除操作,表的实际有效元素数减少,而表的填充程度仍然较高,重新哈希有助于清除已删除标记的槽。
- 插入失败:当插入操作失败时,可能需要扩展哈希表并重新哈希已有元素。
3. 重新哈希的过程
- 构建更大的哈希表:当哈希表的负载因子过高时,重新哈希通常是通过构建一个大约是原哈希表两倍大小的新表来进行的。
- 处理已删除元素:在重新哈希过程中,旧哈希表中的已删除元素会被忽略。每个非删除的键值对都会重新计算哈希值,并放入新表的适当位置。
- 重新计算哈希值:不能简单地将旧表的数据复制到新表,因为新表的大小和哈希函数发生了变化,因此必须为每个元素重新计算哈希值。
- 时间复杂度:重新哈希的时间复杂度是 $ O(N) $,其中 $ N $ 是哈希表中的元素个数。但是,重新哈希操作发生的频率较低,因此总体影响较小。
4. 重新哈希的策略
有几种常见的重新哈希策略,决定何时进行重新哈希:
- 重新哈希时表格达到一半的填充度:当哈希表的负载因子接近 0.5 时,就开始重新哈希。
- 插入失败时重新哈希:仅当插入操作失败(即哈希表已经满时)才触发重新哈希。
- 中庸之道的策略(最佳):当表格达到一个特定的负载因子(如 0.75)时执行重新哈希,这通常是最平衡的策略,既不会过早也不会过晚执行重新哈希。
- 负载因子达到某个阈值时重新哈希:当表的负载因子达到某个预定的阈值时(如 0.7 或 0.8),就进行重新哈希。
5. 开链哈希(Open Hashing)示例
开链哈希是一种解决哈希冲突的方法,其中每个槽位存储一个链表(或其他数据结构)来处理冲突。重新哈希时,可以将哈希函数从 $ h_1(x) = x $ 变更为 $ h_2(x) = x $,以适应新的哈希表大小。
开放寻址分析(Analysis of Open Addressing)
1. 度量指标(Measurements)
在开放寻址哈希中,分析的核心度量指标是执行操作时记录访问的次数,主要关注以下几种操作:
- 插入(Insertion):插入操作时,如果要插入的记录不存在(即该位置没有相同的键值),则需要进行插入。
- 删除(Deletion):删除操作时,需要找到目标记录并删除。
- 搜索(Search):查询操作时,寻找目标记录。
2. 操作成本分析
- 当哈希表几乎为空时,记录很可能被存储在它们的原始位置(即哈希值对应的位置)。此时,插入、删除和搜索操作仅需一次记录访问即可找到空位或目标记录。
- 当哈希表接近满时,记录可能被存储在离其原始位置较远的地方。此时,插入、删除和搜索的操作成本会增加,因为碰撞会变得更加频繁。
3. 哈希操作的预期成本
哈希操作的预期成本是哈希表负载因子(λ)的函数,负载因子(λ)定义为哈希表中元素的数量除以哈希表的总容量($ N/M $)。
插入操作的预期探测次数(Expected Number of Probes for Insertion)
假设探测序列是哈希表槽的一个随机排列,并且每个槽位有相等的概率成为下一个记录的目标位置。插入操作的预期探测次数可以通过以下公式计算:
发生 i 次碰撞的概率: $ P( i) = ( )^i $
预期的探测次数: $ = 1 + _{i=1}^{} ( )^i = $ 这意味着,当负载因子 λ 趋近于 1 时,预期的探测次数会急剧增大。
哈希的预期成本: 预期的哈希成本是插入该记录的原始成本的一个函数。其计算公式为: $ _0^ dx = ( ) $
4. 线性探测下的平均成本
在使用线性探测时,插入和删除操作的真实平均成本可以通过以下公式表示:
插入的平均成本: $ ( 1 + ) $
删除的平均成本: $ ( 1 + ) $
5. 经验法则(Rule of Thumb)
设计哈希系统时,应该确保哈希表的负载因子不超过 0.5。这样可以避免过多的碰撞,并保证操作的效率。
6. 减少碰撞时的访问成本
为减少碰撞对哈希操作的影响,可以采取以下措施:
- 频繁访问的记录优先放置:如果两个记录的哈希位置相同(即发生碰撞),应将访问频率更高的记录放在哈希表的原始位置。这可以减少访问成本,因为频繁访问的记录将更容易被找到。
- 按访问频率排序:记录可以根据其访问频率沿着探测序列进行排序,这样可以优化访问频率较高的记录的查找速度。
哈希函数的注意事项(Caveats)
1. 哈希函数常常是性能问题的根源
哈希函数在哈希表的操作中起着至关重要的作用。如果哈希函数设计不当,可能导致大量的碰撞,从而严重影响哈希表的性能。例如,碰撞过多会导致哈希表的查询、插入和删除操作变得低效。哈希函数的质量直接影响到数据存取的效率,因此在选择和设计哈希函数时需要特别谨慎。
2. 哈希函数可能导致代码不可移植
哈希函数的实现往往与具体的系统架构和编程语言的特性紧密相关。因此,某些哈希函数在不同平台或编程语言中可能会表现出不同的行为,进而影响程序的可移植性。例如,某些平台的整数溢出行为或字符编码方式可能与其他平台不同,这可能会导致相同的数据在不同平台上生成不同的哈希值,从而影响程序的正确性和性能。
3. 如果哈希函数在特定数据上表现不佳,选择另一个哈希函数
如果你发现某个哈希函数在特定数据集上表现不佳(如碰撞过多,导致性能下降),应该考虑更换一个更合适的哈希函数。例如,针对不同类型的数据(如字符串、整数等),可以选择专门为该类型优化的哈希函数。选择合适的哈希函数是提升哈希表性能的重要步骤。
4. 始终检查时间花费的地方
在使用哈希函数时,尤其是在性能要求较高的场合,要密切关注哈希操作的时间消耗。如果程序运行缓慢,可能是哈希函数导致的性能瓶颈。为了优化性能,开发者应该使用工具来分析程序的时间消耗,找到哈希操作中可能的瓶颈并进行优化。例如,可以使用性能分析器来检查不同操作(如插入、查找、删除)所消耗的时间,从而做出改进。
练习
学了很多应该要头晕了吧,时间不够的话只需要会做下面这两道题就OK了。
问题1:
我们有一个大小为 7 的哈希表,编号从 0 到 6,哈希函数为 $ h(k) = k $,并且使用线性探测法(linear probing)来解决冲突。请展示在将以下键值插入哈希表后的结果:
键值: 3, 12, 9, 2, 10
解决方案
首先,回顾一下线性探测法:如果计算得到的插入位置已经被占用,那么我们会按照顺序向后探测一个位置,直到找到一个空位为止。
插入过程
插入 3:
- 哈希函数计算:$ h(3) = 3 = 3 $
- 哈希表位置 3 为空,插入 3 到位置 3。
哈希表状态:
1
[ , , , 3, , , ]
插入 12:
- 哈希函数计算:$ h(12) = 12 = 5 $
- 哈希表位置 5 为空,插入 12 到位置 5。
哈希表状态:
1
[ , , , 3, , 12, ]
插入 9:
- 哈希函数计算:$ h(9) = 9 = 2 $
- 哈希表位置 2 为空,插入 9 到位置 2。
哈希表状态:
1
[ , , 9, 3, , 12, ]
插入 2:
- 哈希函数计算:$ h(2) = 2 = 2 $
- 哈希表位置 2 已经被占用(插入了 9),所以使用线性探测法,尝试下一个位置 3。
- 哈希表位置 3 已经被占用(插入了 3),继续探测下一个位置 4。
- 哈希表位置 4 为空,插入 2 到位置 4。
哈希表状态:
1
[ , , 9, 3, 2, 12, ]
插入 10:
- 哈希函数计算:$ h(10) = 10 = 3 $
- 哈希表位置 3 已经被占用(插入了 3),所以使用线性探测法,尝试下一个位置 4。
- 哈希表位置 4 已经被占用(插入了 2),继续探测下一个位置 5。
- 哈希表位置 5 已经被占用(插入了 12),继续探测下一个位置 6。
- 哈希表位置 6 为空,插入 10 到位置 6。
哈希表状态:
1
[ , , 9, 3, 2, 12, 10]
最终哈希表
1 | [ , , 9, 3, 2, 12, 10] |
解释
- 插入 3:通过哈希函数 $ h(3) = 3 = 3 $,位置 3 为空,因此插入 3。
- 插入 12:通过哈希函数 $ h(12) = 12 = 5 $,位置 5 为空,因此插入 12。
- 插入 9:通过哈希函数 $ h(9) = 9 = 2 $,位置 2 为空,因此插入 9。
- 插入 2:通过哈希函数 $ h(2) = 2 = 2 $,位置 2 已被占用,使用线性探测法,位置 3 也被占用,位置 4 为空,因此插入 2。
- 插入 10:通过哈希函数 $ h(10) = 10 = 3 $,位置 3 已被占用,位置 4 也已被占用,位置 5 已被占用,位置 6 为空,因此插入 10。
最终,哈希表的状态是:[ , , 9, 3, 2, 12, 10]。
问题2:
我们有一个大小为 13 的哈希表,编号从 0 到 12,使用开放地址法和双重哈希(double hashing)来解决冲突。给定以下哈希函数:
- 主哈希函数:$ H1(k) = k $
- 次哈希函数:$ H2(k) = (k + 1) $
请展示在将以下键值插入哈希表后的结果:
键值: 2, 8, 31, 20, 19, 18, 53, 26
解决方案
首先,回顾双重哈希的插入过程:对于每个键 $ k $,我们使用主哈希函数计算哈希值,然后如果该位置被占用,我们通过次哈希函数递增探测次数,直到找到一个空位置。
插入过程
插入 2:
- 主哈希函数计算:$ H1(2) = 2 = 2 $
- 哈希表位置 2 为空,插入 2 到位置 2。
哈希表状态:
1
[ , , 2, , , , , , , , , , ]
插入 8:
- 主哈希函数计算:$ H1(8) = 8 = 8 $
- 哈希表位置 8 为空,插入 8 到位置 8。
哈希表状态:
1
[ , , 2, , , , , , 8, , , , ]
插入 31:
- 主哈希函数计算:$ H1(31) = 31 = 5 $
- 哈希表位置 5 为空,插入 31 到位置 5。
哈希表状态:
1
[ , , 2, , , 31, , , 8, , , , ]
插入 20:
- 主哈希函数计算:$ H1(20) = 20 = 7 $
- 哈希表位置 7 为空,插入 20 到位置 7。
哈希表状态:
1
[ , , 2, , , 31, , 20, 8, , , , ]
插入 19:
- 主哈希函数计算:$ H1(19) = 19 = 6 $
- 哈希表位置 6 为空,插入 19 到位置 6。
哈希表状态:
1
[ , , 2, , , 31, 19, 20, 8, , , , ]
插入 18:
- 主哈希函数计算:$ H1(18) = 18 = 5 $
- 哈希表位置 5 已经被占用(插入了 31),所以使用次哈希函数进行探测。
- 次哈希函数计算:$ H2(18) = (18 + 1) = 19 = 8 $
- 哈希表位置 5 + 8 = 13($ 13 = 0 $),位置 0 为空,插入 18 到位置 0。
哈希表状态:
1
[ 18, , 2, , , 31, 19, 20, 8, , , , ]
插入 53:
- 主哈希函数计算:$ H1(53) = 53 = 1 $
- 哈希表位置 1 为空,插入 53 到位置 1。
哈希表状态:
1
[ 18, 53, 2, , , 31, 19, 20, 8, , , , ]
插入 26:
- 主哈希函数计算:$ H1(26) = 26 = 0 $
- 哈希表位置 0 已经被占用(插入了 18),所以使用次哈希函数进行探测。
- 次哈希函数计算:$ H2(26) = (26 + 1) = 27 = 5 $
- 哈希表位置 0 + 5 = 5,位置 5 已经被占用(插入了 31),继续使用次哈希函数探测。
- 次哈希函数再次计算:$ H2(26) = 5 $
- 哈希表位置 5 + 5 = 10,位置 10 为空,插入 26 到位置 10。
哈希表状态:
1
[ 18, 53, 2, , , 31, 19, 20, 8, , 26, , ]
最终哈希表
1 | [ 18, 53, 2, , , 31, 19, 20, 8, , 26, , ] |
解释
- 插入 2:通过哈希函数 $ H1(2) = 2 = 2 $,位置 2 为空,因此插入 2。
- 插入 8:通过哈希函数 $ H1(8) = 8 = 8 $,位置 8 为空,因此插入 8。
- 插入 31:通过哈希函数 $ H1(31) = 31 = 5 $,位置 5 为空,因此插入 31。
- 插入 20:通过哈希函数 $ H1(20) = 20 = 7 $,位置 7 为空,因此插入 20。
- 插入 19:通过哈希函数 $ H1(19) = 19 = 6 $,位置 6 为空,因此插入 19。
- 插入 18:通过哈希函数 $ H1(18) = 18 = 5 \(,位置 5 已被占用,使用次哈希函数计算,位置 5 + 8 = 13,\) 13 = 0 $,位置 0 为空,插入 18。
- 插入 53:通过哈希函数 $ H1(53) = 53 = 1 $,位置 1 为空,因此插入 53。
- 插入 26:通过哈希函数 $ H1(26) = 26 = 0 $,位置 0 已被占用,使用次哈希函数计算,位置 0 + 5 = 5,位置 5 已被占用,继续使用次哈希函数探测,位置 5 + 5 = 10,位置 10 为空,插入 26。
最终,哈希表的状态是:[ 18, 53, 2, , , 31, 19, 20, 8, , 26, , ]。