Привет. Сегодня рассмотрим такую интересную структуру данных как hashmap, а именно ее реализацию в Go. Вкратце разберем что такое hashmap, как это выглядит под капотом Go 1.19. Посмотрим отличия реализации с Java и Python. Реализуем hashmap из-под капота с помощью дженериков.

Что такое hashmap

Для тех, кто еще не знаком с hashmap, прикладываю информацию из Википедии:
Hashmap - это структура данных, которая позволяет хранить пары ключ-значение. Ключи в hashmap уникальные. Можно представить как обычный массив, в котором для индекса можем использовать не только целые числа, но и, например, строки.

Сложность:

Операция

Средняя

Худшая

Вставка

O(1)

O(n)

Поиск

O(1)

O(n)

Удаление

O(1)

O(n)

Память

O(n)

O(n)

При углублении в реализацию такой структуры данных можно встретить следующие слова: хэш-функция, коллизии, бакеты, фактор заполненности. Разберемся что они значат:

  • Хэш-функция(Hash function). Под ней понимают функцию, которая принимает значение (ключ) неопределенного размера и возвращает значение фиксированной длины. В случае c Go она возвращает uint64. Одно из главных свойств - стабильность. Для одного и того же переданного значения она должна возвращать один и тот же результат;

  • Бакет(Bucket/Slot). Так называемая структура данных, в которой хранятся ключи и значения в мапе. В некоторых реализациях hashmap в одном бакете хранится одно значение, а в других - несколько. Например, в Go данные внутри бакета хранятся в массиве, и в одном бакете может быть до восьми элементов;

  • Коллизия (Collision). Так как хэш-функция не идеальна, передав в нее два разных значения мы можем получить один и тот же результат. В случае с бакетами нам нужно два разных значения положить в один и тот же бакет. Это называется коллизией. Для реализации hashmap необходимо иметь алгоритм их разрешения. Существует несколько таких алгоритмов (стратегий):

    • Closed addressing. Храним элементы с одинаковым хэшем с помощью дополнительных структур данных, таких как: связный список, двоичное дерево, массив и др. Используется в следующих языках: Go, Java, Scala;

    • Open addressing. В бакете хранится только одно значение. При возникновении коллизии выбирается следующий свободный бакет по какой-либо формуле. Такая стратегия используется в Python, Ruby, Rust, C++ и др;

    • Perfect hashing. Выбирается такая хэш-функция, при которой не будет коллизий. Подбирается для статичного, заранее известного набора ключей.

  • Фактор заполненности мапы (Overload factor). Это число (порог), превысив которое считается, что нужно увеличить количество бакетов (обычно вдвое) для сохранения константной скорости O(1)

Как hashmap реализована в Go

Упрощенный вариант работы hashmap в Go.
Упрощенный вариант работы hashmap в Go.

Рассмотрим по шагам упрощенный принцип работы hashmap. Для примера будем хранить оценки фильмов в формате название-оценка:

  • Передаем ключ "The Matrix" в хэш-функцию. Получаем uint64 - 18002143618149980261;

  • Вычисляем маску для наших бакетов. Она равна n-1, где n - количество бакетов. В примере 4 бакета, а маска равна 3;

  • Вычисляем номер бакета, в котором сохраним наше значение. Для этого используем битовое "и": hash & mask == 18002143618149980261 & 3 == 01 & 11(отбросили нули) = 01, что рано 1 в десятичной системе счисления;

  • Идем в бакет по индексу 1 и перебором проверяем массив на наличие нашего ключа. Если находим совпадение по ключу, то перезаписываем значение, иначе добавляем в первое свободное место;

Под капотом структуры мапы и бакета выглядят так:
runtime/map.go

// A header for a Go map.
type hmap struct {
	count     int // размер мапы. используется функцией len()
	flags     uint8
	B         uint8  // log_2 количества бакетов. Для 8 бакетов B=3, для 16 B=4 и так далее.
	noverflow uint16 // примерное число переполненных бакетов
	hash0     uint32 // seed для хэш-функции, генерируется при создании мапы. нужен для рандомизации хэш-функции

	buckets    unsafe.Pointer // ссылка на массив из 2^B бакетов; nil, если count==0
	oldbuckets unsafe.Pointer // ссылка на массив предыдущих бакетов. в процессе роста мапы здесь будет массив старых бакетов, откуда потихоньку будут переноситься значения в новые бакеты.
	nevacuate  uintptr        // количество "эвакуированных" бакетов.

	extra *mapextra // опциональные поля
}

