Хэш-таблицы. Что это такое и как работают

Курс по структурам данных: https://stepik.org/a/134212

Мы продолжаем курс по структурам данных, и на этом занятии речь пойдет о новой структуре под названием хэш-таблица. Она обладает поистине впечатляющими характеристиками. Для стандартных операций вставки, чтения и удаления данных она, в среднем, выполняется за константное время O(1), то есть, быстро и не зависимо от размера таблицы (объема данных):

Хэш-таблица

Динамические массивы

Связные списки

В среднем

В худшем

Вставка

O(1)

O(n)

O(n)

O(1)

Чтение

O(1)

O(n)

O(1)

O(n)

Удаление

O(1)

O(n)

O(n)

O(1)

И в этом она превосходит подобные ей структуры: динамические массивы и связные списки. Получается, что хэш-таблица может их полностью заменить? Но не спешите и давайте во всем подробно разберемся. Первым делом рассмотрим принцип работы хэш-таблиц.

Предположим, в продуктовом магазине необходимо хранить товары и выдавать цену по их названиям.

Товар

Цена

Бананы

100

Брокколи

200

Сыр «Папандос»

150

Как это сделать? Из того, что мы знаем на данный момент, вполне подошел бы связный список, т.к. в него достаточно быстро можно добавлять новые товары и удалять ненужные. Правда, поиск будет выполняться линейное время O(n). И это нас не очень устраивает, т.к. товаров в магазине может быть очень много и нам бы хотелось иметь более быстрый доступ к цене товара по его названию. Поэтому сделаем несколько иначе. Единственная из всех рассмотренных структур, которая предоставляет быстрый доступ к элементу за время O(1) – это массивы (и динамические массивы).

Но здесь нас ожидают сразу две проблемы: во-первых, мы знаем названия, но не индексы элементов, а значит, поиск нужного товара займет O(n) времени, и, во-вторых, операции добавления и удаления товара также потребуют O(n) времени. Получается довольно неудачный выбор структуры для данных. Но не спешите с выводами. Давайте пойдем на небольшую хитрость. Придумаем алгоритм, который бы переводил названия товаров в индексы, и тогда, мы получим возможность сразу обращаться к нужной ячейке таблицы (массива), чтобы записать его туда, прочитать оттуда, или удалить, когда это потребуется:

Так вот, алгоритм преобразования некоторой строки в индекс массива получил название хэш-функция, а процесс его работы – хэширование. Отсюда пошло название хэш-таблица.

Видите, какое элегантное решение у нас получается?! Достаточно придумать алгоритм (хэш-функцию), который бы переводил названия товаров в индексы массива и все, мы имеем структуру данных с операциями записи, чтения и удаления, которые выполняются за постоянное время O(1), не зависящее от размера таблицы (массива). Но можно ли найти такую хэш-функцию в общем случае? Очевидно, она должна обладать следующими свойствами:

  • для одного и того же ключа (названия товара) должна выдавать одно и то же значение (свойство последовательности);
  • для разных ключей (названий товаров) выдавать разные значения (индексы);
  • формируемые значения должны находиться в диапазоне от 0 до N-1, где N – размер массива, т.е. индексы должны быть действительными для используемой таблицы;
  • возможные ключи (названия) должны равномерно записываться в ячейки таблицы.

Как видите, имеются достаточно жесткие требования (ограничения) на алгоритм вычисления индекса (хэш-функции). Естественно, возникает вопрос, можно ли вообще построить такую функцию? Сразу отвечу, что в общем случае нет, невозможно. Хотя бы исходя из того, что количество ключей (в нашем примере – это названия товаров) может быть огромное количество K. И оно явно больше числа ячеек таблицы (размера массива) M. Поэтому мы просто не сможем всем ключам выдать уникальные индексы. Вторая проблема – это равномерность заполнения таблицы. Хорошая хэш-функция должна разные ключи как можно равномернее распределять по массиву, минимизируя, тем самым, дублирование индексов. Но обо всем этом мы еще поговорим. А пока будем полагать, что у нас уже есть алгоритм хэширования, и посмотрим на работу хэш-таблиц в целом.

Добавление элементов в хэш-таблицу

Давайте предположим, что мы бы хотели по английским буквам получать соответствующие аналоги русских букв. Например:

Ключ

Значение

a

а

b

б

c

с

d

д

f

ф

Причем нам наперед неизвестно, сколько именно букв  будет храниться в хэш-таблице. Поэтому, чтобы зря не расходовать память, начальный размер таблицы будет иметь m=5 элементов. Сами ячейки таблицы будут хранить адреса на объекты с данными. Если данных нет, то указатели принимают значение NULL. Далее, у нас имеется хэш-функция, которая для каждой буквы латинского алфавита вычисляет индекс в массиве T. Предположим, мы хотим по ключу b записать значение б. На вход хэш-функции подается символ b. На выходе получаем индекс массива, по которому этот ключ должен располагаться в таблице. Пусть это будет индекс 1:

Затем, в памяти создается новый объект с ключом b и значенем б, и адрес этого объекта сохраняется во втором элементе таблицы. То есть, массив хранит не сами данные, а ссылки на объекты с данными. Это наиболее частая реализация хэш-таблиц. В результате мы добавили новый ключ b и его значение б в хэш-таблицу. На уровне языков программирования эта операция часто записывается в виде:

T["b"] = "б"

