Files
hello-algo/ru/chapters/chapter_06.md
2026-03-25 16:54:42 +08:00

74 KiB
Raw Blame History

Хеш-таблицы

{width="3.8073009623797027in" height="4.927083333333333in"}

хеш-таблицы

Хеш-таблица реализует эффективный поиск элементов через установле- ние соответствия между ключом key и значением value. Более конкретно, передав ключ в хеш-таблицу, можно получить соответствующее значение за время O(1).

Пусть имеется n студентов, у каждого из которых есть имя и номер. Если нужно реализовать функцию «ввести номер студента и получить соответству- ющее имя», то можно использовать хеш-таблицу, как показано на рис. 6.1.

Рис. 6.1. Схематичное представление хеш-таблицы

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

  • Добавление элемента: достаточно добавить элемент в конец массива (списка) за время O(1).

  • Поиск элемента: так как массив (список) не упорядочен, необходимо просмотреть все элементы за время O(n).

  • Удаление элемента: сначала нужно найти элемент, а затем удалить его из массива (списка), понадобится время O(n).

Таблица 6.1. Сравнение эффективности поиска элементов

+-----------------------+--------------+----------------------+-------------------+ | | > Массив | > Связный список | > Хеш-таблица | +=======================+:============:+:====================:+:=================:+ | > Поиск элемента | > O(n) | > O(n) | > O(1) | +-----------------------+--------------+----------------------+-------------------+ | > Добавление элемента | > O(1) | > O(1) | > O(1) | +-----------------------+--------------+----------------------+-------------------+ | > Удаление элемента | > O(n) | > O(n) | > O(1) | +-----------------------+--------------+----------------------+-------------------+

Мы видим, что в хеш-таблице операции добавления, удаления и поис- ка имеют временную сложность O(1), что очень эффективно.

  1. Основные операции с хеш-таблицами

К основным операциям с хеш-таблицами относятся: инициализация, поиск, добавление и удаление пар ключ--значение. Ниже приведен пример кода.

# === File: hash_map.py ===

# Инициализация хеш-таблицы. hmap: dict = {}

# Операция добавления.

# Добавление пары ключ-значение (key, value) в хеш-таблицу. hmap[12836] = "Иван"

hmap[15937] = "Петр" hmap[16750] = "Владимир" hmap[13276] = "Максим" hmap[10583] = "Андрей"

# Операция поиска.

# Ввод ключа key в хеш-таблицу для получения значения value. name: str = hmap[15937]

# Операция удаления.

# Удаление пары ключ-значение (key, value) из хеш-таблицы. hmap.pop(10583)

Существует три распространенных способа обхода хеш-таблицы: обход пар ключ--значение, обход ключей и обход значений. Ниже приведен пример кода.

# === File: hash_map.py ===

# Обход хеш-таблицы.

# Обход пар ключ-значение key->value. for key, value in hmap.items():

print(key, "->", value) # Обход только ключей key. for key in hmap.keys():

print(key)

# Обход только значений value. for value in hmap.values():

print(value)

Простая реализация хеш-таблицы

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

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

Процесс вычисления хеш-функции для ключа включает следующие этапы:

  1. вычисление хеш-значения с помощью некоторого хеш-алгоритма hash();

  2. взятие остатка от деления хеш-значения на количество корзин capacity (длину массива) для получения индекса массива index, соответствующе- го ключу key.

index = hash(key) % capacity

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

Предположим, что длина массива capacity = 100, хеш-алгоритм hash(key) = key. Тогда хеш-функция будет иметь вид key % 100. На рис. 6.2 показан принцип ра- боты хеш-функции на примере ключа «номер» и значения «имя» для студента.

Ввод key

Индекс Массив

Вывод value

"Иван" "Иван"

"Петр"

Хеш-функция

"Яков" "Яков"

"Ира" "Ира"

"Аня"

(Каждая ячейка хранит одну пару ключ--значение)

Рис. 6.2. Принцип работы хеш-функции