// A bucket for a Go map.
type bmap struct {
	tophash [bucketCnt]uint8 // массив tophash
	// После массива tophash идет массив размера bucketCnt ключей и массив размера bucketCnt элементов.
}

В структуре бакета явно не описаны поля ключей, значений и ссылка на overflow бакет. Для получения этих значений используется адресная арифметика через unsafe.Pointer.

Интересности реализации:

  • В качестве ключа можно использовать любой тип данных для которого определена операция сравнения. Например, можно использовать структуру с тем же условием для всех ее полей;

  • При отсутствии элемента возвращается нулевое значение для типа. Вторым параметром можно получить флаг наличия элемента по ключу;

  • Нельзя получить адрес элемента. Потому что при росте мапы оно переедет в другой бакет и адрес у него, соответственно, поменяется;

  • Мапа не безопасна для конкурентного использования. Для этого можно использовать обертку из sync.Map или мьютекс;

  • Порядок итерации не сохраняется. При каждой новой итерации мапы последовательность возвращаемых элементов может отличаться. Под капотом каждый раз выбирается рандомный бакет, с которого начинается итерация. Для сохранения нужного порядка придется сохранять ключи в отдельном массиве и итерироваться по нему;

  • При каждом создании мапы генерируется seed для рандомизации хэш-функции. Это сделано для безопасности, так как зная хэш-функцию можно подобрать такие ключи, что все значения попадут в один бакет и мы получим линейную скорость поиска;

  • При коллизиях используется стратегия сlosed addressing. Мы перебираем все ячейки бакета (их 8) и ищем первое свободное место;

  • OverloadFactor равен 6.5 (~81% заполненности бакетов). Когда бакеты в среднем заполнены больше чем на 6.5 элементов, тригерится рост мапы, и все элементы перемещаются в новые бакеты, которых создается в два раза больше.

  • При росте элементы переносятся в новые бакеты постепенно, а не все сразу;

Некоторые отличия от реализаций в других языках

Python 3.6+. Подробнее можно посмотреть в очень интересном (правда) докладе Raymond Hettinger Modern Python Dictionaries (2017)

  • Сохраняется последовательность вставки;

  • При коллизиях свободный бакет ищется с помощью стратегии open addressing. Для оптимизации поиска свободного бакета используются следующие формулы: j = ((5*j) + 1) mod 2**i и j = (5*j) + 1 + perturb;

  • Данные хранятся отдельно от бакетов. Сами бакеты используются только как указатели (индексы) на данные. Выглядит это примерно так:

  indices =  [None, 1, None, None, None, 0, None, 2]
  entries =  [[-9092791511155847987, 'timmy', 'red'],
              [-8522787127447073495, 'barry', 'green'],
              [-6480567542315338377, 'guido', 'blue']]
  • Рядом с данными хранится полный хэш. Это позволяет не пересчитывать его при росте мапы.

  • LoadFactor - 2/3 = ~66%

Java. Разбор можно почитать в статье Внутренняя работа HashMap в Java:

  • При коллизиях используется стратегия сlosed addressing, но вместо массивов используется связный список. Также когда длина списка больше восьми он превращается в TreeMap. Это дает скорость поиска O(logn)вместо O(n)как в случае со связным списком или массивом;

  • Все ключи должны быть объектами. Для этого примитивные типы (boolean, int, char, float и т.д) должны быть сконвертированы в объекты через boxing;

  • LoadFactor по умолчанию - 75%. При создании мапы можно указать свое значение параметра.

Также небольшое сравнение с реализациями Java и C++ можно посмотреть у Dave Cheney.

Кодирование

Реализуем базово hashmap из исходников Go 1.19. Не будем учитывать рост мапы, конкурентное обращение и возможность использовать разные типы для ключей.

Начнем с ведер

Определим структуру бакета.

Нам нужно два массива для ключей и значений, и ссылка на бакет для хранения дополнительных значений в случае переполнения текущего. Для оптимизации поиска храним массив первых восьми бит от хэша ключа, которые называются tophash.

const bucketSize = 8 // размер массивов внутри бакета

type bucket[T any] struct {
	tophash [bucketSize]uint8 // массив первых восьми бит от хэша ключа; некоторые значения используем как флаги, подробности ниже.

	keys   [bucketSize]string // массив ключей
	values [bucketSize]T // массив значений

	overflow *bucket[T] // ссылка на бакет в случае переполнения текущего
}

