Суффиксный массив, автомат и дерево обобщённо называют суффиксными структурами данных. Они применяются в множестве различных задач, встречающихся как на олимпиадах, так и на практике.
Суффиксные структуры часто (но не всегда) взаимозаменяемые, и более того, конвертируются друг в друга за линейное время. Суффиксный массив — самый простой из них, поэтому мы с него и начнём.
«*Паблик с тупыми шутками про проганье*»Суффиксным массивом строки
Как это использовать. Пусть вы решили основать ООО «Ещё Один Поисковик», и чтобы получить финансирование, вы хотите сделат хоть что-то минимально работающие — просто научиться искать по ключевому слову документы, включающие его, а также позиции их вхождения (в 90-е это был бы уже довольно сильный MVP). Простыми алгоритмами (полиномиальными хэшами, z- и префикс-функцией и даже Ахо-Корасиком) это сделать быстро нельзя, а суффиксными структурами — можно.
В случае с суффиксным массивом можно сделать следующее: найти бинарным поиском первый суффикс в суффиксном массиве, который меньше искомого слова, а также последний, который меньше. Все суффиксы между этими двумя будут включать искомую строку как префикс.
Работать такой алгоритм будет за
Теперь научимся его строить.
Для удобства мы допишем в конец строки какой-нибудь символ, который лексикографически меньше любого другого, и будем выполнять сортировку не суффиксов, а циклических сдвигов. Строки мы, соответственно, тоже будем рассматривать циклические. Для стандартной ASCII обычно выбирают либо «$» либо «#», либо просто нулевой символ, если это c-string (в языке Си все строки и так заканчиваются нулевым символом). Легко убедиться, что сортировка таких циклических сдвигов эквивалентна сортировке суффиксов — можно просто убрать всё, что идёт после доллара.
Мы могли бы просто взять перестановку от std::sort
, что будет работать за
Наш алгоритм будет состоять из
Заметим, что, в отличие сортировки суффиксов, сортировка подстрок не всегда однозначная — они могут быть одинаковыми. Поэтому на каждой фазе алгоритм помимо перестановки cls
(изначально она равна количеству различных символов).
Пример:
Как настоящие программисты, мы нумеруем этапы с нуля, поэтому на нулевом этапе мы отсортируем строки длины
Следующие этапы нужно проводить, используя информацию с предыдущих. Можем снова создать перестановку и применить к ней std::sort
со своим компаратором.
Как быстро сравнить две подстроки? Мы можем использовать
Примечание. Зачастую этот способ построения работает быстро, поскольку имеет небольшую константу.
Оптимизация до
// строка -- это последовательность чисел от 1 до размера алфавита
vector<int> suffix_array (vector<int> &s) {
s.push_back(0); // добавляем нулевой символ в конец строки
int n = (int) s.size(),
cnt = 0, // вспомогательная переменная: счётчик для сортировки
cls = 0; // количество классов эквивалентности
vector<int> c(n), p(n);
map< int, vector<int> > t;
for (int i = 0; i < n; i++)
t[s[i]].push_back(i);
// «нулевой» этап
for (auto &x : t) {
for (int u : x.second)
c[u] = cls, p[cnt++] = u;
cls++;
}
// пока все суффиксы не стали уникальными
for (int l = 1; cls < n; l++) {
vector< vector<int> > a(cls); // массив для сортировки подсчётом
vector<int> _c(n); // новые классы эквивалентности
int d = (1<<l)/2;
int _cls = cnt = 0; // новое количество классов
for (int i = 0; i < n; i++) {
int k = (p[i]-d+n)%n;
a[c[k]].push_back(k);
}
for (int i = 0; i < cls; i++) {
for (size_t j = 0; j < a[i].size(); j++) {
// если суффикс начинает новый класс эквивалентности
if (j == 0 || c[(a[i][j]+d)%n] != c[(a[i][j-1]+d)%n])
_cls++;
_c[a[i][j]] = _cls-1;
p[cnt++] = a[i][j];
}
}
c = _c;
cls = _cls;
}
return vector<int>(p.begin()+1, p.end());
}
TODO: переписать это
Для многих применений очень часто требуется искать длину наибольшего общего префикса (англ — largest common prefix) для различных суффиксов строки. Например, просто чтобы сравнить две подстроки, мы можем найти общий префикс соответствующих суффиксов и посмотреть на следующий символ — так же, как мы делали с хэшами.
Пусть мы знаем lcp(s[i:], s[j:])
, надо найти lcp(s[p[k]:], s[p[k + 1]:])
(для доказательства можно представить массив
Осталось придумать способ быстро посчитать массив
Алгоритм Касаи, Аримуры, Арикавы, Ли, Парка. В простонародье называется как угодно, но не исходным способом (алгоритм Касаи, алгоритм пяти корейцев, итд.). Используется для подсчета
Доказательство — если для
Для того, чтобы оценить сложность алгоритма, посчитаем, сколько раз мы сделаем наивное прибавление.
Код алгоритма:
vector<int> calc_lcp(vector<int> &val, vector<int> &c, vector<int> &p) {
int n = val.size();
int current_lcp = 0;
vector<int> lcp(n);
for (int i = 0; i < n; i++) {
if (c[i] == n - 1)
continue;
int nxt = p[c[i] + 1];
while (max(i, nxt) + current_lcp < n && val[i + current_lcp] == val[nxt + current_lcp])
current_lcp++;
lcp[c[i]] = current_lcp;
current_lcp = max(0, current_lcp - 1);
}
return lcp;
}