В коде ниже реализуется простая хеш-таблица. Здесь key и value заключены в класс Pair для представления пары ключ--значение.

# === File: array_hash_map.py === class Pair:

""" Пара ключ-значение."""

def init (self, key: int, val: str): self.key = key

self.val = val

class ArrayHashMap:

""" Хеш-таблица на основе массива."""

def init (self):

""" Конструктор."""

# Инициализация массива, содержащего 100 корзин. self.buckets: list[Pair | None] = [None] * 100

def hash_func(self, key: int) -> int: """ Хеш-функция."""

index = key % 100 return index

def get(self, key: int) -> str: """ Операция поиска."""

index: int = self.hash_func(key) pair: Pair = self.buckets[index] if pair is None:

return None return pair.val

def put(self, key: int, val: str): """ Операция добавления.""" pair = Pair(key, val)

index: int = self.hash_func(key) self.buckets[index] = pair

def remove(self, key: int): """ Операция удаления."""

index: int = self.hash_func(key)

# Установка в None означает удаление. self.buckets[index] = None

def entry_set(self) -> list[Pair]:

""" Получение всех пар ключ-значение.""" result: list[Pair] = []

for pair in self.buckets: if pair is not None:

result.append(pair) return result

def key_set(self) -> list[int]: """ Получение всех ключей.""" result = []

for pair in self.buckets: if pair is not None:

result.append(pair.key) return result

def value_set(self) -> list[str]: """ Получение всех значений.""" result = []

for pair in self.buckets: if pair is not None:

result.append(pair.val) return result

def print(self):

""" Печать хеш-таблицы.""" for pair in self.buckets:

if pair is not None: print(pair.key, "->", pair.val)

Хеш-коллизии и расширение

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

Для хеш-функции в примере выше, если последние две цифры ключа совпа- дают, результат хеш-функции также совпадает. Например, при запросе студен- тов с номерами 12836 и 20336 мы получим:

12836 % 100 = 36

20336 % 100 = 36.

Оба номера указывают на одно и то же имя, что очевидно неверно, см. рис. 6.3. Такую ситуацию, когда несколько входов соответствуют одному вы- ходу, называют хеш-коллизией.

Ввод key

Индекс Массив Хеш-коллизия

Вывод value

"Иван"

Хеш-функция

"Петр"

"Яков"

"Иван"

"Иван"

"Ира"

"Аня"

(Каждая ячейка хранит одну пару ключ--значение)

Рис. 6.3. Пример хеш-коллизии

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

Как показано на рис. 6.4, до увеличения емкости пары ключ--значение (136, A)

и (236, D) попадали в одну корзину, а после увеличения емкости коллизия исчезла.

Увеличение хеш-таблицы

Рис. 6.4. Увеличение емкости хеш-таблицы

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

Коэффициент заполнения является важным понятием для хеш-таблицы. Он определяется как количество элементов в хеш-таблице, деленное на коли- чество корзин, и используется для оценки степени серьезности хеш-коллизий, а также часто служит условием для увеличения емкости хеш-таблицы. Например, в Java, когда коэффициент заполнения превышает 0.75, система увеличивает емкость хеш-таблицы в 2 раза.

хеш-коллизии

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

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

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

  1. улучшение структуры данных хеш-таблицы, чтобы она могла нор- мально функционировать при возникновении хеш-коллизий;

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

Основные методы улучшения структуры хеш-таблицы включают цепную адресацию и открытую адресацию.

Цепная адресация

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

Рис. 6.5. Хеш-таблица с цепной адресацией

Методы работы с хеш-таблицей, реализованной на основе цепной адреса- ции, изменяются следующим образом.

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

  • Добавление элемента: сначала с помощью хеш-функции осуществля- ется доступ к головному узлу списка, затем узел (пара ключ--значение) добавляется в список.

  • Удаление элемента: на основе результата хеш-функции осуществляется доступ к головному узлу списка, затем выполняется обход списка для по- иска целевого узла и его удаления.

Цепная адресация имеет следующие ограничения.

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

  • Снижение эффективности поиска: необходимо линейно обходить связный список для нахождения соответствующего элемента.

