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

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

Смотреть материал на YouTube | RuTube

Мы продолжаем курс по структурам данных, и на этом занятии речь пойдет о новой структуре под названием хэш-таблица. Она обладает поистине впечатляющими характеристиками. Для стандартных операций вставки, чтения и удаления данных она, в среднем, выполняется за константное время 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?utm_source=proproprogs

Видео по теме