Files
hello-algo/ru/docs/chapter_hashing/hash_collision.md
Yudong Jin 22b3b568ef fix Ru translation (#1894)
* 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
2026-04-14 18:10:12 +08:00

18 KiB
Raw Blame History

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

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

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

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

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

Метод цепочек

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