Ниже приведен простой пример реализации хеш-таблицы с цепной адреса- цией. Следует обратить внимание на следующие моменты.

  • Использование списка (динамического массива) вместо связного списка для упрощения кода. В данной конфигурации хеш-таблица (массив) со- держит несколько корзин, каждая из которых является списком.

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

# === File: hash_map_chaining.py === class HashMapChaining:

""" Хеш-таблица с цепной адресацией."""

def init (self):

""" Конструктор."""

self.size = 0 # Количество пар ключ-значение. self.capacity = 4 # Вместимость хеш-таблицы.

self.load_thres = 2.0 / 3.0 # Порог коэффициента заполнения для расши-

рения.

self.extend_ratio = 2 # Коэффициент расширения.

self.buckets = [[] for _ in range(self.capacity)] # Массив корзин.

def hash_func(self, key: int) -> int: """ Хеш-функция."""

return key % self.capacity

def load_factor(self) -> float:

""" Коэффициент заполнения.""" return self.size / self.capacity

def get(self, key: int) -> str | None: """ Операция поиска."""

index = self.hash_func(key) bucket = self.buckets[index]

# Обход корзины, если ключ найден, возвращается соответствующее значение. for pair in bucket:

if pair.key == key: return pair.val

# Если ключ не найден, возвращается None. return None

def put(self, key: int, val: str): """ Операция добавления."""

# При превышении коэффициента заполнения выполняется расширение. if self.load_factor() > self.load_thres:

self.extend()

index = self.hash_func(key) bucket = self.buckets[index]

# Обход корзины; если ключ найден, значение обновляется # и выполняется выход из функции.

for pair in bucket:

if pair.key == key: pair.val = val return

# Если ключ отсутствует, пара ключ-значение добавляется в конец. pair = Pair(key, val)

bucket.append(pair) self.size += 1

def remove(self, key: int): """ Операция удаления."""

index = self.hash_func(key) bucket = self.buckets[index]

# Обход корзины, удаление пары ключ-значение. for pair in bucket:

if pair.key == key: bucket.remove(pair) self.size -= 1 break

def extend(self):

""" Расширение хеш-таблицы."""

# Сохранение исходной хеш-таблицы. buckets = self.buckets

# Инициализация новой расширенной хеш-таблицы. self.capacity *= self.extend_ratio

self.buckets = [[] for _ in range(self.capacity)] self.size = 0

# Перенос пар ключ-значение из исходной хеш-таблицы в новую. for bucket in buckets:

for pair in bucket: self.put(pair.key, pair.val)

def print(self):

""" Печать хеш-таблицы.""" for bucket in self.buckets:

res = []

for pair in bucket:

res.append(str(pair.key) + " -> " + pair.val) print(res)

Стоит отметить, что в длинных списках эффективность поиска O(n) весьма низка. В этом случае можно преобразовать список в АВЛ-дерево или крас- но-черное дерево, чтобы оптимизировать временную сложность операции поиска до O(log n).

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

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

Далее на примере линейного зондирования рассмотрим механизм работы хеш-таблицы с открытой адресацией.

Линейное зондирование

При линейном зондировании используется линейный поиск с фиксирован- ным шагом. Метод зондирования отличается от обычной хеш-таблицы.

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

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

На рис. 6.6 демонстрируется распределение пар ключ--значение в хеш- таблице с открытой адресацией (линейное зондирование). Согласно этой хеш- функции ключи с одинаковыми последними двумя цифрами отображаются в одну корзину. Однако благодаря линейному зондированию они последова- тельно размещаются в этой и следующих корзинах.

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

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

Рис. 6.6. Распределение пар ключ--значение в хеш-таблице с открытой адресацией (линейное зондирование)

Рис. 6.7. Проблемы поиска, вызванные удалением элементов при открытой адресации

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

