18 KiB
Хеш-коллизии
Как уже говорилось в предыдущем разделе, в обычных условиях входное пространство хеш-функции намного больше выходного пространства , поэтому теоретически хеш-коллизии неизбежны. Например, если входное пространство состоит из всех целых чисел, а выходное пространство ограничено размером массива, то неизбежно несколько целых чисел будут отображаться в один и тот же индекс бакета.
Хеш-коллизии могут приводить к ошибочным результатам поиска и серьезно влиять на работоспособность хеш-таблицы. Чтобы решить эту проблему, можно при каждом конфликте выполнять расширение хеш-таблицы, пока конфликт не исчезнет. Этот метод понятен и прост, но слишком неэффективен, потому что расширение хеш-таблицы требует большого объема переноса данных и вычислений хеш-значений. Чтобы повысить эффективность, можно использовать следующие стратегии.
- Улучшить структуру данных хеш-таблицы, чтобы она могла корректно работать даже при возникновении хеш-коллизий.
- Выполнять расширение только тогда, когда это действительно необходимо, то есть когда хеш-коллизии становятся достаточно серьезными.
Основные способы улучшения структуры хеш-таблицы включают метод цепочек и открытую адресацию.
Метод цепочек
В исходной хеш-таблице каждый бакет может хранить только одну пару ключ-значение. Метод цепочек (separate chaining) превращает отдельный элемент в связный список: пары ключ-значение становятся узлами списка, и все конфликтующие пары ключ-значение хранятся в одном и том же списке. На рисунке ниже показан пример хеш-таблицы, реализованной методом цепочек.
Методы работы с хеш-таблицей, построенной на основе метода цепочек, меняются следующим образом.
- Поиск элемента: передаем
key, по хеш-функции получаем индекс бакета, после чего обращаемся к голове списка и обходим список, сравниваяkey, пока не найдем целевую пару ключ-значение. - Добавление элемента: сначала через хеш-функцию получаем голову списка, затем добавляем узел (пару ключ-значение) в этот список.
- Удаление элемента: по результату хеш-функции обращаемся к голове списка, затем обходим список, находим целевой узел и удаляем его.
Метод цепочек имеет следующие ограничения.
- Рост потребления памяти: связный список содержит указатели на узлы, поэтому по сравнению с массивом он требует больше памяти.
- Снижение эффективности поиска: для нахождения нужного элемента нужно линейно обходить связный список.
Ниже приведена простая реализация хеш-таблицы методом цепочек. Следует обратить внимание на два момента.
- Для упрощения кода вместо связного списка используется список (динамический массив). В этой реализации хеш-таблица (массив) содержит несколько бакетов, и каждый бакет представляет собой список.
- Ниже включен метод расширения хеш-таблицы. Когда коэффициент загрузки превышает
\frac{2}{3}, мы расширяем хеш-таблицу до2раз от прежней емкости.
[file]{hash_map_chaining}-[class]{hash_map_chaining}-[func]{}
Следует отметить, что когда связный список становится очень длинным, эффективность поиска O(n) оказывается низкой. В этом случае список можно преобразовать в AVL-дерево или красно-черное дерево , чтобы оптимизировать временную сложность поиска до O(\log n) .
Открытая адресация
Открытая адресация (open addressing) не вводит дополнительных структур данных, а обрабатывает хеш-коллизии с помощью многократного пробирования; основные варианты пробирования включают линейное пробирование, квадратичное пробирование и повторное хеширование.
Ниже на примере линейного пробирования рассмотрим механизм работы хеш-таблицы с открытой адресацией.
Линейное пробирование
Линейное пробирование использует линейный поиск с фиксированным шагом. Его методы работы отличаются от обычной хеш-таблицы.
- Вставка элемента: по хеш-функции вычисляется индекс бакета; если бакет уже занят, то от места конфликта выполняется линейный обход вперед (шаг обычно равен
1), пока не будет найден пустой бакет, после чего элемент вставляется туда. - Поиск элемента: если возник конфликт, то с тем же шагом продолжается линейный обход вперед, пока не будет найден целевой элемент и возвращено
value; если встречается пустой бакет, это означает, что искомого элемента в хеш-таблице нет, и возвращаетсяNone.
На рисунке ниже показано распределение пар ключ-значение в хеш-таблице с открытой адресацией (линейное пробирование). Для этой хеш-функции все key с одинаковыми двумя последними цифрами отображаются в один и тот же бакет. Благодаря линейному пробированию они по очереди сохраняются в этом бакете и в следующих за ним бакетах.
Однако линейное пробирование легко приводит к кластеризации. Иначе говоря, чем длиннее непрерывная занятая область в массиве, тем выше вероятность новых коллизий в этой области, что еще сильнее способствует росту этой группы и в итоге ухудшает эффективность операций добавления, удаления, поиска и обновления.
Стоит заметить, что мы не можем напрямую удалять элементы из хеш-таблицы с открытой адресацией. Причина в том, что удаление создаст внутри массива пустой бакет None , а при поиске элемента линейное пробирование остановится на этом пустом бакете и вернет результат, из-за чего элементы ниже этого бакета уже не смогут быть найдены, и программа может ошибочно посчитать, что их не существует, как показано на рисунке ниже.
Чтобы решить эту проблему, можно использовать механизм ленивого удаления (lazy deletion): он не удаляет элемент из хеш-таблицы напрямую, **а помечает этот бакет специальной константой TOMBSTONE **. В этом механизме и None , и TOMBSTONE означают пустой бакет, и оба могут быть использованы для размещения пары ключ-значение. Но есть важное различие: при линейном пробировании, встретив TOMBSTONE , нужно продолжать обход, потому что ниже него все еще могут существовать пары ключ-значение.
Однако ленивое удаление может ускорять деградацию производительности хеш-таблицы. Это связано с тем, что каждая операция удаления создает новую метку удаления; по мере роста числа TOMBSTONE время поиска тоже увеличивается, потому что линейное пробирование может быть вынуждено перескакивать через множество TOMBSTONE , прежде чем найдет целевой элемент.
Поэтому имеет смысл при линейном пробировании запоминать индекс первого встреченного TOMBSTONE и затем менять найденный целевой элемент местами с этим TOMBSTONE . Преимущество такого подхода в том, что при каждом поиске или добавлении элемент будет перемещаться в бакет, расположенный ближе к его идеальной позиции (начальной точке пробирования), а значит, эффективность поиска улучшится.
Ниже приведена реализация хеш-таблицы с открытой адресацией, то есть с линейным пробированием, включающая ленивое удаление. Чтобы пространство хеш-таблицы использовалось более полно, мы рассматриваем ее как кольцевой массив: когда обход выходит за конец массива, он возвращается к началу и продолжается.
[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-бакет, а когда таких бакетов становится слишком много, выполняется специальное расширение того же масштаба, чтобы сохранить производительность.


