Перейти до основного вмісту

Пошук повторів

Дано рядок ss довжини nn.

Повтором (тандемним повтором) називаються два входження одного рядка підряд. Інакше кажучи, тандемний повтор можна описати парою індексів i<ji < j такою, що підрядок s[ij]s[i \dots j] складається з двох однакових рядків, записаних один за одним.

Задача полягає в тому, щоб знайти всі тандемні повтори в заданому рядку ss. Або спрощений варіант: знайти будь-який повтор чи знайти найдовший повтор.

Описаний тут алгоритм опублікували 1982 року Мейн і Лоренц.

Коли підходить цей алгоритм?
  • Шукаєте тандемні повтори (два однакові рядки підряд) — усі, найдовший або будь-який?
  • Потрібен загальний розв'язок «розділяй і володарюй» за O(nlogn)O(n \log n) для всіх повторів, а не лише пошук конкретного взірця? (якщо шукаєте задане слово → КМП)
  • Не вистачає простіших засобів і потрібна саме структурна інформація про повтори у тексті? (для загальних підрядкових запитів → Суфіксний автомат)

Приклад

Розгляньмо повтори в такому прикладі рядка:

acababaeeacababaee

Цей рядок містить такі три тандемні повтори:

  • s[25]=ababs[2 \dots 5] = abab
  • s[36]=babas[3 \dots 6] = baba
  • s[78]=ees[7 \dots 8] = ee

Ще один приклад:

abaabaabaaba

Тут є лише два тандемні повтори

  • s[05]=abaabas[0 \dots 5] = abaaba
  • s[23]=aas[2 \dots 3] = aa

Кількість повторів

Загалом у рядку довжини nn може бути до O(n2)O(n^2) тандемних повторів. Очевидний приклад — рядок, що складається з nn однакових літер; у цьому випадку будь-який підрядок парної довжини є повтором. Загалом будь-який періодичний рядок із коротким періодом міститиме багато повторів.

З іншого боку, цей факт не заважає обчислити кількість повторів за час O(nlogn)O(n \log n), бо алгоритм може видавати повтори в стиснутому вигляді — групами по кілька штук одразу.

Існує навіть поняття, яке описує групи періодичних підрядків четвірками чисел. Доведено, що кількість таких груп щонайбільше лінійна відносно довжини рядка.

Також ось іще кілька цікавих результатів, пов'язаних із кількістю повторів:

  • Кількість примітивних повторів (тих, чиї половини самі не є повторами) щонайбільше O(nlogn)O(n \log n).

  • Якщо кодувати повтори четвірками чисел (так звані трійки Крошмора) (i, p, r)(i,~ p,~ r) (де ii — позиція початку, pp — довжина повторюваного підрядка, а rr — кількість повторень), то всі повтори можна описати за допомогою O(nlogn)O(n \log n) таких трійок.

  • Рядки Фібоначчі, означені як

    t0=a,t1=b,ti=ti1+ti2,\begin{align} t_0 &= a, \\\\ t_1 &= b, \\\\ t_i &= t_{i-1} + t_{i-2}, \end{align}

    є «сильно» періодичними. Кількість повторів у рядку Фібоначчі fif_i, навіть стиснута трійками Крошмора, дорівнює O(fnlogfn)O(f_n \log f_n). Кількість примітивних повторів також становить O(fnlogfn)O(f_n \log f_n).

Алгоритм Мейна–Лоренца

В основі алгоритму Мейна–Лоренца лежить ідея «розділяй і володарюй».

Він ділить початковий рядок навпіл і двома рекурсивними викликами обчислює кількість повторів, що цілком лежать у кожній половині. Далі йде складна частина. Алгоритм знаходить усі повтори, що починаються в першій половині й закінчуються в другій (називатимемо їх перехресними повторами). Це і є основна частина алгоритму Мейна–Лоренца, яку ми тут детально розглянемо.

Складність алгоритмів за принципом «розділяй і володарюй» добре досліджена. Основна теорема каже, що в підсумку ми отримаємо алгоритм за O(nlogn)O(n \log n), якщо зможемо обчислити перехресні повтори за час O(n)O(n).

Пошук перехресних повторів

Отже, ми хочемо знайти всі такі повтори, що починаються в першій половині рядка, назвімо її uu, і закінчуються в другій половині, назвімо її vv:

s=u+vs = u + v

Їхні довжини приблизно дорівнюють довжині ss, поділеній навпіл.