Однако ленивое удаление может ускорить падение производительно- сти хеш-таблицы. Это происходит потому, что каждая операция удаления оставляет метку удаления, и с увеличением количества значений TOMBSTONE время поиска также увеличивается, поскольку при линейном зондировании может потребоваться пропуск нескольких TOMBSTONE.

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

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

# === File: hash_map_open_addressing.py === class HashMapOpenAddressing:

""" Открытая адресация хеш-таблицы."""

def init (self):

""" Конструктор."""

self.size = 0 # Количество пар ключ-значение. self.capacity = 4 # Вместимость хеш-таблицы.

self.load_thres = 2.0 / 3.0 # Порог коэффициента заполнения для расширения. self.extend_ratio = 2 # Коэффициент расширения.

self.buckets: list[Pair | None] = [None] * self.capacity # Массив корзин. self.TOMBSTONE = Pair(-1, "-1") # Метка удаления.

def hash_func(self, key: int) -> int: """ Хеш-функция."""

return key % self.capacity

def load_factor(self) -> float:

""" Коэффициент заполнения.""" return self.size / self.capacity

def find_bucket(self, key: int) -> int:

""" Поиск индекса корзины по ключу.""" index = self.hash_func(key) first_tombstone = -1

# Линейное зондирование, выход при встрече пустой корзины. while self.buckets[index] is not None:

# Если найден ключ, возвращается соответствующий индекс корзины. if self.buckets[index].key == key:

# Если ранее была метка удаления, перемещение пары

# ключ-значение в этот индекс. if first_tombstone != -1:

self.buckets[first_tombstone] = self.buckets[index] self.buckets[index] = self.TOMBSTONE

return first_tombstone # Возврат индекса перемещенной корзины. return index # Возврат индекса корзины.

# Фиксация первого встретившегося удаления.

if first_tombstone == -1 and self.buckets[index] is self.TOMBSTONE: first_tombstone = index

# Вычисление индекса корзины, при переходе за конец

# возвращение к началу.

index = (index + 1) % self.capacity

# Если ключ не существует, возвращается индекс для добавления. return index if first_tombstone == -1 else first_tombstone

def get(self, key: int) -> str: """ Операция запроса."""

# Поиск индекса корзины по ключу. index = self.find_bucket(key)

# Если найдена пара ключ-значение, возвращается соответствующее значение. if self.buckets[index] not in [None, self.TOMBSTONE]:

return self.buckets[index].val

# Если пара ключ-значение не существует, возвращается None. return None

def put(self, key: int, val: str): """ Операция добавления."""

# При превышении порога коэффициента заполнения выполняется расширение. if self.load_factor() > self.load_thres:

self.extend()

# Поиск индекса корзины по ключу. index = self.find_bucket(key)

# Если найдена пара ключ-значение, значение перезаписывается и возвращается. if self.buckets[index] not in [None, self.TOMBSTONE]:

self.buckets[index].val = val return

# Если пара ключ-значение не существует, добавляется новая пара. self.buckets[index] = Pair(key, val)

self.size += 1

def remove(self, key: int): """ Операция удаления."""

# Поиск индекса корзины по ключу. index = self.find_bucket(key)

# Если найдена пара ключ-значение, она заменяется меткой удаления. if self.buckets[index] not in [None, self.TOMBSTONE]:

self.buckets[index] = self.TOMBSTONE self.size -= 1

def extend(self):

""" Расширение хеш-таблицы."""

# Временное сохранение оригинальной хеш-таблицы. buckets_tmp = self.buckets

# Инициализация новой хеш-таблицы после расширения. self.capacity *= self.extend_ratio

self.buckets = [None] * self.capacity self.size = 0

# Перенос пар ключ-значение из оригинальной хеш-таблицы в новую. for pair in buckets_tmp:

if pair not in [None, self.TOMBSTONE]: self.put(pair.key, pair.val)

def print(self):

""" Печать хеш-таблицы.""" for pair in self.buckets:

if pair is None: print("None")

elif pair is self.TOMBSTONE: print("TOMBSTONE")

else:

print(pair.key, "->", pair.val)

Квадратичное зондирование