Про tophash

В массиве с tophash мы резервируем несколько значений для дальнейшего использования. Ко всем значениям tophash, которые меньше minTopHash будем прибавлять его.

const (
	emptyRest  = 0 // эта и все следующие ячейки с бОльшим индексом пустые
	emptyCell  = 1 // ячейка пустая
	minTopHash = 3 // минимальное значение tophash означающее что в ячейке есть значение
)

По умолчанию, в Go массив заполнен нулевыми значениями, соответственно для tophash массива, который типа uint8, будут нули. При добавлении или получении элемента из бакета в первую очередь ориентируемся на массив tophash, а не массив ключей. Это позволяет оптимизировать поиск внутри бакета.

Добавляем элемент в мапу

Алгоритм добавления элемента:

  • В цикле ищем совпадения по tophash и ключу или свободное место. Сохраняем данные и возвращаем соответствующий флаг для подсчета элементов в мапе.

  • Если не нашли нужного места, то повторяем упражнения на overflow бакете.

// Добавляем значение в бакет.
// Если переданные ключ уже присутствует в бакете, то значение перезапишется.
// Если бакет переполнен, то значение сохраняется в бакете overflow.
func (b *bucket[T]) Put(key string, topHash uint8, value T) (isAdded bool) {
	var insertIdx *int

	for i := range b.tophash {
		// сравниваем tophash а не ключ, т.к. это позволяет нам использовать флаги и это работает быстрее
		if b.tophash[i] != topHash {
			if b.tophash[i] == emptyRest {
				insertIdx = new(int)
				*insertIdx = i
				break
			}

			if insertIdx == nil && isCellEmpty(b.tophash[i]) {
				insertIdx = new(int)
				*insertIdx = i
			}
			continue
		}

		// tophash значения не уникальны, по этому при совпадении проверяем дополнительно и сам ключ
		if b.keys[i] != key {
			continue
		}

		b.values[i] = value
		return false
	}
  
  	// проверяем overflow бакет.
	if insertIdx == nil || b.overflow != nil {
		if b.overflow == nil {
			b.overflow = &Bucket[T]{}
		}

		return b.overflow.Put(key, topHash, value)
	}

	// сохраняем ключ, значение и tophash
	b.keys[*insertIdx] = key
	b.values[*insertIdx] = value
	b.tophash[*insertIdx] = topHash

	// возвращаем признак успеха. по нему калькулируем количество элементов в мапе.
	return true
}

// проверяем что значение tophash <= чем зарезервированное значение для пустой ячейки
func isCellEmpty(val uint8) bool {
	return val <= emptyCell
}

Получаем элемент

Алгоритм поиска элемента такой же как и в методе Put. Дополнительно возвращаем флаг наличия элемента в бакете.

func (b bucket[T]) Get(key string, topHash uint8) (T, bool) {
	for i := range b.tophash {
		if b.tophash[i] != topHash {
			// константа означает что все следующие ячейки пустые -- выходим из цикла.
			if b.tophash[i] == emptyRest {
				break
			}
			continue
		}

		if !isCellEmpty(b.tophash[i]) && b.keys[i] == key {
			return b.values[i], true
		}
	}

	// проверим бакет overflow
	if b.overflow != nil {
		return b.overflow.Get(key, topHash)
	}

	return *new(T), false
}

Удаляем элемент

Вместо фактического удаления просто помечаем ячейку пустой.

// Delete - удаляет элемент по переданному ключу
func (b *bucket[T]) Delete(key string, topHash uint8) (deleted bool) {
	for i := range b.tophash {
		if b.tophash[i] != topHash {
			// если встречаем флаг все след. ячейки пустые, то просто выходим из функции
			if b.tophash[i] == emptyRest {
				return false
			}
			continue
		}

		// дополнительно проверяем совпадение по ключу, т.к. tophash не уникальный
		if b.keys[i] == key {
			b.tophash[i] = emptyCell
			return true
		}
	}

	// проверяем overflow бакет
	if b.overflow != nil {
		return b.overflow.Delete(key, topHash)
	}

	return false
}

Структура мапы

Храним размер мапы, количество бакетов, seed для хэш-функции и слайс самих бакетов.

