* 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
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-бакет, а когда таких бакетов становится слишком много, выполняется специальное расширение того же масштаба, чтобы сохранить производительность.