Розгляньмо довільний повтор і подивімося на його серединний символ (точніше, на перший символ другої половини повтору). Тобто якщо повтор — це підрядок s[ij]s[i \dots j], то серединний символ має позицію (i+j+1)/2(i + j + 1) / 2.

Ми називаємо повтор лівим або правим залежно від того, у якому рядку розташований цей символ — у рядку uu чи в рядку vv. Інакше кажучи, повтор називається лівим, якщо більша його частина лежить у uu, інакше називаємо його правим.

Тепер ми обговоримо, як знайти всі ліві повтори. Знаходження всіх правих повторів виконується так само.

Позначмо довжину лівого повтору через 2l2l (тобто кожна половина повтору має довжину ll). Розгляньмо перший символ повтору, що потрапляє в рядок vv (він стоїть на позиції u|u| в рядку ss). Він збігається із символом, що стоїть на ll позицій раніше; позначмо цю позицію cntrcntr.

Ми зафіксуємо цю позицію cntrcntr і шукатимемо всі повтори в цій позиції cntrcntr.

Наприклад:

c acntr c  a d ac ~ \underset{cntr}{a} ~ c ~ | ~ a ~ d ~ a

Вертикальні риски розділяють дві половини. Тут ми зафіксували позицію cntr=1cntr = 1, і в цій позиції знаходимо повтор cacacaca.

Зрозуміло, що, зафіксувавши позицію cntrcntr, ми одночасно фіксуємо довжину можливих повторів: l=ucntrl = |u| - cntr. Щойно ми навчимося знаходити ці повтори, ми проітеруємо по всіх можливих значеннях cntrcntr від 00 до u1|u|-1 і знайдемо всі ліві перехресні повтори довжини l=u, u1, ,1l = |u|,~ |u|-1,~ \dots, 1.

Критерій лівих перехресних повторів

Тож як нам знайти всі такі повтори для фіксованого cntrcntr? Пам'ятаймо, що таких повторів усе ще може бути кілька.

Знову подивімося на ілюстрацію, цього разу для повтору abcabcabcabc:

al1 bcntr cl2 al1  b cl2\overbrace{a}^{l_1} ~ \overbrace{\underset{cntr}{b} ~ c}^{l_2} ~ \overbrace{a}^{l_1} ~ | ~ \overbrace{b ~ c}^{l_2}

Тут ми позначили довжини двох частин повтору через l1l_1 і l2l_2: l1l_1 — це довжина повтору до позиції cntr1cntr-1, а l2l_2 — довжина повтору від cntrcntr до кінця половини повтору. Загальна довжина повтору дорівнює 2l=l1+l2+l1+l22l = l_1 + l_2 + l_1 + l_2.

Сформулюймо необхідні та достатні умови такого повтору в позиції cntrcntr довжини 2l=2(l1+l2)=2(ucntr)2l = 2(l_1 + l_2) = 2(|u| - cntr):

  • Нехай k1k_1 — найбільше число таке, що перші k1k_1 символів перед позицією cntrcntr збігаються з останніми k1k_1 символами в рядку uu:
u[cntrk1cntr1]=u[uk1u1]u[cntr - k_1 \dots cntr - 1] = u[|u| - k_1 \dots |u| - 1]
  • Нехай k2k_2 — найбільше число таке, що k2k_2 символів, починаючи з позиції cntrcntr, збігаються з першими k2k_2 символами в рядку vv:
u[cntrcntr+k21]=v[0k21] u[cntr \dots cntr + k_2 - 1] = v[0 \dots k_2 - 1]
  • Тоді ми маємо повтор рівно для будь-якої пари (l1, l2)(l_1,~ l_2) з
l1k1,l2k2. \begin{align} l_1 &\le k_1, \\\\ l_2 &\le k_2. \\\\ \end{align}

Підсумуймо:

  • Ми фіксуємо конкретну позицію cntrcntr.
  • Усі повтори, які ми тепер знайдемо, мають довжину 2l=2(ucntr)2l = 2(|u| - cntr). Таких повторів може бути кілька, вони залежать від довжин l1l_1 та l2=ll1l_2 = l - l_1.
  • Ми знаходимо k1k_1 та k2k_2, як описано вище.
  • Тоді всі підхожі повтори — це ті, для яких довжини частин l1l_1 та l2l_2 задовольняють умови:
l1+l2=l=ucntrl1k1,l2k2. \begin{align} l_1 + l_2 &= l = |u| - cntr \\\\ l_1 &\le k_1, \\\\ l_2 &\le k_2. \\\\ \end{align}