// hmap - структура мапы
type hmap[T any] struct {
	len  int // количество реальных значений в мапе
	b    uint8 // log_2 от количества бакетов
	seed uint64 // начальный хэш

	buckets []Bucket[T] // слайс бакетов
}

// интерфейс hashmap, методы которого реализуем
type Hashmap[T any] interface {
	Get(key string) T
	Get2(key string) (T, bool)
	Put(key string, value T)
	Delete(key string)
	Range(f func(k string, v T) bool)
	Len() int
}

При инициализации генерируем seed и создаем нужное количество бакетов.

const (
	// Максимальное среднее количество элементов в бакете которое тригерит рост мапы - 6.5
	// Представлен как loadFactorNum/loadFactorDen, для того чтобы производить вычисления через int.
	loadFactorNum = 13
	loadFactorDen = 2

    ptrSize = 4 << (^uintptr(0) >> 63) // размер указателя. используется для вычисления tophash
)

func New[T any](size int) Hashmap[T] {
	h := new(hmap[T])

	h.seed = generateSeed()

	// тот самый log_2 от количества элементов
	B := uint8(0)
	// увеличиваем B пока средняя заполненность бакетов > loadFactor 
	for overLoadFactor(size, B) {
		B++
	}
	h.b = B

	h.buckets = make([]bucket[T], bucketsNum(h.b))

	return h
}

// функция определяет будут ли бакеты, для size количества элементов, загружены больше чем loadFactor в среднем.
// этой же функцией потом будем определять нужно ли начать рост мапы
func overLoadFactor(size int, b uint8) bool {
	return size > bucketSize && uint64(size) > loadFactorNum*(bucketsNum(b)/loadFactorDen)
}

// здесь b - log_2 от количества элементов
func bucketsNum(b uint8) uint64 {
	return 1 << b
}

Get/Set/Delete

Основную логику мы заложили в модели бакета. Остается только вычислить tophash и номер бакета. Для put/delete учитываем изменение количества хранимых элементов.

// Get - возвращает значение по ключу key
// вернется нулевое значение типа <T> если по ключу key не существует значение.
func (h hmap[T]) Get(key string) T {
	tophash, targetBucket := h.locateBucket(key)

    v, _ := h.buckets[targetBucket].Get(key, tophash)
    return v
}

// Get2 - возвращает значение по ключу key и флаг наличия этого значения в мапе
// вернет нулевое значение типа <T> и false если по ключу key не существует значения.
func (h hmap[T]) Get2(key string) (T, bool) {
	tophash, targetBucket := h.locateBucket(key)

    return h.buckets[targetBucket].Get(key, tophash)
}

// Put - добавляет элемент в мапу
func (h hmap[T]) Put(key string, value T) {
	tophash, targetBucket := h.locateBucket(key)

	if h.buckets[targetBucket].Put(key, tophash, value) {
		h.len++
	}
}

// Delete - удаляем элемент из мапы по переданному ключу
func (h hmap[T]) Delete(key string) {
	tophash, targetBucket := h.locateBucket(key)
	if h.buckets[targetBucket].Delete(key, tophash){
		h.len--
	}
}

Вычисление номера бакета и tophash:

// locateBucket - возвращает индекс бакета и tophash
func (h hmap[T]) locateBucket(key string) (tophash uint8, targetBucket uint64) {
	hash := hash(key, h.seed)
	tophash = topHash(hash)
	mask := bucketMask(h.b)

	targetBucket = hash & mask

	return tophash, targetBucket
}

// возвращает первые 8 бит от значения
func topHash(val uint64) uint8 {
	tophash := uint8(val >> (ptrSize*8 - 8))
	if tophash < minTopHash {
		tophash += minTopHash
	}
	return tophash
}

// bucketMask возвращает маску бакетов
func bucketMask(b uint8) uint64 {
	return bucketsNum(b) - 1
}

// для хэширования используется алгоритм wyhash
func hash(key string, seed uint64) uint64 {
	return wyhash.Hash([]byte(key), seed)
}

Итерация

Итерацию сделаем через callback функцию. Вместо оператора break будем использовать флаг возвращаемый callback функцией. Для рандомизации последовательности значений при итерации будем так же начинать со случайного бакета.

