*Математика хеширования

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

Средняя длина цепочки

Итак, как мы с вами выяснили, время работы каждой операции в хеш-таблице будет зависеть от длины цепочки в бакете. Давайте честно вычислим среднюю длину цепочки для хеш-таблицы с $N$ элементами и $M = \alpha N$ бакетами.

Но вот вопрос - а по какому параметру брать среднюю длину цепочки? Если вы думаете, что по среднее по всем бакетам, то вы ошибаетесь, поскольку так наша средняя длина будет зависеть от самих элементов в хеш-таблице, а не от их количества. Поэтому мы будем брать среднюю длину ПО МНОЖЕСТВУ возможных ХЕШ-ФУНКЦИЙ $\mathcal H = {\{h_1, h_2, \ldots,\}}$.

Но из чего состоит всё это множество? Давайте возьмем вообще множество ВСЕХ ВОЗМОЖНЫХ функций, которые отображают $[0, A-1]$ в $[0, M-1]$, то есть всего в этом множестве будет $A^M$ различных функций. Среди таких функций мы будем равновероятно выбирать одну, которая и будет использоваться в хеш-таблице. Теперь мы готовы посчитать среднюю длину цепочки в бакете для нового элемента $a_n$: распишем через индикаторы того, что какой-то другой элемент $a_i$ попал в тот же бакет, что и $a_n$:

\[E_h[L] = E_h\left[\sum\limits_{i=0}^{n-1} I[h(a_i) = h(a_n)]\right]\]

По линейности математического ожидания мы можем вынести сумму за знак ожидания:

\[E_h[L] = E_h\left[\sum\limits_{i=0}^{n-1} I[h(a_i) = h(a_n)]\right] = \sum\limits_{i=0}^{n-1} E_h[I[h(a_i) = h(a_n)]]\]

Матожидание индикатора - это вероятность того, что событие произошло, то есть:

\[E_h[L] = \sum\limits_{i=0}^{n-1} E_h[I[h(a_i) = h(a_n)]] = \sum\limits_{i=0}^{n-1} P_h[h(a_i) = h(a_n)]\]

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

\[P_h[h(a_i) = h(a_n)] = \begin{cases} \frac{1}{M}, & \text{если } a_i \neq a_n \\ 1, & \text{иначе} \end{cases}\]

Таким образом, искомое матожидание можно оценить сверху как $1 + \frac{N}{M}$ (потому что в бакете запрещены одинаковые элементы):

\[E_h[L] = \sum\limits_{i=0}^{n-1} P_h[h(a_i) = h(a_n)] \leq 1 + \sum\limits_{i=0}^{n-1} \frac{1}{M} = 1 + \frac{N}{M} = 1 + \alpha = \Theta(\alpha)\]

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

Где-то проблема

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

Почему не можем

Чтобы закодировать произвольную функцию из $[0, A-1]$ в $[0, M-1]$, нам нужно запомнить всю таблицу переходов $\forall a \in [0, A-1] \to [0, \ldots, M-1]$. То есть нам нужно $O(A \log M)$ памяти, чтобы закодировать произвольную функцию, а мы то хотели использовать $O(M)$ памяти. Поэтому мы не можем выбрать произвольную хеш-функцию.

Как же быть?

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

  • вероятность того, что два различных элемента имеют одинаковый хеш должна не превосходить $\frac{1}{M}$

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

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

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

Далее мы будем искать именно такие семейства хеш-функций, которые удовлетворяют всем нашим требованиям.

Хорошее семейство

Сделаем несколько предположений:

  • Пусть $x \in [0, 1, \ldots, P-1]$ - это элемент, который мы хотим захешировать и он ялвяется целым числом
  • Пусть $P$ - это простое число (если $x$ ограничивался свеху не простым числом, то найдем ближайшее большее простое число)
  • Пусть $M$ - это размер хеш-таблицы (количество бакетов)

Давайте докажем, что линейные хеш-функции удовлетворяют всем нашим требованиям:

\[\mathcal H = \left\{h(x) = (ax + b) \bmod P \bmod M\right\},\]

где $1 \leq a < P$, $0 \leq b < P$ - случайные числа, которые мы будем выбирать из равномерного распределения. То есть среди такого множества хеш-функций мы умеем равновероятно выбирать случайную за $O(1)$, да сама функция вычисляется за $O(1)$. Осталось проверить, что вероятность того, что два различных элемента имеют одинаковый хеш не превосходит $\frac{1}{M}$.

