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

Алгоритм Манакера — пошук усіх підпаліндромів за O(N)O(N)

Постановка задачі

Дано рядок ss довжини nn. Знайти всі пари (i,j)(i, j) такі, що підрядок s[ij]s[i\dots j] є паліндромом. Рядок tt є паліндромом, коли t=trevt = t_{rev} (trevt_{rev} — це обернений рядок до tt).

Точніше формулювання

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

Але інформацію про паліндроми можна зберігати компактно: для кожної позиції ii ми знайдемо кількість непорожніх паліндромів із центром у цій позиції.

Паліндроми зі спільним центром утворюють неперервний ланцюжок: якщо ми маємо паліндром довжини ll із центром у ii, то ми також маємо паліндроми довжин l2l-2, l4l-4 і так далі, теж із центром у ii. Тому ми збираємо інформацію про всі паліндромні підрядки саме в такий спосіб.

Паліндроми непарної та парної довжини враховуються окремо як dodd[i]d_{odd}[i] і deven[i]d_{even}[i]. Для паліндромів парної довжини ми вважаємо, що вони мають центр у позиції ii, якщо їхні два центральні символи — це s[i]s[i] і s[i1]s[i-1].

Наприклад, рядок s=abababcs = abababc має три паліндроми непарної довжини з центрами в позиції s[3]=bs[3] = b, тобто dodd[3]=3d_{odd}[3] = 3:

a b a bs3 a bdodd[3]=3ca\ \overbrace{b\ a\ \underbrace{b}_{s_3}\ a\ b}^{d_{odd}[3]=3} c

А рядок s=cbaabds = cbaabd має два паліндроми парної довжини з центрами в позиції s[3]=as[3] = a, тобто deven[3]=2d_{even}[3] = 2:

c b a as3 bdeven[3]=2dc\ \overbrace{b\ a\ \underbrace{a}_{s_3}\ b}^{d_{even}[3]=2} d

Дивовижний факт полягає в тому, що існує достатньо простий алгоритм, який обчислює ці «масиви паліндромності» dodd[]d_{odd}[] і deven[]d_{even}[] за лінійний час. Цей алгоритм описано в цій статті.

Коли підходить цей алгоритм?
  • Задача про паліндроми (усі підпаліндроми, найдовший паліндром, кількість паліндромних підрядків)?
  • Потрібен саме лінійний O(n)O(n) розв'язок з малою сталою, а не O(nlogn)O(n \log n) через хеші? (якщо лінійність не критична → Хешування рядків)
  • Достатньо інформації про паліндроми навколо центрів, без загальних запитів про довільні підрядки? (якщо потрібні підрядкові запити → Суфіксний автомат)

Розв'язок

Загалом ця задача має багато розв'язків: за допомогою хешування рядків її можна розв'язати за O(nlogn)O(n\cdot \log n), а за допомогою суфіксних дерев і швидкого LCA цю задачу можна розв'язати за O(n)O(n).

Але описаний тут метод значно простіший і має меншу приховану сталу в складності за часом і пам'яттю. Цей алгоритм відкрив Glenn K. Manacher у 1975 році.

Ще один сучасний спосіб розв'язати цю задачу і працювати з паліндромами загалом — це так зване паліндромне дерево, або eertree.

Тривіальний алгоритм

Щоб уникнути неоднозначностей у подальшому викладі, позначимо, що таке «тривіальний алгоритм».

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

Такий алгоритм повільний — він може обчислити відповідь лише за O(n2)O(n^2).

Реалізація тривіального алгоритму:

vector<int> manacher_odd_trivial(string s) {
int n = s.size();
s = "$" + s + "^";
vector<int> p(n + 2);
for(int i = 1; i <= n; i++) {
while(s[i - p[i]] == s[i + p[i]]) {
p[i]++;
}
}
return vector<int>(begin(p) + 1, end(p) - 1);
}

Кінцеві символи $ і ^ використано, щоб не обробляти кінці рядка окремо.

Алгоритм Манакера

Опишемо алгоритм для пошуку всіх підпаліндромів непарної довжини, тобто для обчислення dodd[]d_{odd}[].

Для швидкого обчислення ми підтримуватимемо виключні межі (l,r)(l, r) найправішого знайденого (під)паліндрома (тобто поточний найправіший (під)паліндром — це s[l+1]s[l+2]s[r1]s[l+1] s[l+2] \dots s[r-1]). Спочатку ми встановлюємо l=0,r=1l = 0, r = 1, що відповідає порожньому рядку.

