Как найти хэш строки

Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.

Хэширование в строковых задачах

Хэш — это какая-то функция, сопоставляющая объектам какого-то множества числовые значения из ограниченного промежутка.

«Хорошая» хэш-функция:

  • Быстро считается — за линейное от размера объекта время;
  • Имеет не очень большие значения — влезающие в 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 мин

Количество просмотров 74K

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

Введение

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

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

Заключение

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

tags e_maxx_link

Translated

string_hashes

String Hashing

Hashing algorithms are helpful in solving a lot of problems.

We want to solve the problem of comparing strings efficiently.
The brute force way of doing so is just to compare the letters of both strings, which has a time complexity of $O(min(n_1, n_2))$ if $n_1$ and $n_2$ are the sizes of the two strings.
We want to do better.
The idea behind the string hashing is the following: we map each string into an integer and compare those instead of the strings.
Doing this allows us to reduce the execution time of the string comparison to $O(1)$.

For the conversion, we need a so-called hash function.
The goal of it is to convert a string into an integer, the so-called hash of the string.
The following condition has to hold: if two strings $s$ and $t$ are equal ($s = t$), then also their hashes have to be equal ($text{hash}(s) = text{hash}(t)$).
Otherwise, we will not be able to compare strings.

Notice, the opposite direction doesn’t have to hold.
If the hashes are equal ($text{hash}(s) = text{hash}(t)$), then the strings do not necessarily have to be equal.
E.g. a valid hash function would be simply $text{hash}(s) = 0$ for each $s$.
Now, this is just a stupid example, because this function will be completely useless, but it is a valid hash function.
The reason why the opposite direction doesn’t have to hold, is because there are exponentially many strings.
If we only want this hash function to distinguish between all strings consisting of lowercase characters of length smaller than 15, then already the hash wouldn’t fit into a 64-bit integer (e.g. unsigned long long) any more, because there are so many of them.
And of course, we don’t want to compare arbitrary long integers, because this will also have the complexity $O(n)$.

So usually we want the hash function to map strings onto numbers of a fixed range $[0, m)$, then comparing strings is just a comparison of two integers with a fixed length.
And of course, we want $text{hash}(s) neq text{hash}(t)$ to be very likely if $s neq t$.

That’s the important part that you have to keep in mind.
Using hashing will not be 100% deterministically correct, because two complete different strings might have the same hash (the hashes collide).
However, in a wide majority of tasks, this can be safely ignored as the probability of the hashes of two different strings colliding is still very small.
And we will discuss some techniques in this article how to keep the probability of collisions very low.

Calculation of the hash of a string

The good and widely used way to define the hash of a string $s$ of length $n$ is

$$begin{align}
text{hash}(s) &= s[0] + s[1] cdot p + s[2] cdot p^2 + … + s[n-1] cdot p^{n-1} mod m \
&= sum_{i=0}^{n-1} s[i] cdot p^i mod m,
end{align}$$

where $p$ and $m$ are some chosen, positive numbers.
It is called a polynomial rolling hash function.

It is reasonable to make $p$ a prime number roughly equal to the number of characters in the input alphabet.
For example, if the input is composed of only lowercase letters of the English alphabet, $p = 31$ is a good choice.
If the input may contain both uppercase and lowercase letters, then $p = 53$ is a possible choice.
The code in this article will use $p = 31$.

Obviously $m$ should be a large number since the probability of two random strings colliding is about $approx frac{1}{m}$.
Sometimes $m = 2^{64}$ is chosen, since then the integer overflows of 64-bit integers work exactly like the modulo operation.
However, there exists a method, which generates colliding strings (which work independently from the choice of $p$).
So in practice, $m = 2^{64}$ is not recommended.
A good choice for $m$ is some large prime number.
The code in this article will just use $m = 10^9+9$.
This is a large number, but still small enough so that we can perform multiplication of two values using 64-bit integers.

Here is an example of calculating the hash of a string $s$, which contains only lowercase letters.
We convert each character of $s$ to an integer.
Here we use the conversion $a rightarrow 1$, $b rightarrow 2$, $dots$, $z rightarrow 26$.
Converting $a rightarrow 0$ is not a good idea, because then the hashes of the strings $a$, $aa$, $aaa$, $dots$ all evaluate to $0$.

long long compute_hash(string const& s) {
    const int p = 31;
    const int m = 1e9 + 9;
    long long hash_value = 0;
    long long p_pow = 1;
    for (char c : s) {
        hash_value = (hash_value + (c - 'a' + 1) * p_pow) % m;
        p_pow = (p_pow * p) % m;
    }
    return hash_value;
}

Precomputing the powers of $p$ might give a performance boost.

Example tasks

Search for duplicate strings in an array of strings

