文章目录
Map
Map通常称哈希表(Hash Table)、散列表等,是根据键(Key)而直接访问在内存储存位置的数据结构。也就是说,它通过计算一个关于键值的函数,将所需查询的数据映射到表中一个位置来访问记录,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做桶。哈希表是计算机科学中的最重要数据结构之一,这不仅因为它 𝑂(1) 的读写性能非常优秀,还因为它提供了键值之间的映射。想要实现一个性能优异的哈希表,需要注意两个关键点 —— 哈希函数和冲突解决方法。哈希表的实现主要需要解决两个问题,哈希函数和冲突解决。
在很多编程语言中都有哈希表的相关库的提供,比如C++,Java等,均在语言内部标准库中实现了哈希表相关数据结构的使用方式,而在Go语言中,哈希表则是一个内嵌类型。
哈希表设计及哈希碰撞解决方式
哈希函数
在哈希表实现过程中,哈希函数是一个很重要的部分,哈希函数选择的好坏很大程度的影响哈希表整体性能的好坏,实际上,哈希表是一个表结构,其在读写时,可以通过使用哈希函数对传入的Key进行哈希计算得出其哈希值,能够将不同Key映射到不同的索引上,一般来说,存储表结构的数据结构通常使用数组来做存储某些Key的哈希值对应的Value,通常存储这些Value的数组的每个位置,我们通常称其为桶。
较为理想的哈希函数实现的哈希表,对其任意元素的查找速度始终为𝑂(1) 。因为其每个Key所映射到的桶都是唯一的,即每个Key都有不同的桶的位置,但是这种情况非常难以实现因为,这要求哈希函数的输出范围大于输入范围,但是由于键的数量会远远大于映射的范围,所以在实际使用时,这个理想的效果是不可能实现的。
那么当两个不同的Key经过哈希函数计算后所得到的桶的位置是相同的时候,就产生了哈希碰撞,要注意的是,两个不同的Key产生哈希碰撞不代表两个Key的哈希值完全相同,有可能只是部分相同,比如高X位或者低X位。
哈希碰撞
当产生哈希碰撞时,该如何解决呢?目前比较常见的就是开放地址法和链表法,接下来我们简要的了解一下这两种哈希冲突的解决办法。
开放地址法
开放地址法的基本思想是:当发生哈希碰撞时,按照某种方法继续探测哈希表中的其他存储单元,直到找到空位置为止。这个过程可用以下过程描述:
首先注明,哈希函数计算哈希索引的公式假若为:
i n d e x = H a s h F u n c t i o n ( K e y ) % L e n ( B u c k e t ) index=HashFunction(Key) \% Len(Bucket) index=HashFunction(Key)%Len(Bucket)
其中HashFunction(Key)为求出当前Key的哈希值,然后对当前桶的总数求余
- 发现发生哈希碰撞,此时计算出的index=1(只是举例)
- 此时发现index=1处有Value存储,则探测index+1处也就是index=2处
- 如果index=2处仍然有Value存储则继续往后探测,直到找到某个桶中不存在Value(写)或者找到目标元素(读)时或到桶的长度时结束
有上述过程可以看出,当哈希表越来越满时聚集越来越严重,这导致产生非常长的探测长度,后续的数据插入将会非常费时。
开放地址法对性能影响最大的是装载因子,它是数组中元素的数量与数组大小的比值。随着装载因子的增加,线性探测的平均用时就会逐渐增加,这会影响哈希表的读写性能。当装载率超过 70% 之后,哈希表的性能就会急剧下降,而一旦装载率达到 100%,整个哈希表就会完全失效,这时查找和插入任意元素的时间复杂度都是 𝑂(𝑛)的,这时需要遍历数组中的全部元素,所以在实现哈希表时一定要关注装载因子的变化。
链表法
链表法的基本思想是:当发生哈希碰撞时,将该Key与其hash值相同的Key链接在同一个单链表中,因为一般来说这个链表的长度并不会太长(比如Go中这个链表长度取8),所以查询仍然可以按照𝑂(1)时间计算,链表法有一个很重要的变量,即装载因子 这个装载因子的大小会影响链表的性能,因此,当装载因子达到一定程度的时候就需要进行哈希表的扩容,装载因子的求出公式为:
装 载 因 子 = 哈 希 表 总 元 素 数 量 ➗ 哈 希 表 桶 的 数 量 装载因子=哈希表总元素数量 ➗ 哈希表桶的数量 装载因子=哈希表总元素数量➗哈希表桶的数量
Go中的Map
概述
Map是一种方便而强大的内置数据结构,它将一种类型的Key(键)与另一种类型的Value(元素或值)关联起来。Key可以是任何可以进行比较的类型,如整数、浮点和复数、字符串、指针、interface{}(但是前提是interface中的数据类型也必须是可以比较的)、结构和数组。和切片一样,Map 也持有对底层数据结构的引用。如果你把一个Map传给一个函数,该函数改变了Map的内容,那么这些改变将在调用者中可见。
数据结构
Map在Go中是按照一种内置数据结构的形式出现,在使用的时候只需要像使用切片那样即可:
func main() {
a := make(map[string]string,10)
a["test"]="test"
}
同时跟切片一样Map在Go语言底层也是一种结构体的实现,现在来看一下Map在Go中最底层的结构体样式以及相关字段功能:
type hmap struct {
// 当前map中元素数量 即len(map)返回的值。
count int
// 当前map所处状态标记,比如正在写入正在迭代等。
flags uint8
// 桶的数量的值,最终map创建的桶的数量为 2^B
// 另外在使用Key的哈希值选桶的时候
// 取的是该哈希值的低B位作为选桶的值
B uint8
// 当前溢出桶的数量
noverflow uint16
// hash种子,在创建该结构体的时候动态生成
hash0 uint32
// 指向第一个桶的指针 是一个连续的地址 因为是个数组
// 这里面存的是 *bmap
buckets unsafe.Pointer
// 旧桶第一个桶的指针,用于在扩容搬迁的时候未完成搬迁时保存之前的旧桶
oldbuckets unsafe.Pointer
// 搬迁桶的进度 就是处于扩容搬迁时,目前搬到哪了
nevacuate uintptr
// 溢出桶 当bmap中存储的数据过多
// 单个bmap已经装满时就会使用 extra.nextOverflow 中桶存储溢出的数据。
// 溢出桶不一定会使用,因为他是个可选字段
extra *mapextra
}
type mapextra struct {
// 如果key和value都不包含指针,而且是内联的,那么我们就将bucket类型标记为不包含指针。
// 这样就避免了对这类地图的扫描。
// 然而,bmap.overflow 是一个指针。为了防止溢出桶被gc处理
// 我们在hmap.extra.overflow和hmap.extra.oldoverflow中存储所有溢出桶的指针。
// overflow和oldoverflow只在key和value不包含指针的情况下使用
overflow *[]*bmap
oldoverflow *[]*bmap
// 存储一个已经创建好了但是暂未使用的空bmap溢出桶
nextOverflow *bmap
}
// 实际存储k,v数据的桶
// 该结构体还有一些字段会在编译期添加
// 比如 下一个溢出桶的地址
// key,value所处的地址等
type bmap struct {
// 这里存储可 Key的哈希值的高8位 例子 11111111 00000000
// 用于在查找时快速的去判断当前Key是否存在这个桶里
// 因此得出,每个桶只能存放8个Key-Value映射
tophash [bucketCnt]uint8
}
关于Map的结构体,就是上述代码中所描述的一些信息,由此可以得出几个信息:
- Map的最大桶的数量为1<<255个,因为hmap.B为uint8类型,最大值为255.
- 桶的数量时1<<B个
- 每个bmap只能存放8对Key-Value,当这个bmap装满的时候就会使用hmap.extra.nextOverflow中已经创建好的空bmap作为溢出桶,如果不存在则创建一个溢出桶。
构建Map
看完了Map的数据结构,那么接下来看一下Map的构建方式以及构建过程之中到底发生了什么,首先Map的几种构建方式如下:
func main() {
a := map[int]int{
1: 2,
2: 3,
}
// let [a] alloc from heap
fmt.Println(a)
// let this map alloc alloc from stack
_ = map[int]int{
1: 2,
2: 3,
}
b := make(map[int]int, 10)
b[1] = 2
b[2] = 3
var c map[int]int
c[1]=2 // wrong! map is not init,it will panic
}
上面4种方式都是构建一个Map的方式,但是第4种方式只是声明了一个Map并未初始化,所以当对其赋值的时候会出现panic,那么这两种构建的方式区别在哪呢?遇事不决先看汇编。
构建方式1–小型Map堆分配
首先看第一个方式的汇编代码:
0x002f 00047 CALL runtime.makemap_small(SB)
0x0034 00052 MOVQ (SP), AX
0x0038 00056 MOVQ AX, "".a+72(SP)
0x003d 00061 MOVQ $1, ""..autotmp_4+56(SP)
首先不看别的只看第一行,00047 部分,此处调用了函数构建了一个新的map,此函数的实现代码如下:
func makemap_small() *hmap {
h := new(hmap)
h.hash0 = fastrand()
return h
}
短短的3行代码,很简单,runtime.makemap_small() 实现了在编译时已知元素数量,即Key-Value个数<=8或者make时明确len<=8,且Map需要在堆上分配时(因为我这个代码下面使用了fmt.Println()输出了a,所以在堆上分配了Map),为make(map[k]v)和make(map[k]v, hint)创建Map。
构建方式2–小型Map栈分配
接下来看一下第二种Map的字面量构建方式,这种方式是在栈上构建的,因为避免输出汇编时出现未使用变量错误所以忽略了变量名,接下来看一下汇编代码:
0x01e2 00482 CALL runtime.fastrand(SB)
0x01e7 00487 MOVQ ""..autotmp_14+312(SP), AX
还是只看第一行,其调用了runtime.fastrand(),这是为什么呢?因为在编译期间,当创建的Map被分配到栈上并且其Key-Value个数<=8或者make时明确len<=8时,Go 语言在编译阶段会使用runtime.fastrand()快速初始化哈希,这也是编译器对小容量的哈希做的优化,也就是说会生成类似下面的代码:
var h *hmap
var hv hmap
var bv bmap
h := &hv
b := &bv
h.buckets = b
h.hash0 = fashtrand0()
常规构建方式–Make的len>8或Key-Value个数>8
第三种方式是经常使用的一种初始化方式,即使用make函数进行初始化,这与切片的初始化方式相同,但是不同的是,使用make初始化Map只可以传递两个参数一个是类型一个是长度,而不能像切片一样传递一个容量。
接下来看一下第三种方式所产生的汇编代码:
0x04b8 01208 CALL runtime.makemap(SB)
从上述汇编可以看出,创建Map调用了runtime.makemap()这个函数不光只有在使用make方式时会调用,也会在不满足上面2种方式的时候进行调用,也就是说runtime.makemap()是一个构建Map最后调用的都是runtime.makemap(),接下来来详细讲解这个函数的逻辑:
// 传入的三个参数分别为
// 1.map的类型即 key和value的类型信息等其他数据
// 2.长度 即 make 传入的len
// 3.hmap结构体 可以为nil
// 返回值为经过处理的 *hmap
func makemap(t *maptype, hint int, h *hmap) *hmap {
// 校验一下需求的长度和类型占用字节数的乘积
// 是否超过内存限制
mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size)
if overflow || mem > maxAlloc {
hint = 0
}
// 创建一个新的 hmap结构体
if h == nil {
h = new(hmap)
}
// 获取一个随机的哈希种子
h.hash0 = fastrand()
B := uint8(0)
// 通过输入的长度 算出一个合适的B值
for overLoadFactor(hint, B) {
B++
}
h.B = B
// 如果B不为0
if h.B != 0 {
var nextOverflow *bmap
// 调用makeBucketArray 返回一个溢出bmap和开辟完内存的桶的首地址指针
h.buckets, nextOverflow = makeBucketArray(t, h.B, nil)
// 如果有溢出bmap
if nextOverflow != nil {
h.extra = new(mapextra)
h.extra.nextOverflow = nextOverflow
}
}
return h
}
func makeBucketArray