【go基础】4.基本数据结构之map

目录

哈希表map

- 主要思想

- 特点

- 哈希函数

- 数据结构

- map初始化

- map value为什么不能寻址

- map为什么是无序的

- map为什么是o(1)的

- 开发时应注意的


哈希表map


理解 Golang 哈希表 Map 的原理 | Go 语言设计与实现
彻底理解Golang Map - 知乎

- 主要思想

1、桶
  • map的底层存储结构式hmap, 里面有一个桶数组,所有kv都是存在这些桶里的,每个桶的结构是bmap
  • 每个桶中最多可以存8个key,以链表形式连起来(内存连续)
  • 具体key属于哪个桶是哈希值取模运算(根据低5位)得来的,可以说一个桶中的key他们的哈希值是一类的
  • 如果哈希值的高8位相同,即存在哈希冲突
2、溢出桶
  • 当桶满已有8个元素,会创建溢出桶(每个桶都有一个自己的溢出桶)
  • 溢出桶也是一个链表(内存连续),如果桶元素超过6个,会将链表转成红黑树,以提高性能
3、查找过程
  • 先根据哈希函数计算出key的哈希值,得到hash值的低5位,确定桶的位置
  • 再根据hash值的高8位(tophash),用于快速查找
  • 遍历桶中元素,先比较tophash,如果不一致则比较下一个,如果一致再比较key是否相等
4、扩容
  • 时机:插入新key时,满足以下之一
  • ① 装载因子超过6.5(装满则为8)
  • ② 溢出桶数量过多。 溢出桶过多,正常通没装满,效率降低。(先插入很多元素创建了很桶,但是装载因子达不到6.5的临界值,未触发扩容,之后,删除元素,再插入很多元素,导致创建很多的溢出桶)
  • 方式
  • ① 增量扩容:对数据太多的情况。将桶数量翻倍,rehash重新计算哈希调整元素位置(哈希函数不变)
  • ② 等量扩容:对溢出桶太多的情况。遍历所有桶,重新计算分配key,较少溢出桶个数,使桶中排列更紧凑、均匀
5、写入
  • 先根据key,根据hash值的低5位,计算出是哪个桶
  • 再遍历桶,用hash值的高8位(tophash),在桶里找到槽位,就是插入的位置
  • 如果在桶里没找到,再去溢出桶里找槽位

- 特点

  • 内存连续
  • map是无序的
  • 插入和查找的复杂度都是o(1)
  • 扩容会分配新的内存并拷贝数据,所以地址会变,因此value本身不能寻址

- 哈希函数

利用哈希函数计算出键对应的索引
哈希碰撞:不同的输入得到了同一个哈希值
如果所有 Key 的数组下标都冲突,那么 Hash 表就退化为一条链表,时间复杂度是 O(N)
哈希碰撞的解决方法:
1、开放寻址法
写入新数据时,如果发生冲突,就将键值对写入到下一个索引不为空的位置。性能高低依赖装载因子
2、拉链法(常用)
数组+链表实现,先用哈希函数选择一个桶,遍历桶中的链表,更新或追加键值。性能好的哈希表桶中元素一般在3个以内。
=÷量,装载因子越大性能越差,一般不超过1,装载因子较大会触发扩容,创建更多的桶以保证性能

- 数据结构

// map的结构
type hmap struct {    // 一个map占8字节
    
    // 哈希表中元素的数量
    count int

    // 桶的数组,每个桶是一个*bmap,存储了链表第一个元素的指针
    buckets unsafe.Pointer

    // 桶的数量。实际是2^B
    B int

    // 扩容时保存旧buckets,大小是当前bucket的一半
    oldbuckets unsafe.Pointer

    // 哈希种子,为哈希函数的结果引入随机性
    hash0 uint32
    
    // 附加的标志位,如是否正在扩容
    extra *mapextra
}

// 桶结构
type bmap struct {
    // 存储key的hash值的高8位,查找时比较高8位,来查找槽位,以提高性能
    tophash [bucketCnt]uint8

    // 存储键值对
    keys    [bucketCnt]keytype
    values  [bucketCnt]valuetype

    // overflow指向一个单向链表,用于处理哈希冲突
    // 当两个键映射到了同一个桶时,它们就会被插入到这个链表的末尾
    overflow *bmap
}

- map初始化

// 使用字面量
hash := map[string]int{
        "1": 2,
        "3": 4,
}
// 使用关键字
hash := make(map[string]int)

元素数量<=25 时,会将所有键值对一次加入哈希表中;元素>25时,编译器会创建两个数组分别存储键和值,循环存入哈希。

元素数量<16(2^4)时,使用溢出桶的可能性较低,不创建溢出桶;大于16时,会创建2^(β-4)个溢出桶。

正常通和溢出桶内存都是连续的。

- map value为什么不能寻址

map扩容是拷贝数据到新的内存,所以地址可能会变, 因此map的value本身是不可寻址的

// 这样写是可以的:
m := make(map[int]*T, 1)
m[1] = &T{n: 1}
m[1].n = 2
// 这样写不行:
//m2 := make(map[int]T, 1)
//m2[1] = T{n: 1}
//m2[1].n = 2   // 报错因为不能寻址

- map为什么是无序的

map 在扩容后,可能会将部分 key 移至新内存,那么这一部分实际上就已经是无序的了。而遍历的过程,其实就是按顺序遍历内存地址,同时按顺序遍历内存地址中的 key,但这时已经是无序的了。当然有人会说,如果我就一个 map,我保证不会对 map 进行修改删除等操作,那么按理说没有扩容就不会发生改变。但也是因为这样,GO设计者加上随机的元素,将遍历 map 的顺序随机化,用来防止使用者用来顺序遍历。

- map为什么是o(1)的

是根据哈希函数计算出键对应的索引。计算桶的位置是位运算效率很高,每个桶中的数量是8个,查找也能在o(1)完成

- 开发时应注意的

1、预估map的大小,在make初始化时传递合适的容量参数,避免map扩容带来的性能损失。
 
// 用:
make(map[x]x, 100) 
// 代替:
make(map[x]x)

2、在高并发环境中,如果读操作较多,使用sync.map更高效

  • sync.Map的性能高体现在读操作远多于写操作的时候。 极端情况下,只有读操作时,是普通map的性能的44.3倍;
  • 反过来,如果是全写,没有读,那么sync.Map还不如加普通map+mutex锁,只有普通map性能的一半;
  • 建议使用sync.Map时一定要考虑读定比例。当写操作只占总操作的<=1/10的时候,使用sync.Map性能会明显高很多;

3、避免在循环中使用map。如 for k, v := range m,会导致每次循环都需要重新计算map的哈希值。如果循环中的操作不需要修改map,那么可以先将map转化为slice,然后在循环中对slice进行操作。

4、使用map存储大对象时注意内存占用。由于map以键值对的形式存储数据,键值对中的key和value都会占用一定的内存。如果map中存储的是大对象,那么每个键值对的内存占用都会很大。可以考虑将大对象分割成多个小对象(sharding),每个小对象作为一个value,使用同一个key来访问这些小对象。

你可能感兴趣的:(go语言原理,golang,数据结构,哈希算法,后端)