Хеш-таблицы: устройство и разрешение коллизий

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

Хеш-таблица даёт поиск за постоянное время — то, чего не умеет ни массив, ни дерево. За этим свойством стоит простая идея: вычислить по ключу адрес ячейки вместо того, чтобы искать её перебором.

  индекс = hash(ключ) mod размер_таблицы

Сложность операций:
  в среднем         вставка, поиск, удаление — O(1)
  в худшем случае   O(n), когда все ключи попали в одну ячейку

Память: O(n), но с запасом — таблицу держат заполненной не полностью.

Требования к хеш-функции

def hash_djb2(s: str) -> int:
    """Классическая функция Бернштейна: множитель 33 даёт хорошее рассеивание."""
    h = 5381
    for ch in s:
        h = (h * 33 + ord(ch)) & 0xFFFFFFFF
    return h

def hash_bad(s: str) -> int:
    """Плохая функция: сумма кодов. Анаграммы дают один хеш."""
    return sum(ord(c) for c in s)

print(hash_bad('abc'), hash_bad('cba'))    # одинаково — коллизия гарантирована
print(hash_djb2('abc'), hash_djb2('cba'))  # разные значения
Размер таблицы берут простым числом или степенью двойки в зависимости от хеш-функции. При размере, кратном множителю функции, младшие биты хеша перестают участвовать в выборе ячейки — распределение резко ухудшается.

Метод цепочек

class HashTable:
    def __init__(self, capacity=16):
        self.capacity = capacity
        self.size = 0
        self.buckets = [[] for _ in range(capacity)]

    def _index(self, key):
        return hash(key) % self.capacity

    def put(self, key, value):
        bucket = self.buckets[self._index(key)]
        for i, (k, _) in enumerate(bucket):
            if k == key:
                bucket[i] = (key, value)      # обновление
                return
        bucket.append((key, value))
        self.size += 1
        if self.size / self.capacity > 0.75:  # порог заполнения
            self._rehash()

    def get(self, key, default=None):
        for k, v in self.buckets[self._index(key)]:
            if k == key:
                return v
        return default

    def remove(self, key):
        bucket = self.buckets[self._index(key)]
        for i, (k, _) in enumerate(bucket):
            if k == key:
                bucket.pop(i)
                self.size -= 1
                return True
        return False

    def _rehash(self):
        old = self.buckets
        self.capacity *= 2
        self.buckets = [[] for _ in range(self.capacity)]
        self.size = 0
        for bucket in old:
            for k, v in bucket:
                self.put(k, v)                # индексы пересчитываются заново

Открытая адресация

При коллизии ищут другую свободную ячейку по правилу пробирования:

  Линейное:      i = (h + k) mod m
     просто, но образуются длинные занятые участки — кластеризация

  Квадратичное:  i = (h + k²) mod m
     кластеризация меньше, но обходятся не все ячейки

  Двойное хеширование: i = (h₁ + k·h₂) mod m
     лучшее распределение, h₂ не должно давать ноль
ЦепочкиОткрытая адресация
ПамятьДополнительная на спискиТолько сама таблица
Коэффициент заполненияМожет превышать 1Строго меньше 1, порог 0,7
Кэш процессораХуже: переходы по указателямЛучше: данные рядом
УдалениеПростоеТребует пометки «удалено»
ДеградацияПлавнаяРезкая при заполнении выше 0,8
В открытой адресации нельзя просто освободить ячейку при удалении: разрыв в цепочке пробирования оборвёт поиск следующих элементов. Ставят специальную метку-надгробие, а её накопление со временем требует полного перестроения таблицы.

Коэффициент заполнения

  α = число_элементов / размер_таблицы

Среднее число сравнений при поиске:
  метод цепочек:        1 + α/2
  линейное пробирование: (1 + 1/(1−α)²)/2

При α = 0,9 линейное пробирование даёт около 50 сравнений,
метод цепочек — менее двух. Отсюда жёсткий порог рехеширования.

Что выбрать под задачу

СтруктураПоискУпорядоченностьКогда брать
Хеш-таблицаO(1) в среднемНетПоиск по точному ключу, кэш, подсчёт частот
Сбалансированное деревоO(log n)ЕстьНужны диапазоны, минимум, обход по порядку
Сортированный массивO(log n) поискомЕстьДанные почти не меняются
СписокO(n)НетМало элементов, важна простота

Типовые применения

from collections import Counter, defaultdict

text = 'алгоритм анализ алгоритм структура анализ алгоритм'
freq = Counter(text.split())
print(freq.most_common(2))     # [('алгоритм', 3), ('анализ', 2)]

# Группировка анаграмм: ключ — отсортированные буквы
groups = defaultdict(list)
for word in ['кот', 'ток', 'дом', 'мод', 'окт']:
    groups[''.join(sorted(word))].append(word)
print(list(groups.values()))

Что показать в курсовой

  1. Реализовать таблицу обоими методами разрешения коллизий.
  2. Сравнить число коллизий для хорошей и плохой хеш-функции на одинаковых данных.
  3. Построить график зависимости среднего числа сравнений от коэффициента заполнения.
  4. Замерить время поиска в хеш-таблице и в списке на 10, 1000 и 100 000 элементах.
  5. Показать эффект рехеширования: провал производительности в момент перестроения.

Частые вопросы

Почему сложность в худшем случае O(n)?

Если все ключи дают один индекс, таблица превращается в один список. На практике это возможно при подобранных специально ключах — так устраивают hash-flooding атаки, из-за которых в языках применяют рандомизацию хеша.

Можно ли использовать список как ключ словаря?

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

Зачем нужны деревья, если хеш-таблица быстрее?

Хеш-таблица не хранит порядок: она не ответит на вопросы «все ключи от 10 до 50» или «следующий по величине». Там, где нужен упорядоченный обход или диапазонный поиск, берут дерево.

Читайте также

Сделаем работу по этой теме

Опишите задачу — ответим в течение 15 минут в личных сообщениях ВКонтакте, назовём срок и цену. Предоплаты за оценку нет.

  • Оценка заявки бесплатно
  • Правки по замечаниям преподавателя
  • Работы по всем техническим и IT-дисциплинам

Нажимая кнопку, вы соглашаетесь на обработку указанных данных для ответа на заявку.