散列表
在线性表和树表的查找中,记录在表中的位置与记录的关键字之间不存在确定关系,因此,在这些表中的查找记录时需进行一系列的关键字比较。这类查找方法建立在“比较”的基础上,查找效率取决于比较的次数。
一、相关概念
散列函数
一个把查找表中的关键字映射成该关键字对应的地址函数,记为 H a s h ( k e y ) = A d d r Hash(key) = Addr Hash(key)=Addr(这里的地址可以时数组下标、索引或内存地址等)
散列函数可能会把两个或两个以上的不同关键字映射到同一地址,称这种情况为 冲突 , 这些发生碰撞的不同关键字称为同义词。一方面,设计得好的散列函数应尽量减少这样的冲突;另一方面,由于这样的冲突时不可避免的,所以还要设计好处理冲突的方法。
散列表
根据关键字二直接进行访问的数据结构。也就是说,散列表建立了关键字和存储地址之间的直接映射关系。
在理想情况下,对散列表进行查找的时间复杂度为O(1),即与表中元素的个数无关。
二、散列函数的构造方法
在构造散列函数时,必须注意以下几点:
- 散列函数的定义域必须包含全部需要存储的关键字,而值域的范围则依赖于散列表的大小或地址范围。
- 散列函数计算出来的地址应该能等概率、均匀地分布在整个地址空间中,从而减少冲突的发生。
- 散列函数应尽量简单,能够在较短的时间内计算出任一关键字对应的散列地址。
三种原则可以归纳为三点: 1. 充分利用空间 2.分布均匀 3.散列索引生成快
下面介绍常用的散列函数:
直接定址法
直接取关键字的某几个线性函数值为散列地址,散列函数为:
H
(
k
e
y
)
=
k
e
y
或
H
(
k
e
y
)
=
a
∗
k
e
y
+
b
H(key) = key 或 H(key) = a * key + b
H(key)=key或H(key)=a∗key+b
式中,a 和 b 是常数。这种方法计算最简单,且不产生冲突。它适合关键字的分布基本连续的情况,若关键字分布不连续,空位较多,则会造成存储空间的浪费。
除留余数法
这是一种最简单、最常用的方法,假定散列表的表长为m,取一个不大于m但最接近或等于m的质数
p
p
p,利用以下公式把关键字转化成散列地址。散列函数为:
H
(
k
e
y
)
=
k
e
y
%
p
H(key) = key \% p
H(key)=key%p
除留余数法的关键是选好
p
p
p,使得每个关键字通过该函数转换后等概率地映射到散列空间上的任一地址,从而尽可能减少冲突的可能性。
数字分析法
设关键字是r进制数(如十进制数),而r个数码在各位上出现的频率不一定相同,可能在某些位上分布均匀写,每种数码出现的机会均等:而在某些位上分布不均匀,只有某几种数码经常出现,此时应选取数码分布较为均匀的若干位作为散列地址。这种方法适合于已知的关键字集合,若更换了关键字,则需要重新构造新的散列函数。
平方取中法
意如其名,这种方法取关键字的平方值的中间几位作为散列地址。具体取多少位要视实际情况而定。这种方法的得到的散列地址与关键字的每位都有关系,因此使得散列地址分布比较均匀,适合于关键字的每位取值都不够均匀或均小于散列地址所需的位数。
采用何种构造散列函数的方法取决于关键字集合的情况,单目标是尽量降低产生冲突的可能性。
三、处理冲突的方法
我们可以注意到,任何设计出来的散列函数都不可能绝对地避免冲突。为此,必须考虑在发生冲突时应该如何处理,即位产生冲突的关键字寻找下一个“空”的Hash地址。用 H i H_{i} Hi表示处理冲突中第i次探测得到的散列地址,假设得到的另一个散列地址 H 1 H_{1} H1仍然发生冲突,只得继续求下一个地址 H 2 H_{2} H2,以此类推,直到 H k H_{k} Hk不发生冲突为止,则 H k H_{k} Hk为关键字在表中的地址。
1. 开放地址法
所谓的开放定址法,是指可存放新表项的空闲地址既向它的同义词表项开放,又向它的非同义词表项开放。其数学递推公式为:
H
i
=
(
H
(
k
e
y
)
+
d
i
)
%
m
H_{i} = (H(key) + d_{i}) \% m
Hi=(H(key)+di)%m
式中,
H
(
k
y
e
)
H(kye)
H(kye)为散列函数;$i = 0,1,2,…,k (k<= m -1) $;m表示散列表表长;d_{i}为增量序列。
取定某一增量序列后,对应的处理方法就是确定的。通常有以下4种取法:
-
线性探测法 当 d i = 0 , 1 , 2 , . . . , m − 1 d_{i} = 0,1,2,...,m-1 di=0,1,2,...,m−1,称为线性探测法。这种方法的特点是:冲突发生时,顺序查看表中下一个单元(探测到表尾地址m-1时,下一个探测地址是表首地址0),直到找出一个空闲单元(当表未被用完时)或查遍全表。
-
平方探测法 当 d i = 0 2 , 1 2 , − 1 2 , . . . , k 2 , − k 2 d_{i} = 0^{2},1^2,-1^2,...,k^2,-k^2 di=02,12,−12,...,k2,−k2时,称为平方探测法,其中 k < = m / 2 k<=m/2 k<=m/2,散列表长度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 ) % m H_{0} = H(key) \% m H0=H(key)%m. i i i 是冲突的次数,初始为0。在再散列法种,最多经过 m − 1 m-1 m−1次探索就会遍历表种所有位置,回到 H 0 H_{0} H0位置。 -
伪随机序列法 当$d_{i} = $伪随机数序列时,称为伪随机序列法。
注意: 在开放定址的情形下,不能随便物理删除表种的已有元素,因为若删除元素,则会截断其他具有相同散列地址的元素的查找地址。因此,要删除一个元素时,可以给它做一个删除标记,进行逻辑删除。但这样做的副作用是:执行多次删除后,表面上看起来散列表很满,实际上有许多位置未利用,因此需要定期维护散列表,要把删除标记的元素物理删除。
2. 拉链法(chaining)
图片来源: 《2022年数据结构考研复习指导》
拉链法其实就是将发生冲突的同义词存储在一个线性表中,这个线性表由其散列地址唯一标识。
例如,关键字序列为 { 19 , 14 , 23 , 01 , 68 , 20 , 84 , 27 , 55 , 11 , 10 , 79 } \{ 19,14,23,01,68,20,84,27,55,11,10,79 \} {19,14,23,01,68,20,84,27,55,11,10,79},散列函数 H ( k e y ) = k e y % 13 H(key) = key \% 13 H(key)=key%13,用拉链法处理冲突,建立的表如上图所示。
四、性能分析
从散列表的查找过程可见:
-
虽然散列表在关键字与记录的存储位置之间建立了直接映像,但由于“冲突"的产生,使得散列表的查找过程仍然是一个给定值和关键字进行比较的过程。因此,仍需要以平均查找长度作为衡量散列表的查找效率的度量。查找长度的具体计算可以看 这篇这里就不细讲了.
-
散列表的查找效率取决于三个因素:散列函数、处理冲突的方法和装填因子。
装填因子,散列表的装填因子一般记为 α \alpha α ,定义为一个表的装满程度,即:
α = 表中记录数 n 散列表长度 m \alpha = \frac{表中记录数n}{散列表长度m} α=散列表长度m表中记录数n
通过这个式子我们可以直观地看出, α \alpha α越大,表示装填的记录越”满“,发生冲突的可能性越大,反之发生冲突的可能性越小。
五、Go 语言实现
下面的代码是对除留余数法的散列函数并用拉链法处理冲突的简单实现。
package main
import "fmt"
const (
expandFactor = 0.75
)
//hash 表的桶
type HashMap struct {
m []*KeyPairs
cap int
len int
}
// 键值对
type KeyPairs struct {
key string
value interface{}
next *KeyPairs
}
// init new hashMap
func NewHashMap(cap int) *HashMap {
if cap < 16 {
cap = 16
}
hashMap := new(HashMap)
hashMap.cap = cap
hashMap.m = make([]*KeyPairs, cap)
return hashMap
}
// 获取hash code
func (h *HashMap) Index(key string) int {
return BKDRHash(key, h.cap)
}
// 向其中插入一个值
func (h *HashMap) Put(key string, value interface{}) {
index := h.Index(key)
element := h.m[index]
if element == nil {
h.m[index] = &KeyPairs{key, value, nil}
} else {
for ; element.next != nil; element = element.next {
if element.key == key {
element.value = value
return
}
}
element.next = &KeyPairs{key, value, nil}
}
h.len++
// 进行扩容
if (float64(h.len) / float64(h.cap)) > expandFactor {
newH := NewHashMap(2 * h.cap)
for _, pairs := range h.m {
for pairs != nil {
newH.Put(pairs.key, pairs.value)
}
}
h.cap = newH.cap
h.m = newH.m
}
}
// 获取值
func (h *HashMap) Get(key string) interface{} {
index := h.Index(key)
for pairs := h.m[index]; ; pairs = pairs.next {
if pairs.key == key {
return pairs.value
}
if pairs.next == nil {
break
}
}
return nil
}
// 删除键值对
func (h *HashMap) Delete(key string) interface{} {
var prev *KeyPairs
index := h.Index(key)
for pairs := h.m[index]; ; pairs = pairs.next {
if pairs.key == key {
if prev == nil {
prev = pairs.next
} else {
prev.next = pairs.next
}
h.m[index] = prev
h.len--
return pairs.value
}
prev = pairs
if pairs.next == nil {
break
}
}
return nil
}
// 查看整个散列表的
func (h *HashMap) View() {
d := h.m
fmt.Printf("====================")
for _, v := range d {
if v != nil {
fmt.Println()
}
for ; v != nil; v = v.next {
fmt.Printf("[%s: %s] -> ", v.key, v.value)
}
}
fmt.Printf("\n====================\n")
}
散列函数
func BKDRHash(str string, cap int) int {
seed := int(131)
hash := int(0)
for i := 0; i < len(str); i++ {
hash = (hash * seed) + int(str[i])
}
return hash % cap
}
测试
func test() {
h := NewHashMap(32)
h.Put("foo", "bar")
h.Put("aaa", "bar")
h.Put("bbb", "bar")
h.Put("ccc", "bar")
h.Put("ddd1", "bar")
h.Put("ee11", "bar")
h.Put("dd22", "bar")
h.Put("ff00", "bar")
h.Put("dd4", "bar")
h.View()
res := h.Get("foo")
fmt.Println(res)
fmt.Printf("%v\n", h.Delete("dd22"))
h.View()
}
Output
====================
[dd4: bar] ->
[ccc: bar] ->
[aaa: bar] -> [ddd1: bar] ->
[foo: bar] ->
[ee11: bar] -> [dd22: bar] -> [ff00: bar] ->
[bbb: bar] ->
====================
bar
bar
====================
[dd4: bar] ->
[ccc: bar] ->
[aaa: bar] -> [ddd1: bar] ->
[foo: bar] ->
[ee11: bar] -> [ff00: bar] ->
[bbb: bar] ->
====================
小结
本来是计划阅读HashMap
的具体实现的 ,不过后面的红黑树还是挺难啃的。因此想先分步学习,毕竟HashMap
也是一种特殊的散列表,只不过对后面的拉链法改进成了红黑树(这里说的是JDK 8的,其他的实现可能不同)。通过下图可以大致了解HashMap
的结构。
本文参考《2022年数据结构考研复习指导》.