Полиномиальные хеши и их применение

в 19:37, , рубрики: Алгоритмы, олимпиадные задачи, поиск подстроки в строке, сравнение строк, строки, хеширование, метки: , , , , ,

Здравствуй. Сегодня я напишу, как можно использовать полиномиальные хеши (далее просто хеши) при решении различных алгоритмических задач.

Введение

Начнем с определения. Пусть у нас есть строка 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]) { ... }

Хешами также можно решать задачи, связанные с нахождением самой большой симметричной подматрицы и похожие на них. Причем я не знаю сравнимых с хешами по скорости и простоте алгоритмов, выполняющих эту работу. Здесь используются те же принципы, что и при поиске палиндромов в одномерном случае (т.е. считать «реверснутые» хеши справа налево и снизу вверх, проводить бинпоиск отдельно для подматриц четной и нечетной длины). Предлагаю попробовать решить эту задачу самостоятельно — эта статья вам поможет!

Заключение

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

Автор: bluesky

Поделиться

* - обязательные к заполнению поля