Квадратичное зондирование, как и линейное, является одной из распростра- ненных стратегий открытой адресации. При возникновении конфликта ква- дратичное зондирование пропускает не просто фиксированное количество шагов, а количество шагов, равное квадрату числа попыток, т. е. 1, 4, 9, ...

Квадратичное зондирование обладает следующими преимуществами:

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

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

Тем не менее квадратичный поиск не является идеальным и обладает сле- дующими недостатками:

  • все еще существует эффект кластеризации, т. е. некоторые позиции за- нимаются более вероятно, чем другие;

  • из-за быстрого роста квадрата квадратичное зондирование может не ох- ватить всю хеш-таблицу. То есть даже при наличии в хеш-таблице пустых корзин квадратичное зондирование может никогда не добраться до них.

Множественное хеширование

Как следует из названия, метод множественного хеширования использует для поиска несколько хеш-функций f1(x), f2(x), f3(x), ...

  • Вставка элемента: если хеш-функция f1(x) вызывает конфликт, вычис- ляется f2(x) и т. д., пока не будет найдено пустое место для вставки эле- мента.

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

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

Выбор языка программирования

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

  • В Python используется метод открытой адресации. В словарях dict для поиска применяется псевдослучайное число.

  • В Java используется цепная адресация. Начиная с JDK 1.8, когда длина мас- сива в HashMap достигает 64, а длина цепочки достигает 8, цепочка преоб- разуется в красно-черное дерево для повышения эффективности поиска.

  • В Go используется цепная адресация. Здесь предусмотрено, что в каждой корзине может храниться не более 8 пар ключ--значение. При превыше- нии емкости подключается дополнительная корзина. При избыточном количестве дополнительных корзин выполняется специальная опера- ция расширения для поддержания производительности.

Алгоритмы хеширования

В предыдущих разделах были рассмотрены принципы работы хеш-таблиц и методы обработки хеш-конфликтов. Однако ни открытая, ни цепная адре- сация не могут уменьшить вероятность возникновения хеш-конфликтов, они лишь обеспечивают корректную работу хеш-таблицы при их воз- никновении.

Если хеш-конфликты происходят слишком часто, производительность хеш- таблицы резко снижается. Как показано на рис. 6.8, для хеш-таблицы с цепной адресацией в идеальном случае пары ключ--значение равномерно распреде- лены по всем корзинам, что обеспечивает наилучшую эффективность поиска. В худшем случае все пары ключ--значение хранятся в одной корзине, и вре- менная сложность повышается до O(n).

Рис. 6.8. Лучший и худший случаи хеш-конфликтов

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

index = hash(key) % capacity

Из этого выражения видно, что при фиксированной емкости хеш-таблицы capacity алгоритм хеширования hash() определяет выходное значение, которое, в свою очередь, определяет распределение пар ключ--значение в хеш-таблице.

Это означает, что для снижения вероятности возникновения хеш-конфликтов следует сосредоточиться на разработке алгоритма хеширования hash().

Цели алгоритма хеширования

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

  • Детерминированность: для одинакового ввода алгоритм хеширования должен всегда давать одинаковый вывод. Это необходимо для обеспече- ния надежности работы хеш-таблицы.

  • Высокая эффективность: процесс вычисления хеш-значения должен быть достаточно быстрым. Чем меньше вычислительные затраты, тем выше практическая ценность хеш-таблицы.

  • Равномерное распределение: алгоритм хеширования должен обе- спечивать равномерное распределение пар ключ--значение в хеш- таблице. Чем равномернее распределение, тем ниже вероятность хеш- конфликтов.

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

  • Хранение паролей: для защиты паролей пользователей система обыч- но не хранит пароли в открытом виде, а сохраняет их хеш-значения. Когда пользователь вводит пароль, система вычисляет его хеш-значение и сравнивает с сохраненным. Если они совпадают, пароль считается правильным.

  • Проверка целостности данных: отправитель данных может вычис- лить хеш-значение данных и отправить его вместе с данными. По- лучатель может заново вычислить хеш-значение полученных данных и сравнить его с полученным. Если они совпадают, данные считаются неизмененными.

