¿Qué es el mapa?
Wikipedia define el mapa así:
En informática, una matriz asociativa, mapa, tabla de símbolos o diccionario es un tipo de datos abstracto compuesto por una colección de pares (clave, valor), de modo que cada clave posible aparece como máximo una vez en la colección.
Una breve explicación: en informática, se denomina matriz asociada, mapa, tabla de símbolos o diccionario, que es una <key, value>
estructura de datos abstracta compuesta por un conjunto de pares, y la misma clave solo aparecerá una vez.
Hay dos puntos clave: el mapa se compone key-value
de pares; key
solo aparece una vez.
Las principales operaciones relacionadas con el mapa son:
- Agregar un par kv: agregar o insertar;
- Eliminar un par kv: eliminar o eliminar;
- Modificar v correspondiente a una determinada k - Reasignar;
- Consulta la v correspondiente a una determinada k - Búsqueda;
En pocas palabras, es el más básico 增删查改
.
El diseño de mapas, también llamado "El problema del diccionario", su tarea es diseñar una estructura de datos para mantener los datos de una colección y realizar operaciones de adición, eliminación, consulta y modificación en la colección al mismo tiempo. Hay dos estructuras de datos principales: 哈希查找表(Hash table)
, 搜索树(Search tree)
.
La tabla de búsqueda hash utiliza una función hash para asignar claves a diferentes depósitos (es decir, diferentes índices de la matriz). De esta forma, la sobrecarga recae principalmente en el cálculo de la función hash y el tiempo de acceso constante a la matriz. En muchos escenarios, el rendimiento de las tablas de búsqueda hash es muy alto.
Las tablas de búsqueda hash generalmente tienen problemas de "colisión", lo que significa que diferentes claves se asignan al mismo depósito. Generalmente hay dos maneras de abordarlo: 链表法
y 开放地址法
. 链表法
Implemente un depósito como una lista vinculada y las claves que se encuentren en el mismo depósito se insertarán en esta lista vinculada. 开放地址法
Después de que ocurre la colisión, de acuerdo con ciertas reglas, se seleccionan "vacantes" en la parte posterior de la matriz para colocar nuevas claves.
El método del árbol de búsqueda generalmente utiliza árboles de búsqueda autoequilibrados, que incluyen: árboles AVL y árboles rojo-negro. Durante las entrevistas, muchas veces nos piden e incluso nos piden que escribamos a mano el código del árbol rojo-negro, muchas veces el entrevistador no puede escribirlo él mismo, lo cual es muy excesivo.
La peor eficiencia de búsqueda del método del árbol de búsqueda autoequilibrado es O (logN), mientras que la peor eficiencia de búsqueda de la tabla de búsqueda hash es O (N). Por supuesto, la eficiencia de búsqueda promedio de una tabla de búsqueda hash es O (1). Si la función hash está bien diseñada, el peor de los casos básicamente no ocurrirá. Otro punto es que al atravesar el árbol de búsqueda autoequilibrado, la secuencia de claves devuelta generalmente estará en orden ascendente, mientras que la tabla de búsqueda hash está desordenada.
Cómo implementar la capa inferior del mapa
Primero declare la versión de Go que uso:
go version go1.9.2 darwin/amd64
Como se mencionó anteriormente, hay varias formas de implementar mapas: el lenguaje Go usa tablas de búsqueda hash y listas vinculadas para resolver conflictos hash.
A continuación, exploraremos los principios básicos del mapa y echaremos un vistazo a su estructura interna.
modelo de memoria de mapas
En el código fuente, la estructura que representa el mapa es hmap, que es la "abreviatura" de hashmap:
// A header for a Go map.
type hmap struct {
// 元素个数,调用 len(map) 时,直接返回此值
count int
flags uint8
// buckets 的对数 log_2
B uint8
// overflow 的 bucket 近似数
noverflow uint16
// 计算 key 的哈希的时候会传入哈希函数
hash0 uint32
// 指向 buckets 数组,大小为 2^B
// 如果元素个数为0,就为 nil
buckets unsafe.Pointer
// 等量扩容的时候,buckets 长度和 oldbuckets 相等
// 双倍扩容的时候,buckets 长度会是 oldbuckets 的两倍
oldbuckets unsafe.Pointer
// 指示扩容进度,小于此地址的 buckets 迁移完成
nevacuate uintptr
extra *mapextra // optional fields
}
Para explicarlo, B
es el logaritmo de la longitud de la matriz de depósitos, lo que significa que la longitud de la matriz de depósitos es 2 ^ B. La clave y el valor se almacenan en el depósito, que se analizará más adelante.
cubos es un puntero, que en última instancia apunta a una estructura:
type bmap struct {
tophash [bucketCnt]uint8
}
Pero esta es solo la estructura de la superficie (src/runtime/hashmap.go), se agregará durante la compilación y se creará dinámicamente una nueva estructura:
type bmap struct {
topbits [8]uint8
keys [8]keytype
values [8]valuetype
pad uintptr
overflow uintptr
}
bmap
Es lo que a menudo llamamos un "depósito". Un depósito puede contener hasta 8 claves. La razón por la que estas claves caen en el mismo depósito es porque después de haber sido procesadas, los resultados del hash son del mismo tipo. En el depósito, los 8 bits superiores del valor hash calculado por la clave se utilizarán para determinar dónde cae la clave en el depósito (hay hasta 8 posiciones en un depósito).
Aquí hay una imagen general:
Cuando la clave y el valor del mapa no son punteros y el tamaño es inferior a 128 bytes, el bmap se marcará como que no contiene punteros, lo que puede evitar escanear todo el hmap durante gc. Sin embargo, vemos que bmap en realidad tiene un campo de desbordamiento, que es de tipo puntero, lo que destruye la idea de que bmap no contiene punteros. En este caso, el desbordamiento se moverá al campo extra.
type mapextra struct {
// overflow[0] contains overflow buckets for hmap.buckets.
// overflow[1] contains overflow buckets for hmap.oldbuckets.
overflow [2]*[]*bmap
// nextOverflow 包含空闲的 overflow bucket,这是预分配的 bucket
nextOverflow *bmap
}
bmap es donde se almacena kv. Acerquémonos y echemos un vistazo más de cerca a la composición interna de bmap.
La imagen de arriba es el modelo de memoria del depósito, HOB Hash
que se refiere al hash superior. Tenga en cuenta que la clave y el valor se combinan por separado, no de key/value/key/value/...
esta forma. El código fuente indica que la ventaja de esto es que en algunos casos el campo de relleno se puede omitir para ahorrar espacio en la memoria.
Por ejemplo, existe un mapa de este tipo:
map[int64]int8
Si key/value/key/value/...
se almacena en este modo, se agregarán 7 bytes adicionales de relleno después de cada par clave/valor, y todas las claves y valores se vincularán por separado. De esta forma, solo es necesario agregar relleno al final key/key/.../value/value/...
.
Cada depósito está diseñado para contener hasta 8 pares clave-valor. Si un noveno valor-clave cae en el depósito actual, es necesario construir otro depósito y conectarlo mediante punteros overflow
.
Crear mapa
Sintácticamente, crear un mapa es sencillo:
ageMp := make(map[string]int)
// 指定 map 长度
ageMp := make(map[string]int, 8)
// ageMp 为 nil,不能向其添加元素,会直接panic
var ageMp map[string]int
Se puede ver en el lenguaje ensamblador que makemap
en realidad se llama a la función subyacente, y su tarea principal es inicializar hmap
varios campos de la estructura, como calcular el tamaño de B, configurar la semilla hash hash0, etc.
func makemap(t *maptype, hint int64, h *hmap, bucket unsafe.Pointer) *hmap {
// 省略各种条件检查...
// 找到一个 B,使得 map 的装载因子在正常范围内
B := uint8(0)
for ; overLoadFactor(hint, B); B++ {
}
// 初始化 hash table
// 如果 B 等于 0,那么 buckets 就会在赋值的时候再分配
// 如果长度比较大,分配内存会花费长一点
buckets := bucket
var extra *mapextra
if B != 0 {
var nextOverflow *bmap
buckets, nextOverflow = makeBucketArray(t, B)
if nextOverflow != nil {
extra = new(mapextra)
extra.nextOverflow = nextOverflow
}
}
// 初始化 hamp
if h == nil {
h = (*hmap)(newobject(t.hmap))
}
h.count = 0
h.B = B
h.extra = extra
h.flags = 0
h.hash0 = fastrand()
h.buckets = buckets
h.oldbuckets = nil
h.nevacuate = 0
h.noverflow = 0
return h
}
[Extensión 1] ¿Cuál es la diferencia entre corte y mapa cuando se usan como parámetros de función?
Tenga en cuenta que el resultado devuelto por esta función es: *hmap
, que es un puntero, mientras que makeslice
la función de la que hablamos antes devuelve Slice
una estructura:
func makeslice(et *_type, len, cap int) slice
Repasemos la definición de estructura de sector:
// runtime/slice.go
type slice struct {
array unsafe.Pointer // 元素指针
len int // 长度
cap int // 容量
}
La estructura contiene punteros de datos subyacentes.
La diferencia entre makemap y makelice trae consigo una diferencia: cuando map y slice se usan como parámetros de función, la operación de map dentro de los parámetros de función afectará al mapa en sí; pero no para el sector (como se mencionó en el artículo anterior sobre el sector).
La razón principal: uno es un puntero ( *hmap
) y el otro es una estructura ( slice
). Todos los parámetros de función en el lenguaje Go se pasan por valor. Dentro de la función, los parámetros se copiarán localmente. *hmap
Después de copiar el puntero, todavía apunta al mismo mapa, por lo que la operación del mapa dentro de la función afectará los parámetros reales. Una vez copiado el segmento, se convertirá en un nuevo segmento y las operaciones realizadas en él no afectarán los parámetros reales.
Función hash
Un punto clave del mapa es la elección de la función hash. Cuando se inicia el programa, detectará si la CPU admite aes. Si es así, use aes hash, de lo contrario use memhash. Esto se alginit()
hace en la función, ubicada src/runtime/alg.go
en la ruta:.
La función hash tiene tipos cifrados y no cifrados.
El tipo cifrado se utiliza generalmente para cifrar datos, resúmenes digitales, etc. Los representantes típicos son md5, sha1, sha256, aes256, el
tipo no cifrado se utiliza generalmente para búsquedas. En el escenario de aplicación del mapa, se utiliza la búsqueda.
Hay dos puntos principales a considerar al elegir una función hash: rendimiento y probabilidad de colisión.
Hablamos de ello antes, la estructura que representa el tipo:
type _type struct {
size uintptr
ptrdata uintptr // size of memory prefix holding all pointers
hash uint32
tflag tflag
align uint8
fieldalign uint8
kind uint8
alg *typeAlg
gcdata *byte
str nameOff
ptrToThis typeOff
}
El alg
campo está relacionado con el hash, que es un puntero a la siguiente estructura:
// src/runtime/alg.go
type typeAlg struct {
// (ptr to object, seed) -> hash
hash func(unsafe.Pointer, uintptr) uintptr
// (ptr to object A, ptr to object B) -> ==?
equal func(unsafe.Pointer, unsafe.Pointer) bool
}
typeAlg contiene dos funciones: la función hash calcula el valor hash del tipo y la función igual calcula si los dos tipos son "iguales hash".
Para el tipo de cadena, sus funciones hash e igual son las siguientes:
func strhash(a unsafe.Pointer, h uintptr) uintptr {
x := (*stringStruct)(a)
return memhash(x.str, h, uintptr(x.len))
}
func strequal(p, q unsafe.Pointer) bool {
return *(*string)(p) == *(*string)(q)
}
Según el tipo de clave, el campo alg de la estructura _type se configurará con las funciones hash e igual del tipo correspondiente.
proceso de posicionamiento clave
Después de realizar el hash de la clave, se obtiene el valor hash, con un total de 64 bits (las máquinas de 64 bits y las máquinas de 32 bits no se discutirán. Ahora la corriente principal son las máquinas de 64 bits). Al calcular en qué depósito caerá en, sólo se utilizan los últimos bits B. ¿Recuerdas que B mencionó antes? Si B = 5, entonces el número de depósitos, es decir, la longitud de la matriz de depósitos es 2 ^ 5 = 32.
Por ejemplo, después de calcular una clave mediante una función hash, el resultado hash es:
10010111 | 000011110110110010001111001010100010010110010101010 │ 01010
Utilice los últimos 5 bits, es decir 01010
, el valor es 10, que es el depósito número 10. Esta operación es en realidad una operación restante, pero la operación restante es demasiado costosa, por lo que la implementación del código utiliza operaciones de bits en su lugar.
Luego use los 8 bits superiores del valor hash para encontrar la ubicación de esta clave en el depósito. Esto busca una clave existente. Al principio no hay ninguna llave en el depósito, y la llave recién agregada encontrará la primera ranura vacía y la colocará.
El número de depósitos es el número de depósito. Cuando dos claves diferentes caen en el mismo depósito, se produce un conflicto de hash. El método de resolución de conflictos es utilizar el método de lista vinculada: en el depósito, busque el primer espacio vacío de adelante hacia atrás. De esta manera, cuando busque una determinada clave, primero busque el depósito correspondiente y luego recorra las claves en el depósito.
Aquí hay una referencia a una imagen en el blog de Github de Cao Da. La imagen original es una imagen ASCII, que está llena de sabor geek. Puede encontrar el blog de Cao Da en los materiales de referencia. Recomiendo a todos que echen un vistazo.
En la figura anterior, se supone que B = 5, por lo que el número total de depósitos es 2^5 = 32. Primero, calcule el hash de la clave que se va a encontrar. Utilice los 5 bits inferiores 00110
para encontrar el cubo correspondiente número 6. Utilice los 8 bits superiores 10010111
, que corresponden al decimal 151. Encuentre la clave con un valor tophash (hash HOB) de 151 en el cubo número 6 y encuéntrelo en la ranura 2, y todo el proceso de búsqueda habrá terminado.
Si no se encuentra en el depósito y el desbordamiento no está vacío, continuará buscando en el depósito de desbordamiento hasta que se encuentre o se hayan buscado todas las ranuras de claves, incluidos todos los depósitos de desbordamiento.
Echemos un vistazo al código fuente, ¡jaja! A través del lenguaje ensamblador podemos ver que la función subyacente para encontrar una determinada clave es mapacess
una serie de funciones, las funciones de las funciones son similares y las diferencias se discutirán en la siguiente sección. Aquí miramos directamente mapacess1
la función:
func mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer {
// ……
// 如果 h 什么都没有,返回零值
if h == nil || h.count == 0 {
return unsafe.Pointer(&zeroVal[0])
}
// 写和读冲突
if h.flags&hashWriting != 0 {
throw("concurrent map read and map write")
}
// 不同类型 key 使用的 hash 算法在编译期确定
alg := t.key.alg
// 计算哈希值,并且加入 hash0 引入随机性
hash := alg.hash(key, uintptr(h.hash0))
// 比如 B=5,那 m 就是31,二进制是全 1
// 求 bucket num 时,将 hash 与 m 相与,
// 达到 bucket num 由 hash 的低 8 位决定的效果
m := uintptr(1)<<h.B - 1
// b 就是 bucket 的地址
b := (*bmap)(add(h.buckets, (hash&m)*uintptr(t.bucketsize)))
// oldbuckets 不为 nil,说明发生了扩容
if c := h.oldbuckets; c != nil {
// 如果不是同 size 扩容(看后面扩容的内容)
// 对应条件 1 的解决方案
if !h.sameSizeGrow() {
// 新 bucket 数量是老的 2 倍
m >>= 1
}
// 求出 key 在老的 map 中的 bucket 位置
oldb := (*bmap)(add(c, (hash&m)*uintptr(t.bucketsize)))
// 如果 oldb 没有搬迁到新的 bucket
// 那就在老的 bucket 中寻找
if !evacuated(oldb) {
b = oldb
}
}
// 计算出高 8 位的 hash
// 相当于右移 56 位,只取高8位
top := uint8(hash >> (sys.PtrSize*8 - 8))
// 增加一个 minTopHash
if top < minTopHash {
top += minTopHash
}
for {
// 遍历 bucket 的 8 个位置
for i := uintptr(0); i < bucketCnt; i++ {
// tophash 不匹配,继续
if b.tophash[i] != top {
continue
}
// tophash 匹配,定位到 key 的位置
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
// key 是指针
if t.indirectkey {
// 解引用
k = *((*unsafe.Pointer)(k))
}
// 如果 key 相等
if alg.equal(key, k) {
// 定位到 value 的位置
v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
// value 解引用
if t.indirectvalue {
v = *((*unsafe.Pointer)(v))
}
return v
}
}
// bucket 找完(还没找到),继续到 overflow bucket 里找
b = b.overflow(t)
// overflow bucket 也找完了,说明没有目标 key
// 返回零值
if b == nil {
return unsafe.Pointer(&zeroVal[0])
}
}
}
La función devuelve el puntero de h [clave]. Si no existe tal clave en h, devolverá un valor cero del tipo de clave correspondiente y no devolverá nil.
El código es relativamente sencillo en general y no hay nada difícil de entender. Simplemente siga los comentarios anteriores para entender paso a paso.
Aquí, hablemos sobre el método para localizar la clave y el valor y cómo escribir el ciclo completo.
// key 定位公式
k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize))
// value 定位公式
v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.valuesize))
b es la dirección de bmap. Aquí, bmap sigue siendo una estructura definida en el código fuente. Solo contiene una matriz tophash. La estructura expandida por el compilador solo contiene campos de clave, valor y desbordamiento. dataOffset es el desplazamiento de la clave en relación con la dirección inicial de bmap:
dataOffset = unsafe.Offsetof(struct {
b bmap
v int64
}{}.v)
Por lo tanto, la dirección inicial de la clave en el depósito no es segura. Puntero (b) + dataOffset. La dirección de la i-ésima clave abarcará el tamaño de i claves sobre esta base; y también sabemos que la dirección del valor es después de todas las claves, por lo que a la dirección del i-ésimo valor también se le deben sumar los desplazamientos de todas las llaves.. Después de comprender esto, las fórmulas de posicionamiento de clave y valor anteriores son fáciles de entender.
Hablemos del método de escritura de todo el gran bucle: la capa más externa es un bucle infinito.
b = b.overflow(t)
Recorrer todos los depósitos, lo que equivale a una lista vinculada de depósitos.
Cuando se ubica un depósito específico, el bucle interno atraviesa todas las celdas del depósito, o todas las ranuras, es decir, bucketCnt = 8 ranuras. Todo el proceso del ciclo:
Hablemos nuevamente de minTopHash: cuando el valor de tophash de una celda es menor que minTopHash, marca el estado de migración de esta celda. Debido a que este valor de estado se coloca en la matriz tophash, para distinguirlo del valor hash normal, se incrementará el valor hash calculado por la clave: minTopHash. Esto distingue entre valores hash superiores normales y valores hash que representan el estado.
Los siguientes estados caracterizan la situación del cubo:
// 空的 cell,也是初始时 bucket 的状态
empty = 0
// 空的 cell,表示 cell 已经被迁移到新的 bucket
evacuatedEmpty = 1
// key,value 已经搬迁完毕,但是 key 都在新 bucket 前半部分,
// 后面扩容部分会再讲到。
evacuatedX = 2
// 同上,key 在后半部分
evacuatedY = 3
// tophash 的最小正常值
minTopHash = 4
La función utilizada en el código fuente para determinar si el depósito ha sido reubicado:
func evacuated(b *bmap) bool {
h := b.tophash[0]
return h > empty && h < minTopHash
}
Solo se toma el primer valor de la matriz tophash para determinar si está entre 0 y 4. Comparando las constantes anteriores, cuando el hash superior es uno de los tres valores ,,, significa que todas las claves en este depósito se han movido al nuevo depósito evacuatedEmpty
.evacuatedX
evacuatedY