Problem: Given a list of $n$ strings $s_i$, each no longer than $m$ characters, find all the duplicate strings and divide them into groups.

From the obvious algorithm involving sorting the strings, we would get a time complexity of $O(n m log n)$ where the sorting requires $O(n log n)$ comparisons and each comparison take $O(m)$ time.
However, by using hashes, we reduce the comparison time to $O(1)$, giving us an algorithm that runs in $O(n m + n log n)$ time.

We calculate the hash for each string, sort the hashes together with the indices, and then group the indices by identical hashes.

vector<vector<int>> group_identical_strings(vector<string> const& s) {
    int n = s.size();
    vector<pair<long long, int>> hashes(n);
    for (int i = 0; i < n; i++)
        hashes[i] = {compute_hash(s[i]), i};

    sort(hashes.begin(), hashes.end());

    vector<vector<int>> groups;
    for (int i = 0; i < n; i++) {
        if (i == 0 || hashes[i].first != hashes[i-1].first)
            groups.emplace_back();
        groups.back().push_back(hashes[i].second);
    }
    return groups;
}

Fast hash calculation of substrings of given string

Problem: Given a string $s$ and indices $i$ and $j$, find the hash of the substring $s [i dots j]$.

By definition, we have:

$$text{hash}(s[i dots j]) = sum_{k = i}^j s[k] cdot p^{k-i} mod m$$

Multiplying by $p^i$ gives:

$$begin{align}
text{hash}(s[i dots j]) cdot p^i &= sum_{k = i}^j s[k] cdot p^k mod m \
&= text{hash}(s[0 dots j]) – text{hash}(s[0 dots i-1]) mod m
end{align}$$

So by knowing the hash value of each prefix of the string $s$, we can compute the hash of any substring directly using this formula.
The only problem that we face in calculating it is that we must be able to divide $text{hash}(s[0 dots j]) – text{hash}(s[0 dots i-1])$ by $p^i$.
Therefore we need to find the modular multiplicative inverse of $p^i$ and then perform multiplication with this inverse.
We can precompute the inverse of every $p^i$, which allows computing the hash of any substring of $s$ in $O(1)$ time.

However, there does exist an easier way.
In most cases, rather than calculating the hashes of substring exactly, it is enough to compute the hash multiplied by some power of $p$.
Suppose we have two hashes of two substrings, one multiplied by $p^i$ and the other by $p^j$.
If $i &lt; j$ then we multiply the first hash by $p^{j-i}$, otherwise, we multiply the second hash by $p^{i-j}$.
By doing this, we get both the hashes multiplied by the same power of $p$ (which is the maximum of $i$ and $j$) and now these hashes can be compared easily with no need for any division.

Applications of Hashing

Here are some typical applications of Hashing:

  • Rabin-Karp algorithm for pattern matching in a string in $O(n)$ time
  • Calculating the number of different substrings of a string in $O(n^2)$ (see below)
  • Calculating the number of palindromic substrings in a string.

Determine the number of different substrings in a string

Problem: Given a string $s$ of length $n$, consisting only of lowercase English letters, find the number of different substrings in this string.

To solve this problem, we iterate over all substring lengths $l = 1 dots n$.
For every substring length $l$ we construct an array of hashes of all substrings of length $l$ multiplied by the same power of $p$.
The number of different elements in the array is equal to the number of distinct substrings of length $l$ in the string.
This number is added to the final answer.

For convenience, we will use $h[i]$ as the hash of the prefix with $i$ characters, and define $h[0] = 0$.

int count_unique_substrings(string const& s) {
    int n = s.size();
    
    const int p = 31;
    const int m = 1e9 + 9;
    vector<long long> p_pow(n);
    p_pow[0] = 1;
    for (int i = 1; i < n; i++)
        p_pow[i] = (p_pow[i-1] * p) % m;

    vector<long long> h(n + 1, 0);
    for (int i = 0; i < n; i++)
        h[i+1] = (h[i] + (s[i] - 'a' + 1) * p_pow[i]) % m;

    int cnt = 0;
    for (int l = 1; l <= n; l++) {
        unordered_set<long long> hs;
        for (int i = 0; i <= n - l; i++) {
            long long cur_h = (h[i + l] + m - h[i]) % m;
            cur_h = (cur_h * p_pow[n-i-1]) % m;
            hs.insert(cur_h);
        }
        cnt += hs.size();
    }
    return cnt;
}

Notice, that $O(n^2)$ is not the best possible time complexity for this problem.
A solution with $O(n log n)$ is described in the article about Suffix Arrays, and it’s even possible to compute it in $O(n)$ using a Suffix Tree or a Suffix Automaton.

Improve no-collision probability