В криптографических приложениях для предотвращения обратного вычис- ления исходного пароля из хеш-значения и других видов обратной инженерии алгоритм хеширования должен обладать дополнительными характеристиками.

  • Необратимость: невозможность извлечь какую-либо информацию о входных данных из хеш-значения.

  • Устойчивость к коллизиям: должно быть крайне сложно найти два раз- личных входа, дающих одинаковое хеш-значение.

  • Эффект лавины: небольшие изменения на входе должны приводить к значительным и непредсказуемым изменениям на выходе.

Следует отметить, что «равномерное распределение» и «устойчивость к коллизиям» -- это два независимых понятия, и выполнение одного из них не обязательно означает выполнение другого. Например, хеш-функция key % 100 при случайном вводе значения key может давать равномерное распределение. Однако этот алгоритм хеширования слишком прост, и все ключи с одинаковыми последними двумя цифрами будут иметь одинако- вый вывод, что позволяет легко извлечь пригодные ключи из хеш-значения и взломать пароль.

Разработка алгоритма хеширования

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

  • Аддитивный хеш: складываются ASCII-коды каждого символа входных данных, полученная сумма используется в качестве хеш-значения.

  • Мультипликативный хеш: используя свойство некоррелированности умножения, на каждом шаге значение хеша умножается на константу, и в результат добавляется ASCII-код очередного символа.

  • Хеш с использованием операции XOR: каждый элемент входных дан- ных накапливается в хеш-значении с помощью операции XOR.

  • Ротационный хеш: ASCII-коды каждого символа накапливаются в хеш- значении, при этом перед каждым накоплением выполняется операция ротации хеш-значения.

# === File: simple_hash.py ===

def add_hash(key: str) -> int: """ Аддитивный хеш.""" hash = 0

modulus = 1000000007 for c in key:

hash += ord(c) return hash % modulus

def mul_hash(key: str) -> int: """ Мультипликативный хеш.""" hash = 0

modulus = 1000000007 for c in key:

hash = 31 * hash + ord(c) return hash % modulus

def xor_hash(key: str) -> int:

""" Хеш с использованием операции XOR.""" hash = 0

modulus = 1000000007 for c in key:

hash ^= ord(c) return hash % modulus

def rot_hash(key: str) -> int: """ Ротационный хеш.""" hash = 0

modulus = 1000000007 for c in key:

hash = (hash << 4) ^ (hash >> 28) ^ ord(c) return hash % modulus

Можно заметить, что последним шагом в каждом из хеш-алгоритмов явля- ется взятие остатка от деления на большое простое число 1000000007, чтобы гарантировать, что хеш-значение находится в допустимом диапазоне. Инте- ресно, почему акцент делается на взятии остатка от деления именно на про- стое число, и какие недостатки могут быть при делении на составное число?

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

Например, если выбрать в качестве модуля составное число 9, кото- рое делится на 3, то все ключи, делящиеся на 3, будут отображаться в хеш- значения 0, 3 и 6:

modulus = 9

key = {0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, ... }

hash = {0, 3, 6, 0, 3, 6, 0, 3, 6, 0, 3, 6, ... }.

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

modulus = 13

key = {0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, ... }

hash = {0, 3, 6, 9, 12, 2, 5, 8, 11, 1, 4, 7, ... }.

Следует отметить, что если ключи распределены случайно и равномерно, то выбор простого или составного числа в качестве модуля не имеет значения -- оба варианта обеспечат равномерное распределение хеш-значений. Однако при наличии периодичности в распределении ключей использование состав- ного числа в качестве модуля может привести к кластеризации.

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

Распространенные хеш-алгоритмы

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

