🌈个人首页: 神马都会亿点点的毛毛张
📌毛毛张今天分享的内容🖆是数据结构中的哈希表,毛毛张主要是依据《大话数据结构📖》的内容来进行整理,不同之处是使用Java语言来介绍其中的代码实现,同时也为后面介绍Java中的集合做铺垫🛤️
文章目录
- 1.散列表查找(哈希表)概述
- 1.1 散列表查找定义
- 1.2 散列表查找步骤
- 2.散列函数的构造方法
- 2.1 直接定址法(了解)
- 2.2 数字分析法(了解)
- 2.3 平方取中法(了解)
- 2.4 折叠法(了解)
- 2.5 除留余数法(掌握)
- 2.6 随机数法(了解)
- 3.处理散列冲突
- 3.1 开放定址法
- 3.2 链地址法
- 3.3 公共溢出区法
- 4.散列表查找实现
- 4.1 散列表查找的算法Java实现
- 4.2 性能分析
- 参考文献
1.散列表查找(哈希表)概述
1.1 散列表查找定义
- 散列技术: 是在记录的存储位置和它的关键字之间建立一个确定的对应关系
f
f
f,使得每个关键字
key
对应一个存储位置 f ( k e y ) f(key) f(key)。查找时,根据这个确定的对应关系找到给定值key
的映射 f ( k e y ) f(key) f(key),若查找集合中存在这个记录,则必定在 f ( k e y ) f(key) f(key)的位置上。我们把这种对应关系 f f f称为散列函数,又称哈希(Hash)函数
存储位置 = f ( 关键字 ) 存储位置 = f(关键字) 存储位置=f(关键字) - 散列表/哈希表(Hash table): 采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表(Hash table)。而关键字对应的记录存储位置我们称为散列地址
- 总结:
- 散列表通过建立关键字和存储地址之间的一种直接映射关系,是一种根据关键字而直接进行访问的数据结构
- 散列技术使得我们可以通过查找关键字不需要比较就可获得需要的记录的存储位置
1.2 散列表查找步骤
- 散列过程步骤: 如下图
- 第一步: 通过散列函数计算记录的散列地址,并按此散列地址存储记录
- 第二步: 当查找记录时,我们通过同样的散列函数计算记录的散列地址,按此散列地址访问该记录,由于存取用的是同一个散列函数,因此结果当然也是相同的
- 散列技术既是一种存储方法, 也是一种查找方法,而散列技术的记录之间不存在什么逻辑关系,它只与关键字有关联,因此散列主要是面向查找的存储结构
- 散列技术最合适的求解问题是查找与给定值相等的记录
- 不适合那种同样关键字,它能对应很多记录的情况,就不适合用散列技术
- 散列表也不适合范围查找,比如查找一个班级18~22岁的同学,在散列表中没法进行
- 冲突(collision): 散列函数可能会把两个或两个以上的不同关键字映射到同一地址,称这种情况为冲突,这些发生碰撞的不同关键字称为同义词
- 一方面,设计得好的散列函数应尽量减少这样的冲突
- 另一方面,由于这样的冲突总是不可避免的,所以还要设计好处理冲突的方法
- 设计一个简单、均匀、存储利用率高的散列函数是散列技术中最关键的问题,一个好的散列表在理想情况下,对散列表进行查找的时间复杂度为$O(1) $,即与表中元素的个数无关
2.散列函数的构造方法
- 好的散列函数设计原则:
- 计算简单: 散列函数的计算应尽量简单,能够在较短的时间内计算出任一关键字对应的散列地址
- 散列地址分布均匀: 散列函数计算出来的地址应该能等概率、均匀地分布在整个地址空间中,从而减少冲突的发生
- 散列函数的定义域必须包含全部需要存储的关键字,而值域的范围则依赖于散列表的大小或地址范围
- 下面介绍接种常用的散列函数构造方法
2.1 直接定址法(了解)
- 直接取关键字的某个线性函数值为散列地址,散列函数为:其中a、b为常数
f ( k e y ) = a × k e y + b f(key)=a×key + b f(key)=a×key+b
- 这种方法计算最简单,且不会产生冲突,但并不常用。它适合关键字的分布基本连续的情况,若关键字分布不连续,空位较多,则会造成存储空间的浪费。
2.2 数字分析法(了解)
- 例如当手机号码为关键字时,其11位数字是有规则的,此时是无需把11位数值全部当做散列地址,这时我们给关键词抽取, 抽取方法是使用关键字的一部分来计算散列存储位置的方法,这在散列函数中是常常用到的手段
- 数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀,就可以考虑用这个方法。
2.3 平方取中法(了解)
- 这个方法计算很简单,假设关键字是1234,那么它的平方就是1522756,再抽取中间的3位就是227,用做散列地址。
- 再比如关键字是4321,那么它的平方就是18671041,抽取中间的3位就可以是671,也可以是710,用做散列地址。
- 平方取中法比较适合于不知道关键字的分布,而位数又不是很大的情况
2.4 折叠法(了解)
- 折叠法 是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
- 折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况
2.5 除留余数法(掌握)
- 除留取余法对于散列表长为
m
的散列函数公式为:f(key)= key mod p (p ≤ m)
,mod
是取模(求余数)的意思,这方法不仅可以对关键字直接取模,也可在折叠、平方取中后再取模。 - 除留余数法的关键是就在于选择合适的 p p p, p p p如果选择不好,就有可能产生同义词,根据前辈门的经验,若散列表表长为 m m m,通常 p p p为小于或等于表长(最好接近 m m m)的最小质数或不包含小于20质因子的合数
- 图例:
- 此方法是最常用的构造散列函数的方法
2.6 随机数法(了解)
- 选择一个随机数,取关键字的随机函数值为它的散列地址。也就是 f ( k e y ) = r a n d o m ( k e y ) f(key)=random(key) f(key)=random(key)这里 r a n d o m random random是随机函数。
- 当关键字的长度不等时,采用这个方法构造散列函数是比较合适的
3.处理散列冲突
- 任何设计出来的散列函数都不可能绝对地避免冲突,因此,必须考虑在发生冲突时应该如何处理,即为产生冲突的关键字寻找下一个空的
Hash
地址 - 用 H i H_i Hi表示处理冲突中第 i i i次探测得到的散列地址,假设得到的另一个散列地址 H 1 H_1 H1仍然发生冲突,只得继续求下一个地址 H 2 H_2 H2,以此类推,直到 H k H_k Hk不发生冲突为止,则 H k H_k Hk为关键字在表中的地址
3.1 开放定址法
- 开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入
- 计算公式: H i ( k e y ) = ( f ( k e y ) + d i ) % m ( d i = 1 , 2 , 3 , . . . , m − 1 ) H_i(key)=(f(key)+d_i) \% m\ (d_i=1,2,3,...,m-1) Hi(key)=(f(key)+di)%m (di=1,2,3,...,m−1),式中, H ( k e y ) H(key) H(key)为散列函数 i = 0 , 1 , 2 , . . . , k ( k < = m − 1 ) i=0,1,2,...,k\ (k<=m-1) i=0,1,2,...,k (k<=m−1); m m m表示散列列表表长; d i d_i di为增量序列
- 取定某一增量序列后,对应的处理方法就是确定的,通常有以下4种方法:
- 线性探测法:当
d
i
=
0
,
1
,
2
,
.
.
.
,
m
−
1
d_i=0,1,2,..., m-1
di=0,1,2,...,m−1时,称为线性探测法
- 特点: 当冲突发生时,顺序查看表中下一个单元(探测到表尾地址 m − 1 m-1 m−1时,下一个探测地址是表首地址 0 0 0),直到找出一个空闲单元(当表未填满时一定能找到一个空闲单元)或查遍全表
- 缺点: 线性探测法可能使第 i i i个散列地址的同义词存入第 i + 1 i+1 i+1个散列地址,这使得本应存入第 i + 1 i+1 i+1个散列地址的元素就争夺第 i + 2 i + 2 i+2个散列地址的元素的地址,从而造成大量元素在相邻的散列地址上堆积,大大降低了查找效率。
- 平方探测法: 当
d
i
=
0
2
,
1
2
,
−
1
2
,
2
2
,
−
2
2
,
.
.
,
k
2
,
−
k
2
d_i=0^2,1^2,-1^2,2^2,-2^2,..,k^2, -k^2
di=02,12,−12,22,−22,..,k2,−k2时,称为平方探测法,其中
k
<
m
/
2
k<m/2
k<m/2,散列表长度
m
m
m必须是一个可以表示成
4
k
+
3
4k+ 3
4k+3的素数,又称二次探测法
- 平方探测法是一种较好的处理冲突的方法,可以避免出现
堆积
问题 - 缺点: 不能探测到散列表上的所有单元,但至少能探测到一半单元
- 平方探测法是一种较好的处理冲突的方法,可以避免出现
- 再散列法: 当
d
i
=
H
a
s
h
2
(
k
e
y
)
d_i= Hash_2(key)
di=Hash2(key)时,称为再散列法,又称双散列法,需要使用两个或者多个散列函数,当通过第一个散列函数
H
(
k
e
y
)
H(key)
H(key)得到的地址发生冲突时,则利用第二个散列函数
H
a
s
h
2
(
k
e
y
)
Hash_2(key)
Hash2(key)计算该关键字的地址增量
- 具体散列函数形式为: H i = ( H ( k e y ) + i ∗ H a s h 2 ( k e y ) ) % m H_i= (H(key) + i*Hash_2(key)) \% m Hi=(H(key)+i∗Hash2(key))%m,初始探测位置 H 0 = H ( k e y ) H_0 = H(key)% m H0=H(key), i i i是冲突的次数,初始为 0 0 0
- 使用该方法,最多经过 m − 1 m-1 m−1次探测就会遍历表中所有位置,回到 H 0 H_0 H0位置
- 这种方法使得关键字不产生剧集,相应地也增加了计算的时间
- 伪随机数探测法: 在发生冲突时,对于位移量
d
i
d_i
di采用随机函数计算得到,称为伪随机探测法
- 伪随机数是说,如果我们设置随机种子相同,则不断调用随机函数可以生成不会重复的数列,我们在查找时,用同样的随机种子,它每次得到的数列是相同的,相同的 d i d_i di当然可以得到相同的散列地址
注意:
- 在开放定址的情形下,不能随便物理删除表中的已有元素,因为若删除元素,则会截断其他具有相同散列地址的元素的查找地址。
- 因此,要删除一个元素时,可给它做一个删除标记,进行逻辑删除。
- 但这样做的副作用是:执行多次删除后,表面上看起来散列表很满,实际上有许多位置未利用,因此需要定期维护散列表,要把删除标记的元素物理删除。
3.2 链地址法
- 将所有关键字为同义词的记录存储在一个单链表中,我们称这种表为同义词子表,在散列表中只存储所有同义词子表的头指针
- 示例: 有关键字序列为
{
12
,
67
,
56
,
16
,
25
,
37
,
22
,
29
,
15
,
47
,
48
,
34
}
\{12, 67, 56, 16, 25, 37, 22, 29,15, 47,48, 34\}
{12,67,56,16,25,37,22,29,15,47,48,34},我们用除留余数法构造散列函数
H
(
k
e
y
)
=
k
e
y
%
12
H(key)=key\%12
H(key)=key%12,用链地址法处理冲突,建立的表如下图所示
- 链地址法对于可能会造成很多冲突的散列函数来说,提供了绝不会出现找不到地址的保障。当然,这也就带来了查找时需要遍历单链表的性能损耗
3.3 公共溢出区法
- 这个方法其实就更加好理解,就是把凡是冲突的家伙额外找个公共场所待着,我们为所有冲突的关键字建立了一个公共的溢出区来存放
- 示例:我们共有三个关键字
37
,
48
,
34
{37,48,34}
37,48,34与之前的关键字位置有冲突,那么就将它们存储到溢出表中,如下图所示
- 查找步骤:
- 在查找时,对给定值通过散列函数计算出散列地址后,先与基本表的相应位置进行对比,如果相等,则查找成功;
- 如果不相等,则到溢出表去进行顺序查找
- 如果相对于基本表而言,有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的
4.散列表查找实现
4.1 散列表查找的算法Java实现
- 首先是需要定义一个散列表的结构以及一些相关的常数,其中
HashTable
就是散列表类,结构当中的elem
为一个动态数组
public class HashTable {
public static final int SUCCESS = 1;
public static final int UNSUCCESS = 0;
public static final int HASHSIZE = 12; // 定义散列表表长为数组的长度
public static final int NULLKEY = -32768; // 代表空地址
private int[] elem; // 数组元素存储基址,动态分配数组
private int count; // 当前数据元素个数
private static int m = HASHSIZE; // 散列表表长,全局变量
}
- 利用构造函数对散列表进行初始化
public HashTable() {
m = HASHSIZE;
this.count = m;
this.elem = new int[m];
for (int i = 0; i < m; i++) {
this.elem[i] = NULLKEY; // 初始化为空地址
}
}
- 为了插入时计算地址,我们需要定义散列函数,散列函数可以根据不同情况更改算法
// 散列函数
public int hash(int key) {
return key % m; // 除留余数法
}
- 初始化完成后,我们可以对散列表进行插入操作,假设我们插入的关键字集合为 12 , 67 , 56 , 16 , 25 , 37 , 22 , 29 , 15 , 47 , 48 , 34 {12,67,56,16,25,37,22,29,15,47, 48,34} 12,67,56,16,25,37,22,29,15,47,48,34
// 插入关键字进散列表
public void insertHash(int key) {
int addr = hash(key); // 通过散列函数求散列地址
// 如果不为空,则冲突
while (this.elem[addr] != NULLKEY) {
addr = (addr + 1) % m; // 开放定址法的线性探测
}
this.elem[addr] = key; // 直到有空位后插入关键字
}
- 代码中插入关键字时,首先算出散列地址,如果当前地址不为空关键字,则说明有冲突。此时我们应用开放定址法的线性探测进行重新寻址,此处也可更改为链地址法等其他解决冲突的办法。
- 散列表存在后,我们在需要时就可以通过散列表查找要的记录
// 散列表查找关键字,找到后用addr保存地址
public boolean searchHash(int key, int[] addr) {
addr[0] = hash(key); // 通过散列函数求得散列地址
// 如果不为空,则有同义词冲突
while (this.elem[addr[0]] != key) {
addr[0] = (addr[0] + 1) % m; // 开放地址法的线性探测
if (this.elem[addr[0]] == NULLKEY || addr[0] == hash(key)) {
// 如果循环到空址或回到原点
return false; // 则说明关键字不存在
}
}
return true;
}
- 查找的代码与插入的代码非常类似,只需做一个不存在关键字的判断而已
- 完整的测试代码如下:
public class HashTable {
public static final int SUCCESS = 1;
public static final int UNSUCCESS = 0;
public static final int HASHSIZE = 12; // 定义散列表表长为数组的长度
public static final int NULLKEY = -32768; // 代表空地址
private int[] elem; // 数组元素存储基址,动态分配数组
private int count; // 当前数据元素个数
private static int m = HASHSIZE; // 散列表表长,全局变量
public HashTable() {
this.elem = new int[HASHSIZE];
this.count = HASHSIZE;
for (int i = 0; i < HASHSIZE; i++) {
this.elem[i] = NULLKEY; // 初始化为空地址
}
}
// 初始化散列表
public boolean initHashTable() {
m = HASHSIZE;
this.count = m;
this.elem = new int[m];
for (int i = 0; i < m; i++) {
this.elem[i] = NULLKEY; // 初始化为空地址
}
return true;
}
// 散列函数
public int hash(int key) {
return key % m; // 除留余数法
}
// 插入关键字进散列表
public void insertHash(int key) {
int addr = hash(key); // 通过散列函数求散列地址
// 如果不为空,则冲突
while (this.elem[addr] != NULLKEY) {
addr = (addr + 1) % m; // 开放定址法的线性探测
}
this.elem[addr] = key; // 直到有空位后插入关键字
}
// 散列表查找关键字,找到后用addr保存地址
public boolean searchHash(int key, int[] addr) {
addr[0] = hash(key); // 通过散列函数求得散列地址
// 如果不为空,则有同义词冲突
while (this.elem[addr[0]] != key) {
addr[0] = (addr[0] + 1) % m; // 开放地址法的线性探测
if (this.elem[addr[0]] == NULLKEY || addr[0] == hash(key)) {
// 如果循环到空址或回到原点
return false; // 则说明关键字不存在
}
}
return true;
}
public static void main(String[] args) {
HashTable hashTable = new HashTable();
boolean isInitialized = hashTable.initHashTable();
System.out.println("HashTable initialized: " + isInitialized);
// 插入一些关键字
hashTable.insertHash(10);
hashTable.insertHash(22);
hashTable.insertHash(31);
hashTable.insertHash(4);
// 查找关键字
int[] addr = new int[1];
boolean found = hashTable.searchHash(22, addr);
System.out.println("Key 22 found: " + found + ", at address: " + addr[0]);
found = hashTable.searchHash(15, addr);
System.out.println("Key 15 found: " + found);
}
}
4.2 性能分析
- 虽然散列表在关键字与记录的存储位置之间建立了直接映像,但由于
冲突
的产生,使得散列表的查找过程仍然是一个给定值和关键字进行比较的过程,因此,仍需要以平均查找长度作为衡量散列表的查找效率的度量。如果没有冲突,散列查找的时间复杂度是最高的,时间复杂度为 O ( 1 ) O(1) O(1) - 若用 c i c_i ci表示每一个关键字查找的次数,则平均查找次数可表示为: A S L = ( ∑ i = 0 m c i ) / m ASL=(\displaystyle\sum_{i=0}^{m}c_i)/m ASL=(i=0∑mci)/m
- 散列表的查找效率取决于三个因素:
- 散列函数是否均匀:散列函数的好坏直接影响着出现冲突的频繁程度,不过,由于不同的散列函数对同一组随机的关键字,产生冲突的可能性是相同的,因此我们可以不考虑它对平均查找长度的影响
- 处理冲突的方法:相同的关键字、相同的散列函数,但处理冲突的方法不同,会使得平均查找长度不同。比如线性探测处理冲突可能会产生堆积,显然就没有二次探测法好,而链地址法处理冲突不会产生任何堆积,因而具有更佳的平均查找性能
- 散列表的装填因子
- 装填因子: 散列表的装填因子一般记为 α \alpha α,它用来表征散列表的装满程度,其计算公式为: α = n ( 表中填入的记录个数 ) / m ( 散列表长度 ) \alpha=n_{(表中填入的记录个数)}/m_{(散列表长度)} α=n(表中填入的记录个数)/m(散列表长度)
- 散列表的平均查找长度取决于散列表的装填因子
α
\alpha
α,而不直接依赖于
n
n
n或
m
m
m
- 直观地看,
α
\alpha
α越大,表示装填的记录越
满
,发生冲突的可能性越大,反之发生冲突的可能性越小
- 直观地看,
α
\alpha
α越大,表示装填的记录越
- 不管记录个数 n n n有多大,我们总可以选择一个合适的装填因子以便将平均查找长度限定在一个范围之内,此时我们散列查找的时间复杂度就真的是 O ( 1 ) O(1) O(1)了。 为了做到这一点,通常我们都是将散列表的空间设置得比查找集合大,此时虽然是浪费了一定的空间,但换来的是查找效率的大大提升,总的来说,还是非常值得的。
参考文献
- 《大话数据结构》
- https://blog.csdn.net/Real_Fool_/article/details/114359564
- https://blog.csdn.net/qq_56884023/article/details/123072671