Пусть $x$ и $y$ - два различных элемента, тогда вероятность того, что они имеют одинаковый хеш будет равна:

\[P_{a, b}[h(x) = h(y)] = P_{a, b}[(ax + b) \bmod P \bmod M = (ay + b) \bmod P \bmod M]\]

Введём обозначения: $x’$ и $y’$:

\[\begin{cases} x' = (ax + b) \bmod P \\ y' = (ay + b) \bmod P \end{cases}\]

Заметим, что при ограничениях $a \in [1, \ldots, P - 1]$, $b \in [0, \ldots, P - 1]$ и $P$ - простое найдется ровно одна подходящая пара $(a, b)$, это позволяет однозначно перейти от $x$ и $y$ к $x’$ и $y’$ и наоборот.

Ещё одно полезное замечение: если $x \neq y$, то после линейного отображения $x’ \neq y’$, поскольку $x’ - y’ = a(x - y) \bmod P$ и $P$ - простое, а $a \neq 0$.

Итак, перейдём в термины $x’$ и $y’$ (теперь вероятность будет браться по ним) и перенесём всё в одну сторону:

\[P_{a, b}[h(x) = h(y)] = P_{x' \neq y'}[h(x') = h(y')] = P_{x' \neq y'}[x' \bmod P \bmod M - y' \bmod P \bmod M = 0]\]

Заметим, что мы можем сначала посчитать выражения в скобках, и только потом посчитать остатки от деления:

\[P_{x' \neq y'}[x' \bmod P \bmod M - y' \bmod P \bmod M = 0] = P_{x' \neq y'}[(x' - y') \bmod P \bmod M = 0]\]

А последнюю вероятность посчитать очень легко: подойдут только пары, расстояние между которыми делится на $M$, то есть не больше чем каждая $M$-я:

\[P_{a, b}[h(x) = h(y)] = P_{x' \neq y'} \leq \frac{1}{M}\]

Что и требовалось доказать.

То есть формально чтобы хеш-таблица работала за $O(1)$ в среднем, нужно, чтобы:

  • ключи были целыми числами (в целом любой тип данных мы можем закодировать в целое число, просто оно получится очень большое)
  • Мы знали некоторое простое число $P$, которое больше всех ключей
  • В начале программы мы выбираем случайные числа $1 \leq a < P$ и $0 \leq b < P$ и используем их в качестве параметров хеш-функции $h(x) = (ax + b) \bmod P \bmod M$

На практике конечно мы делаем всё проще.

Упражнения

Задача 1. Предположим, что мы хотим захешировать строку $s_0s_1\ldots s_{n-1}$ (заранее известна ее максимальная длина). Пусть $\mathcal H$ - это некоторое универсальное семейство хеш-функций, $h_0, h_1, \ldots, h_n \in \mathcal H$ - случайно выбранные функции из $\mathcal H$. Тогда семейство $h_0, h_1, \ldots, h_n$ будет универсальным, для хеша:

\[h(s) = \left(h_0(s_0) + h_1(s_1) + \ldots + h_{n-1}(s_{n-1})\right) \bmod M\]

Данный подход - это по сути первая вариация хеша строки, которую я упомянул. А далее мы сделали некоторый трюк - полиномиальный хеш, который позволяет нам не хранить все $h_i$ в памяти. И вот оказывается, что полиномиальный хеш тоже можно представить как универсальное семейство!

Задача 2. Пусть мы хотим хешировать строки вида $s=s_0s_1\ldots s_{n-1}$, а $P > \max(M, s_i)$ - простое число, тогда случайная хеш-функция $h_{int}$ из универсального семейства $\mathcal H: [0, \ldots, P-1] \to [0, \ldots, M-1]$ и случайная константа $Q \in [0, \ldots, P - 1]$ тоже образуют универсальное семейство хеш-функций вида:

\[h(s) = h_{int}\left((s_0 + Qs_1 + \ldots + Q^{n-1}s_{n-1}) \bmod P\right)\]

Ссылки

  1. Лекции по алгоритмам в ШАД от Максима Бабенко
  2. Universal hashing на википедии