На практике обычно используются стандартные хеш-алгоритмы, такие как MD5, SHA-1, SHA-2 и SHA-3. Они могут отображать входные данные произ- вольной длины в хеш-значения фиксированной длины.

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

  • В MD5 и SHA-1 были обнаружены многочисленные уязвимости, поэтому они не используются в сценариях, в которых требуется высокий уровень безопасности.

  • SHA-256 из серии SHA-2 является одним из самых безопасных хеш- алгоритмов, до сих пор не было обнаружено ни одной уязвимости, по- этому он часто используется в различных приложениях и протоколах безопасности.

  • SHA-3 имеет меньшие затраты на реализацию и более высокую вычис- лительную эффективность по сравнению с SHA-2, но в настоящее время его использование не так широко распространено, как серии SHA-2.

+--------------------------+------------------------------------------------------------------+----------------+-----------------------+--------------------------------------------------+ | | > MD5 | > SHA-1 | > SHA-2 | > SHA-3 | +==========================+==================================================================+================+=======================+==================================================+ | > Год появления | > 1992 | > 1995 | > 2002 | > 2008 | +--------------------------+------------------------------------------------------------------+----------------+-----------------------+--------------------------------------------------+ | > Длина вывода | > 128 бит | > 160 бит | > 256/512 бит | > 224/256/384/512 бит | +--------------------------+------------------------------------------------------------------+----------------+-----------------------+--------------------------------------------------+ | > Хеш-конфликты | > Много | > Много | > Мало | > Мало | +--------------------------+------------------------------------------------------------------+----------------+-----------------------+--------------------------------------------------+ | > Уровень без- опасности | > Низкий, есть из- вестные уязви- мости | > Низкий, есть | > Высокий | > Высокий | | | | > | | | | | | > уязвимости | | | +--------------------------+------------------------------------------------------------------+----------------+-----------------------+--------------------------------------------------+ | > Применение | > Устарел, но еще используется для проверки целост- ности данных | > Устарел | > Проверка транзакций | > Может использовать- ся в качестве замены SHA-2 | | | | | > | | | | | | > в криптовалюте, | | | | | | > | | | | | | > подписи и т. д. | | +--------------------------+------------------------------------------------------------------+----------------+-----------------------+--------------------------------------------------+

известные

цифровые

Хеш-значения для структур данных

Ключи в хеш-таблице могут быть представлены в виде целых чисел, дробей или строк. Языки программирования обычно предоставляют встроенные хеш- алгоритмы для своих типов данных, чтобы вычислять индексы корзин в хеш- таблице. Например, в Python можно вызвать функцию hash() для вычисления хеш-значений для различных типов данных.

  • Хеш-значение целых чисел и булевых величин совпадает с их значением.

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

  • Хеш-значение кортежа получается путем хеширования каждого элемен- та и объединения этих хеш-значений в одно.

  • Хеш-значение объекта генерируется на основе его адреса в памяти. Пу- тем переопределения метода хеширования объекта можно реализовать генерацию хеша на основе его содержимого.

# === File: built_in_hash.py ===

num = 3

hash_num = hash(num)

# Хеш-значение целого числа 3 равно 3.

bol = True

hash_bol = hash(bol)

# Хеш-значение булевой величины True равно 1.

dec = 3.14159

hash_dec = hash(dec)

# Хеш-значение дробного числа 3.14159 равно 326484311674566659.

str = "Hello 算法"

hash_str = hash(str)

# Хеш-значение строки "Hello 算法" равно 4617003410720528961.

tup = (12836, " 小哈") hash_tup = hash(tup)

# Хеш-значение кортежа (12836, '小哈') равно 1029005403108185979.

obj = ListNode(0) hash_obj = hash(obj)

# Хеш-значение объекта <ListNode object at 0x1058fd810> равно 274267521.

Во многих языках программирования только неизменяемые объекты могут использоваться в качестве ключей в хеш-таблице. Если список (ди- намический массив) используется в качестве ключа, то при изменении его со- держимого хеш-значение также изменится, и мы не сможем найти исходное значение.

Хотя переменные-члены пользовательских объектов (например, узлов связного списка) могут быть изменяемыми, сами объекты можно хеширо- вать. Это связано с тем, что хеш-значение объекта обычно генериру- ется на основе его адреса в памяти, и даже если содержимое объекта из- меняется, адрес остается неизменным, а значит, и хеш-значение также оста- ется прежним.

