字典和散列表
字典的概念
字典是一些元素的集合,每个元素有一个称作关键码(key)的域,不同元素的关键码互不相同。
在python中,字典的实现有dict
数据结构
在c++中,字典的实现有STL中的map
和unordered_map
,前者基于红黑树,后者基于哈希表
字典的抽象数据类型
const int DefaultSize = 26;
template <class Name, class Attribute>
class Dictionary {
//对象: 一组<名字-属性>对, 其中, 名字是唯一的
public:
Dictionary (int size = DefaultSize); //构造函数
bool Member (Name name); //判name是否在字典中
Attribute *Search (Name name); //在字典中搜索关键码与name匹配的表项
void Insert (Name name, Attribute attr);
//若name在字典中, 则修改相应<name, Attr>对的attr项;
//否则插入<name, Attr>到字典中
void Remove (Name name);
//若name在字典中, 则在字典中删除相应的<name, Attr>对
};
-
字典可以保存在线性序列 (e1,e2,…) 中,其中ei 是字典中的元素,其关键码从左到右依次增大。为了适应这种描述方式,可以定义有序顺序表和有序链表。
-
用有序链表来表示字典时,链表中的每个结点表示字典中的一个元素,各个结点按照结点中保存的数据值非递减链接,即e1≤e2 ≤ …。因此,在一个有序链表中寻找一个指定元素时,一般不用搜索整个链表。
跳表
在一个有序顺序表中进行折半搜索,时间效率很高。
但是在有序链表中进行搜索,只能顺序搜索,需要执行O(n)次关键码比较。
如果在链表中部结点中增加一个指针,则比较次数可以减少到 n/2+1。在搜索时,首先用要搜索元素与中间元素进行比较,如果要搜索元素小于中间元素,则仅需搜索链表的前半部分,否则只要在链表的后半部分进行比较即可。(二分思想)
散列表(Hash)
散列表(Hash Table)是一种用于存储和快速查找数据的数据结构。它通过将数据映射到一个固定大小的数组中,从而能够在常数时间 O(1) 内进行插入、删除和查找操作。散列表的核心思想是使用一个哈希函数将数据(通常是键)转换为数组的索引(或位置)。
Hash表的基本概念
Hash函数的构造方法
除留余数法
除留余数法(Division method)是常见的散列函数构造方法之一,通常用于将输入的数据(如字符串、整数等)映射到散列表中的一个位置。其基本思想是通过对输入数据进行除法运算,然后取余数来确定数组的索引位置。
主要原理:
假设我们有一个散列表(数组)H
,其大小为 m
。给定一个输入的键 k
,我们通过以下步骤来计算它在散列表中的位置(索引):
H a s h ( k ) = k m o d m Hash(k)=k\mod m Hash(k)=kmod m
这里,k
是输入的键,m
是散列表的大小,mod
表示取余操作。
构造方法:
- 选择合适的模数
m
:- 通常,
m
是一个大于或等于元素数量的素数,以减少碰撞的概率。素数常常被选择因为它们能够减少对某些特殊数据的碰撞。
- 通常,
- 哈希函数:
- 计算哈希值时,
k mod m
将会返回一个从 0 到m-1
之间的数值,用作散列表的索引位置。
- 计算哈希值时,
示例:
假设我们有一个包含 10 个槽(位置)的散列表,即 m = 10
,并且我们要将一些整数键 k
插入到这个散列表中。我们选择的哈希函数是:
H a s h ( k ) = k m o d 10 {Hash}(k) = k \mod 10 Hash(k)=kmod10
对于键 k
,我们通过取余操作确定其在散列表中的索引位置:
- 对于键
k = 23
,我们计算23 mod 10 = 3
,因此将键23
存储在位置3
。 - 对于键
k = 45
,我们计算45 mod 10 = 5
,因此将键45
存储在位置5
。 - 对于键
k = 12
,我们计算12 mod 10 = 2
,因此将键12
存储在位置2
。 - 对于键
k = 37
,我们计算37 mod 10 = 7
,因此将键37
存储在位置7
。
优点:
- 简单:除留余数法实现非常简单,计算效率高,适合用来构造散列函数。
- 计算快:计算哈希值时,只需要进行一个除法和取余操作,时间复杂度为 O(1),非常快速。
缺点:
- 碰撞问题:如果选择的模数
m
不合适,或者输入数据的分布不均匀,可能会导致大量的哈希冲突(不同的键映射到相同的索引)。例如,如果m
是 10,所有以 10 的倍数为结尾的数字(如 10、20、30)都会被映射到相同的位置。 - 选择
m
的困难:为避免碰撞,通常需要选择一个合适的模数m
。如果m
选择不当,碰撞的概率会增大。
直接定址法
直接定址法的核心思想是:通过键本身作为索引,直接存取数据。
优点:
- 简单且高效:由于不需要复杂的哈希函数,直接定址法可以在常数时间内进行查找、插入和删除操作(O(1))。
- 快速查找:查找操作的时间复杂度为 O(1),因为可以直接通过索引访问数组。
缺点:
- 空间浪费:如果键空间 UUU 很大,而实际存储的数据很少,直接定址法会浪费大量内存。例如,如果键空间是 0 到 1000,但是只存储了 10 个元素,那么数组大小是 1001,绝大部分位置会为空。
- 键空间必须固定:直接定址法要求键空间 UUU 是已知且固定的。对于动态变化或非常大的键空间,这种方法并不适用。
- 无法处理非整数键:直接定址法通常只能处理整数类型的键,对于其他类型(如字符串或浮点数)的键,使用起来较为困难,必须先进行映射。
数据分析法
数字分析法通过直接操作数据的数字特性(如位运算)来构建哈希函数。这些方法通常用于处理较长的数字或字符数据,利用其各个数字或字符的位置、数值特征,逐步构建散列值。
在这种方法中,数据的每一部分(例如一个数字或字符串的字符)都会影响最终的哈希值,而不是仅仅依赖于数据的整体值。这使得散列函数能更好地分散数据,从而减少碰撞。
冲突
在哈希表(Hash Table)和其他基于哈希的数据结构中,冲突(Collision)是指不同的输入数据被哈希函数映射到相同的哈希值,即它们的散列值相同。
哈希函数的目标是将数据映射到一个固定大小的数组中,称为哈希表。哈希函数通过输入的键生成一个哈希值,这个哈希值对应哈希表中的一个位置(桶)。然而,由于哈希表的大小有限,存在的输入数据种类(键)通常远远多于哈希表的桶的数量。这样就不可避免地会出现不同的键被映射到同一个桶的位置,从而发生哈希冲突。
处理冲突一般有三种方法:闭散列法,开散列法,双Hash法
闭散列法
闭散列法(Closed Hashing),也叫做开放定址法(Open Addressing),是一种解决哈希冲突的方法。在闭散列法中,当哈希函数计算的桶已经被占用时,不会使用链表或其他外部结构来存储数据,而是在哈希表内寻找其他空位来存储发生冲突的元素。所有的元素都存储在哈希表的内部,不使用额外的数据结构,因此称为**“闭散列”**。
当发生哈希冲突时,闭散列法通过一种预定义的方式来查找哈希表中下一个可用的位置,直到找到一个空桶来存储数据。常见的查找策略包括:
- 线性探查(Linear Probing)
- 二次探查(Quadratic Probing)
- 双重散列(Double Hashing)
线性探查
在线性探查中,当哈希冲突发生时,会按照顺序线性地检查哈希表的下一个桶,直到找到一个空桶为止。例如,如果哈希表的某个位置已被占用,就检查该位置后面的第一个桶(即索引加1),如果该位置也被占用,就继续检查下一个位置,依此类推,直到找到空位置。
例子:
假设哈希表大小为 10,元素分别为 12, 22, 32。
H(12) = 12 % 10 = 2
(放入位置 2)H(22) = 22 % 10 = 2
(位置 2 已被占用,检查位置 3,位置 3 空,放入位置 3)H(32) = 32 % 10 = 2
(位置 2 和 3 都已占用,检查位置 4,位置 4 空,放入位置 4)
线性探查的优点:
- 简单易懂,易于实现。
- 在负载因子较低时,性能较好。
线性探查的缺点:
- 聚集问题(Clustering):当多个元素被映射到相邻的位置时,它们可能会集中在一起,导致查找效率变低。
- 当负载因子增大时,线性探查的性能会迅速下降,因为需要查找的空桶越来越少。
二次探查
二次探查是一种解决聚集问题的改进方法。在二次探查中,当发生冲突时,检查的位置不再是连续的,而是按照二次方的方式进行跳跃。具体地,如果位置 h(key)
已经被占用,则尝试位置 h(key) + 1^2
,然后是 h(key) + 2^2
,依此类推。
简要而言,二次探查以平方数的间距进行跳跃
伪随机探查再散列
在伪随机探查中,当发生冲突时,我们不再简单地按顺序查找下一个位置(如线性探查),而是使用一个伪随机数生成器来生成下一个探查位置。这个生成器根据当前的探查位置生成一个新的位置,通常这个过程会使用一个简单的公式来生成探查步长。
开散列法
开散列法(Open Hashing)是一种处理哈希冲突的解决方法,它与闭散列法(开放定址法)不同,开散列法并不会在哈希表内部继续探查空位置,而是使用外部的数据结构(通常是链表)来存储那些发生冲突的元素。换句话说,开散列法通过将冲突的元素链在一起,来避免冲突时的元素丢失。
基本思想:为每一个Hash地址建立一个链表,凡散列地址为 i 的记录都插入到第 i 个链表中。
开散列法的工作原理
- 计算哈希值:使用哈希函数将元素的键映射到哈希表中的一个位置。
- 检查桶:如果该位置为空(没有冲突),则直接将元素存入该位置。
- 处理冲突:如果该位置已经有元素(发生冲突),则在该位置的链表中插入新的元素。每个桶都有一个链表,所有映射到同一桶的元素将按照某种规则被插入到链表中。
开散列法的代码实现
使用链表存储相同冲突位置的元素
const int defaultSize = 100;
template <class E, class K>
struct ChainNode { //各桶中同义词子表的链结点定义
E data; //元素
ChainNode<E, K> *link; //链指针
};
template <class E, class K>
class HashTable { //散列表(表头指针向量)定义
public:
HashTable (int d, int sz = defaultSize); //散列表的构造函数
~HashTable() { delete [] ht; } //析构函数
bool Search (K k1, E& e1); //搜索
bool Insert (K k1, E& e1); //插入
bool Remove (K k1, E& e1); //删除
private:
int divisor; //除数(必须是质数)
int TableSize; //容量(桶的个数)
ChainNode<E, K> **ht; //散列表定义
ChainNode<E, K> *FindPos (K k1); //散列
};
FindPos
函数:
ChainNode<E, K>* HashTable<E, K>::FindPos(K k1)
{
int index = k1 % divisor; // 基于除留余数法计算哈希值
ChainNode<E, K>* pos = ht[index]; // 获取桶的位置
return pos; // 返回该位置的链表头指针
}
FindPos
函数是一个简单的哈希函数,使用了除留余数法(Modular Division Method)来计算元素k1
的哈希值,具体做法是用k1
对divisor
(通常是质数)取模。- 通过模运算可以得到哈希表中的桶索引(位置),然后返回该位置的链表头指针。
构造函数和析构函数:
HashTable(int d, int sz = defaultSize)
{
divisor = d; // 设置除数
TableSize = sz; // 设置桶的数量
ht = new ChainNode<E, K>*[sz]; // 为桶分配内存
for (int i = 0; i < sz; i++)
ht[i] = nullptr; // 初始化每个桶为空链表
}
~HashTable() { delete [] ht; } // 释放散列表内存
构造函数:初始化散列表时,构造函数接受一个除数 d
和一个桶的数量 sz
。它为散列表的头指针数组 ht
分配内存,并将每个桶初始化为空链表(nullptr
)。
析构函数:析构函数用于释放散列表的内存,使用 delete [] ht
来释放动态分配的内存。
搜索、插入和删除操作:
- Search:该函数根据键
k1
查找散列表中是否存在对应的元素。如果找到了对应的元素,将其值赋给e1
,并返回true
,否则返回false
。 - Insert:该函数通过哈希函数将元素插入到对应的桶(链表)。若该位置已有元素(发生冲突),则将新元素添加到链表的头部或尾部。
- Remove:该函数根据键
k1
查找对应的元素,并将其从链表中删除。
装载因子α= n/m
散列方法预期的代价与装载因子α= n/m有关
- α 较小时,散列表比较空,所插入的记录比较容易插入到其空闲的基地址。
- α 较大时,插入记录很可能要靠冲突解决策略来寻找探查序列中合适的另一个单元。
随着α增加,越来越多的记录有可能放到离其基地址更远的地方。
置已有元素(发生冲突),则将新元素添加到链表的头部或尾部。
- Remove:该函数根据键
k1
查找对应的元素,并将其从链表中删除。
装载因子α= n/m
散列方法预期的代价与装载因子α= n/m有关
- α 较小时,散列表比较空,所插入的记录比较容易插入到其空闲的基地址。
- α 较大时,插入记录很可能要靠冲突解决策略来寻找探查序列中合适的另一个单元。
随着α增加,越来越多的记录有可能放到离其基地址更远的地方。
[外链图片转存中…(img-iPJrt4sx-1732807747804)]