mirror of
http://bgp.hk.skcks.cn:10086/https://github.com/krahets/hello-algo
synced 2026-04-20 21:00:58 +08:00
* docs(ru): replace prose quotes with guillemets * docs(ru): replace prose semicolons with periods * docs(ru): align animation title forms * docs(ru): align figure and table references
109 lines
18 KiB
Markdown
109 lines
18 KiB
Markdown
# Хеш-коллизии
|
||
|
||
Как уже говорилось в предыдущем разделе, **в обычных условиях входное пространство хеш-функции намного больше выходного пространства** , поэтому теоретически хеш-коллизии неизбежны. Например, если входное пространство состоит из всех целых чисел, а выходное пространство ограничено размером массива, то неизбежно несколько целых чисел будут отображаться в один и тот же индекс бакета.
|
||
|
||
Хеш-коллизии могут приводить к ошибочным результатам поиска и серьезно влиять на работоспособность хеш-таблицы. Чтобы решить эту проблему, можно при каждом конфликте выполнять расширение хеш-таблицы, пока конфликт не исчезнет. Этот метод понятен и прост, но слишком неэффективен, потому что расширение хеш-таблицы требует большого объема переноса данных и вычислений хеш-значений. Чтобы повысить эффективность, можно использовать следующие стратегии.
|
||
|
||
1. Улучшить структуру данных хеш-таблицы, **чтобы она могла корректно работать даже при возникновении хеш-коллизий**.
|
||
2. Выполнять расширение только тогда, когда это действительно необходимо, то есть когда хеш-коллизии становятся достаточно серьезными.
|
||
|
||
Основные способы улучшения структуры хеш-таблицы включают метод цепочек и открытую адресацию.
|
||
|
||
## Метод цепочек
|
||
|
||
В исходной хеш-таблице каждый бакет может хранить только одну пару ключ-значение. <u>Метод цепочек (separate chaining)</u> превращает отдельный элемент в связный список: пары ключ-значение становятся узлами списка, и все конфликтующие пары ключ-значение хранятся в одном и том же списке. На рисунке ниже показан пример хеш-таблицы, реализованной методом цепочек.
|
||
|
||

|
||
|
||
Методы работы с хеш-таблицей, построенной на основе метода цепочек, меняются следующим образом.
|
||
|
||
- **Поиск элемента**: передаем `key` , по хеш-функции получаем индекс бакета, после чего обращаемся к голове списка и обходим список, сравнивая `key` , пока не найдем целевую пару ключ-значение.
|
||
- **Добавление элемента**: сначала через хеш-функцию получаем голову списка, затем добавляем узел (пару ключ-значение) в этот список.
|
||
- **Удаление элемента**: по результату хеш-функции обращаемся к голове списка, затем обходим список, находим целевой узел и удаляем его.
|
||
|
||
Метод цепочек имеет следующие ограничения.
|
||
|
||
- **Рост потребления памяти**: связный список содержит указатели на узлы, поэтому по сравнению с массивом он требует больше памяти.
|
||
- **Снижение эффективности поиска**: для нахождения нужного элемента нужно линейно обходить связный список.
|
||
|
||
Ниже приведена простая реализация хеш-таблицы методом цепочек. Следует обратить внимание на два момента.
|
||
|
||
- Для упрощения кода вместо связного списка используется список (динамический массив). В этой реализации хеш-таблица (массив) содержит несколько бакетов, и каждый бакет представляет собой список.
|
||
- Ниже включен метод расширения хеш-таблицы. Когда коэффициент загрузки превышает $\frac{2}{3}$ , мы расширяем хеш-таблицу до $2$ раз от прежней емкости.
|
||
|
||
```src
|
||
[file]{hash_map_chaining}-[class]{hash_map_chaining}-[func]{}
|
||
```
|
||
|
||
Следует отметить, что когда связный список становится очень длинным, эффективность поиска $O(n)$ оказывается низкой. **В этом случае список можно преобразовать в AVL-дерево или красно-черное дерево** , чтобы оптимизировать временную сложность поиска до $O(\log n)$ .
|
||
|
||
## Открытая адресация
|
||
|
||
<u>Открытая адресация (open addressing)</u> не вводит дополнительных структур данных, а обрабатывает хеш-коллизии с помощью многократного пробирования. Основные варианты пробирования включают линейное пробирование, квадратичное пробирование и повторное хеширование.
|
||
|
||
Ниже на примере линейного пробирования рассмотрим механизм работы хеш-таблицы с открытой адресацией.
|
||
|
||
### Линейное пробирование
|
||
|
||
Линейное пробирование использует линейный поиск с фиксированным шагом. Его методы работы отличаются от обычной хеш-таблицы.
|
||
|
||
- **Вставка элемента**: по хеш-функции вычисляется индекс бакета. Если бакет уже занят, то от места конфликта выполняется линейный обход вперед (шаг обычно равен $1$ ), пока не будет найден пустой бакет, после чего элемент вставляется туда.
|
||
- **Поиск элемента**: если возник конфликт, то с тем же шагом продолжается линейный обход вперед, пока не будет найден целевой элемент и возвращено `value`. Если встречается пустой бакет, это означает, что искомого элемента в хеш-таблице нет, и возвращается `None` .
|
||
|
||
На рисунке ниже показано распределение пар ключ-значение в хеш-таблице с открытой адресацией (линейное пробирование). Для этой хеш-функции все `key` с одинаковыми двумя последними цифрами отображаются в один и тот же бакет. Благодаря линейному пробированию они по очереди сохраняются в этом бакете и в следующих за ним бакетах.
|
||
|
||

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