Возможно, вы заметили, что при запуске программы в разных окнах выво- димые хеш-значения отличаются. Это связано с тем, что интерпретатор Py- thon при каждом запуске добавляет случайное значение «соли» к функ- ции хеширования строк. Такой подход эффективно предотвращает атаки типа HashDoS и повышает безопасность хеш-алгоритма.

резюме

Ключевые моменты
  • При вводе ключа key хеш-таблица может найти значение value за время

O(1), что очень эффективно.

  • Основные операции с хеш-таблицами включают поиск, добавление пар ключ--значение, удаление пар и обход хеш-таблицы.

  • Хеш-функция отображает ключ в индекс массива, что позволяет полу- чить доступ к соответствующей корзине и извлечь значение.

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

    • Чем больше емкость хеш-таблицы, тем ниже вероятность хеш- конфликтов. Поэтому расширение хеш-таблицы может уменьшить ко- личество конфликтов, но, как и в случае с массивами, расширение хеш- таблицы требует значительных затрат.

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

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

    • Метод открытой адресации решает хеш-конфликты с помощью многократ- ного поиска (зондирования). Линейное зондирование использует фикси- рованный шаг, но его элементы нельзя удалять, и он склонен к кластери- зации. Множественное хеширование использует несколько хеш-функций для поиска, что снижает вероятность кластеризации по сравнению с ли- нейным зондированием, но увеличивает вычислительную нагрузку.

    • Разные языки программирования реализуют хеш-таблицы по-разному. Например, класс HashMap в Java использует цепную адресацию, тогда как Dict в Python применяет метод открытой адресации.

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

    • Хеш-алгоритмы обычно используют большие простые числа в качестве модуля, чтобы гарантировать максимально равномерное распределение хеш-значений и уменьшить количество хеш-коллизий.

    • К распространенным хеш-алгоритмам относятся MD5, SHA-1, SHA-2 и SHA-3. MD5 часто используется для проверки целостности файлов, а SHA-2 -- для приложений и протоколов безопасности.

    • Языки программирования обычно предоставляют встроенные хеш- алгоритмы для типов данных, которые используются для вычисления индексов корзин в хеш-таблицах. Как правило, хешировать можно толь- ко неизменяемые объекты.

Вопросы и ответы

Вопрос. В каких случаях временная сложность хеш-таблицы составляет O(n)?

Ответ. Когда хеш-коллизий становится достаточно много, временная слож- ность хеш-таблицы может ухудшиться до O(n). Если хеш-функция хорошо спроектирована, установлена разумная емкость, а коллизии распределены равномерно, временная сложность составляет O(1). При использовании встро- енных хеш-таблиц языков программирования обычно предполагается, что временная сложность составляет O(1).

Вопрос. Почему бы не использовать хеш-функцию f(x) = x? Тогда не будет коллизий.

Ответ. При использовании хеш-функции f(x) = x каждому элементу соот- ветствует уникальный индекс корзины, что эквивалентно массиву. Однако пространство входных данных обычно значительно больше пространства выходных данных (длины массива), поэтому последним этапом работы хеш- функции часто является взятие остатка от деления на длину массива. Други- ми словами, цель хеш-таблицы -- отобразить большее пространство состояний в меньшее пространство и обеспечить эффективность запросов O(1).

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

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

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

Наконец, временная сложность хеш-таблицы может ухудшиться. Например, при использовании цепной адресации поиск выполняется в связном списке или красно-черном дереве, что все еще может привести к ухудшению до времени O(n). Вопрос. Есть ли у метода множественного хеширования недостаток невоз- можности прямого удаления элементов? Можно ли повторно использовать

пространство, помеченное как удаленное?

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

Вопрос. Почему в линейном зондировании при поиске элемента возникают хеш-коллизии?

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

Вопрос. Почему расширение хеш-таблицы может уменьшить количество хеш-коллизий?

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

Глава 7