Отже, ми хочемо обчислити dodd[i]d_{odd}[i] для наступного ii, причому всі попередні значення в dodd[]d_{odd}[] уже обчислені. Ми робимо таке:

  • Якщо ii лежить поза поточним підпаліндромом, тобто iri \geq r, ми просто запускаємо тривіальний алгоритм.

    Тобто ми послідовно збільшуємо dodd[i]d_{odd}[i] і щоразу перевіряємо, чи є поточний найправіший підрядок [idodd[i]i+dodd[i]][i - d_{odd}[i]\dots i + d_{odd}[i]] паліндромом. Коли ми знаходимо перше неспівпадіння або досягаємо меж ss, ми зупиняємось. У цьому випадку ми остаточно обчислили dodd[i]d_{odd}[i]. Після цього не слід забути оновити (l,r)(l, r). Значення rr потрібно оновити так, щоб воно відповідало останньому індексу поточного найправішого підпаліндрома.

  • Тепер розглянемо випадок, коли iri \le r. Ми спробуємо видобути певну інформацію з уже обчислених значень у dodd[]d_{odd}[]. Отже, знайдемо «дзеркальну» позицію ii у підпаліндромі (l,r)(l, r), тобто отримаємо позицію j=l+(ri)j = l + (r - i), і перевіримо значення dodd[j]d_{odd}[j]. Оскільки jj — це позиція, симетрична до ii відносно (l+r)/2(l+r)/2, ми майже завжди можемо присвоїти dodd[i]=dodd[j]d_{odd}[i] = d_{odd}[j]. Ілюстрація цього (паліндром навколо jj фактично «копіюється» в паліндром навколо ii):

     sl+1  sjdodd[j]+1  sj  sj+dodd[j]1 palindrome  sidodd[j]+1  si  si+dodd[j]1 palindrome  sr1 palindrome \ldots\ \overbrace{ s_{l+1}\ \ldots\ \underbrace{ s_{j-d_{odd}[j]+1}\ \ldots\ s_j\ \ldots\ s_{j+d_{odd}[j]-1}\ }_\text{palindrome}\ \ldots\ \underbrace{ s_{i-d_{odd}[j]+1}\ \ldots\ s_i\ \ldots\ s_{i+d_{odd}[j]-1}\ }_\text{palindrome}\ \ldots\ s_{r-1}\ }^\text{palindrome}\ \ldots

    Але є хитрий випадок, який треба обробити коректно: коли «внутрішній» паліндром досягає меж «зовнішнього», тобто jdodd[j]lj - d_{odd}[j] \le l (або, що те саме, i+dodd[j]ri + d_{odd}[j] \ge r). Оскільки симетрія поза «зовнішнім» паліндромом не гарантована, просто присвоїти dodd[i]=dodd[j]d_{odd}[i] = d_{odd}[j] буде неправильно: у нас недостатньо даних, щоб стверджувати, що паліндром у позиції ii має таку саму довжину.

    Насправді нам поки що слід обмежити довжину нашого паліндрома, тобто присвоїти dodd[i]=rid_{odd}[i] = r - i, щоб коректно обробити такі ситуації. Після цього ми запустимо тривіальний алгоритм, який спробує збільшити dodd[i]d_{odd}[i], доки це можливо.

    Ілюстрація цього випадку (паліндром із центром jj обмежено так, щоб він уміщувався в «зовнішній» паліндром):

     sl+1  sj  sj+(jl)1 palindrome  si(ri)+1  si  sr1palindrome palindrome try moving here\ldots\ \overbrace{ \underbrace{ s_{l+1}\ \ldots\ s_j\ \ldots\ s_{j+(j-l)-1}\ }_\text{palindrome}\ \ldots\ \underbrace{ s_{i-(r-i)+1}\ \ldots\ s_i\ \ldots\ s_{r-1} }_\text{palindrome}\ }^\text{palindrome}\ \underbrace{ \ldots \ldots \ldots \ldots \ldots }_\text{try moving here}

    На ілюстрації показано, що паліндром із центром jj міг би бути більшим і виходити за межі «зовнішнього» паліндрома, але з центром у ii ми можемо використати лише ту частину, яка повністю вміщується в «зовнішній» паліндром. Проте відповідь для позиції ii (dodd[i]d_{odd}[i]) може бути значно більшою за цю частину, тому далі ми запустимо наш тривіальний алгоритм, який спробує розширити її за межі нашого «зовнішнього» паліндрома, тобто в область «спробувати рухатись сюди».

