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

808 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Хеш-таблицы
![](ru/docs/assets/media/image193.jpeg){width="3.8073009623797027in" height="4.927083333333333in"}
#### хеш-таблицы
> *Хеш-таблица* реализует эффективный поиск элементов через установле- ние соответствия между ключом key и значением value. Более конкретно, передав ключ в хеш-таблицу, можно получить соответствующее значение за время *O*(1).
>
> Пусть имеется *n* студентов, у каждого из которых есть имя и номер. Если нужно реализовать функцию «ввести номер студента и получить соответству- ющее имя», то можно использовать хеш-таблицу, как показано на рис. 6.1.
![](ru/docs/assets/media/image195.jpeg)
> **Рис. 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 показан принцип ра- боты хеш-функции на примере ключа «номер» и значения «имя» для студента.
>
> ![](ru/docs/assets/media/image197.jpeg) **Ввод 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. Такую ситуацию, когда несколько входов соответствуют одному вы- ходу, называют хеш-коллизией.
>
> ![](ru/docs/assets/media/image199.jpeg) **Ввод key**
>
> **Индекс Массив Хеш-коллизия**
>
> **Вывод value**
>
> \"Иван\"
Хеш-функция
\"Петр\"
\"Яков\"
> \"Иван\"
>
> \"Иван\"
>
> \"Ира\"
>
> \"Аня\"
>
> (Каждая ячейка хранит одну пару ключ--значение)
>
> **Рис. 6.3.** Пример хеш-коллизии
>
> Логично предположить, что чем больше емкость хеш-таблицы *n*, тем ниже вероятность распределения нескольких ключей в одну корзину и тем меньше коллизий. Поэтому **можно уменьшить количество хеш-коллизий**, **увели- чивая емкость хеш-таблицы**.
>
> Как показано на рис. 6.4, до увеличения емкости пары ключ--значение (136, A)
и (236, D) попадали в одну корзину, а после увеличения емкости коллизия исчезла.
> ![](ru/docs/assets/media/image201.jpeg)**Увеличение хеш-таблицы**
>
> **Рис. 6.4.** Увеличение емкости хеш-таблицы
>
> Подобно увеличению емкости массива, увеличение емкости хеш-таблицы требует переноса всех пар ключ--значение из старой хеш-таблицы в новую, что является очень затратной по времени операцией. Кроме того, поскольку емкость хеш-таблицы изменяется, необходимо заново вычислять местополо- жение хранения всех пар ключ--значение с помощью хеш-функции, что еще больше увеличивает вычислительные затраты процесса расширения. Поэтому в языках программирования обычно резервируется достаточно большая ем- кость хеш-таблицы, чтобы избежать частого увеличения.
>
> Коэффициент заполнения является важным понятием для хеш-таблицы. Он определяется как количество элементов в хеш-таблице, деленное на коли- чество корзин, и используется для оценки степени серьезности хеш-коллизий, **а также часто служит условием для увеличения емкости хеш-таблицы**. Например, в Java, когда коэффициент заполнения превышает 0.75, система увеличивает емкость хеш-таблицы в 2 раза.
#### хеш-коллизии
> Как упоминалось в предыдущем разделе, **в обычных условиях входное про- странство хеш-функции значительно больше выходного пространства**,
>
> поэтому хеш-коллизии теоретически неизбежны. Например, если входное пространство состоит из всех целых чисел, а выходное пространство соответ- ствует размеру массива, то обязательно несколько целых чисел будут отобра- жаться в один и тот же индекс корзины.
>
> Хеш-коллизии могут привести к ошибкам в результатах запросов, серьезно влияя на работоспособность хеш-таблицы. Чтобы решить эту проблему, при возникновении хеш-коллизий выполняется увеличение емкости хеш-таблицы до тех пор, пока коллизии не исчезнут. Этот метод понятен и прост в реализа- ции, но крайне неэффективен, поскольку увеличение емкости хеш-таблицы требует значительных затрат на перенос данных и вычисление хеш-значений. Для повышения эффективности можно использовать следующие стратегии:
1) улучшение структуры данных хеш-таблицы, **чтобы она могла нор- мально функционировать при возникновении хеш-коллизий**;
2) выполнение увеличения емкости только при необходимости, т. е. когда хеш-коллизии становятся достаточно серьезными.
> Основные методы улучшения структуры хеш-таблицы включают цепную адресацию и открытую адресацию.
### Цепная адресация
> В исходной хеш-таблице каждая корзина может хранить только одну пару ключ--значение. Цепная адресация преобразует отдельный элемент в связный список, где пары ключ--значение выступают в качестве узлов списка, и все пары ключ--значение, вызвавшие коллизии, хранятся в одном и том же списке. На рис. 6.5 представлен пример хеш-таблицы с цепной адресацией.
![](ru/docs/assets/media/image203.jpeg)
> **Рис. 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.
>
> ![](ru/docs/assets/media/image205.jpeg)
>
> **Рис. 6.6.** Распределение пар ключ--значение в хеш-таблице с открытой адресацией (линейное зондирование)
![](ru/docs/assets/media/image207.jpeg)
> **Рис. 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, \...
>
> Квадратичное зондирование обладает следующими преимуществами:
- квадратичное зондирование, пропуская расстояние, равное квадрату числа попыток, стремится сгладить эффект кластеризации линейного зондирования;
- квадратичное зондирование пропускает большее расстояние в поисках пустого места, что способствует более равномерному распределению данных.
> Тем не менее квадратичный поиск не является идеальным и обладает сле- дующими недостатками:
- все еще существует эффект кластеризации, т. е. некоторые позиции за- нимаются более вероятно, чем другие;
- из-за быстрого роста квадрата квадратичное зондирование может не ох- ватить всю хеш-таблицу. То есть даже при наличии в хеш-таблице пустых корзин квадратичное зондирование может никогда не добраться до них.
##### Множественное хеширование
> Как следует из названия, метод множественного хеширования использует для поиска несколько хеш-функций *f*1(*x*), *f*2(*x*), *f*3(*x*), \...
- **Вставка элемента**: если хеш-функция *f*1(*x*) вызывает конфликт, вычис- ляется *f*2(*x*) и т. д., пока не будет найдено пустое место для вставки эле- мента.
- **Поиск элемента**: поиск выполняется в том же порядке хеш-функций, пока не будет найден целевой элемент. Если встречается пустое место или были испробованы все хеш-функции, это означает, что элемента в хеш-таблице нет, и возвращается None.
> По сравнению с линейным зондированием метод множественного хеши- рования менее склонен к кластеризации, но использование нескольких хеш- функций увеличивает вычислительную нагрузку.
### Выбор языка программирования
> Различные языки программирования используют разные стратегии реализа- ции хеш-таблиц, ниже приведено несколько примеров.
- В Python используется метод открытой адресации. В словарях dict для поиска применяется псевдослучайное число.
- В Java используется цепная адресация. Начиная с JDK 1.8, когда длина мас- сива в HashMap достигает 64, а длина цепочки достигает 8, цепочка преоб- разуется в красно-черное дерево для повышения эффективности поиска.
- В Go используется цепная адресация. Здесь предусмотрено, что в каждой корзине может храниться не более 8 пар ключ--значение. При превыше- нии емкости подключается дополнительная корзина. При избыточном количестве дополнительных корзин выполняется специальная опера- ция расширения для поддержания производительности.
#### Алгоритмы хеширования
> В предыдущих разделах были рассмотрены принципы работы хеш-таблиц и методы обработки хеш-конфликтов. Однако ни открытая, ни цепная адре- сация не могут уменьшить вероятность возникновения хеш-конфликтов, **они лишь обеспечивают корректную работу хеш-таблицы при их воз- никновении**.
>
> Если хеш-конфликты происходят слишком часто, производительность хеш- таблицы резко снижается. Как показано на рис. 6.8, для хеш-таблицы с цепной адресацией в идеальном случае пары ключ--значение равномерно распреде- лены по всем корзинам, что обеспечивает наилучшую эффективность поиска. В худшем случае все пары ключ--значение хранятся в одной корзине, и вре- менная сложность повышается до *O*(*n*).
>
> ![](ru/docs/assets/media/image209.jpeg)
>
> **Рис. 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