// Range - итерация по всем значениям с вызовом переданной функции
func (m hmap[T]) Range(f func(key string, value T) bool) {
	for i := range m.randRangeSequence() {
		bucket := &m.buckets[i]
		for bucket != nil {
			for j, th := range bucket.tophash {
				// идет к след. бакету если видим флаг о пустых ячейках после индекса j
				if th == emptyRest {
					continue
				}
				// если в ячейке есть значение, то передаем в функцию
				if th >= minTopHash {
					// прерываем итерацию если получили false
					if !f(bucket.keys[j], bucket.values[j]) {
						return
					}
				}
			}
			// проверяем overflow бакеты
			bucket = bucket.overflow
		}
	}
}

// формируем последовательность индексов для начала поиска со случайного бакета.
func (m hmap[T]) randRangeSequence() []int {
	i := rand.Intn(len(m.buckets))

	seq := make([]int, 0, len(m.buckets))
	for len(seq) != len(m.buckets) {
		seq = append(seq, i)
		i++
		if i >= len(m.buckets) {
			i = 0
		}
	}

	return seq
}

Тесты, бенчмарки

Исходники тестов можно посмотреть на Github. Ради интереса напишем бенчмарки для сравнения с мапой из коробки.
При сравнении методов Put и Get в пределах выделенной памяти разница достигает 20%:

var sizes = []int{128, 1024, 8192}
func BenchmarkGet(b *testing.B) {
	for _, n := range sizes {
		// выделяем память под n элементов
		mm := New[int64](n)
		for i := 0; i < n; i++ {
			mm.Put(fmt.Sprintf("key__%d", i), int64(i)*2)
		}

		b.Run(fmt.Sprintf("generic-map_%d", n), func(b *testing.B) {
			var got int64
			j := 0
			for i := 0; i < b.N; i++ {
				if j > n {
					j = 0
				}
				got = mm.Get(fmt.Sprintf("key__%d", j))
				j++
			}
			_ = got
		})
	}
	// такой же код для std мапы 
}
go test . -run=^$ -bench . -benchmem
goos: darwin
goarch: arm64
pkg: github.com/w1kend/go-map
BenchmarkGet/generic-map_128-8          12884936                85.63 ns/op            8 B/op          1 allocs/op
BenchmarkGet/generic-map_1024-8         13559966                87.57 ns/op           14 B/op          1 allocs/op
BenchmarkGet/generic-map_8192-8         11720404               101.1 ns/op            22 B/op          1 allocs/op
BenchmarkGet/std-map_128-8              17141264                70.01 ns/op            8 B/op          1 allocs/op
BenchmarkGet/std-map_1024-8             16442701                72.42 ns/op           14 B/op          1 allocs/op
BenchmarkGet/std-map_8192-8             14521720                81.84 ns/op           22 B/op          1 allocs/op
BenchmarkPut/generic-map_128-8          16028941                74.27 ns/op            8 B/op          1 allocs/op
BenchmarkPut/generic-map_1024-8         15961150                75.22 ns/op           14 B/op          1 allocs/op
BenchmarkPut/generic-map_8192-8         12941882                85.13 ns/op           22 B/op          1 allocs/op
BenchmarkPut/std-map_128-8              16949132                70.37 ns/op            8 B/op          1 allocs/op
BenchmarkPut/std-map_1024-8             16461930                71.99 ns/op           14 B/op          1 allocs/op
BenchmarkPut/std-map_8192-8             14040560                82.28 ns/op           22 B/op          1 allocs/op

Переполнение мапы. Выделяем память на 1000 элементов и заполняем до 10 000, 100 000 и 1 000 000 элементов:

func BenchmarkPutWithOverflow(b *testing.B) {
	startSize := 1_000
	targetSize := []int{10_000, 100_000, 1_000_000}

	for _, n := range targetSize {
		mm := New[int64](startSize)
		b.Run(fmt.Sprintf("generic-map-with-overflow__%d", n), func(b *testing.B) {
			j := 0
			for i := 0; i < b.N; i++ {
				if j > n {
					j = 0
				}
				mm.Put(fmt.Sprintf("key__%d", j), int64(j))
				j++
			}
		})
	}

	for _, n := range targetSize {
		stdm := make(map[string]int64, startSize)
		b.Run(fmt.Sprintf("std-map-with-evacuation__%d", n), func(b *testing.B) {
			j := 0
			for i := 0; i < b.N; i++ {
				if j > n {
					j = 0
				}
				stdm[fmt.Sprintf("key__%d", j)] = int64(j)
				j++
			}
		})
	}
}

