- PVSM.RU - https://www.pvsm.ru -
Здравствуй. Сегодня я напишу, как можно использовать полиномиальные хеши (далее просто хеши) при решении различных алгоритмических задач.
Начнем с определения. Пусть у нас есть строка 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]) { ... }
работать не будет.
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]) { ... }
Теперь мы более подробно рассмотрим задачи, где можно применять хеши.
Первое, и главное, применение, как уже было сказано, это быстрое сравнение двух подстрок — на нем основываются все остальные алгоритмы с хешами. Код в прошлом разделе довольно громоздкий, поэтому я напишу более удобный код, который будет использоваться в дальнейшем.
Следующая функция вычисляет хеш подстроки 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 — значит, чтобы сравнение происходило корректно, их надо домножить на недостающий множитель.
Примечание: имеет смысл сначала проверить, совпадают ли длины подстрок. Если нет, то строки в принципе не могут быть равны, и тогда можно не проверять вышезаписанное условие.
Хеши позволяют искать подстроку в строке за асимптотически минимальное время. Это делает так называемый алгоритм Рабина-Карпа.
Пусть есть строка 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
}
}
Z-функцией [2] строки s называется массив z, i-ый элемент которого равен наидлиннейшему префиксу подстроки, начинающейся с позиции i в строке s, который одновременно является и префиксом всей строки s. Значение z-функции в нулевой позиции будем считать равным длине строки s, хотя некоторые источники принимают его за ноль (но это не критично).
Конечно, есть алгоритм [3] нахождения 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), однако я знаю некоторых довольно сильных олимпиадных программистов, которые так и не разобрались в нем. Пока они будут в нем разбираться, мы снова применим хеши.
Алгоритм следующий. Сначала примем саму строку 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). И опять мы будем решать ее с помощью хешей.
Подстрока 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]) { ... }
Хешами также можно решать задачи, связанные с нахождением самой большой симметричной подматрицы и похожие на них. Причем я не знаю сравнимых с хешами по скорости и простоте алгоритмов, выполняющих эту работу. Здесь используются те же принципы, что и при поиске палиндромов в одномерном случае (т.е. считать «реверснутые» хеши справа налево и снизу вверх, проводить бинпоиск отдельно для подматриц четной и нечетной длины). Предлагаю попробовать решить эту задачу самостоятельно — эта статья вам поможет!
Итак, в нашем распоряжении есть довольно неплохой аппарат, позволяющий делать многие вещи либо с лучшей возможной асимптотикой, либо лишь чуть-чуть (в логарифм раз) медленнее, чем специализированные алгоритмы. Неплохо, не так ли?
Автор: bluesky
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/algoritmy/6088
Ссылки в тексте:
[1] доступно: http://e-maxx.ru/algo/reverse_element
[2] Z-функцией: http://ru.wikipedia.org/wiki/Z-%D1%84%D1%83%D0%BD%D0%BA%D1%86%D0%B8%D1%8F
[3] алгоритм: http://e-maxx.ru/algo/z_function
[4] алгоритм Дюваля: http://e-maxx.ru/algo/duval_algorithm
[5] решение: http://e-maxx.ru/algo/palindromes_count
Нажмите здесь для печати.