Разумеется, если T – это хэш-таблица.

По аналогии можно добавить еще несколько ключей и значений. Например, ключи f, d, u:

И наш массив почти заполнен! В теории хэш-таблиц степень их заполненности определяется коэффициентом:

α = n / m

где n – количество хранимых ключей (в нашем примере 4); m – размер массива (в нашем примере 5). Получаем, значение степени заполнения таблицы:

α = 4 / 5 = 0,8

То есть, пока этот коэффициент меньше единицы, в массиве есть свободные элементы, куда теоретически еще можно добавить новые ключи. Если α = 1, то массив заполнен полностью. Если же α > 1, то число ключей превышает размер хэш-таблицы. (Как такое может быть, мы еще будем говорить.)

Итак, на данный момент коэффициент α = 0,8, значит, массив почти заполнен. Что делать дальше? Выход только один: увеличить размер таблицы, то есть, рассматривать массив как динамический и, например, при α близкой к 1 увеличивать его размер в 2 раза. Давайте так и сделаем.

Сначала мы должны в памяти создать новый массив длиной в 2 раза больше предыдущего. После этого хэш-функция будет уже выдавать новый диапазон индексов [0; 9]. Поэтому элементы должны не просто копироваться в новый массив, а заново прогоняться через новую хэш-функцию. Получим (как пример):

Только после этого мы сюда можем добавлять новые ключи. Вот общий принцип работы алгоритма добавления ключей и значений в хэш-таблицы. В среднем, эта операция выполняется за фиксированное время O(1).

Разрешение коллизий в хэш-таблицах

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

И избежать этого невозможно, так как алгоритмически мы должны обеспечивать не только различие в индексах, но и равномерность заполнения таблицы. Кроме того, число ячеек M в таблице много меньше возможного числа ключей. Поэтому вполне вероятны ситуации, когда разным ключам назначается один и тот же индекс. Такая ситуация называется коллизией. Спрашивается, как быть в таком случае? Существует два основных метода разрешения коллизий:

  • метод цепочек;
  • метод открытой адресации.

Самый простой и очевидный способ обработки коллизий – это метод цепочек. Давайте предположим, что в хэш-таблицу T осуществляется добавление следующих пар ключ-значение:

T["b"] = б
T["ba"] = ба
T["d"] = д
T["f"] = ф
T["bb"] = бб
T["fa"] = фа

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

У такого решения есть положительные и отрицательные стороны. К положительным можно отнести простоту реализации. Сформировать двусвязные списки там, где необходимо хранить несколько ключей, не составляет особого труда. Также относительно быстро происходит вставка новых ключей и удаление существующих в таких списках (цепочках). А основным недостатком является возможность появления длинных цепочек в хэш-таблицах. Тогда поиск нужного ключа может занять продолжительное время и преимущества хэш-таблиц будут сведены к нулю. Очевидно, чтобы избежать такого неблагоприятного случая (образования длинных цепочек), нужно правильно выбирать хэш-функцию, которая бы равномерно распределяла возможные ключи по индексам таблицы. Благо, существуют подходы, позволяющие создавать такие функции, но об этом мы еще будем говорить. А сейчас посмотрим, как в хэш-таблицах со списками выполняется поиск и удаление ключей.

Алгоритм поиска ключей

Давайте предположим, что у нас имеется ранее сформированная хэш-таблица с цепочками и в ней требуется взять значение по определенному ключу. Пусть это будет ключ «ba», то есть, нужно выполнить операцию:

val = T["ba"]

Для этого мы подаем ключ «ba» на вход хэш-функции, получаем значение индекса 1 в таблице и видим здесь двусвязный список. На начало этого списка ведет указатель ptr. Сформируем временный указатель:

p = ptr

который также будет ссылаться на начало этого списка. Далее, мы последовательно проходим по элементам этого списка и сравниваем в них ключи на равенство заданного ключа «ba». Этот ключ мы находим во втором элементе списка. На этом поиск останавливается и возвращается значение "ба" этого ключа.

Если мы указываем не существующий ключ, например, «t», то либо попадем в пустую ячейку таблицы, либо не найдем этот ключ в списке.

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

Алгоритм удаления ключей

Давайте теперь посмотрим, как можно выполнять удаление существующих ключей из хэш-таблицы с цепочками. У нас по-прежнему будет та же самая таблица и мы хотим удалить из нее ключ «ba»:

del T["ba"]

Вначале также подаем этот ключ на вход хэш-функции и получаем индекс 1 в таблице. По этому индексу хранится несколько ключей в двусвязном списке. С помощью временного указателя p находим элемент с ключом «ba». Это второй элемент. И удаляем его. (Как удалять элементы в двухсвязных списках мы с вами уже говорили на предыдущих занятиях). Все, ключ удален из хэш-таблицы.

Если происходит удаление единственного ключа в ячейке, например, ключа «d», то проверяется, что в единственном элементе действительно хранится ключ «d», если так, то объект удаляется и ячейка принимает значение NULL.

Наконец, если пытаемся удалить не существующий в таблице ключ, например, «s», то либо сразу попадаем в ячейку со значением NULL, либо на цепочку из ключей, в которой ключ «s» будет отсутствовать. В любом случае, при отсутствии ключа никаких действий с таблицей не выполняется и она остается в неизменном виде.

Итоги

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

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

Курс по структурам данных: https://stepik.org/a/134212

Видео по теме