І знову, ми не повинні забути оновити значення (l,r)(l, r) після обчислення кожного dodd[i]d_{odd}[i].

Складність алгоритму Манакера

На перший погляд не очевидно, що цей алгоритм має лінійну часову складність, бо ми часто запускаємо наївний алгоритм під час пошуку відповіді для конкретної позиції.

Однак уважніший аналіз показує, що алгоритм є лінійним. Власне, алгоритм побудови Z-функції, який виглядає схожим на цей алгоритм, теж працює за лінійний час.

Можна помітити, що кожна ітерація тривіального алгоритму збільшує rr на одиницю. Також rr не може зменшуватись протягом роботи алгоритму. Отже, тривіальний алгоритм зробить O(n)O(n) ітерацій загалом.

Інші частини алгоритму Манакера, очевидно, працюють за лінійний час. Таким чином, ми отримуємо часову складність O(n)O(n).

Реалізація алгоритму Манакера

Для обчислення dodd[]d_{odd}[] ми отримуємо такий код. На що слід звернути увагу:

  • ii — це індекс центральної літери поточного паліндрома.
  • Якщо ii перевищує rr, dodd[i]d_{odd}[i] ініціалізується нулем.
  • Якщо ii не перевищує rr, то dodd[i]d_{odd}[i] або ініціалізується значенням dodd[j]d_{odd}[j], де jj — дзеркальна позиція ii в (l,r)(l,r), або dodd[i]d_{odd}[i] обмежується розміром «зовнішнього» паліндрома.
  • Цикл while позначає тривіальний алгоритм. Ми запускаємо його незалежно від значення kk.
  • Якщо розмір паліндрома з центром у ii дорівнює xx, то dodd[i]d_{odd}[i] зберігає x+12\frac{x+1}{2}.
vector<int> manacher_odd(string s) {
int n = s.size();
s = "$" + s + "^";
vector<int> p(n + 2);
int l = 0, r = 1;
for(int i = 1; i <= n; i++) {
if(i <= r) {
p[i] = min(r - i, p[l + (r - i)]);
}
while(s[i - p[i]] == s[i + p[i]]) {
p[i]++;
}
if(i + p[i] > r) {
l = i - p[i], r = i + p[i];
}
}
return vector<int>(begin(p) + 1, end(p) - 1);
}

Робота з парностями

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

Щоб цьому зарадити, можна звести всю задачу до випадку, коли ми працюємо лише з паліндромами непарної довжини. Для цього ми можемо вставити додатковий символ # між кожною літерою рядка, а також на початку та в кінці рядка:

abcbcba#a#b#c#b#c#b#a#,abcbcba \to \#a\#b\#c\#b\#c\#b\#a\#, d=[1,2,1,2,1,4,1,8,1,4,1,2,1,2,1].d = [1,2,1,2,1,4,1,8,1,4,1,2,1,2,1].

Як бачите, d[2i]=2deven[i]+1d[2i]=2 d_{even}[i]+1 і d[2i+1]=2dodd[i]d[2i+1]=2 d_{odd}[i], де dd позначає масив Манакера для паліндромів непарної довжини в рядку, з'єднаному символами #, а doddd_{odd} і devend_{even} відповідають масивам, означеним вище для початкового рядка.

Справді, символи # не впливають на паліндроми непарної довжини, які все ще мають центр у символах початкового рядка, але тепер паліндроми парної довжини початкового рядка стають паліндромами непарної довжини нового рядка з центром у символах #.

Зауважимо, що d[2i]d[2i] і d[2i+1]d[2i+1] — це, по суті, збільшені на 11 довжини найбільших паліндромів непарної та парної довжини відповідно з центром у ii.

Це зведення реалізовано так:

vector<int> manacher(string s) {
string t;
for(auto c: s) {
t += string("#") + c;
}
auto res = manacher_odd(t + "#");
return vector<int>(begin(res) + 1, end(res) - 1);
}

Для простоти розбиття масиву на doddd_{odd} і devend_{even}, а також їх явне обчислення тут опущено.

Задачі

Відеоматеріали