go test . -run=^$ -bench ^BenchmarkPutWithOverflow$ -benchmem
goos: darwin
goarch: arm64
pkg: github.com/w1kend/go-map
BenchmarkPutWithOverflow/generic-map-with-overflow__10000-8             11472094               104.9 ns/op            23 B/op          1 allocs/op
BenchmarkPutWithOverflow/generic-map-with-overflow__100000-8             3440781               344.7 ns/op            23 B/op          1 allocs/op
BenchmarkPutWithOverflow/generic-map-with-overflow__1000000-8            1000000              8376 ns/op              57 B/op          3 allocs/op
BenchmarkPutWithOverflow/std-map-with-evacuation__10000-8               14312827                83.43 ns/op           23 B/op          1 allocs/op
BenchmarkPutWithOverflow/std-map-with-evacuation__100000-8              12691999                90.62 ns/op           23 B/op          1 allocs/op
BenchmarkPutWithOverflow/std-map-with-evacuation__1000000-8              7283215               168.7 ns/op            23 B/op          1 allocs/op

Пока что такой бенчмарк не особо информативный, так как, очевидно, что результаты будут хуже. Интересно на сколько:

Увеличение элементов с 1000 до

Std map

Generic map

Разница

10 000

83.43 ns/op

104.9 ns/op

1.25

100 000

90.62 ns/op

344.7 ns/op

3.8

1 000 000

168.7 ns/op

8376 ns/op

49.65


На этом все, спасибо за внимание. Ставьте лайки, подписывайтесь на гитхаб.
В следующей части:

  • Научим мапу расти;

  • Замерим производительность при росте;

  • Добавим дженерик ключи;

  • Подумаем про конкурентное обращение.

И, наверное, стоит добавить что смысл данной статьи именно показать как hashmap реализована в Go под капотом, и показать более читаемый пример реализации на самом Go.

Ссылки

Исходники
Картинки гоферов
Гоферы от Ashley McNamara

Go:
GopherCon 2016: Keith Randall - Inside the Map Implementation
How the Go runtime implements maps efficiently (without generics)

Python:
Raymond Hettinger Modern Python Dictionaries A confluence of a dozen great ideas PyCon 2017 Raymond Hettinger. More compact dictionaries with faster iteration

Java:
Внутренняя работа HashMap в Java
The Java HashMap Under the Hood

Лекция cs166 stanford про Liner probing
An Analysis of Hash Map Implementations in Popular Languages

Комментарии (7)


  1. xakep666
    12.12.2022 09:45

    Вот тут https://github.com/tidwall/hashmap/ есть реализация на основе открытой адресации. Судя по бенчмаркам, оно иногда даже быстрее, чем стандартная мапа. Возможно, это связано с тем, что при последовательном расположении элементов кеш процессора работает эффективнее.


    1. wikendp Автор
      12.12.2022 23:14

      Да, якобы и правда быстрее. В пределах тех синтетических тестов) Попробую как-нибудь разобраться из-за чего конкретно оно работает быстрее.


  1. Usul
    12.12.2022 12:40

    if insertIdx == nil && isCellEmpty(b.tophash[i]) {
        insertIdx = new(int)
        *insertIdx = i
    }

    Вот здесь, в bucket.Put(), у вас случаем break не пропущен?


    1. wikendp Автор
      12.12.2022 12:57

      Если кратко - нет)

      m.Put("key1", 1) // массив tophash [val1, 0, 0, ...]
      m.Put("key2", 2) // [val1, val2, 0, 0, ...]
      m.Delete("key1") // [1, val2, 0, 0, ... ]
      m.Put("key2", 3) 

      Например, добавляем два элемента и удаляем первый из них. У нас в массиве tophash появится "дыра" со значением 1 == emptyCell. По этому значению мы понимаем что ячейка пустая, но положить сразу туда значение мы не можем, т.к. возможно значение для key2 находится дальше. По этому мы просто запоминаем первую свободную ячейку на случай когда в конце итерации мы не нашли совпадение по переданному ключу.


      1. Usul
        12.12.2022 13:13
        +1

        Понятно, спасибо. А что будет, если "key2" лежит не в начальном бакете, а в overflow? Вы же не проверяете overflow, если ячейка уже запомнена.


        1. wikendp Автор
          12.12.2022 13:41

          Да, в таком случае и правда не сходим в overflow. Будет два значения по одному ключу.
          Спасибо, поправлю.


  1. margaritatxt
    12.12.2022 19:11

    Всё по полочкам, супер. Спасибо!