前言
map作为经典的元素集合方式,能通过键来直接读取到值,极大的方便了程序员的日常开发。在某个摸鱼时刻,咱忽然想到,map这么强大,读写才为O(1)时间复杂度的,它到底是什么原理呢,而go又是怎么实现的呢?
map方案
首先咱们来了解下map的实现思路,常见的map实现有两种方案,分别是 开放寻址法 和 拉链法
开放寻址法
开放寻址法是什么意思呢,举个例子来理解下。
这是总的流程图,咱们一步步拆解来看。
这是我们要写入的键值对,键是a,储存的值是A。
Hash代表某种算法,作用是接收一个字符串,计算得出唯一的很长的哈希值,然后让该哈希值取余数组长度(储存map数据的底层数组长度),得到数组索引。比如传入a,得到哈希值0x5c520101110001010010b,
若底层数组长度为5,该哈希值取余后得到2,然后返回。
这是map储存数据的底层数组,里面每个元素都储存着键值对,K代表键,V代表值,若数组为空则表示没有储存数据。
理解每一层的含义后,咱们回到总流程图。
现在咱们要写入一个键值对a:A。
首先map会通过Hash算法,接收a计算并返回映射的索引2,说明a:A要储存在数组的第三个位置。
然后将a:A储存到该位置,若发现数组中该位置已有数据,即发生哈希冲突,则继续往后找,直到找到数组空闲的位置。
比如图中的第三个位置已经储存了一个键值对K:V了,则map会安排a:A往后寻找,发现数组的第四个位置是没有数据的,故将a:A储存到该位置。
若数组存满了,则将数组扩容即可。
拉链法
拉链法的思想和开放寻址法类似,但相比开放寻址法,拉链法储存的数据位置更加明确,因此使用更加广泛。
这是总的流程图,同样咱们拆解一步步来看。
前面部分和寻址法一样,a:A为要写入的键值对,Hash哈希算法,将字符串计算取余后得出数组索引。
但是这个底层数组的结构就不一样了,拉链法的数组储存的不再是键值对了,而是链表的指针,这种差异会导致什么变化呢。
咱们回到总流程图。
例如,咱们要写入键值对为
a:A,同样通过Hash得出数组索引为2,而在数组这个位置,已经有数据了,a:A便储存到该链表的尾部。
以上便是拉链法的原理,比较一下开放寻址法,拉链法有几个优点:
- 若有哈希冲突,拉链法会循着链表竖向往下储存,减少了频繁扩容。
- 拉链法对每个键的索引定位明确,
Go的map实现
接下来我们分析下go是怎么实现map的。
map的底层结构位于runtime\map.go
// A header for a Go map.
type hmap struct {
count int
flags uint8
B uint8
noverflow uint16
hash0 uint32 // hash seed
buckets unsafe.Pointer
oldbuckets unsafe.Pointer
nevacuate uintptr
extra *mapextra
}
咱们理解下重要的几个属性
buckets,可看作储存数据的底层数组,称之为桶。大小永远是2的B次方
B,为log_2的对数,与buckets的大小有关系。若buckets大小为8,为2的3次方,则B=3
hash0, 哈希算法的随机种子
停上几秒,我们继续
buckets是一个可指向任意类型的指针unsafe.Pointer,实际指向的是bmap结构体,bmap里是一个大小为8的uint8数组,但到编译时会动态生成新的结构。
// A bucket for a Go map.
type bmap struct {
tophash [bucketCnt]uint8
}
const (
bucketCntBits = 3
bucketCnt = 1 << bucketCntBits
...
)
// 以下是编译时生成的新结构
type bmap struct {
topbits [8]uint8 // 储存hash值的高8位
keys [8]keytype // 储存键的数组
elems [8]elemtype // 储存值的数组
overflow uintptr // 溢出的bucket指针
}
通过图示来理解会更加清楚,蓝色bmap为正常桶,优先储存数据。红色bmap为溢出桶,当蓝色的bmap空间不足时,会用溢出桶来储存数据。
那map如何读写的呢?比如读取键值对a:A。
首先计算桶号,咱们得知道a:A储存在哪个桶里。
如图,a为键,hash0为hmap中哈希算法的随机种子,hasher为哈希算法
map会将a、hash0传入hasher计算出哈希值0x5c520101110001010010b,再根据B的值取哈希值的后面几位010。
图示B=3,则取010,010十进制为2,故取buckets里的第三个bmap。
然后使用tophash(),用于计算哈希值的高地址。
通过取出哈希值前八位,传入tophash(), 得出值0x5c,再拿该值去遍历topbits后得到索引0,然后到elems中取出第一个值A返回,即读取成功。
以上即是map的实现过程,由于读写只遍历了topbits, 而topbits又是固定8个容量,为常数,因此平均情况下时间复杂度为O(1)。
思考
此处留下几个问题思考,有兴趣的可以根据这些问题继续深入了解map。
- 溢出桶是什么,为什么要弄溢出桶?
- 如果哈希冲突过多,map是怎么应对的?
- map容量不够时是如何扩容的?