|
||
|
||
Чтобы решить эту проблему, можно использовать механизм <u>ленивого удаления (lazy deletion)</u>: он не удаляет элемент из хеш-таблицы напрямую, **а помечает этот бакет специальной константой `TOMBSTONE` **. В этом механизме и `None` , и `TOMBSTONE` означают пустой бакет, и оба могут быть использованы для размещения пары ключ-значение. Но есть важное различие: при линейном пробировании, встретив `TOMBSTONE` , нужно продолжать обход, потому что ниже него все еще могут существовать пары ключ-значение.
|
||
|
||
Однако **ленивое удаление может ускорять деградацию производительности хеш-таблицы**. Это связано с тем, что каждая операция удаления создает новую метку удаления. По мере роста числа `TOMBSTONE` время поиска тоже увеличивается, потому что линейное пробирование может быть вынуждено перескакивать через множество `TOMBSTONE` , прежде чем найдет целевой элемент.
|
||
|
||
Поэтому имеет смысл при линейном пробировании запоминать индекс первого встреченного `TOMBSTONE` и затем менять найденный целевой элемент местами с этим `TOMBSTONE` . Преимущество такого подхода в том, что при каждом поиске или добавлении элемент будет перемещаться в бакет, расположенный ближе к его идеальной позиции (начальной точке пробирования), а значит, эффективность поиска улучшится.
|
||
|
||
Ниже приведена реализация хеш-таблицы с открытой адресацией, то есть с линейным пробированием, включающая ленивое удаление. Чтобы пространство хеш-таблицы использовалось более полно, мы рассматриваем ее как кольцевой массив: когда обход выходит за конец массива, он возвращается к началу и продолжается.
|
||
|
||
```src
|
||
[file]{hash_map_open_addressing}-[class]{hash_map_open_addressing}-[func]{}
|
||
```
|
||
|
||
### Квадратичное пробирование
|
||
|
||
Квадратичное пробирование похоже на линейное пробирование и тоже является одной из распространенных стратегий открытой адресации. При возникновении конфликта оно не пропускает фиксированное число шагов, а переходит на расстояние, равное «квадрату числа попыток», то есть на $1, 4, 9, \dots$ шагов.
|
||
|
||
Квадратичное пробирование имеет следующие основные преимущества.
|
||
|
||
- Квадратичное пробирование пытается смягчить эффект кластеризации линейного пробирования, так как пропускает расстояния, равные квадрату номера попытки.
|
||
- Квадратичное пробирование перепрыгивает на более дальние позиции в поисках свободного места, что помогает распределять данные более равномерно.
|
||
|
||
Однако квадратичное пробирование не является идеальным.
|
||
|
||
- Кластеризация все равно существует: некоторые позиции по-прежнему занимают чаще других.
|
||
- Из-за быстрого роста квадрата квадратичное пробирование может не охватить всю хеш-таблицу, а это означает, что даже при наличии пустых бакетов оно может так до них и не добраться.
|
||
|
||
### Повторное хеширование
|
||
|
||
Как видно из названия, метод повторного хеширования использует для пробирования несколько хеш-функций $f_1(x)$, $f_2(x)$, $f_3(x)$, $\dots$ .
|
||
|
||
- **Вставка элемента**: если хеш-функция $f_1(x)$ вызывает конфликт, то пробуем $f_2(x)$ , и так далее, пока не будет найдено пустое место для вставки элемента.
|
||
- **Поиск элемента**: поиск идет в том же порядке хеш-функций, пока не будет найден целевой элемент. Если встречается пустая позиция или уже были опробованы все хеш-функции, это означает, что элемента в хеш-таблице нет, и возвращается `None` .
|
||
|
||
По сравнению с линейным пробированием метод повторного хеширования меньше подвержен кластеризации, но несколько хеш-функций приносят дополнительные вычислительные затраты.
|
||
|
||
!!! tip
|
||
|
||
Обрати внимание: у хеш-таблиц с открытой адресацией (линейное пробирование, квадратичное пробирование и повторное хеширование) есть общая проблема: в них нельзя напрямую удалять элементы.
|
||
|
||
## Выбор в языках программирования
|
||
|
||
Разные языки программирования используют разные стратегии реализации хеш-таблиц. Ниже приведено несколько примеров.
|
||
|
||
- Python использует открытую адресацию. В словаре `dict` для пробирования применяются псевдослучайные числа.
|
||
- Java использует метод цепочек. Начиная с JDK 1.8, когда длина массива внутри `HashMap` достигает 64, а длина списка достигает 8, этот список преобразуется в красно-черное дерево для повышения производительности поиска.
|
||
- Go использует метод цепочек. В Go установлено, что каждый бакет может хранить не более 8 пар ключ-значение. При переполнении подключается overflow-бакет, а когда таких бакетов становится слишком много, выполняется специальное расширение того же масштаба, чтобы сохранить производительность.
|