Quite often the above mentioned polynomial hash is good enough, and no collisions will happen during tests.
Remember, the probability that collision happens is only $approx frac{1}{m}$.
For $m = 10^9 + 9$ the probability is $approx 10^{-9}$ which is quite low.
But notice, that we only did one comparison.
What if we compared a string $s$ with $10^6$ different strings.
The probability that at least one collision happens is now $approx 10^{-3}$.
And if we want to compare $10^6$ different strings with each other (e.g. by counting how many unique strings exists), then the probability of at least one collision happening is already $approx 1$.
It is pretty much guaranteed that this task will end with a collision and returns the wrong result.

There is a really easy trick to get better probabilities.
We can just compute two different hashes for each string (by using two different $p$, and/or different $m$, and compare these pairs instead.
If $m$ is about $10^9$ for each of the two hash functions than this is more or less equivalent as having one hash function with $m approx 10^{18}$.
When comparing $10^6$ strings with each other, the probability that at least one collision happens is now reduced to $approx 10^{-6}$.

Practice Problems

  • Good Substrings – Codeforces
  • A Needle in the Haystack – SPOJ
  • Double Profiles – Codeforces
  • Password – Codeforces
  • SUB_PROB – SPOJ
  • INSQ15_A
  • SPOJ – Ada and Spring Cleaning
  • GYM – Text Editor
  • 12012 – Detection of Extraterrestrial
  • Codeforces – Games on a CD
  • UVA 11855 – Buzzwords
  • Codeforces – Santa Claus and a Palindrome
  • Codeforces – String Compression
  • Codeforces – Palindromic Characteristics
  • SPOJ – Test
  • Codeforces – Palindrome Degree
  • Codeforces – Deletion of Repeats
  • HackerRank – Gift Boxes

Хэширование в строковых задачах

Хэш — это какая-то функция, сопоставляющая объектам какого-то множества числовые значения из ограниченного промежутка.

«Хорошая» хэш-функция:

  • Быстро считается — за линейное от размера объекта время;
  • Имеет не очень большие значения — влезающие в 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.

«Решите» задачу.

Если хочешь подтянуть свои знания, загляни на наш курс «Алгоритмы и структуры данных», на котором ты:

  1. углубишься в решение практических задач;
  2. узнаешь все про сложные алгоритмы, сортировки, сжатие данных и многое другое.

Ты также будешь на связи с преподавателем и другими студентами.

В итоге будешь браться за сложные проекты и повысишь чек за свою работу 🙂

***

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

Хеширование строк позволяет эффективно отвечать на вопрос о равенстве строк, сравнивая их хеш-коды. Хеш-код – целое число, вычисляемое по символам строки. Если две строки равны, то их хеш-коды тоже равны.

Рассмотрим полиномиальное хеширование строк, при котором хеш-функция вычисляется как перевод из n-ичной системы в десятичную. Пусть дана строка s, основание BASE и кольцо вычетов по модулю MOD.

Тогда хеш-код строки вычисляется следующим образом:
(s[0] * BASE0 + s[1] * BASE1 + ... + s[n – 1] * BASE(n-1)) % MOD

Или же наоборот:
(s[0] * BASE(n-1) + s[1] * BASE(n-2) + .. + s[n – 1] * BASE0) % MOD

Выбор направления расстановки степеней не принципиален. Главное – использовать такое простое основание BASE, чтобы оно было взаимно простым с MOD, и его значение было больше количества символов в алфавите. MOD также должен быть простым, как правило, он равен 109 + 7 или 109 + 9.

Получение индекса символа s[i] в алфавите. В C++ обычно делают так: (int)s[i] – 'a' + 1. Но чтобы не загрязнять код, можно поступить проще, например, использовать приведение к signed char, который вернёт значение от 0 до 255. Важно помнить, что основание должно быть больше максимального значения возвращаемого кода, поэтому возьмём BASE = 2011 или 2017.

Чтобы иметь возможность получать хеш любой подстроки s[l..r] строки s за O(1), воспользуемся хеш-функцией на префиксах. Рассмотрим её подробнее. Пусть максимальная длина строки – 106. Заведём массив hash[SIZE], хранящий 64-битовые числа. Далее заполняем массив простейшей динамикой по вышеописанным формулам.

        #include <bits/stdc++.h>
 
using namespace std;
 
const int SIZE = 100100;
const int BASE = 2017;
const int MOD = 1000000009;

int64_t hash[SIZE];

void init_hash(const string &line, int64_t *h, int base, int mod)
{
    h[0] = 0;
    int n = line.length();
    for (int i = 1; i <= n; ++i)
    {
        h[i] = h[i - 1] * base % mod + (signed char)line[i - 1] % mod;
    }
}
    

Также понадобится массив степеней выбранного BASE. Заведём powers[SIZE], хранящий 64-битовые числа и заполним его по динамике: powers[i] = powers[i – 1] * BASE % MOD.

        int64_t powers[SIZE];

void init_powers(int64_t *p, int base, int mod)
{
    p[0] = 1;
    for (int i = 1; i < SIZE; ++i)
    {
        p[i] = p[i - 1] * base % mod;
    }
}
    

Рассмотрим получение хеша подстроки. Принцип такой же, как и при запросе суммы на диапазоне. От хеша с индексом r отнимаем хеш с индексом l, умноженный на BASE в степени разности r и l.

        int64_t get_hash(int l, int r, int64_t *h, int64_t *p, int mod)
{
    return (h[r] - h[l] * p[r - l] % mod + mod) % mod;
}
    

Вот и всё, можем пользоваться хешированием. Рассмотрим использование на простой задаче и проверим, что всё работает. Пусть нам даны две строки, a и b, q запросов, в которых даны границы подстрок: l1, r1, l2, r2 для первой и второй строки соответственно. Требуется ответить, равны ли подстроки.

        #include <bits/stdc++.h>
 
using namespace std;
 
const int SIZE = 100100;
const int BASE = 2017;
const int MOD = 1000000009;

int64_t ahash[SIZE];
int64_t bhash[SIZE];

int64_t powers[SIZE];

void init_powers(int64_t *p, int base, int mod)
{
    p[0] = 1;
    for (int i = 1; i < SIZE; ++i)
    {
        p[i] = p[i - 1] * base % mod;
    }
}

void init_hash(const string &line, int64_t *h, int base, int mod)
{
    h[0] = 0;
    int n = line.length();
    for (int i = 1; i <= n; ++i)
    {
        h[i] = h[i - 1] * base % mod + (signed char)line[i - 1] % mod;
    }
}

int64_t get_hash(int l, int r, int64_t *h, int64_t *p, int mod)
{
    return (h[r] - h[l] * p[r - l] % mod + mod) % mod;
}

int main()
{
    string a, b;
    cin >> a >> b;
    init_powers(powers, BASE, MOD);
    init_hash(a, ahash, BASE, MOD);
    init_hash(b, bhash, BASE, MOD);
    int q;
    cin >> q;
    while (q--)
    {
        int al, ar, bl, br;
        cin >> al >> ar >> bl >> br;
        --al; --bl;
        if (get_hash(al, ar, ahash, powers, MOD) == get_hash(bl, br, bhash, powers, MOD))
        {
            cout << "YESn";
        }
        else
        {
            cout << "NOn";
        }
    }
    return 0;
}
    

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

Избавиться от коллизий при длине строк ~106 невозможно, потому что количество различных строк больше количества различных хеш-кодов. Вероятность коллизии можно свести к минимуму (почти к нулю), если написать ещё один хеш, т. е. написать первый хеш с основанием 2011 по модулю 109 + 7, а второй хеш – с основанием 2017 по модулю 109 + 9 и использовать оба хеша в сравнениях.

Алгоритм Кнута – Морриса – Пратта

Префикс-функция от строки s равна массиву P, где P[i] обозначает максимальную длину собственного (не совпадающего с полной строкой) префикса, равного собственному суффиксу. Говоря простым языком, мы идём по строке слева направо, добавляя s[i], и смотрим: есть ли префикс, совпадающий с суффиксом. Если да, то какова его максимальная длина.

Элементарный пример префикс-функции для строки <code class="inline-code">"abacaba"</code>

Элементарный пример префикс-функции для строки "abacaba"

В определённых случаях префиксы и суффиксы могут перекрываться, как, например, в строке "ABABABA".

Наивный алгоритм нахождения префикс-функции имеет сложность O(N3), что уже неприемлемо для строки длиной хотя бы 103.

Алгоритм Кнута – Морриса – Пратта (КМП) позволяет находить префикс-функцию от строки за линейное время, и имеет довольно лаконичную реализацию, по длине не превышающую наивный алгоритм.

Заметим важное свойство: P[i] <= P[i – 1] + 1. Префикс-функция от следующего элемента превосходит функцию от текущего не более чем на 1. Тогда в 0-индексации верно следующее свойство: s[i] = s[P[i – 1]] => P[i] = P[i – 1] + 1.

Рассмотрим случай, когда s[i] != s[P[i – 1]]: найдём такую длину j, что s[0..j-1] = s[i-j..i-1], но при этом j < P[i – 1]. Если s[i] = s[j], то P[i] = j + 1, тогда j = P[P[i – 1] – 1]. Иначе уменьшаем j по той же формуле: j = P[i – 1]. Пытаемся найти префикс, пока j != 0.

s[14] != s[P[13]]; j = P[P[13] – 1] = 2; s[14] = s[2]; P[14] = j + 1 = 3

s[14] != s[P[13]]; j = P[P[13] – 1] = 2; s[14] = s[2]; P[14] = j + 1 = 3

Теперь можем писать код. Будем использовать 1-индексацию, чтобы не путаться с лишними единицами:

        #include <bits/stdc++.h>
 
using namespace std;

const int SIZE = 100100;

int f[SIZE];

int main()
{
    string line;
    cin >> line;
    f[0] = f[1] = 0;
    for (int i = 1; i < line.length(); ++i)
    {
        int current = f[i];
        while (current > 0 && line[current] != line[i])
        {
            current = f[current];
        }

        if (line[i] == line[current])
        {
            f[i + 1] = current + 1;
        }
        else
        {
            f[i + 1] = 0;
        }
    }

    for (int i = 1; i <= line.length(); ++i)
    {
        cout << f[i] << " ";
    }

    return 0;
}
    

Префиксное дерево

Префиксное дерево (также бор, нагруженное дерево, англ. trie) – древовидная структура данных для хранения множества строк. Каждая строка представлена в виде цепочки символов, начинающейся в корне. Если у двух строк есть общий префикс, то у них будет общий корень и некоторое количество общих вершин. Построив префиксное дерево, можно эффективно отвечать на вопрос, содержится ли в множестве данных строк слово, которое нужно найти.

В изображённом боре хранится следующая информация: 7 слов <code class="inline-code">"to",</code> 3 слова <code class="inline-code">"tea"</code>, 4 слова <code class="inline-code">"ted"</code>, 12 слов <code class="inline-code">"ten"</code>, 9 слов <code class="inline-code">"inn"</code>, 5 слов <code class="inline-code">"in"</code>, 11 слов <code class="inline-code">"i"</code> и 15 слов <code class="inline-code">"A"</code>

В изображённом боре хранится следующая информация: 7 слов "to", 3 слова "tea", 4 слова "ted", 12 слов "ten", 9 слов "inn", 5 слов "in", 11 слов "i" и 15 слов "A"

Принято считать, что бор – конечный детерминированный автомат, состояния которого – вершины подвешенного дерева. И правда – когда мы находимся в определённой вершине, т. е. в определённом состоянии, мы можем переместиться в другие по двум параметрам: текущей вершине v и нужному символу c. То есть, мы можем найти такую вершину v', которая обозначает самую длинную строку, состоящую из суффикса текущей вершины v и нужного символа c.

Будем писать бор с использованием структур таким образом, что каждой вершине будет соответствовать объект типа vertex, в котором будут храниться ссылки на следующие объекты (т. е. дочерние вершины) и количество строк, заканчивающихся в текущей вершине. Каждая вершина соответствует определённому символу. Поскольку бор – частный случай автомата, некоторые его вершины будут терминальными, а именно такие вершины, в которых количество заканчивающихся строк больше нуля. Для реализации нам понадобится динамическое выделение памяти.

        #include <bits/stdc++.h>
 
using namespace std;
 
const int ALPHABET_SIZE = 26;
 
struct vertex
{
    vertex* next[ALPHABET_SIZE]; // массив указателей на дочерние вершины
    int strings_amount;
 
    vertex(); // конструктор
};
 
vertex::vertex()
{
    for (int i = 0; i < ALPHABET_SIZE; ++i) // изначально у текущей вершины не существует дочерних
    {
        next[i] = nullptr;
    }
    strings_amount = 0; // и не существует слов, оканчивающихся в ней
}
    

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

        vertex* root = new vertex();
 
void add_string(string &line)
{
    vertex* current = root;
    int n = line.length();
    for (int i = 0; i < n; ++i) // создаём все необходимые вершины, если они ещё не созданы
    {
        int symbol = line[i] - 'a';
        if (current->next[symbol] == nullptr)
        {
            current->next[symbol] = new vertex();
        }
        current = current->next[symbol];
    }
    current->strings_amount++; // увеличиваем кол-во слов, заканчивающихся в последней вершине
}
    

Реализуем проверку на то, содержится ли слово в боре. Алгоритм схож с добавлением. Асимптотика такого поиска будет O(|S|).

        bool has_a_string(string &line)
{
    vertex* current = root; // начинаем идти от вершины
    int n = line.length();
    for (int i = 0; i < n; ++i) // идём по вершинам дерева в соответствии с символами в строке
    {
        current = current->next[line[i] - 'a'];
        if (current == nullptr) // если такого символа нет в дереве, значит, проверяемого слова тоже нет в дереве, выходим
        {
            return false;
        }
    }
    return current->strings_amount > 0;
}
    

Удаление можно реализовать «лениво», пройдясь до терминальной вершины и уменьшив количество слов, оканчивающихся в этой вершине.

        void delete_string(string &line)
{
    vertex* current = root;
    int n = line.length();
    for (int i = 0; i < n; ++i)
    {
        current = current->next[line[i] - 'a'];
        if (current == nullptr)
        {
            return;
        }
    }
    current->strings_amount--;
}
    

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

        string output = "";
 
void output_all_strings(vertex* current = root)
{
    for (int i = 0; i < current->strings_amount; ++i)
    {
        cout << output << "n";
    }
    for (int i = 0; i < ALPHABET_SIZE; ++i)
    {
        if (current->next[i] != nullptr)
        {
            output.push_back('a' + i);
            output_all_strings(current->next[i]);
            output.pop_back();
        }
    }
}
    

Немного об асимптотике. Как было указано ранее, вставка элемента – O(|Si|), а получение всех ключей – O(k). Это делает бор более эффективной структурой данных по сравнению с обычным деревом, вставка у которого происходит за O(|S|log(k)), и с хеш-таблицей, получение всех ключей которой происходит за O(klog(k)). Но также важно помнить, что в определённых случаях префиксное дерево требует много памяти. Например, когда у всех слов, добавленных в бор, нет пересечений по префиксу, тогда структура будет использовать O(|S||Σ|) памяти, где |S| – сумма длин всех строк, а |Σ| – мощность алфавита.

Полный код:

        #include <bits/stdc++.h>

using namespace std;

const int ALPHABET_SIZE = 26;

struct vertex
{
    vertex* next[ALPHABET_SIZE];
    int strings_amount;

    vertex();
};

vertex::vertex()
{
    for (int i = 0; i < ALPHABET_SIZE; ++i)
    {
        next[i] = nullptr;
    }
    strings_amount = 0;
}

vertex* root = new vertex();

void add_string(string &line)
{
    vertex* current = root;
    int n = line.length();
    for (int i = 0; i < n; ++i) 
    {
        int symbol = line[i] - 'a';
        if (current->next[symbol] == nullptr)
        {
            current->next[symbol] = new vertex();
        }
        current = current->next[symbol];
    }
    current->strings_amount++;
}

void delete_string(string &line)
{
    vertex* current = root;
    int n = line.length();
    for (int i = 0; i < n; ++i)
    {
        current = current->next[line[i] - 'a'];
        if (current == nullptr)
        {
            return;
        }
    }
    current->strings_amount--;
}

bool has_a_string(string &line)
{
    vertex* current = root;
    int n = line.length();
    for (int i = 0; i < n; ++i)
    {
        current = current->next[line[i] - 'a'];
        if (current == nullptr)
        {
            return false;
        }
    }
    return current->strings_amount > 0;
}

string output = "";

void output_all_strings(vertex* current = root)
{
    for (int i = 0; i < current->strings_amount; ++i)
    {
        cout << output << "n";
    }
    for (int i = 0; i < ALPHABET_SIZE; ++i)
    {
        if (current->next[i] != nullptr)
        {
            output.push_back('a' + i);
            output_all_strings(current->next[i]);
            output.pop_back();
        }
    }
}

int main()
{ 
    int q;
    cin >> q;
    for (int req = 0; req < q; ++req)
    {
        char type;
        cin >> type;
        if (type == '1')
        {
            string word;
            cin >> word;
            add_string(word);
        }
        else if (type == '2')
        {
            string word;
            cin >> word;
            delete_string(word);
        }
        else if (type == '3')
        {
            string word;
            cin >> word;
            has_a_string(word) ? cout << "YESn" : cout << "NOn";
        }
        else if (type == '4')
        {
            cout << "n";
            output = "";
            output_all_strings(root);
        }
    }
    return 0;
}
    

Чтобы лучше понять, как работает префиксное дерево, можете «поиграться» с визуализацией работы структуры данных здесь.

Алгоритм Ахо – Корасик

Пусть дано множество неких строк/паттернов S = {s0, ... si, ... sn} и текст T. Необходимо найти все вхождения строк внутри текста.

Для решения необходимо построить автомат по бору (см. Алгоритм Ахо – Корасик). Мы уже написали функцию поиска строки в боре, которая решает похожую задачу. Но асимптотика такого решения O(n|si|), что неприемлемо.

Для решения поставленной задачи введём понятие суффиксной ссылки. Для начала обозначим, что [u] – строка, по которой мы пришли из корня в вершину u. Суффиксная ссылка вершины u p(u) – это указатель на такую вершину v, что [v] является суффиксом [u] максимальной длины.

Построение суффиксных ссылок

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

Во-вторых, введём понятие перехода. Переход δ(v, c) – переход в боре из вершины v по символу c. В основе алгоритма лежит следующее равенство: p(u) = δ(p(u->parent), cu).

То есть, суффиксная ссылка текущей вершины – переход из вершины, в которую ведёт суффиксная ссылка предка текущей вершины, по символу текущей вершины. Грубо говоря, нужно пойти в предка, посмотреть, куда ведёт суффиксная ссылка (обозначим эту вершину за v'), перейти по ней, а затем попытаться перейти по символу. Если перехода из v' по символу c не существует, то справедливо следующее равенство: δ(v', c) = δ(p(v'), c) , т.е. от вершины, в которую вела суффиксная ссылка предка, нужно ещё раз пройти по суффиксной ссылке, и так вплоть до корня. Если из корня мы не можем перейти по символу c, то пропускаем его. Этот алгоритм справедлив, поскольку суффиксные ссылки всегда ведут в вершину дерева, которое меньше по глубине, следовательно, рано или поздно, мы сможем дойти до корня.

Реализация алгоритма

В каждой вершине помимо того, что мы хранили в обычном боре, будем хранить массив автоматных переходов go, суффиксную ссылку link , предка parent, последний символ parent_char, предыдущий суффикс prev для данной позиции, который является паттерном. Также будем хранить массив ind, который содержит индексы паттернов, оканчивающихся в этой вершине. Выглядит это всё так:

        const int ALPHABET_SIZE = 26;
 
struct vertex
{
    vertex* next[ALPHABET_SIZE];
    vertex* go[ALPHABET_SIZE];
    vertex* link = 0;
    vertex* parent;
    vertex* prev;
    int parent_char;
    vector<int> ind;
 
    vertex();
    vertex(int parent_char, vertex* parent);
};
 
vertex::vertex()
{
    parent_char = 0;
    parent = nullptr;
    prev = nullptr;
    for (int i = 0; i < ALPHABET_SIZE; ++i)
    {
        next[i] = nullptr;
        go[i] = nullptr;
    }
}

vertex::vertex(int parent_char, vertex* parent)
{
    this->parent_char = parent_char;
    this->parent = parent;
    prev = nullptr;
    for (int i = 0; i < ALPHABET_SIZE; ++i)
    {
        next[i] = nullptr;
        go[i] = nullptr;
    }
}  
    

Добавление паттерна почти такое же, как и в обычном боре:

        vertex* root = new vertex(-1, 0);
 
void add_string(string &line, int ind)
{
    vertex* current = root;
    int n = line.length();
    for (int i = 0; i < n; ++i)
    {
        int symbol = line[i] - 'a';
        if (current->next[symbol] == nullptr)
        {
            current->next[symbol] = new vertex(symbol, current);
        }
        current = current->next[symbol];
    }
    current->ind.push_back(ind);
}
    

Поскольку мы утверждаем, что при использовании суффиксных ссылок, они ведут в меньшее по глубине дерево, суффиксные ссылки и автоматные переходы можно посчитать динамическим программированием. Подсчитывать динамики go и link будем «лениво»: введём для них две функции, которые будут мемоизировать результат выполнения.

        vertex* go(vertex* current, int c);
 
vertex* link(vertex* current)
{
    if (!current->link)
    {
        if (current == root || current->parent == root) // если длина строки < 2, то суффиксная ссылка - корень
        {
            current->link = root;
        }
        else
        {
            current->link = go(link(current->parent), current->parent_char); // в остальных случаях применяем формулу
        }
    }
    return current->link;
}
 
vertex* go(vertex* current, int c)
{
    if (!current->go[c])
    {
        if (current->next[c]) // если обычный переход есть, то автоматный должен вести туда же
        {
            current->go[c] = current->next[c];
        }
        else if (current == root) // если нет перехода из корня, делаем петлю
        {
            current->go[c] = root;
        }
        else
        {
            current->go[c] = go(link(current), c); // в остальных случаях применяем формулу
        }
    }
    return current->go[c];
}
    

Напишем функцию получения суффикса prev, который является паттерном:

        vertex* prev(vertex* current)
{
    if (current == root)
    {
        return nullptr;
    }
    if (!current->prev)
    {
        if (link(current)->ind.size() != 0)
        {
            current->prev = link(current);
        } 
        else
        {
            current->prev = prev(link(current));
        }
    }
    return current->prev;
}
    

Теперь напишем main. Нам нужно ввести n паттернов, добавив их в бор, текст и для каждого паттерна вывести его границы в тексте. Заведём вектор векторов ans, в котором для каждого i-го паттерна будем хранить его правую границу каждого j-го вхождения в текст.

Далее необходимо пройти по тексту, для каждого символа сделать автоматный переход. Каждый паттерн, который оканчивается вершиной, в которую был совершён автоматный переход, добавляем в ans. Пока будет существовать суффикс, prev который будет являться паттерном, будем переходить в него. Затем выведем все вхождения. Код:

        int main() 
{
    int n;
    cin >> n;
    vector<string> patterns;
    for (int i = 0; i < n; i++) 
    {
        string s;
        cin >> s;
        add_string(s, i);
        patterns.push_back(s);
    }
    string text;
    cin >> text;
    vector<vector<int>> ans(n);
    vertex* cur = root;
    for (int i = 0; i < text.size(); i++) 
    {
        cur = go(cur, text[i] - 'a');
        vertex* cur_ans = cur;
        while (cur_ans) 
        {
            for (int ind: cur_ans->ind)
            {
                ans[ind].push_back(i + 1);
            }
            cur_ans = prev(cur_ans);
        }
    }
    for (int i = 0; i < n; i++)
    {
        cout << patterns[i] << " : ";
        int psize = patterns[i].size();
        if (ans[i].size() == 0) 
        {
            cout << "no matching";
        }
        else
        {
            for (int j: ans[i])
            {
                cout << j - psize + 1 << " - " << j << "; ";
            }
        }
        cout << endl;
    }
    return 0;
}
    

Весь код:

        #include <bits/stdc++.h>
 
using namespace std;
 
const int ALPHABET_SIZE = 26;
 
struct vertex
{
    vertex* next[ALPHABET_SIZE];
    vertex* go[ALPHABET_SIZE];
    vertex* link = 0;
    vertex* parent;
    vertex* prev;
    int parent_char;
    vector<int> ind;
 
    vertex();
    vertex(int parent_char, vertex* parent);
};
 
vertex::vertex()
{
    parent_char = 0;
    parent = nullptr;
    prev = nullptr;
    for (int i = 0; i < ALPHABET_SIZE; ++i)
    {
        next[i] = nullptr;
        go[i] = nullptr;
    }
}
 
vertex::vertex(int parent_char, vertex* parent)
{
    this->parent_char = parent_char;
    this->parent = parent;
    prev = nullptr;
    for (int i = 0; i < ALPHABET_SIZE; ++i)
    {
        next[i] = nullptr;
        go[i] = nullptr;
    }
}  
 
vertex* root = new vertex(-1, 0);
 
void add_string(string &line, int ind)
{
    vertex* current = root;
    int n = line.length();
    for (int i = 0; i < n; ++i)
    {
        int symbol = line[i] - 'a';
        if (current->next[symbol] == nullptr)
        {
            current->next[symbol] = new vertex(symbol, current);
        }
        current = current->next[symbol];
    }
    current->ind.push_back(ind);
}
 
vertex* go(vertex* current, int c);
 
vertex* link(vertex* current)
{
    if (!current->link)
    {
        if (current == root || current->parent == root)
        {
            current->link = root;
        }
        else
        {
            current->link = go(link(current->parent), current->parent_char);
        }
    }
    return current->link;
}
 
vertex* go(vertex* current, int c)
{
    if (!current->go[c])
    {
        if (current->next[c])
        {
            current->go[c] = current->next[c];
        }
        else if (current == root)
        {
            current->go[c] = root;
        }
        else
        {
            current->go[c] = go(link(current), c);
        }
    }
    return current->go[c];
}
 
vertex* prev(vertex* current)
{
    if (current == root)
    {
        return nullptr;
    }
    if (!current->prev)
    {
        if (link(current)->ind.size() != 0)
        {
            current->prev = link(current);
        } 
        else
        {
            current->prev = prev(link(current));
        }
    }
    return current->prev;
}
 
int main() 
{
    int n;
    cin >> n;
    vector<string> patterns;
    for (int i = 0; i < n; i++) 
    {
        string s;
        cin >> s;
        add_string(s, i);
        patterns.push_back(s);
    }
    string text;
    cin >> text;
    vector<vector<int>> ans(n);
    vertex* cur = root;
    for (int i = 0; i < text.size(); i++) 
    {
        cur = go(cur, text[i] - 'a');
        vertex* cur_ans = cur;
        while (cur_ans) 
        {
            for (int ind: cur_ans->ind)
            {
                ans[ind].push_back(i + 1);
            }
            cur_ans = prev(cur_ans);
        }
    }
    for (int i = 0; i < n; i++)
    {
        cout << patterns[i] << " : ";
        int psize = patterns[i].size();
        if (ans[i].size() == 0) 
        {
            cout << "no matching";
        }
        else
        {
            for (int j: ans[i])
            {
                cout << j - psize + 1 << " - " << j << "; ";
            }
        }
        cout << endl;
    }
    return 0;
}
    

Другие задачи на алгоритм Ахо – Корасик:

  • Антимат
  • Censored!
  • I love strings!!
  • Электронное правительство

***

Эта статья написана читателем Библиотеки программиста – не стесняйтесь присылать свои публикации! 🐸 Если статья оказалась для вас трудной, посмотрите наш пост про книги по C++. Cреди перечисляемых есть книги с уклоном на алгоритмы. Больше статей по теме – по тегу Алгоритмы.

Добавить комментарий