Отже, лишається тільки питання, як швидко обчислити значення k1k_1 та k2k_2 для кожної позиції cntrcntr. На щастя, ми можемо обчислити їх за O(1)O(1) за допомогою Z-функції:

  • Значення k1k_1 для кожної позиції можна знайти, обчисливши Z-функцію для рядка u\overline{u} (тобто оберненого рядка uu). Тоді значення k1k_1 для конкретного cntrcntr дорівнюватиме відповідному значенню масиву Z-функції.
  • Щоб попередньо обчислити всі значення k2k_2, обчислюємо Z-функцію для рядка v+#+uv + \# + u (тобто рядка uu, з'єднаного з символом-роздільником #\# і рядком vv). Знову ж таки, нам потрібно лише знайти відповідне значення в Z-функції, щоб отримати значення k2k_2.

Цього достатньо, щоб знайти всі ліві перехресні повтори.

Праві перехресні повтори

Для обчислення правих перехресних повторів ми діємо аналогічно: центр cntrcntr ми означуємо як символ, що відповідає останньому символу в рядку uu.

Тоді довжину k1k_1 означуємо як найбільшу кількість символів перед позицією cntrcntr (включно), які збігаються з останніми символами рядка uu. А довжину k2k_2 означуємо як найбільшу кількість символів, починаючи з cntr+1cntr + 1, які збігаються із символами рядка vv.

Таким чином, ми можемо знайти значення k1k_1 та k2k_2, обчисливши Z-функцію для рядків u+#+v\overline{u} + \# + \overline{v} та vv.

Після цього ми можемо знайти повтори, переглянувши всі позиції cntrcntr і застосувавши той самий критерій, який ми мали для лівих перехресних повторів.

Реалізація

Реалізація алгоритму Мейна–Лоренца знаходить усі повтори у вигляді специфічних четвірок: (cntr, l, k1, k2)(cntr,~ l,~ k_1,~ k_2) за час O(nlogn)O(n \log n). Якщо вам потрібно лише знайти кількість повторів у рядку або знайти лише найдовший повтор у рядку, цієї інформації достатньо, і час роботи все одно становитиме O(nlogn)O(n \log n).

Зауважте, що якщо ви захочете розгорнути ці четвірки, щоб отримати початкову та кінцеву позиції кожного повтору, то час роботи становитиме O(n2)O(n^2) (пам'ятайте, що повторів може бути O(n2)O(n^2)). У цій реалізації ми так і зробимо й зберігатимемо всі знайдені повтори у векторі пар початкового та кінцевого індексів.

vector<int> z_function(string const& s) {
int n = s.size();
vector<int> z(n);
for (int i = 1, l = 0, r = 0; i < n; i++) {
if (i <= r)
z[i] = min(r-i+1, z[i-l]);
while (i + z[i] < n && s[z[i]] == s[i+z[i]])
z[i]++;
if (i + z[i] - 1 > r) {
l = i;
r = i + z[i] - 1;
}
}
return z;
}

int get_z(vector<int> const& z, int i) {
if (0 <= i && i < (int)z.size())
return z[i];
else
return 0;
}

vector<pair<int, int>> repetitions;

void convert_to_repetitions(int shift, bool left, int cntr, int l, int k1, int k2) {
for (int l1 = max(1, l - k2); l1 <= min(l, k1); l1++) {
if (left && l1 == l) break;
int l2 = l - l1;
int pos = shift + (left ? cntr - l1 : cntr - l - l1 + 1);
repetitions.emplace_back(pos, pos + 2*l - 1);
}
}

void find_repetitions(string s, int shift = 0) {
int n = s.size();
if (n == 1)
return;

int nu = n / 2;
int nv = n - nu;
string u = s.substr(0, nu);
string v = s.substr(nu);
string ru(u.rbegin(), u.rend());
string rv(v.rbegin(), v.rend());

find_repetitions(u, shift);
find_repetitions(v, shift + nu);

vector<int> z1 = z_function(ru);
vector<int> z2 = z_function(v + '#' + u);
vector<int> z3 = z_function(ru + '#' + rv);
vector<int> z4 = z_function(v);

for (int cntr = 0; cntr < n; cntr++) {
int l, k1, k2;
if (cntr < nu) {
l = nu - cntr;
k1 = get_z(z1, nu - cntr);
k2 = get_z(z2, nv + 1 + cntr);
} else {
l = cntr - nu + 1;
k1 = get_z(z3, nu + 1 + nv - 1 - (cntr - nu));
k2 = get_z(z4, (cntr - nu) + 1);
}
if (k1 + k2 >= l)
convert_to_repetitions(shift, cntr < nu, cntr, l, k1, k2);
}
}