Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.
Хэширование в строковых задачах
Хэш — это какая-то функция, сопоставляющая объектам какого-то множества числовые значения из ограниченного промежутка.
«Хорошая» хэш-функция:
- Быстро считается — за линейное от размера объекта время;
- Имеет не очень большие значения — влезающие в 64 бита;
- «Детерминированно-случайная» — если хэш может принимать (n) различных значений, то вероятность того, что хэши от двух случайных объектов совпадут, равна примерно (frac{1}{n}).
Обычно хэш-функция не является взаимно однозначной: одному хэшу может соответствовать много объектов. Такие функции называют сюръективными.
Для некоторых задач удобнее работать с хэшами, чем с самими объектами. Пусть даны (n) строк длины (m), и нас просят (q) раз проверять произвольные две на равенство. Вместо наивной проверки за (O(q cdot n cdot m)), мы можем посчитать хэши всех строк, сохранить, и во время ответа на запрос сравнивать два числа, а не две строки.
Применения в реальной жизни
- Чек-суммы. Простой и быстрый способ проверить целостность большого передаваемого файла — посчитать хэш-функцию на стороне отправителя и на стороне получателя и сравнить.
- Хэш-таблица. Класс
unordered_set
из STL можно реализовать так: заведём (n) изначально пустых односвязных списков. Возьмем какую-нибудь хэш-функцию (f) с областью значений ([0, n)). При обработке.insert(x)
мы будем добавлять элемент (x) в (f(x))-тый список. При ответе на.find(x)
мы будем проверять, лежит ли (x)-тый элемент в (f(x))-том списке. Благодаря «равномерности» хэш-функции, после (k) добавлений ожидаемое количество сравнений будет равно (frac{k}{n}) = (O(1)) при правильном выборе (n). - Мемоизация. В динамическом программировании нам иногда надо работать с состояниями, которые непонятно как кодировать, чтобы «разгладить» в массив. Пример: шахматные позиции. В таком случае нужно писать динамику рекурсивно и хранить подсчитанные значения в хэш-таблице, а для идентификации состояния использовать его хэш.
- Проверка на изоморфизм. Если нам нужно проверить, что какие-нибудь сложные структуры (например, деревья) совпадают, то мы можем придумать для них хэш-функцию и сравнивать их хэши аналогично примеру со строками.
- Криптография. Правильнее и безопаснее хранить хэши паролей в базе данных вместо самих паролей — хэш-функцию нельзя однозначно восстановить.
- Поиск в многомерных пространствах. Детерминированный поиск ближайшей точки среди (m) точек в (n)-мерном пространстве быстро не решается. Однако можно придумать хэш-функцию, присваивающую лежащим рядом элементам одинаковые хэши, и делать поиск только среди элементов с тем же хэшом, что у запроса.
Хэшируемые объекты могут быть самыми разными: строки, изображения, графы, шахматные позиции, просто битовые файлы.
Сегодня же мы остановимся на строках.
Полиномиальное хэширование
Лайфхак: пока вы не выучили все детерминированные строковые алгоритмы, научитесь пользоваться хэшами.
Будем считать, что строка — это последовательность чисел от (1) до (m) (размер алфавита). В C++ char
это на самом деле тоже число, поэтому можно вычитать из символов минимальный код и кастовать в число: int x = (int) (c - 'a' + 1)
.
Определим прямой полиномиальный хэш строки как значение следующего многочлена:
[
h_f = (s_0 + s_1 k + s_2 k^2 + ldots + s_n k^n) mod p
]
Здесь (k) — произвольное число больше размера алфавита, а (p) — достаточно большой модуль, вообще говоря, не обязательно простой.
Его можно посчитать за линейное время, поддерживая переменную, равную нужной в данный момент степени (k):
const int k = 31, mod = 1e9+7;
string s = "abacabadaba";
long long h = 0, m = 1;
for (char c : s) {
int x = (int) (c - 'a' + 1);
h = (h + m * x) % mod;
m = (m * k) % mod;
}
Можем ещё определить обратный полиномиальный хэш:
[
h_b = (s_0 k^n + s_1 k^{n-1} + ldots + s_n) mod p
]
Его преимущество в том, что можно написать на одну строчку кода меньше:
long long h = 0;
for (char c : s) {
int x = (int) (c - 'a' + 1);
h = (h * k + x) % mod;
}
Автору проще думать об обычных многочленах, поэтому он будет везде использовать прямой полиномиальный хэш и обозначать его просто буквой (h).
Зачем это нужно?
Используя тот факт, что хэш это значение многочлена, можно быстро пересчитывать хэш от результата выполнения многих строковых операций.
Например, если нужно посчитать хэш от конкатенации строк (a) и (b) (т. е. (b) приписали в конец строки (a)), то можно просто хэш (b) домножить на (k^{|a|}) и сложить с хэшом (a):
[
h(ab) = h(a) + k^{|a|} cdot h(b)
]
Удалить префикс строки можно так:
[
h(b) = frac{h(ab) — h(a)}{k^{|a|}}
]
А суффикс — ещё проще:
[
h(a) = h(ab) — k^{|a|} cdot h(b)
]
В задачах нам часто понадобится домножать (k) в какой-то степени, поэтому имеет смысл предпосчитать все нужные степени и сохранить в массиве:
const int maxn = 1e5+5;
int p[maxn];
p[0] = 1;
for (int i = 1; i < maxn; i++)
p[i] = (p[i-1] * k) % mod;
Как это использовать в реальных задачах? Пусть нам надо отвечать на запросы проверки на равенство произвольных подстрок одной большой строки. Подсчитаем значение хэш-функции для каждого префикса:
int h[maxn];
h[0] = 0; // h[k] -- хэш префикса длины k
// будем считать, что s это уже последовательность int-ов
for (int i = 0; i < n; i++)
h[i+1] = (h[i] + p[i] * s[i]) % mod;
Теперь с помощью этих префиксных хэшей мы можем определить функцию, которая будет считать хэш на произвольном подотрезке:
[
h(s[l:r]) = frac{h_r-h_l}{k^l}
]
Деление по модулю возможно делать только при некоторых k
и mod
(а именно — при взаимно простых). В любом случае, писать его долго, и мы это делать не хотим.
Для нашей задачи не важно получать именно полиномиальный хэш — главное, чтобы наша функция возвращала одинаковый многочлен от одинаковых подстрок. Вместо приведения к нулевой степени приведём многочлен к какой-нибудь достаточно большой — например, к (n)-ной. Так проще — нужно будет домножать, а не делить.
[
hat{h}(s[l:r]) = k^{n-l} (h_r-h_l)
]
int hash_substring (int l, int r) {
return (h[r+1] - h[l]) * p[n-l] % mod;
}
Теперь мы можем просто вызывать эту функцию от двух отрезков и сравнивать числовое значение, отвечая на запрос за (O(1)).
Упражнение. Напишите то же самое, но используя обратный полиномиальный хэш — этот способ тоже имеет право на существование, и местами он даже проще. Обратный хэш подстроки принято считать и использовать в стандартном виде из определения, поскольку там нет необходимости в делении.
Лайфхак. Если взять обратный полиномиальный хэш короткой строки на небольшом алфавите с (k=10), то числовое значение хэша строки будет наглядно соотноситься с самой строкой:
[
h(abacaba)=1213121
]
Этим удобно пользоваться при дебаге.
Примеры задач
Количество разных подстрок. Посчитаем хэши от всех подстрок за (O(n^2)) и добавим их все в std::set
. Чтобы получить ответ, просто вызовем set.size()
.
Поиск подстроки в строке. Можно посчитать хэши от шаблона (строки, которую ищем) и пройтись «окном» размера шаблона по тексту, поддерживая хэш текущей подстроки. Если хэш какой-то из этих подстрок совпал с хэшом шаблона, то мы нашли нужную подстроку. Это называется алгоритмом Рабина-Карпа.
Сравнение строк (больше-меньше, а не только равенство). У любых двух строк есть какой-то общий префикс (возможно, пустой). Сделаем бинпоиск по его длине, а дальше сравним два символа, идущие за ним.
Палиндромность подстроки. Можно посчитать два массива — обратные хэши и прямые. Проверка на палиндром будет заключаться в сравнении значений hash_substring()
на первом массиве и на втором.
Количество палиндромов. Можно перебрать центр палиндрома, а для каждого центра — бинпоиском его размер. Проверять подстроку на палиндромность мы уже умеем. Как и всегда в задачах на палиндромы, случаи четных и нечетных палиндромов нужно обрабатывать отдельно.
Хранение строк в декартовом дереве
Если для вас всё вышеперечисленное тривиально: можно делать много клёвых вещей, если «оборачивать» строки в декартово дерево. В вершине дерева можно хранить символ, а также хэш подстроки, соответствующей её поддереву. Чтобы поддерживать хэш, нужно просто добавить в upd()
пересчёт хэша от конкатенации трёх строк — левого сына, своего собственного символа и правого сына.
Имея такое дерево, мы можем обрабатывать запросы, связанные с изменением строки: удаление и вставка символа, перемещение и переворот подстрок, а если дерево персистентное — то и копирование подстрок. При запросе хэша подстроки нам, как обычно, нужно просто вырезать нужную подстроку и взять хэш, который будет лежать в вершине-корне.
Если нам не нужно обрабатывать запросы вставки и удаления символов, а, например, только изменения, то можно использовать и дерево отрезков вместо декартова.
Вероятность ошибки и почему это всё вообще работает
У алгоритмов, использующих хэширование, есть один неприятный недостаток: недетерминированность. Если мы сгенерируем бесконечное количество примеров, то когда-нибудь нам не повезет, и программа отработает неправильно. На CodeForces даже иногда случаются взломы решений, использующих хэширование — можно в оффлайне сгенерировать тест против конкретного решения.
Событие, когда два хэша совпали, а не должны, называется коллизией. Пусть мы решаем задачу определения количества различных подстрок — мы добавляем в set
(O(n^2)) различных случайных значений в промежутке ([0, m)). Понятно, что если произойдет коллизия, то мы какую-то строку не учтем и получим WA. Насколько большим следует делать (m), чтобы не бояться такого?
Выбор констант
Практическое правило: если вам нужно хранить (n) различных хэшей, то безопасный модуль — это число порядка (10 cdot n^2). Обоснование — см. парадокс дней рождений.
Не всегда такой можно выбрать один — если он будет слишком большой, будут происходить переполнения. Вместо этого можно брать два или даже три модуля и считать много хэшей параллельно.
Можно также брать модуль (2^{64}). У него есть несколько преимуществ:
- Он большой — второй модуль точно не понадобится.
- С ним ни о каких переполнениях заботиться не нужно — если все хранить в
unsigned long long
, процессор сам автоматически сделает эти взятия остатков при переполнении. - С ним хэширование будет быстрее — раз переполнение происходит на уровне процессора, можно не выполнять долгую операцию
%
.
Всё с этим модулем было прекрасно, пока не придумали тест против него. Однако, его добавляют далеко не на все контесты — имейте это в виду.
В выборе же (k) ограничения не такие серьезные:
- Она должна быть чуть больше размера словаря — иначе можно изменить две соседние буквы и получить коллизию.
- Она должна быть взаимно проста с модулем — иначе в какой-то момент всё может занулиться.
Главное — чтобы значения (k) и модуля не знал человек, который генерирует тесты.
Парадокс дней рождений
В группе, состоящей из 23 или более человек, вероятность совпадения дней рождения хотя бы у двух людей превышает 50%.
Более общее утверждение: в мультимножество нужно добавить (Theta(sqrt{n})) случайных чисел от 1 до n, чтобы какие-то два совпали.
Первое доказательство (для любителей матана). Пусть (f(n, d)) это вероятность того, что в группе из (n) человек ни у кого не совпали дни рождения. Будем считать, что дни рождения распределены независимо и равномерно в промежутке от (1) до (d).
[
f(n, d) = (1-frac{1}{d}) times (1-frac{2}{d}) times … times (1-frac{n-1}{d})
]
Попытаемся оценить (f):
[
begin{aligned}
e^x & = 1 + x + frac{x^2}{2!} + ldots & text{(ряд Тейлора для экспоненты)} \
& simeq 1 + x & text{(аппроксимация для $|x| ll 1$)} \
e^{-frac{n}{d}} & simeq 1 — frac{n}{d} & text{(подставим $frac{n}{d} ll 1$)} \
f(n, d) & simeq e^{-frac{1}{d}} times e^{-frac{2}{d}} times ldots times e^{-frac{n-1}{d}} & \
& = e^{-frac{n(n-1)}{2d}} & \
& simeq e^{-frac{n^2}{2d}} & \
end{aligned}
]
Из последнего выражения более-менее понятно, что вероятность (frac{1}{2}) достигается при (n approx sqrt{d}) и в этой точке изменяется очень быстро.
Второе доказательство (для любителей теорвера). Введем (frac{n(n-1)}{2}) индикаторов — по одному для каждой пары людей ((i, j)) — каждый будет равен единице, если дни рождения совпали. Ожидание и вероятность каждого индикатора равна (frac{1}{d}).
Обозначим за (X) число совпавших дней рождений. Его ожидание равно сумме ожиданий этих индикаторов, то есть (frac{n (n-1)}{2} cdot frac{1}{d}).
Отсюда понятно, что если (d = Theta(n^2)), то ожидание равно константе, а если (d) асимптотически больше или меньше, то (X) стремится нулю или бесконечности соответственно.
Примечание: формально, из этого явно не следует, что вероятности тоже стремятся к 0 и 1.
Бонус: «мета-задача»
Дана произвольная строка, по которой известным только авторам задачи способом генерируется ответ yes/no. В задаче 100 тестов. У вас есть 20 попыток отослать решение. В качестве фидбэка вам доступны вердикты на каждом тесте. Вердиктов всего два: OK (ответ совпал) и WA. Попытки поделить на ноль, выделить терабайт памяти и подобное тоже считаются как WA.
«Решите» задачу.
Полиномиальные хеши и их применение
Время на прочтение
9 мин
Количество просмотров 75K
Здравствуй, хабр. Сегодня я напишу, как можно использовать полиномиальные хеши (далее просто хеши) при решении различных алгоритмических задач.
Введение
Начнем с определения. Пусть у нас есть строка s0..n-1. Полиномиальным хешем этой строки называется число h = hash(s0..n-1) = s0 + ps1 + p2s2 +… + pn-1sn-1, где p — некоторое натуральное число (позже будет сказано, какое именно), а si — код i-ого символа строки s (почти во всех современных языках он записывается s[i]
).
Хеши обладают тем свойством, что у одинаковых строк хеши обязательно равны. Поэтому основная операция, которую позволяют выполнять хеши — быстрое сравнение двух подстрок на равенство. Конечно, чтобы сравнить 2 строки, мы можем написать подобную функцию (будем считать, что длины строк s и t совпадают и равны n):
boolean equals(char[] s, char[] t) {
for (int i = 0; i < n; i++)
if (s[i] != t[i]) {
return false;
}
}
return true;
}
Однако в худшем случае эта функция обязана проверить все символы, что дает асимптотику O(n).
Сравнение строк с помощью хешей
Теперь посмотрим, как справляются с этой задачей хеши. Так как хеш — это всего лишь число, для их сравнения нам потребуется O(1) времени. Правда, для того, чтобы посчитать хеш одной подстроки наивным способом, требуется O(n) времени. Поэтому потребуется немного повозиться с математическими формулами и научиться находить за O(n) хеши
сразу всех
подстрок. Давайте сравним подстроки sL..R и tX..Y (одинаковой длины). Пользуясь определением хеша, мы можем записать:
sL + psL+1 +… + pR-L-1sR-1 + pR-LsR = tX + ptX+1 +… + pY-X-1tY-1 + pY-XtY
Проведем небольшие преобразования в левой части (в правой части все будет происходить аналогично). Запишем хеш подстроки s0..R, он нам понадобится:
hash(s0..R) = s0 + ps1 +… + pL-1sL-1 + pLsL + pL+1sL+1 +… + pR-1sR-1 + pRsR
Разобьем это выражение на две части…
hash(s0..R) = (s0 + ps1 +… + pL-1sL-1) + (pLsL + pL+1sL+1 +… + pR-1sR-1 + pRsR)
… и вынесем из второй скобки множитель pL:
hash(s0..R) = (s0 + ps1 +… + pL-1sL-1) + pL(sL + psL+1 +… + pR-L-1sR-1 + pR-LsR)
Выражение в первой скобке есть не что иное, как хеш подстроки s0..L-1, а во второй — хеш нужной нам подстроки sL..R. Итак, мы получили, что:
hash(s0..R) = hash(s0..L-1) + pLhash(sL..R)
Отсюда вытекает следующая формула для hash(sL..R):
hash(sL..R) = (1 / pL)(hash(s0..R) — hash(s0..L-1))
Аналогично, для второй нашей подстроки будет выполнено равенство hash(tX..Y) = (1 / pX)(hash(t0..Y) — hash(t0..X-1)).
Внимательно глядя на эти формулы, можно заметить, что для вычисления хеша любой подстроки нам необходимо знать лишь хеши префиксов этой строки s0..0, s0..1, …, s0..n-2, s0..n-1. А, так как хеш каждого следующего префикса выражается через хеш предыдущего, их несложно посчитать линейным проходом по строке. Все сразу за O(n) времени. Степени числа p тоже надо заранее предпросчитать и сохранить в массиве.
Пример кода:
// сохраняем в массиве степени числа p, которые нам могут понадобиться
pow[0] = 1;
for (int i = 1; i <= MAX; i++) {
pow[i] = pow[i - 1] * p;
}
// считаем хеши префиксов строки s
hs[0] = s[0];
for (int i = 1; i < n; i++) {
hs[i] = hs[i - 1] + pow[i] * s[i];
}
// считаем хеши префиксов строки t
ht[0] = t[0];
for (int i = 1; i < m; i++) {
ht[i] = ht[i - 1] + pow[i] * t[i];
}
Казалось бы, мы теперь во всеоружии и умеем сравнивать 2 любые подстроки за O(1). Но не все так просто: математические формулы нуждаются в некоторой доработке. К примеру, подобный код:
if ((hs[R] - hs[L - 1]) / pow[L] == (ht[Y] - ht[X - 1]) / pow[X]) { ... }
работать не будет.
- Замечание первое: L (или X) может оказаться равным нулю, и при вычислении
hs[L - 1]
произойдет выход за границы массива. Однако если L равно нулю, то интересующий нас хеш подстроки sL..R хранится в точности вhs[R]
. Поэтому правильнее вместоhs[L - 1]
писать так:L == 0 ? 0 : hs[L - 1]
.
- Замечание второе: даже тип
long
содержит всего 64 бита (я использую Java), а наши строки могут быть длиной в несколько тысяч, и при вычислении хешей неизбежно произойдет переполнение. Эту проблему решить проще всего: надо закрыть на нее глаза. Ну, или почти закрыть: хеши у нас будут считаться, по сути, по модулю 264 (и поэтому не потребуется выполнять операции взятия остатка от деления — красота!). Число p для их подсчета должно быть, во-первых, больше кода самого большого символа в строках, а во-вторых, взаимно простым с модулем (в нашем случае — с 264, т.е. оно должно быть нечетным). Почему так, я здесь рассказывать не буду — об этом можно почитать в книжках по алгебре. Конечно, неизбежно появляется вероятность коллизии, но она крайне мала, поэтому при решении олимпиадных задач можно ей просто пренебречь. - Замечание третье: так как все операции мы теперь выполняем по модулю, деление для нас недоступно (точнее, доступно, но писать это довольно неэффективно). Поэтому от него надо избавляться. Делается это довольно просто, способом, который в школе называют «пропорцией»: выражение приводится к общему знаменателю, и вместо деления используется умножение:
if ((hs[R] - (L == 0 ? 0 : hs[L - 1])) * pow[X] == (ht[Y] - (X == 0 ? 0 : ht[X - 1])) * pow[L]) { ... }
Теперь мы более подробно рассмотрим задачи, где можно применять хеши.
Задачи, решаемые с помощью хешей
1. Сравнение подстрок
Первое, и главное, применение, как уже было сказано, это быстрое сравнение двух подстрок — на нем основываются все остальные алгоритмы с хешами. Код в прошлом разделе довольно громоздкий, поэтому я напишу более удобный код, который будет использоваться в дальнейшем.
Следующая функция вычисляет хеш подстроки sL..R, умноженный на pL:
long getHash(long[] h, int L, int R) {
long result = h[R];
if (L > 0) result -= h[L - 1];
return result;
}
Теперь сравнение двух подстрок мы выполняем следующей строчкой:
if (getHash(hs, L, R) * pow[X] == getHash(ht, X, Y) * pow[L]) { ... }
Умножение на степени числа p можно назвать «приведением к одной степени». Первый хеш был умножен на pL, а второй — на pX — значит, чтобы сравнение происходило корректно, их надо домножить на недостающий множитель.
Примечание: имеет смысл сначала проверить, совпадают ли длины подстрок. Если нет, то строки в принципе не могут быть равны, и тогда можно не проверять вышезаписанное условие.
2. Поиск подстроки в строке за O(n + m)
Хеши позволяют искать подстроку в строке за асимптотически минимальное время. Это делает так называемый алгоритм Рабина-Карпа.
Пусть есть строка s длины n, в которой мы хотим найти все вхождения строки t длины m. Найдем хеш строки t (всей строки целиком) и хеши всех префиксов строки s, а затем будем двигаться по строке s окном длины m, сравнивая подстроки si..i+m-1.
Код:
// считаем хеш строки t
long ht = t[0];
for (int i = 1; i < m; i++) {
ht += pow[i] * t[i];
}
// проверяем все позиции
for (int i = 0; i + m <= n; i++) {
if (getHash(h, i, i + m - 1) == ht * pow[i]) {
// обнаружено совпадение на позиции i
}
}
3. Нахождение z-функции за O(n log n)
Z-функцией строки s называется массив z, i-ый элемент которого равен наидлиннейшему префиксу подстроки, начинающейся с позиции i в строке s, который одновременно является и префиксом всей строки s. Значение z-функции в нулевой позиции будем считать равным длине строки s, хотя некоторые источники принимают его за ноль (но это не критично).
Конечно, есть алгоритм нахождения z-функции за O(n). Но когда его не знаешь или не помнишь (а алгоритм довольно громоздкий), на помощь приходят хеши.
Идея следующая: для каждого i = 0, 1, …, n-1 будем искать zi бинарным поиском, т.е. на каждой итерации сокращая интервал возможных значений вдвое. Это корректно, потому что равенство s0..k-1 = si..i+k-1 обязательно выполняется для всех k, меньших zi, и обязательно не выполняется для больших k.
Код:
int[] z = new int[n];
for (int i = 0; i < n; i++) {
int left = 1, right = n - i; // текущий интервал значений
while (left <= right) {
// находим middle - середину интервала
int middle = (left + right) / 2;
// и проверяем, совпадают ли подстроки s[0..middle-1] и s[i..i+middle-1]
if (getHash(h, 0, middle - 1) * pow[i] == getHash(h, i, i + middle - 1)) {
// если совпадают, то ответ как равен минимум middle, и надо проверить большие значения
z[i] = middle;
left = middle + 1;
} else {
// если не совпадают, надо проверить меньшие значения
right = middle - 1;
}
}
}
4. Поиск лексикографически минимального циклического сдвига строки за O(n log n).
Существует алгоритм Дюваля, который позволяет решать эту задачу за O(n), однако я знаю некоторых довольно сильных олимпиадных программистов, которые так и не разобрались в нем. Пока они будут в нем разбираться, мы снова применим хеши.
Алгоритм следующий. Сначала примем саму строку s за лучший (лексикографически минимальный) ответ. Затем для каждого циклического сдвига с помощью бинарного поиска найдем длину максимального общего префикса этого сдвига и текущего лучшего ответа. После этого достаточно сравнить следующие за этим префиксом символы и, если надо, обновить ответ.
Еще заметим, что для удобства здесь рекомендуется приписать строку s к самой себе — не придется делать операции взятия по модулю при обращениям к символам строки s. Будем считать, что это уже сделано.
Код:
int bestPos = 0;
for (int i = 1; i < n; i++) {
int left = 1, right = n, length = 0;
// находим length - длину максимального общего префикса
while (left <= right) {
int middle = (left + right) / 2;
if (getHash(h, bestPos, bestPos + middle - 1) * pow[i] ==
getHash(h, i, i + middle - 1) * pow[bestPos]) {
length = middle;
left = middle + 1;
} else {
right = middle - 1;
}
}
// сравниваем следующий за общим префиксом символ и обновляем ответ
// если длина этого префикса равна n,
// то текущий циклический сдвиг совпадает с минимальным,
// и ответ обновлять не нужно
if (length < n && s[i + length] < s[bestPos + length]) {
bestPos = i;
}
}
Примечание: по сути, внутри цикла for
написан компаратор, сравнивающий лексикографически два циклических сдвига. Используя его, можно за O(n log2 n) отсортировать все циклические сдвиги.
5. Поиск всех палиндромов в строке за O(n log n).
Опять же, существует решение этой задачи за O(n). И опять мы будем решать ее с помощью хешей.
Подстрока sL..R называется палиндромом, если sL = sR, sL+1 = sR-1, и т.д. Если выражаться русским языком, то это означает, что она читается одинаково как слева направо, так и справа налево.
Возможно, вы уже знаете или догадались, при чем тут хеши. Помимо массива h[]
, содержащего хеши для подстрок s0..0, s0..1, …, s0..n-2, s0..n-1, посчитаем второй массив rh[]
(для «перевернутой» строки), который будем обходить справа налево. Он будет содержать соответственно хеши s0..n-1, s1..n-1, …, sn-2..n-1, sn-1..n-1:
rh[n - 1] = s[n - 1];
for (int i = n - 2, j = 1; i >= 0; i--, j++) {
rh[i] = rh[i + 1] + pow[j] * s[i];
}
Должно уже быть понятно, как за O(1) определять, является ли строка палиндромом. Я напишу функцию getRevHash(), аналогичную getHash(), а потом приведу необходимое условие сравнения. Вы можете самостоятельно убедиться в правильности этого выражения, проделав математические выкладки, подобные тем, что приводились в начале статьи.
long getRevHash(long[] rh, int L, int R) {
long result = rh[L];
if (R < n - 1) result -= rh[R + 1];
return result;
}
boolean isPalindrome(long[] h, long[] rh, long[] pow, int L, int R) {
return getHash(h, L, R) * pow[n - R - 1] == getRevHash(rh, L, R) * pow[L];
}
Теперь рассмотрим позицию i в строке. Пусть существует палиндром нечетной длины d с центром в позиции i (в случае четной длины — с центром между позициями i-1 и i). Если обрезать с его краев по одному символу, он останется палиндромом. И так можно продолжать, пока его длина не станет равной нулю.
Таким образом, нам достаточно для каждой позиции хранить 2 значения: сколько существует палиндромов нечетной длины с центром в позиции i, и сколько существует палиндромов четной длины с центром между позициями i-1 и i. Обратите внимание, что эти 2 значения абсолютно независимы друг от друга, и обрабатывать их надо отдельно.
Применим, как и ранее, бинарный поиск:
int[] oddCount = new int[n];
for (int i = 0; i < n; i++) {
int left = 1, right = min(i + 1, n - i);
while (left <= right) {
int middle = (left + right) / 2;
if (isPalindrome(h, rh, pow, i - middle + 1, i + middle - 1)) {
oddCount[i] = middle;
left = middle + 1;
} else {
right = middle - 1;
}
}
}
int[] evenCount = new int[n];
for (int i = 0; i < n; i++) {
int left = 1, right = min(i, n - i);
while (left <= right) {
int middle = (left + right) / 2;
if (isPalindrome(h, rh, pow, i - middle, i + middle - 1)) {
evenCount[i] = middle;
left = middle + 1;
} else {
right = middle - 1;
}
}
}
Теперь можно, к примеру, найти общее количество всех палиндромов в строке, или длину максимального палиндрома. Длина максимального нечетного палиндрома с центром в позиции i считается как 2 * oddCount[i] - 1
, а максимального четного палиндрома — 2 * evenCount[i]
.
Еще раз напомню, что нужно быть внимательнее с палиндромами четной и нечетной длины — как правило, их надо обрабатывать независимо друг от друга.
Хеши в матрицах
Наконец, рассмотрим более изощренные применения хешей. Теперь наше пространство будет двумерным, и сравнивать мы будем подматрицы. К счастью, хеши очень хорошо обобщаются на двумерный случай (трехмерных и более я не встречал).
Теперь вместо числа p и массива pow
у нас будут два различных числа p, q и два массива pow1
, pow2
: по одному числу и по одному массиву в каждом направлении: по вертикали и горизонтали.
Хешем матрицы a0..n-1, 0..m-1 будем называть сумму по всем i = 0, …, n-1, j = 0,…, m-1 величин piqjaij.
Теперь научимся считать хеши подматриц, содержащих левый верхний элемент a00. Очевидно, что hash(a0..0, 0..0) = a00. Почти так же очевидно, что для всех j = 1,…, m-1 hash(a0..0, 0..j) = hash(a0..0, 0..j-1) + qja0j, для всех i = 1,…, n-1 hash(a0..i, 0..0) = hash(a0..i-1, 0..0) + piai0. Это напрямую вытекает из одномерного случая.
Как посчитать хеш подматрицы a0..i, 0..j? Можно догадаться, что hash(a0..i, 0..j) = hash(a0..i-1, 0..j) + hash(a0..i, 0..j-1) — hash(a0..i-1, 0..j-1) + piqjaij. Эту формулу можно получить из следующих соображений: сложим все слагаемые (хеш, напомню, это сумма нескольких слагаемых), составляющие хеш подматриц a0..i-1, 0..j и a0..i, 0..j-1. При этом мы два раза учли слагаемые, составляющие подматрицу a0..i-1, 0..j-1, так что вычтем их, чтобы они учитывались один раз. Теперь не хватает только элемента aij, умноженного на соответствующие степени p и q.
Примерно из тех же соображений, что и в первой части статьи (вы уже заметили причастность формулы включений-исключений?) строится функция для вычисления хеша произвольной подматрицы ax1..x2, y1..y2:
long getMatrixHash(long[][] h, int x1, int x2, int y1, int y2) {
long result = h[x2][y2];
if (x1 > 0) result -= h[x1 - 1][y2];
if (y1 > 0) result -= h[x2][y1 - 1];
if (x1 > 0 && y1 > 0) result += h[x1 - 1][y1 - 1];
return result;
}
Эта функция возвращает хеш подматрицы ax1..x2, y1..y2, умноженный на величину px1qy1.
А сравнение двух подматриц aax1..ax2, ay1..ay2 и abx1..bx2, by1..by2 выполняется с помощью следующего выражения:
if (getMatrixHash(h, ax1, ax2, ay1, ay2) * pow1[bx1] * pow2[by1] ==
getMatrixHash(h, bx1, bx2, by1, by2) * pow1[ax1] * pow2[ay1]) { ... }
Хешами также можно решать задачи, связанные с нахождением самой большой симметричной подматрицы и похожие на них. Причем я не знаю сравнимых с хешами по скорости и простоте алгоритмов, выполняющих эту работу. Здесь используются те же принципы, что и при поиске палиндромов в одномерном случае (т.е. считать «реверснутые» хеши справа налево и снизу вверх, проводить бинпоиск отдельно для подматриц четной и нечетной длины). Предлагаю попробовать решить эту задачу самостоятельно — эта статья вам поможет!
Заключение
Итак, в нашем распоряжении есть довольно неплохой аппарат, позволяющий делать многие вещи либо с лучшей возможной асимптотикой, либо лишь чуть-чуть (в логарифм раз) медленнее, чем специализированные алгоритмы. Неплохо, не так ли?
Алгоритм Рабина-Карпа предназначен для поиска подстроки в строке.
Содержание
- 1 Метод хеширования
- 2 Решение
- 2.1 Алгоритм
- 2.2 Псевдокод
- 2.3 Время работы
- 3 Сравнение с другими алгоритмами
- 4 См. также
- 5 Источники информации
Метод хеширования
Наивный алгоритм поиска подстроки в строке работает за в худшем случае — слишком долго. Чтобы ускорить этот процесс, можно воспользоваться методом хеширования.
Определение: |
Пусть дана строка . Тогда полиномиальным хешем (англ. polynomial hash) строки называется число , где — некоторое простое число, а код -ого символа строки . |
Проблему переполнения при вычислении хешей довольно больших строк можно решить так считать хеши по модулю (или ), чтобы модуль брался автоматически при переполнении типов.
Для работы алгоритма потребуется считать хеш подстроки . Делать это можно следующим образом:
Рассмотрим хеш :
Разобьем это выражение на две части:
Вынесем из последней скобки множитель :
Выражение в первой скобке есть не что иное, как хеш подстроки , а во второй — хеш нужной нам подстроки . Итак, мы получили, что:
Отсюда получается следующая формула для :
Однако, как видно из формулы, чтобы уметь считать хеш для всех подстрок начинающихся с , нужно предпосчитать все для . Это займет много памяти. Но поскольку нам нужны только подстроки размером мы можем подсчитать хеш подстроки , а затем пересчитывать хеши для всех за следующим образом:
.
.
Получается : .
Решение
Алгоритм
Алгоритм начинается с подсчета и , а также с подсчета , для ускорения ответов на запрос.
Для вычисляется и сравнивается с . Если они оказались равны, то образец скорее всего содержится в строке начиная с позиции , хотя возможны и ложные срабатывания алгоритма. Если требуется свести такие срабатывания к минимуму или исключить вовсе, то применяют сравнение некоторых символов из этих строк, которые выбраны случайным образом, или применяют явное сравнение строк, как в наивном алгоритме поиска подстроки в строке. В первом случае проверка произойдет быстрее, но вероятность ложного срабатывания, хоть и небольшая, останется. Во втором случае проверка займет время, равное длине образца, но полностью исключит возможность ложного срабатывания.
Если требуется найти индексы вхождения нескольких образцов, или сравнить две строки выгоднее будет предпосчитать все степени , а также хеши всех префиксов строки .
Псевдокод
Приведем пример псевдокода, который находит все вхождения строки в строку и возвращает массив позиций, откуда начинаются вхождения.
vector<int> rabinKarp (s : string, w : string):
vector<int> answer
int n = s.length
int m = w.length
int hashS = hash(s[0..m - 1])
int hashW = hash(w[0..m - 1])
for i = 0 to n - m
if hashS == hashW
answer.add(i)
hashS = (p * hashS - p * hash(s[i]) + hash(s[i + m])) mod r // r — некоторое большое число, p — некоторое просто число
return answer
Новый хеш был получен с помощью быстрого пересчёта. Для сохранения корректности алгоритма нужно считать, что — пустой символ.
Время работы
Изначальный подсчёт хешей выполняется за .
Каждая итерация выполняется за , В цикле всего итераций.
Итоговое время работы алгоритма .
Однако, если требуется исключить ложные срабатывания алгоритма полностью, т.е. придется проверить все полученные позиции вхождения на истинность, то в худшем случае итоговое время работы алгоритма будет .
Сравнение с другими алгоритмами
Преимущества:
- Быстрая скорость работы — , где — длина строки, — длина образца;
- Простая и понятная реализация;
Недостатки:
- Возможно подобрать входные данные так, что количество ложных срабатываний будет недопустимо большим;
См. также
- Наивный алгоритм поиска подстроки в строке
- Поиск наибольшей общей подстроки двух строк с использованием хеширования
Источники информации
- Кормен, Томас Х., Лейзерсон, Чарльз И., Ривест, Рональд Л., Штайн Клиффорд Алгоритмы: построение и анализ, 3-е издание. Пер. с англ. — М.:Издательский дом «Вильямс», 2014. — 1328 с.: ил. — ISBN 978-5-8459-1794-2 (рус.) — страницы 1036–1041.
Hash Function
A Hash function is a function that maps any kind of data of arbitrary size to fixed-size values. The values returned by the function are called Hash Values or digests. There are many popular Hash Functions such as DJBX33A, MD5, and SHA-256. This post will discuss the key features, implementation, advantages and drawbacks of the Polynomial Rolling Hash Function.
Note that if two strings are equal, their hash values should also be equal. But the inverse need not be true.
The polynomial rolling hash function
Polynomial rolling hash function is a hash function that uses only multiplications and additions. The following is the function:
or simply,
Where
Below is the implementation of the Polynomial Rolling Hash Function
C
#include <stdio.h>
#include <string.h>
int
get_hash(
const
char
* s,
const
int
n) {
long
long
p = 31, m = 1e9 + 7;
long
long
hash = 0;
long
long
p_pow = 1;
for
(
int
i = 0; i < n; i++) {
hash = (hash + (s[i] -
'a'
+ 1) * p_pow) % m;
p_pow = (p_pow * p) % m;
}
return
hash;
}
int
main() {
char
s[] =
"geeksforgeeks"
;
int
n =
strlen
(s);
printf
(
"Hash of %s is %dn"
, s, get_hash(s, n));
return
0;
}
C++
#include <bits/stdc++.h>
using
namespace
std;
struct
Hash {
long
long
p = 31, m = 1e9 + 7;
long
long
hash_value;
Hash(
const
string& s)
{
long
long
hash_so_far = 0;
long
long
p_pow = 1;
const
long
long
n = s.length();
for
(
long
long
i = 0; i < n; ++i) {
hash_so_far
= (hash_so_far + (s[i] -
'a'
+ 1) * p_pow)
% m;
p_pow = (p_pow * p) % m;
}
hash_value = hash_so_far;
}
bool
operator==(
const
Hash& other)
{
return
(hash_value == other.hash_value);
}
};
int
main()
{
const
string s =
"geeksforgeeks"
;
Hash h(s);
cout <<
"Hash of "
<< s <<
" is: "
<< h.hash_value
<<
'n'
;
return
0;
}
Java
class
Hash {
final
int
p =
31
, m =
1000000007
;
int
hash_value;
Hash(String S)
{
int
hash_so_far =
0
;
final
char
[] s = S.toCharArray();
long
p_pow =
1
;
final
int
n = s.length;
for
(
int
i =
0
; i < n; i++) {
hash_so_far = (
int
)((hash_so_far
+ (s[i] -
'a'
+
1
) * p_pow)
% m);
p_pow = (p_pow * p) % m;
}
hash_value = hash_so_far;
}
}
class
Main {
public
static
void
main(String[] args)
{
String s =
"geeksforgeeks"
;
Hash h =
new
Hash(s);
System.out.println(
"Hash of "
+ s +
" is "
+ h.hash_value);
}
}
Python3
class
Hash
:
def
__init__(
self
, s:
str
):
self
.hash_value
=
0
self
.p,
self
.m
=
31
,
10
*
*
9
+
7
self
.length
=
len
(s)
hash_so_far
=
0
p_pow
=
1
for
i
in
range
(
self
.length):
hash_so_far
=
(hash_so_far
+
(
1
+
ord
(s[i])
-
ord
(
'a'
))
*
p_pow)
%
self
.m
p_pow
=
(p_pow
*
self
.p)
%
self
.m
self
.hash_value
=
hash_so_far
def
__eq__(
self
, other):
return
self
.hash_value
=
=
other.hash_value
if
__name__
=
=
'__main__'
:
s
=
"geeksforgeeks"
h
=
Hash
(s)
print
(
"Hash value of {} is {}"
.
format
(s, h.hash_value))
C#
using
System;
public
struct
Hash
{
public
long
p;
public
long
m;
public
long
hash_value;
public
Hash(
string
s)
{
p = 31;
m = 1000000007;
long
hash_so_far = 0;
long
p_pow = 1;
long
n = s.Length;
for
(
long
i = 0; i < n; ++i)
{
hash_so_far = (hash_so_far + (s[(
int
)i] -
'a'
+ 1) * p_pow) % m;
p_pow = (p_pow * p) % m;
}
hash_value = hash_so_far;
}
public
static
bool
operator
==(Hash a, Hash b)
{
return
a.hash_value == b.hash_value;
}
public
static
bool
operator
!=(Hash a, Hash b)
{
return
!(a == b);
}
}
public
class
MainClass
{
public
static
void
Main()
{
string
s =
"geeksforgeeks"
;
Hash h =
new
Hash(s);
Console.WriteLine($
"Hash of {s} is: {h.hash_value}"
);
}
}
Javascript
function
get_hash(s) {
const p = 31;
const m = 1e9 + 7;
let hash = 0;
let pPow = 1;
for
(let i = 0; i < s.length; i++) {
hash = (hash + (s.charCodeAt(i) -
'a'
.charCodeAt(0) + 1) * pPow) % m;
pPow = (pPow * p) % m;
}
return
hash;
}
const s =
"geeksforgeeks"
;
console.log(`Hash of ${s} is ${get_hash(s)}`);
Output
Hash of geeksforgeeks is 609871790
Time Complexity: O(N)
Auxiliary Space: O(1)
Collisions in Polynomial Rolling Hash
Since the output of the Hash function is an integer in the range , there are high chances for two strings producing the same hash value.
For instance, the strings and produce the same hash value for and .
Also, the strings and produce the same hash value for and .
We can guarantee a collision within a very small domain. Consider a set of strings, , consisting of only lower-case letters, such that the length of any string in doesn’t exceed .
We have . Since the range of the Hash Function is , one-one mapping is impossible. Hence, we can guarantee a collision by arbitrarily generating two strings whose length doesn’t exceed .
Collision Resolution
We can note that the value of affects the chances of collision. We have seen that the probability of collision is . We can increase the value of to reduce the probability of collision. But that affects the speed of the algorithm. Larger the value of , the slower the algorithm. Also, some languages (C, C++, Java) have a limit on the size of the integer. Hence, we can’t increase the value of to a very large value.
Then how can we minimise the chances of a collision?
Note that the hash of a string depends on two parameters: and .
We have seen that the strings and produce the same hash value for and . But for and , they produce different hashes.
Observation:
If two strings produce the same hash values for a pair , they will produce different hashes for a different pair, .
Strategy:
We cannot, however, nullify the chances of collision because there are infinitely many strings. But, surely, we can reduce the probability of two strings colliding.
We can reduce the probability of collision by generating a pair of hashes for a given string. The first hash is generated using and , while the second hash is generated using and .
Why will this work?
We are generating two hashes using two different modulo values, and . The probability of a collision is now . Since both and are greater than , the probability that a collision occurs is now less than which is so much better than the original probability of collision, .
Below is the implementation for the same
C++
#include <bits/stdc++.h>
using
namespace
std;
struct
Hash {
const
int
p1 = 31, m1 = 1e9 + 7;
const
int
p2 = 37, m2 = 1e9 + 9;
int
hash1 = 0, hash2 = 0;
Hash(
const
string& s) {
compute_hash1(s);
compute_hash2(s);
}
void
compute_hash1(
const
string& s) {
long
p_pow = 1;
for
(
char
ch: s) {
hash1 = (hash1 + (ch + 1 -
'a'
) * p_pow) % m1;
p_pow = (p_pow * p1) % m1;
}
}
void
compute_hash2(
const
string& s) {
long
p_pow = 1;
for
(
char
ch: s) {
hash2 = (hash2 + (ch + 1 -
'a'
) * p_pow) % m2;
p_pow = (p_pow * p2) % m2;
}
}
bool
operator==(
const
Hash& other) {
return
(hash1 == other.hash1 && hash2 == other.hash2);
}
};
int
main() {
const
string s =
"geeksforgeeks"
;
Hash h(s);
cout <<
"Hash values of "
<< s <<
" are: "
;
cout <<
"("
<< h.hash1 <<
", "
<< h.hash2 <<
")"
<<
'n'
;
return
0;
}
C
#include <stdio.h>
#include <string.h>
int
get_hash1(
const
char
* s,
int
length) {
const
int
p = 31, m = 1e9 + 7;
int
hash_value = 0;
long
p_pow = 1;
for
(
int
i = 0; i < length; i++) {
hash_value = (hash_value + (s[i] -
'a'
+ 1) * p_pow) % m;
p_pow = (p_pow * p) % m;
}
return
hash_value;
}
int
get_hash2(
const
char
* s,
int
length) {
const
int
p = 37, m = 1e9 + 9;
int
hash_value = 0;
long
p_pow = 1;
for
(
int
i = 0; i < length; i++) {
hash_value = (hash_value + (s[i] -
'a'
+ 1) * p_pow) % m;
p_pow = (p_pow * p) % m;
}
return
hash_value;
}
int
main() {
char
s[] =
"geeksforgeeks"
;
int
length =
strlen
(s);
int
hash1 = get_hash1(s, length);
int
hash2 = get_hash2(s, length);
printf
(
"Hash values of %s are: (%d, %d)n"
, s, hash1, hash2);
return
0;
}
Java
class
Hash {
final
int
p1 =
31
, m1 =
1000000007
;
final
int
p2 =
37
, m2 =
1000000009
;
int
hash_value1, hash_value2;
Hash(String s) {
compute_hash1(s);
compute_hash2(s);
}
void
compute_hash1(String s) {
int
hash_so_far =
0
;
final
char
[] s_array = s.toCharArray();
long
p_pow =
1
;
final
int
n = s_array.length;
for
(
int
i =
0
; i < n; i++) {
hash_so_far = (
int
)((hash_so_far + (s_array[i] -
'a'
+
1
) * p_pow) % m1);
p_pow = (p_pow * p1) % m1;
}
hash_value1 = hash_so_far;
}
void
compute_hash2(String s) {
int
hash_so_far =
0
;
final
char
[] s_array = s.toCharArray();
long
p_pow =
1
;
final
int
n = s_array.length;
for
(
int
i =
0
; i < n; i++) {
hash_so_far = (
int
)((hash_so_far + (s_array[i] -
'a'
+
1
) * p_pow) % m2);
p_pow = (p_pow * p2) % m2;
}
hash_value2 = hash_so_far;
}
}
class
Main {
public
static
void
main(String[] args) {
String s =
"geeksforgeeks"
;
Hash h =
new
Hash(s);
System.out.println(
"Hash values of "
+ s +
" are: "
+ h.hash_value1 +
", "
+ h.hash_value2);
}
}
Python3
class
Hash
:
def
__init__(
self
, s:
str
):
self
.p1,
self
.m1
=
31
,
10
*
*
9
+
7
self
.p2,
self
.m2
=
37
,
10
*
*
9
+
9
self
.hash1,
self
.hash2
=
0
,
0
self
.compute_hashes(s)
def
compute_hashes(
self
, s:
str
):
pow1, pow2
=
1
,
1
hash1, hash2
=
0
,
0
for
ch
in
s:
seed
=
1
+
ord
(ch)
-
ord
(
'a'
)
hash1
=
(hash1
+
seed
*
pow1)
%
self
.m1
hash2
=
(hash2
+
seed
*
pow2)
%
self
.m2
pow1
=
(pow1
*
self
.p1)
%
self
.m1
pow2
=
(pow2
*
self
.p2)
%
self
.m2
self
.hash1,
self
.hash2
=
hash1, hash2
def
__eq__(
self
, other):
return
self
.hash1
=
=
other.hash1
and
self
.hash2
=
=
other.hash2
def
__str__(
self
):
return
f
'({self.hash1}, {self.hash2})'
if
__name__
=
=
'__main__'
:
s
=
"geeksforgeeks"
hash
=
Hash
(s)
print
(
"Hash of "
+
s
+
" is "
+
str
(
hash
))
C#
using
System;
class
Hash {
readonly
int
p1 = 31, m1 = 1000000007;
readonly
int
p2 = 37, m2 = 1000000009;
public
int
hash_value1, hash_value2;
public
Hash(
string
s) {
compute_hash1(s);
compute_hash2(s);
}
void
compute_hash1(
string
s) {
int
hash_so_far = 0;
char
[] s_array = s.ToCharArray();
long
p_pow = 1;
int
n = s_array.Length;
for
(
int
i = 0; i < n; i++) {
hash_so_far = (
int
)((hash_so_far + (s_array[i] -
'a'
+ 1) * p_pow) % m1);
p_pow = (p_pow * p1) % m1;
}
hash_value1 = hash_so_far;
}
void
compute_hash2(
string
s) {
int
hash_so_far = 0;
char
[] s_array = s.ToCharArray();
long
p_pow = 1;
int
n = s_array.Length;
for
(
int
i = 0; i < n; i++) {
hash_so_far = (
int
)((hash_so_far + (s_array[i] -
'a'
+ 1) * p_pow) % m2);
p_pow = (p_pow * p2) % m2;
}
hash_value2 = hash_so_far;
}
}
class
Program {
public
static
void
Main(
string
[] args) {
string
s =
"geeksforgeeks"
;
Hash h =
new
Hash(s);
Console.WriteLine(
"Hash values of "
+ s +
" are: "
+ h.hash_value1 +
", "
+ h.hash_value2);
}
}
Javascript
function
get_hash1(s, length) {
const p = 31, m = 1e9 + 7;
let hash_value = 0;
let p_pow = 1;
for
(let i = 0; i < length; i++) {
hash_value = (hash_value + (s.charCodeAt(i) - 97 + 1) * p_pow) % m;
p_pow = (p_pow * p) % m;
}
return
hash_value;
}
function
get_hash2(s, length) {
const p = 37, m = 1e9 + 9;
let hash_value = 0;
let p_pow = 1;
for
(let i = 0; i < length; i++) {
hash_value = (hash_value + (s.charCodeAt(i) - 97 + 1) * p_pow) % m;
p_pow = (p_pow * p) % m;
}
return
hash_value;
}
const s =
"geeksforgeeks"
;
const length = s.length;
const hash1 = get_hash1(s, length);
const hash2 = get_hash2(s, length);
console.log(`Hash values of ${s} are: (${hash1}, ${hash2})`);
Output
Hash values of geeksforgeeks are: (609871790, 642799661)
Time Complexity: O(N)
Auxiliary Space: O(1)
Features of Polynomial rolling hash function
Calculation of Hashes of any substring of a given string in
Note that computing the hash of the string S will also compute the hashes of all of the prefixes. We just have to store the hash values of the prefixes while computing. Say text{hash[i]} denotes the hash of the prefix text{S[0…i]}, we have
This allows us to quickly compute the hash of the substring in provided we have powers of ready.
The behaviour of the hash when a character is changed
Recall that the hash of a string is given by
Say, we change a character at some index to some other character . How will the hash change?
If denotes the hash value before changing and is the hash value after changing, then the relation between them is given by
Therefore, queries can be performed very quickly instead of recalculating the hash from beginning, provided we have the powers of ready.
A more elegant implementation is provided below.
C++
#include <bits/stdc++.h>
using
namespace
std;
long
long
power(
long
long
x,
long
long
y,
long
long
p) {
long
long
result = 1;
for
(; y; y >>= 1, x = x * x % p) {
if
(y & 1) {
result = result * x % p;
}
}
return
result;
}
long
long
inverse(
long
long
x,
long
long
p) {
return
power(x, p - 2, p);
}
class
Hash {
private
:
int
length;
const
int
mod1 = 1e9 + 7, mod2 = 1e9 + 9;
const
int
p1 = 31, p2 = 37;
vector<
int
> hash1, hash2;
pair<
int
,
int
> hash_pair;
public
:
inline
static
vector<
int
> inv_pow1, inv_pow2;
inline
static
int
inv_size = 1;
Hash() {}
Hash(
const
string& s) {
length = s.size();
hash1.resize(length);
hash2.resize(length);
int
h1 = 0, h2 = 0;
long
long
p_pow1 = 1, p_pow2 = 1;
for
(
int
i = 0; i < length; i++) {
h1 = (h1 + (s[i] -
'a'
+ 1) * p_pow1) % mod1;
h2 = (h2 + (s[i] -
'a'
+ 1) * p_pow2) % mod2;
p_pow1 = (p_pow1 * p1) % mod1;
p_pow2 = (p_pow2 * p2) % mod2;
hash1[i] = h1;
hash2[i] = h2;
}
hash_pair = make_pair(h1, h2);
if
(inv_size < length) {
for
(; inv_size < length; inv_size <<= 1);
inv_pow1.resize(inv_size, -1);
inv_pow2.resize(inv_size, -1);
inv_pow1[inv_size - 1] = inverse(power(p1, inv_size - 1, mod1), mod1);
inv_pow2[inv_size - 1] = inverse(power(p2, inv_size - 1, mod2), mod2);
for
(
int
i = inv_size - 2; i >= 0 && inv_pow1[i] == -1; i--) {
inv_pow1[i] = (1LL * inv_pow1[i + 1] * p1) % mod1;
inv_pow2[i] = (1LL * inv_pow2[i + 1] * p2) % mod2;
}
}
}
int
size() {
return
length;
}
pair<
int
,
int
> prefix(
const
int
index) {
return
{hash1[index], hash2[index]};
}
pair<
int
,
int
> substr(
const
int
l,
const
int
r) {
if
(l == 0) {
return
{hash1[r], hash2[r]};
}
int
temp1 = hash1[r] - hash1[l - 1];
int
temp2 = hash2[r] - hash2[l - 1];
temp1 += (temp1 < 0 ? mod1 : 0);
temp2 += (temp2 < 0 ? mod2 : 0);
temp1 = (temp1 * 1LL * inv_pow1[l]) % mod1;
temp2 = (temp2 * 1LL * inv_pow2[l]) % mod2;
return
{temp1, temp2};
}
bool
operator==(
const
Hash& other) {
return
(hash_pair == other.hash_pair);
}
};
int
main() {
string my_str =
"geeksforgeeks"
;
const
int
n = my_str.length();
auto
hash = Hash(my_str);
auto
hash_pair = hash.substr(0, n - 1);
cout <<
"Hashes of the string "
<< my_str <<
" are:n"
;
cout << hash_pair.first <<
' '
<< hash_pair.second <<
'n'
;
return
0;
}
Python3
class
RollingHash:
def
__init__(
self
, s):
self
.length
=
len
(s)
self
.mod1
=
10
*
*
9
+
7
self
.mod2
=
10
*
*
9
+
9
self
.p1
=
31
self
.p2
=
37
self
.hash1
=
[
0
]
*
self
.length
self
.hash2
=
[
0
]
*
self
.length
h1
=
h2
=
0
p_pow1
=
p_pow2
=
1
for
i
in
range
(
self
.length):
h1
=
(h1
+
(
ord
(s[i])
-
ord
(
'a'
)
+
1
)
*
p_pow1)
%
self
.mod1
h2
=
(h2
+
(
ord
(s[i])
-
ord
(
'a'
)
+
1
)
*
p_pow2)
%
self
.mod2
p_pow1
=
(p_pow1
*
self
.p1)
%
self
.mod1
p_pow2
=
(p_pow2
*
self
.p2)
%
self
.mod2
self
.hash1[i]
=
h1
self
.hash2[i]
=
h2
def
prefix(
self
, index):
return
(
self
.hash1[index],
self
.hash2[index])
def
substr(
self
, l, r):
if
l
=
=
0
:
return
(
self
.hash1[r],
self
.hash2[r])
temp1
=
self
.hash1[r]
-
self
.hash1[l
-
1
]
temp2
=
self
.hash2[r]
-
self
.hash2[l
-
1
]
temp1
+
=
self
.mod1
if
temp1 <
0
else
0
temp2
+
=
self
.mod2
if
temp2 <
0
else
0
temp1
=
(temp1
*
pow
(
self
.p1,
self
.length
-
l,
self
.mod1))
%
self
.mod1
temp2
=
(temp2
*
pow
(
self
.p2,
self
.length
-
l,
self
.mod2))
%
self
.mod2
return
(temp1, temp2)
def
__eq__(
self
, other):
return
self
.prefix(
self
.length
-
1
)
=
=
other.prefix(other.length
-
1
)
my_str
=
"geeksforgeeks"
hash
=
RollingHash(my_str)
hash_pair
=
hash
.substr(
0
,
len
(my_str)
-
1
)
print
(
"Hashes of the string"
, my_str,
"are:"
)
print
(hash_pair)
In the above implementation, we are computing the inverses of powers of $p$ in linear time.
Applications:
Consider this problem: Given a sequence S of N strings and Q queries. In each query, you are given two indices, i and j, your task is to find the length of the longest common prefix of the strings S[i] and S[j].
Before getting into the approach to solve this problem, note that the constraints are:
Using Hashing, the problem can be solved in O(N + Q/log|S|_{max}). The approach is to compute hashes for all the strings in O(N) time, Then for each query, we can binary search the length of the longest common prefix using hashing. The implementation for this approach is provided below.
C++14
#include <bits/stdc++.h>
using
namespace
std;
long
long
power(
long
long
x,
long
long
y,
long
long
p) {
long
long
result = 1;
for
(; y; y >>= 1, x = x * x % p) {
if
(y & 1) {
result = result * x % p;
}
}
return
result;
}
long
long
inverse(
long
long
x,
long
long
p) {
return
power(x, p - 2, p);
}
class
Hash {
private
:
int
length;
const
int
mod1 = 1e9 + 7, mod2 = 1e9 + 9;
const
int
p1 = 31, p2 = 37;
vector<
int
> hash1, hash2;
pair<
int
,
int
> hash_pair;
public
:
inline
static
vector<
int
> inv_pow1, inv_pow2;
inline
static
int
inv_size = 1;
Hash() {}
Hash(
const
string& s) {
length = s.size();
hash1.resize(length);
hash2.resize(length);
int
h1 = 0, h2 = 0;
long
long
p_pow1 = 1, p_pow2 = 1;
for
(
int
i = 0; i < length; i++) {
h1 = (h1 + (s[i] -
'a'
+ 1) * p_pow1) % mod1;
h2 = (h2 + (s[i] -
'a'
+ 1) * p_pow2) % mod2;
p_pow1 = (p_pow1 * p1) % mod1;
p_pow2 = (p_pow2 * p2) % mod2;
hash1[i] = h1;
hash2[i] = h2;
}
hash_pair = make_pair(h1, h2);
if
(inv_size < length) {
for
(; inv_size < length; inv_size <<= 1);
inv_pow1.resize(inv_size, -1);
inv_pow2.resize(inv_size, -1);
inv_pow1[inv_size - 1] = inverse(power(p1, inv_size - 1, mod1), mod1);
inv_pow2[inv_size - 1] = inverse(power(p2, inv_size - 1, mod2), mod2);
for
(
int
i = inv_size - 2; i >= 0 && inv_pow1[i] == -1; i--) {
inv_pow1[i] = (1LL * inv_pow1[i + 1] * p1) % mod1;
inv_pow2[i] = (1LL * inv_pow2[i + 1] * p2) % mod2;
}
}
}
int
size() {
return
length;
}
pair<
int
,
int
> prefix(
const
int
index) {
return
{hash1[index], hash2[index]};
}
pair<
int
,
int
> substr(
const
int
l,
const
int
r) {
if
(l == 0) {
return
{hash1[r], hash2[r]};
}
int
temp1 = hash1[r] - hash1[l - 1];
int
temp2 = hash2[r] - hash2[l - 1];
temp1 += (temp1 < 0 ? mod1 : 0);
temp2 += (temp2 < 0 ? mod2 : 0);
temp1 = (temp1 * 1LL * inv_pow1[l]) % mod1;
temp2 = (temp2 * 1LL * inv_pow2[l]) % mod2;
return
{temp1, temp2};
}
bool
operator==(
const
Hash& other) {
return
(hash_pair == other.hash_pair);
}
};
void
query(vector<Hash>& hashes,
const
int
N) {
int
i = 0, j = 0;
cin >> i >> j;
i--, j--;
int
lb = 0, ub = min(hashes[i].size(), hashes[j].size());
int
max_length = 0;
while
(lb <= ub) {
int
mid = (lb + ub) >> 1;
if
(hashes[i].prefix(mid) == hashes[j].prefix(mid)) {
if
(mid + 1 > max_length) {
max_length = mid + 1;
}
lb = mid + 1;
}
else
{
ub = mid - 1;
}
}
cout << max_length <<
'n'
;
}
int
main() {
int
N = 0, Q = 0;
cin >> N >> Q;
vector<Hash> hashes;
for
(
int
i = 0; i < N; i++) {
string s;
cin >> s;
hashes.push_back(Hash(s));
}
for
(; Q > 0; Q--) {
query(hashes, N);
}
return
0;
}
Input: 5 4 geeksforgeeks geeks hell geeksforpeaks hello 1 2 1 3 3 5 1 4Expected Output: 5 0 4 8
Last Updated :
10 Apr, 2023
Like Article
Save Article
Хэширование в строковых задачах
Хэш — это какая-то функция, сопоставляющая объектам какого-то множества числовые значения из ограниченного промежутка.
«Хорошая» хэш-функция:
- Быстро считается — за линейное от размера объекта время;
- Имеет не очень большие значения — влезающие в 64 бита;
- «Детерминированно-случайная» — если хэш может принимать $n$ различных значений, то вероятность того, что хэши от двух случайных объектов совпадут, равна примерно $frac{1}{n}$.
Обычно хэш-функция не является взаимно однозначной: одному хэшу может соответствовать много объектов. Такие функции называют сюръективными.
Для некоторых задач удобнее работать с хэшами, чем с самими объектами. Пусть даны $n$ строк длины $m$, и нас просят $q$ раз проверять произвольные две на равенство. Вместо наивной проверки за $O(q cdot n cdot m)$, мы можем посчитать хэши всех строк, сохранить, и во время ответа на запрос сравнивать два числа, а не две строки.
Применения в реальной жизни
- Чек-суммы. Простой и быстрый способ проверить целостность большого передаваемого файла — посчитать хэш-функцию на стороне отправителя и на стороне получателя и сравнить.
-
Хэш-таблица. Класс
unordered_set
из STL можно реализовать так: заведём $n$ изначально пустых односвязных списков. Возьмем какую-нибудь хэш-функцию $f$ с областью значений $[0, n)$. При обработке.insert(x)
мы будем добавлять элемент $x$ в $f(x)$-тый список. При ответе на.find(x)
мы будем проверять, лежит ли $x$-тый элемент в $f(x)$-том списке. Благодаря «равномерности» хэш-функции, после $k$ добавлений ожидаемое количество сравнений будет равно $frac{k}{n}$ = $O(1)$ при правильном выборе $n$. - Мемоизация. В динамическом программировании нам иногда надо работать с состояниями, которые непонятно как кодировать, чтобы «разгладить» в массив. Пример: шахматные позиции. В таком случае нужно писать динамику рекурсивно и хранить подсчитанные значения в хэш-таблице, а для идентификации состояния использовать его хэш.
- Проверка на изоморфизм. Если нам нужно проверить, что какие-нибудь сложные структуры (например, деревья) совпадают, то мы можем придумать для них хэш-функцию и сравнивать их хэши аналогично примеру со строками.
- Криптография. Правильнее и безопаснее хранить хэши паролей в базе данных вместо самих паролей — хэш-функцию нельзя однозначно восстановить.
- Поиск в многомерных пространствах. Детерминированный поиск ближайшей точки среди $m$ точек в $n$-мерном пространстве быстро не решается. Однако можно придумать хэш-функцию, присваивающую лежащим рядом элементам одинаковые хэши, и делать поиск только среди элементов с тем же хэшом, что у запроса.
Хэшируемые объекты могут быть самыми разными: строки, изображения, графы, шахматные позиции, просто битовые файлы.
Сегодня же мы остановимся на строках.
Полиномиальное хэширование
Лайфхак: пока вы не выучили все детерминированные строковые алгоритмы, научитесь пользоваться хэшами.
Будем считать, что строка — это последовательность чисел от $1$ до $m$ (размер алфавита). В C++ char
это на самом деле тоже число, поэтому можно вычитать из символов минимальный код и кастовать в число: int x = (int) (c - 'a' + 1)
.
Определим прямой полиномиальный хэш строки как значение следующего многочлена:
$$
h_f = (s_0 + s_1 k + s_2 k^2 + ldots + s_n k^n) mod p
$$
Здесь $k$ — произвольное число больше размера алфавита, а $p$ — достаточно большой модуль, вообще говоря, не обязательно простой.
Его можно посчитать за линейное время, поддерживая переменную, равную нужной в данный момент степени $k$:
const int k = 31, mod = 1e9+7; string s = "abacabadaba"; long long h = 0, m = 1; for (char c : s) { int x = (int) (c - 'a' + 1); h = (h + m * x) % mod; m = (m * k) % mod; }
Можем ещё определить обратный полиномиальный хэш:
$$
h_b = (s_0 k^n + s_1 k^{n-1} + ldots + s_n) mod p
$$
Его преимущество в том, что можно написать на одну строчку кода меньше:
long long h = 0; for (char c : s) { int x = (int) (c - 'a' + 1); h = (h * k + x) % mod; }
Автору проще думать об обычных многочленах, поэтому он будет везде использовать прямой полиномиальный хэш и обозначать его просто буквой $h$.
Зачем это нужно?
Используя тот факт, что хэш это значение многочлена, можно быстро пересчитывать хэш от результата выполнения многих строковых операций.
Например, если нужно посчитать хэш от конкатенации строк $a$ и $b$ (т. е. $b$ приписали в конец строки $a$), то можно просто хэш $b$ домножить на $k^{|a|}$ и сложить с хэшом $a$:
$$
h(ab) = h(a) + k^{|a|} cdot h(b)
$$
Удалить префикс строки можно так:
$$
h(b) = frac{h(ab) — h(a)}{k^{|a|}}
$$
А суффикс — ещё проще:
$$
h(a) = h(ab) — k^{|a|} cdot h(b)
$$
В задачах нам часто понадобится домножать $k$ в какой-то степени, поэтому имеет смысл предпосчитать все нужные степени и сохранить в массиве:
const int maxn = 1e5+5; int p[maxn]; p[0] = 1; for (int i = 1; i < maxn; i++) p[i] = (p[i-1] * k) % mod;
Как это использовать в реальных задачах? Пусть нам надо отвечать на запросы проверки на равенство произвольных подстрок одной большой строки. Подсчитаем значение хэш-функции для каждого префикса:
int h[maxn]; h[0] = 0; // h[k] -- хэш префикса длины k // будем считать, что s это уже последовательность int-ов for (int i = 0; i < n; i++) h[i+1] = (h[i] + p[i] * s[i]) % mod;
Теперь с помощью этих префиксных хэшей мы можем определить функцию, которая будет считать хэш на произвольном подотрезке:
$$
h(s[l:r]) = frac{h_r-h_l}{k^l}
$$
Деление по модулю возможно делать только при некоторых k
и mod
(а именно — при взаимно простых). В любом случае, писать его долго, и мы это делать не хотим.
Для нашей задачи не важно получать именно полиномиальный хэш — главное, чтобы наша функция возвращала одинаковый многочлен от одинаковых подстрок. Вместо приведения к нулевой степени приведём многочлен к какой-нибудь достаточно большой — например, к $n$-ной. Так проще — нужно будет домножать, а не делить.
$$
hat{h}(s[l:r]) = k^{n-l} (h_r-h_l)
$$
int hash_substring (int l, int r) { return (h[r+1] - h[l]) * p[n-l] % mod; }
Теперь мы можем просто вызывать эту функцию от двух отрезков и сравнивать числовое значение, отвечая на запрос за $O(1)$.
Упражнение. Напишите то же самое, но используя обратный полиномиальный хэш — этот способ тоже имеет право на существование, и местами он даже проще. Обратный хэш подстроки принято считать и использовать в стандартном виде из определения, поскольку там нет необходимости в делении.
Лайфхак. Если взять обратный полиномиальный хэш короткой строки на небольшом алфавите с $k=10$, то числовое значение хэша строки будет наглядно соотноситься с самой строкой:
$$
h(abacaba)=1213121
$$
Этим удобно пользоваться при дебаге.
Примеры задач
Количество разных подстрок. Посчитаем хэши от всех подстрок за $O(n^2)$ и добавим их все в std::set
. Чтобы получить ответ, просто вызовем set.size()
.
Поиск подстроки в строке. Можно посчитать хэши от шаблона (строки, которую ищем) и пройтись «окном» размера шаблона по тексту, поддерживая хэш текущей подстроки. Если хэш какой-то из этих подстрок совпал с хэшом шаблона, то мы нашли нужную подстроку. Это называется алгоритмом Рабина-Карпа.
Сравнение строк (больше-меньше, а не только равенство). У любых двух строк есть какой-то общий префикс (возможно, пустой). Сделаем бинпоиск по его длине, а дальше сравним два символа, идущие за ним.
Палиндромность подстроки. Можно посчитать два массива — обратные хэши и прямые. Проверка на палиндром будет заключаться в сравнении значений hash_substring()
на первом массиве и на втором.
Количество палиндромов. Можно перебрать центр палиндрома, а для каждого центра — бинпоиском его размер. Проверять подстроку на палиндромность мы уже умеем. Как и всегда в задачах на палиндромы, случаи четных и нечетных палиндромов нужно обрабатывать отдельно.
Хранение строк в декартовом дереве
Если для вас всё вышеперечисленное тривиально: можно делать много клёвых вещей, если «оборачивать» строки в декартово дерево. В вершине дерева можно хранить символ, а также хэш подстроки, соответствующей её поддереву. Чтобы поддерживать хэш, нужно просто добавить в upd()
пересчёт хэша от конкатенации трёх строк — левого сына, своего собственного символа и правого сына.
Имея такое дерево, мы можем обрабатывать запросы, связанные с изменением строки: удаление и вставка символа, перемещение и переворот подстрок, а если дерево персистентное — то и копирование подстрок. При запросе хэша подстроки нам, как обычно, нужно просто вырезать нужную подстроку и взять хэш, который будет лежать в вершине-корне.
Если нам не нужно обрабатывать запросы вставки и удаления символов, а, например, только изменения, то можно использовать и дерево отрезков вместо декартова.
Вероятность ошибки и почему это всё вообще работает
У алгоритмов, использующих хэширование, есть один неприятный недостаток: недетерминированность. Если мы сгенерируем бесконечное количество примеров, то когда-нибудь нам не повезет, и программа отработает неправильно. На CodeForces даже иногда случаются взломы решений, использующих хэширование — можно в оффлайне сгенерировать тест против конкретного решения.
Событие, когда два хэша совпали, а не должны, называется коллизией. Пусть мы решаем задачу определения количества различных подстрок — мы добавляем в set
$O(n^2)$ различных случайных значений в промежутке $[0, m)$. Понятно, что если произойдет коллизия, то мы какую-то строку не учтем и получим WA. Насколько большим следует делать $m$, чтобы не бояться такого?
Выбор констант
Практическое правило: если вам нужно хранить $n$ различных хэшей, то безопасный модуль — это число порядка $10 cdot n^2$. Обоснование — см. парадокс дней рождений.
Не всегда такой можно выбрать один — если он будет слишком большой, будут происходить переполнения. Вместо этого можно брать два или даже три модуля и считать много хэшей параллельно.
Можно также брать модуль $2^{64}$. У него есть несколько преимуществ:
- Он большой — второй модуль точно не понадобится.
- С ним ни о каких переполнениях заботиться не нужно — если все хранить в
unsigned long long
, процессор сам автоматически сделает эти взятия остатков при переполнении. - С ним хэширование будет быстрее — раз переполнение происходит на уровне процессора, можно не выполнять долгую операцию
%
.
Всё с этим модулем было прекрасно, пока не придумали тест против него. Однако, его добавляют далеко не на все контесты — имейте это в виду.
В выборе же $k$ ограничения не такие серьезные:
- Она должна быть чуть больше размера словаря — иначе можно изменить две соседние буквы и получить коллизию.
- Она должна быть взаимно проста с модулем — иначе в какой-то момент всё может занулиться.
Главное — чтобы значения $k$ и модуля не знал человек, который генерирует тесты.
Парадокс дней рождений
В группе, состоящей из 23 или более человек, вероятность совпадения дней рождения хотя бы у двух людей превышает 50%.
Более общее утверждение: в мультимножество нужно добавить $Theta(sqrt{n})$ случайных чисел от 1 до n, чтобы какие-то два совпали.
Первое доказательство (для любителей матана). Пусть $f(n, d)$ это вероятность того, что в группе из $n$ человек ни у кого не совпали дни рождения.
Будем считать, что дни рождения распределены независимо и равномерно в промежутке от $1$ до $d$.
$$
f(n, d) = (1-frac{1}{d}) times (1-frac{2}{d}) times … times (1-frac{n-1}{d})
$$
Попытаемся оценить $f$:
$$
begin{aligned}
e^x & = 1 + x + frac{x^2}{2!} + ldots & text{(ряд Тейлора для экспоненты)} \
& simeq 1 + x & text{(аппроксимация для $|x| ll 1$)} \
e^{-frac{n}{d}} & simeq 1 — frac{n}{d} & text{(подставим $frac{n}{d} ll 1$)} \
f(n, d) & simeq e^{-frac{1}{d}} times e^{-frac{2}{d}} times ldots times e^{-frac{n-1}{d}} & \
& = e^{-frac{n(n-1)}{2d}} & \
& simeq e^{-frac{n^2}{2d}} & \
end{aligned}
$$
Из последнего выражения более-менее понятно, что вероятность $frac{1}{2}$ достигается при $n approx sqrt{d}$ и в этой точке изменяется очень быстро.
Второе доказательство (для любителей теорвера). Введем $frac{n(n-1)}{2}$ индикаторов — по одному для каждой пары людей $(i, j)$ — каждый будет равен единице, если дни рождения совпали. Ожидание и вероятность каждого индикатора равна $frac{1}{d}$.
Обозначим за $X$ число совпавших дней рождений. Его ожидание равно сумме ожиданий этих индикаторов, то есть $frac{n (n-1)}{2} cdot frac{1}{d}$.
Отсюда понятно, что если $d = Theta(n^2)$, то ожидание равно константе, а если $d$ асимптотически больше или меньше, то $X$ стремится нулю или бесконечности соответственно.
Примечание: формально, из этого явно не следует, что вероятности тоже стремятся к 0 и 1.
Бонус: «мета-задача»
Дана произвольная строка, по которой известным только авторам задачи способом генерируется ответ yes/no. В задаче 100 тестов. У вас есть 20 попыток отослать решение. В качестве фидбэка вам доступны вердикты на каждом тесте. Вердиктов всего два: OK (ответ совпал) и WA. Попытки поделить на ноль, выделить терабайт памяти и подобное тоже считаются как WA.
«Решите» задачу.