Префікс-функція. Алгоритм Кнута–Морріса–Пратта
Означення префікс-функції
Нам дано рядок довжини . Префікс-функція для цього рядка означається як масив довжини , де — це довжина найдовшого власного префікса підрядка , який водночас є суфіксом цього підрядка. Власний префікс рядка — це префікс, який не дорівнює самому рядку. За означенням .
Математично означення префікс-функції можна записати так:
Наприклад, префікс-функція рядка "abcabcd" дорівнює , а префікс-функція рядка "aabaaab" дорівнює .
- Шукаєте один взірець у тексті (або працюєте з періодами/бордерами одного рядка)? (якщо взірців багато одночасно → Ахо-Корасік)
- Потрібен точний збіг підрядка, а не порівняння довільних підрядків за ? (якщо потрібні саме порівняння будь-яких підрядків → Хешування рядків)
- Достатньо лінійних структур, без багатьох запитів про суфікси/підрядки? (якщо потрібні лексикографічні запити на суфіксах → Суфіксний масив)
Тривіальний алгоритм
Алгоритм, який точно слідує означенню префікс-функції, такий:
- C++
- Python
- TypeScript
- Go
vector<int> prefix_function(string s) {
int n = (int)s.length();
vector<int> pi(n);
for (int i = 0; i < n; i++)
for (int k = 0; k <= i; k++)
if (s.substr(0, k) == s.substr(i-k+1, k))
pi[i] = k;
return pi;
}
def prefix_function(s: str) -> list[int]:
n = len(s)
pi = [0] * n
for i in range(n):
for k in range(i + 1):
# Порівнюємо префікс довжини k із суфіксом, що закінчується в позиції i
if s[0:k] == s[i - k + 1:i + 1]:
pi[i] = k
return pi
function prefixFunction(s: string): number[] {
const n = s.length;
const pi = new Array<number>(n).fill(0);
for (let i = 0; i < n; i++) {
for (let k = 0; k <= i; k++) {
// Порівнюємо префікс довжини k із суфіксом, що закінчується в позиції i
if (s.slice(0, k) === s.slice(i - k + 1, i + 1)) {
pi[i] = k;
}
}
}
return pi;
}
func prefixFunction(s string) []int {
n := len(s)
pi := make([]int, n)
for i := 0; i < n; i++ {
for k := 0; k <= i; k++ {
// Порівнюємо префікс довжини k із суфіксом, що закінчується в позиції i
if s[0:k] == s[i-k+1:i+1] {
pi[i] = k
}
}
}
return pi
}
Легко бачити, що його складність становить , що залишає простір для покращення.
Ефективний алгоритм
Цей алгоритм запропонували Кнут і Пратт та незалежно від них Морріс у 1977 році. Його використовували як основну функцію алгоритму пошуку підрядка.
Перша оптимізація
Перше важливе спостереження полягає в тому, що значення префікс-функції можуть зростати щонайбільше на одиницю.
Справді, інакше, якби , то ми могли б узяти цей суфікс, що закінчується в позиції , довжини і прибрати з нього останній символ. Ми отримали б суфікс, що закінчується в позиції , довжини , що краще за , тобто маємо суперечність.
Наступна ілюстрація показує цю суперечність. Найдовший власний суфікс у позиції , який водночас є префіксом, має довжину , а в позиції — довжину . Тому рядок дорівнює рядку , а це означає, що й рядки та рівні, отже має дорівнювати .
Отже, переходячи до наступної позиції, значення префікс-функції може або зрости на одиницю, або лишитися тим самим, або зменшитися на якусь величину. Цей факт уже дозволяє нам знизити складність алгоритму до , бо за один крок префікс-функція може зрости щонайбільше на одиницю. Загалом функція може зрости щонайбільше кроків, а отже й зменшитися загалом теж щонайбільше кроків. Це означає, що нам потрібно виконати лише порівнянь рядків, і ми досягаємо складності .
Друга оптимізація
Підемо далі — ми хочемо позбутися порівнянь рядків. Щоб цього досягти, нам треба використати всю інформацію, обчислену на попередніх кроках.
Отже, обчислимо значення префікс-функції для . Якщо , то ми можемо з упевненістю сказати, що , оскільки ми вже знаємо, що суфікс у позиції довжини дорівнює префіксу довжини . Це знову проілюстровано на прикладі.
Якщо ж це не так, , то нам потрібно спробувати коротший рядок. Щоб пришвидшити справу, ми хотіли б одразу перейти до найбільшої довжини такої, що в позиції виконується префіксна властивість, тобто :
Справді, якщо ми знайдемо таку довжину , то нам знову потрібно лише порівняти символи та . Якщо вони рівні, то ми можемо присвоїти . Інакше нам потрібно буде знайти найбільше значення, менше за , для якого виконується префіксна властивість, і так далі. Може статися, що це триватиме до . Якщо тоді , ми присвоюємо , а інакше .
Отже, у нас уже є загальна схема алгоритму. Лишається єдине запитання — як ефективно знаходити довжини для . Підсумуймо: для поточної довжини у позиції , для якої виконується префіксна властивість, тобто , ми хочемо знайти найбільше , для якого виконується префіксна властивість.
Ілюстрація показує, що це має бути значення , яке ми вже обчислили раніше.
Остаточний алгоритм
Отже, ми нарешті можемо побудувати алгоритм, який не виконує жодних порівнянь рядків і виконує лише дій.
Ось остаточна процедура:
- Ми обчислюємо значення префікс-функції у циклі, ітеруючи від до (значенню просто присвоюємо ).
- Щоб обчислити поточне значення , ми задаємо змінну , що позначає довжину найкращого суфікса для . Спочатку .
- Перевіряємо, чи суфікс довжини є також і префіксом, порівнюючи і . Якщо вони рівні, то присвоюємо , інакше зменшуємо до і повторюємо цей крок.
- Якщо ми досягли довжини і досі не маємо збігу, то присвоюємо і переходимо до наступного індексу .
Реалізація
Реалізація виявляється напрочуд короткою та виразною.
- C++
- Python
- TypeScript
- Go
vector<int> prefix_function(string s) {
int n = (int)s.length();
vector<int> pi(n);
for (int i = 1; i < n; i++) {
int j = pi[i-1];
while (j > 0 && s[i] != s[j])
j = pi[j-1];
if (s[i] == s[j])
j++;
pi[i] = j;
}
return pi;
}
def prefix_function(s: str) -> list[int]:
n = len(s)
pi = [0] * n
for i in range(1, n):
j = pi[i - 1]
# Відкочуємося за значеннями префікс-функції, доки не знайдемо збіг
while j > 0 and s[i] != s[j]:
j = pi[j - 1]
if s[i] == s[j]:
j += 1
pi[i] = j
return pi
function prefixFunction(s: string): number[] {
const n = s.length;
const pi = new Array<number>(n).fill(0);
for (let i = 1; i < n; i++) {
let j = pi[i - 1];
// Відкочуємося за значеннями префікс-функції, доки не знайдемо збіг
while (j > 0 && s[i] !== s[j]) {
j = pi[j - 1];
}
if (s[i] === s[j]) {
j++;
}
pi[i] = j;
}
return pi;
}
func prefixFunction(s string) []int {
n := len(s)
pi := make([]int, n)
for i := 1; i < n; i++ {
j := pi[i-1]
// Відкочуємося за значеннями префікс-функції, доки не знайдемо збіг
for j > 0 && s[i] != s[j] {
j = pi[j-1]
}
if s[i] == s[j] {
j++
}
pi[i] = j
}
return pi
}
Це онлайн-алгоритм, тобто він обробляє дані в міру їх надходження — наприклад, можна читати символи рядка по одному й одразу їх обробляти, знаходячи значення префікс-функції для кожного наступного символу. Алгоритм усе ще потребує зберігання самого рядка та раніше обчислених значень префікс-функції, але якщо ми наперед знаємо максимальне значення , яке префікс-функція може набути на рядку, то ми можемо зберігати лише перших символів рядка та таку саму кількість значень префікс-функції.
Застосування
Пошук підрядка в рядку. Алгоритм Кнута–Морріса–Пратта
Це задача є класичним застосуванням префікс-функції.
Дано текст і рядок , ми хочемо знайти та вивести позиції всіх входжень рядка у текст .
Для зручності позначимо через довжину рядка , а через — довжину тексту .
Ми утворюємо рядок , де — це роздільник, який не зустрічається ані в , ані в . Обчислимо префікс-функцію для цього рядка. Тепер подумаймо про значення префікс-функції, крім перших елементів (які належать рядку і роздільнику). За означенням значення показує найбільшу довжину підрядка, що закінчується в позиції , який збігається з префіксом. Але в нашому випадку це не що інше, як найбільший блок, що збігається з і закінчується в позиції . Ця довжина не може бути більшою за через роздільник. Але якщо досягається рівність , то це означає, що рядок повністю з’являється в цій позиції, тобто закінчується в позиції . Тільки не забуваймо, що позиції індексуються в рядку .
Отже, якщо в якійсь позиції ми маємо , то в позиції у рядку з’являється рядок .
Як уже згадувалося в описі обчислення префікс-функції, якщо ми знаємо, що значення префікса ніколи не перевищують певного значення, то нам не потрібно зберігати весь рядок і всю функцію, а лише її початок. У нашому випадку це означає, що нам потрібно зберігати лише рядок і значення префікс-функції для нього. Ми можемо читати рядок по одному символу за раз і обчислювати поточне значення префікс-функції.
Отже, алгоритм Кнута–Морріса–Пратта розв’язує задачу за час і пам’ять .
Підрахунок кількості входжень кожного префікса
Тут ми обговорюємо одразу дві задачі. Дано рядок довжини . У першому варіанті задачі ми хочемо підрахувати кількість появ кожного префікса у тому самому рядку. У другому варіанті задачі дано інший рядок , і ми хочемо підрахувати кількість появ кожного префікса у .
Спочатку розв’яжемо першу задачу. Розгляньмо значення префікс-функції у позиції . За означенням це означає, що префікс довжини рядка зустрічається й закінчується в позиції , і немає довшого префікса, який задовольняв би це означення. Водночас коротші префікси можуть закінчуватися в цій позиції. Неважко бачити, що ми маємо те саме запитання, на яке ми вже відповіли, коли обчислювали саму префікс-функцію: дано префікс довжини , який є суфіксом, що закінчується в позиції , — який наступний менший префікс , що теж є суфіксом, який закінчується в позиції . Отже, у позиції закінчується префікс довжини , префікс довжини , префікс , і так далі, доки індекс не стане нулем. Отже, ми можемо обчислити відповідь у такий спосіб.
- C++
- Python
- TypeScript
- Go
vector<int> ans(n + 1);
for (int i = 0; i < n; i++)
ans[pi[i]]++;
for (int i = n-1; i > 0; i--)
ans[pi[i-1]] += ans[i];
for (int i = 0; i <= n; i++)
ans[i]++;
ans = [0] * (n + 1)
for i in range(n):
ans[pi[i]] += 1
for i in range(n - 1, 0, -1):
ans[pi[i - 1]] += ans[i]
for i in range(n + 1):
ans[i] += 1
const ans = new Array<number>(n + 1).fill(0);
for (let i = 0; i < n; i++) {
ans[pi[i]]++;
}
for (let i = n - 1; i > 0; i--) {
ans[pi[i - 1]] += ans[i];
}
for (let i = 0; i <= n; i++) {
ans[i]++;
}
ans := make([]int, n+1)
for i := 0; i < n; i++ {
ans[pi[i]]++
}
for i := n - 1; i > 0; i-- {
ans[pi[i-1]] += ans[i]
}
for i := 0; i <= n; i++ {
ans[i]++
}
Тут для кожного значення префікс-функції ми спочатку рахуємо, скільки разів воно зустрічається в масиві , а потім обчислюємо остаточні відповіді: якщо ми знаємо, що префікс довжини з’являється рівно разів, то це число треба додати до кількості входжень його найдовшого суфікса, який водночас є префіксом. Наприкінці нам потрібно додати до кожного результату, оскільки нам потрібно врахувати й самі вихідні префікси.
Тепер розгляньмо другу задачу. Ми застосовуємо трюк з алгоритму Кнута–Морріса–Пратта: ми утворюємо рядок і обчислюємо його префікс-функцію. Єдина відмінність від першої задачі полягає в тому, що нас цікавлять лише значення префікса, які стосуються рядка , тобто для . З цими значеннями ми можемо виконати точно такі самі обчислення, як у першій задачі.
Кількість різних підрядків у рядку
Дано рядок довжини . Ми хочемо обчислити кількість різних підрядків, що в ньому зустрічаються.
Ми розв’язуватимемо цю задачу ітеративно. А саме, ми навчимося, знаючи поточну кількість різних підрядків, перераховувати цю кількість, додаючи символ у кінець.
Отже, нехай — це поточна кількість різних підрядків у , і ми додаємо символ у кінець . Очевидно, що з’являться деякі нові підрядки, які закінчуються на . Ми хочемо підрахувати ці нові підрядки, яких не було раніше.
Ми беремо рядок і обертаємо його. Тепер задача перетворюється на обчислення того, скільки є префіксів, які більше ніде не зустрічаються. Якщо ми обчислимо максимальне значення префікс-функції оберненого рядка , то найдовший префікс, який зустрічається в , має довжину . Зрозуміло, що в ньому також зустрічаються всі префікси меншої довжини.
Тому кількість нових підрядків, що з’являються, коли ми додаємо новий символ , дорівнює .
Отже, для кожного доданого символу ми можемо обчислити кількість нових підрядків за час , що дає часову складність загалом.
Варто зауважити, що ми також можемо обчислити кількість різних підрядків, додаючи символи на початок, або видаляючи символи з початку чи з кінця.
Стиснення рядка
Дано рядок довжини . Ми хочемо знайти найкоротше «стиснуте» подання рядка, тобто ми хочемо знайти рядок найменшої довжини такий, що можна подати як конкатенацію однієї або кількох копій .
Зрозуміло, що нам потрібно знайти лише довжину . Знаючи довжину, відповіддю до задачі буде префікс цієї довжини.
Обчислимо префікс-функцію для . Використовуючи її останнє значення, ми визначаємо величину . Ми покажемо, що якщо ділить , то буде відповіддю, інакше ефективного стиснення не існує, і відповідь — це .
Нехай ділиться на . Тоді рядок можна розбити на блоки довжини . За означенням префікс-функції префікс довжини дорівнюватиме своєму суфіксу. Але це означає, що останній блок дорівнює блоку перед ним. А блок перед ним має дорівнювати блоку перед ним. І так далі. У результаті виявляється, що всі блоки рівні, отже, ми можемо стиснути рядок до довжини .
Звісно, нам ще потрібно показати, що це справді оптимум. Справді, якби існувало стиснення менше за , то префікс-функція в кінці була б більшою за . Тому справді є відповіддю.
Тепер припустімо, що не ділиться на . Ми покажемо, що це означає, що довжина відповіді дорівнює . Доведемо це від супротивного. Припустимо, що відповідь існує, і стиснення має довжину ( ділить ). Тоді останнє значення префікс-функції має бути більшим за , тобто суфікс частково накриватиме перший блок. Тепер розгляньмо другий блок рядка. Оскільки префікс дорівнює суфіксу, і обидва — і префікс, і суфікс — накривають цей блок, а їхнє зміщення одне відносно одного не ділить довжину блоку (інакше ділило б ), то всі символи блоку мають бути однаковими. Але тоді рядок складається лише з одного символу, повтореного знову і знову, отже, ми можемо стиснути його до рядка розміру , що дає , і ділить . Суперечність.
Побудова автомата за префікс-функцією
Повернімося до конкатенації двох рядків через роздільник, тобто для рядків і ми обчислюємо префікс-функцію рядка . Очевидно, оскільки — це роздільник, значення префікс-функції ніколи не перевищить . Звідси випливає, що достатньо зберігати лише рядок і значення префікс-функції для нього, а префікс-функцію для всіх наступних символів ми можемо обчислювати на льоту:
Справді, у такій ситуації, знаючи наступний символ і значення префікс-функції попередньої позиції, ми маємо достатньо інформації, щоб обчислити наступне значення префікс-функції, не використовуючи жодних попередніх символів рядка і значень префікс-функції в них.
Іншими словами, ми можемо побудувати автомат (скінченний автомат): станом у ньому є поточне значення префікс-функції, а перехід з одного стану в інший виконуватиметься через наступний символ.
Отже, навіть не маючи рядка , ми можемо побудувати таку таблицю переходів , використовуючи той самий алгоритм, що й для обчислення таблиці переходів:
- C++
- Python
- TypeScript
- Go
void compute_automaton(string s, vector<vector<int>>& aut) {
s += '#';
int n = s.size();
vector<int> pi = prefix_function(s);
aut.assign(n, vector<int>(26));
for (int i = 0; i < n; i++) {
for (int c = 0; c < 26; c++) {
int j = i;
while (j > 0 && 'a' + c != s[j])
j = pi[j-1];
if ('a' + c == s[j])
j++;
aut[i][c] = j;
}
}
}
def compute_automaton(s: str) -> list[list[int]]:
s += "#"
n = len(s)
pi = prefix_function(s)
aut = [[0] * 26 for _ in range(n)]
for i in range(n):
for c in range(26):
j = i
# Символ алфавіту 'a' + c подаємо кодом
while j > 0 and ord("a") + c != ord(s[j]):
j = pi[j - 1]
if ord("a") + c == ord(s[j]):
j += 1
aut[i][c] = j
return aut
function computeAutomaton(s: string): number[][] {
s += "#";
const n = s.length;
const pi = prefixFunction(s);
const aut: number[][] = Array.from({ length: n }, () => new Array<number>(26).fill(0));
const a = "a".charCodeAt(0);
for (let i = 0; i < n; i++) {
for (let c = 0; c < 26; c++) {
let j = i;
// Символ алфавіту 'a' + c подаємо кодом
while (j > 0 && a + c !== s.charCodeAt(j)) {
j = pi[j - 1];
}
if (a + c === s.charCodeAt(j)) {
j++;
}
aut[i][c] = j;
}
}
return aut;
}
func computeAutomaton(s string) [][]int {
s += "#"
n := len(s)
pi := prefixFunction(s)
aut := make([][]int, n)
for i := range aut {
aut[i] = make([]int, 26)
}
for i := 0; i < n; i++ {
for c := 0; c < 26; c++ {
j := i
// Символ алфавіту 'a' + c подаємо кодом
for j > 0 && byte('a')+byte(c) != s[j] {
j = pi[j-1]
}
if byte('a')+byte(c) == s[j] {
j++
}
aut[i][c] = j
}
}
return aut
}
Однак у такій формі алгоритм працює за час для малих літер алфавіту. Зауважимо, що ми можемо застосувати динамічне програмування й використати вже обчислені частини таблиці. Щоразу, коли ми переходимо від значення до значення , ми насправді маємо на увазі, що перехід веде до того самого стану, що й перехід , а ця відповідь уже точно обчислена.
- C++
- Python
- TypeScript
- Go
void compute_automaton(string s, vector<vector<int>>& aut) {
s += '#';
int n = s.size();
vector<int> pi = prefix_function(s);
aut.assign(n, vector<int>(26));
for (int i = 0; i < n; i++) {
for (int c = 0; c < 26; c++) {
if (i > 0 && 'a' + c != s[i])
aut[i][c] = aut[pi[i-1]][c];
else
aut[i][c] = i + ('a' + c == s[i]);
}
}
}
def compute_automaton(s: str) -> list[list[int]]:
s += "#"
n = len(s)
pi = prefix_function(s)
aut = [[0] * 26 for _ in range(n)]
for i in range(n):
for c in range(26):
ch = ord("a") + c
if i > 0 and ch != ord(s[i]):
# Перевикористовуємо вже обчислений рядок таблиці
aut[i][c] = aut[pi[i - 1]][c]
else:
aut[i][c] = i + (1 if ch == ord(s[i]) else 0)
return aut
function computeAutomaton(s: string): number[][] {
s += "#";
const n = s.length;
const pi = prefixFunction(s);
const aut: number[][] = Array.from({ length: n }, () => new Array<number>(26).fill(0));
const a = "a".charCodeAt(0);
for (let i = 0; i < n; i++) {
for (let c = 0; c < 26; c++) {
const ch = a + c;
if (i > 0 && ch !== s.charCodeAt(i)) {
// Перевикористовуємо вже обчислений рядок таблиці
aut[i][c] = aut[pi[i - 1]][c];
} else {
aut[i][c] = i + (ch === s.charCodeAt(i) ? 1 : 0);
}
}
}
return aut;
}
func computeAutomaton(s string) [][]int {
s += "#"
n := len(s)
pi := prefixFunction(s)
aut := make([][]int, n)
for i := range aut {
aut[i] = make([]int, 26)
}
for i := 0; i < n; i++ {
for c := 0; c < 26; c++ {
ch := byte('a') + byte(c)
if i > 0 && ch != s[i] {
// Перевикористовуємо вже обчислений рядок таблиці
aut[i][c] = aut[pi[i-1]][c]
} else {
match := 0
if ch == s[i] {
match = 1
}
aut[i][c] = i + match
}
}
}
return aut
}
У результаті ми будуємо автомат за час .
Коли такий автомат корисний? Для початку згадаймо, що ми використовуємо префікс-функцію для рядка і її значення переважно з єдиною метою: знайти всі входження рядка у рядок .
Тому найочевидніша користь цього автомата — це пришвидшення обчислення префікс-функції для рядка . Побудувавши автомат для , нам більше не потрібно зберігати рядок чи значення префікс-функції в ньому. Усі переходи вже обчислені в таблиці.
Але є й друге, менш очевидне застосування. Ми можемо використовувати автомат, коли рядок — це гігантський рядок, побудований за певними правилами. Це можуть бути, наприклад, рядки Грея або рядок, утворений рекурсивною комбінацією кількох коротких рядків із вхідних даних.
Для повноти ми розв’яжемо таку задачу: дано число і рядок довжини . Нам потрібно обчислити кількість входжень у -й рядок Грея. Нагадаймо, що рядки Грея означаються так:
У таких випадках навіть побудувати рядок буде неможливо через його астрономічну довжину. -й рядок Грея має довжину символів. Однак ми можемо ефективно обчислити значення префікс-функції в кінці рядка, знаючи лише значення префікс-функції на початку.
Окрім самого автомата, ми також обчислюємо значення — значення автомата після обробки рядка , починаючи зі стану . А додатково ми обчислюємо значення — кількість входжень у під час обробки , починаючи зі стану . Власне, — це кількість разів, коли префікс-функція набула значення під час виконання операцій. Відповіддю до задачі тоді буде .
Як ми можемо обчислити ці значення? Спочатку базові значення такі: і . А всі наступні значення можна обчислити з попередніх значень, використовуючи автомат. Щоб обчислити значення для деякого , згадаймо, що рядок складається з , -го символу алфавіту та . Отже, автомат перейде у стан:
Значення для також легко підрахувати.
Отже, ми можемо розв’язати задачу для рядків Грея, а так само й величезну кількість інших подібних задач. Наприклад, точно той самий метод також розв’язує таку задачу: нам дано рядок і деякі взірці , кожен з яких задається так: це рядок зі звичайних символів, і там можуть бути деякі рекурсивні вставки попередніх рядків виду , що означає, що в цьому місці ми маємо вставити рядок разів. Приклад таких взірців:
Рекурсивні підстановки роздувають рядок так, що їхні довжини можуть сягати порядку .
Нам потрібно знайти кількість разів, коли рядок з’являється в кожному з рядків.
Задачу можна розв’язати тим самим способом, побудувавши автомат префікс-функції, а потім ми обчислюємо переходи для кожного взірця, використовуючи попередні результати.
Задачі для практики
- UVA # 455 "Periodic Strings"
- UVA # 11022 "String Factoring"
- UVA # 11452 "Dancing the Cheeky-Cheeky"
- UVA 12604 - Caesar Cipher
- UVA 12467 - Secret Word
- UVA 11019 - Matrix Matcher
- SPOJ - Pattern Find
- SPOJ - A Needle in the Haystack
- Codeforces - Anthem of Berland
- Codeforces - MUH and Cube Walls
- Codeforces - Prefixes and Suffixes