Хеш-таблицы

Сейчас мы с вами вновь рассмотрим задачу построения частотного словаря, которую уже умеем решать двумя способами. Частотным словарём некоторого текста называется соответствие между каждым словом этого текста и частотой его встречаемости. Например, в тексте “hello world hello abc” слово hello встречается два раза, а слова world и abc по разу. Мы уже знаем два способа написать такую программу: один с двумя массивами, слов и счётчиков, другой с помощью бинарного дерева, причём способ с массивами работает за O(N^2), а с бинарными деревьями – за O(N log N).

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

Интерфейс и свойства хеш-таблиц

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

  1. Создание. В случае простых реализаций хеш-таблицы можно просто определить переменную, в случае сложных может понадобится вызвать специальную функцию.
  2. Удаление. Хеш-таблица в процессе своей работы выделяет память, и эту память надо корректно освободить.
  3. Добавление новой пары “ключ-значение” (в терминах нашей изначальной задачи добавление слова и соответствующего ему счётчика). Среднее время работы O(1).
  4. Чтение значения по ключу. В среднем занимает время O(1).
  5. Удаление пары ключ-значение по ключу. В среднем занимает время O(1).
  6. Перебор всех пар ключ-значение. Занимает время O(N). Из-за особенностей внутренних алгоритмов порядок перебора пар отличается от порядка их добавления, и может сильно изменяться при добавлении новых элементов.

Реализация хеш-таблиц

Сразу стоит отметить, что есть несколько способов реализации хеш-таблиц. Мы здесь будем рассматривать только способ с цепочками.

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

Внутри хеш-таблицы есть массив, в каждой ячейке которого хранится указатель на связный список. Для примера возьмём пустую хеш-таблицу с внутренним массивом длинной 5:

temp/HT1.jpg

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

temp/HT2.jpg

Очевидно, что если мы будем добавлять в хеш-таблицу значения с другими ключами, рано или поздно нам попадётся ключ с тем же значением hash(key) % n, что и уже добавленного ключа. В таком случае следует пройтись по списку пар, сравнивая сохранённые с списке ключи с обрабатываемым, и, если совпадений нет, то добавить пару в цепочку:

temp/HT3.jpg

Если же совпадающий ключ есть, то следует заменить старый ключ в ячейке цепочки на новый:

temp/HT4.jpg

Поиск в хеш-таблице проходит аналогичным образом: вычисляется hash(key), берётся остаток от деления, в цепочке находится ячейка с ключом, из неё извлекается значение. Если такая ячейка не найдена, то значение для данного ключа отсутствует.

Однако, возникает естественный вопрос: а откуда берётся ускорение? При добавлении N пар в хеш-таблицу количество вычислений уменьшится только в n раз. n у нас пока что константа, так что выигрыш в количестве сравнений у нас только в n раз, N / n – это всё равно линейное время. Но из этих соображений следует, что если удерживать длину цепочки не более некоторой максимальной длины, например, 7 ячеек, то время поиска будет постоянным! Для выполнения этого свойства необходимо, чтобы после добавления каждого элемента проверялась длина текущей цепочки, и, если она больше максимальной, надо сделать рехеширование: выделить память под новый массив, заполнить его цепочками уже на основании новых значений hash(key) % n, а память из-под старого освободить.

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

temp/REHASHING.jpg

TODO: простые числа и размер хеш-таблицы

Хеш-функции

Простейшая хеш-функция – это просто сумма байт строки:

unsigned int hash(char *key)
{
    int i;
    unsigned char *ukey = (unsigned char *) key;
    unsigned int sum = 0;
    for (i = 0; ukey[i] != '\0'; ++i) {
        sum += ukey[i];
    }
    return sum;
}

в этой функции заслуживает внимание лишь приведение типа char * аргумента key к типу unsigned char * переменной ukey, чтобы байты строки интерпретировались не как числа со знаком, а как числа без знака. Это позволит гарантировать, что сумма положительна и остаток от деления её на n тоже положителен.

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

TODO общие свойства хеш-функций TODO улучшенные хеш-функции TODO перерисовать картинки