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

Z-функція та її обчислення

Нехай задано рядок ss довжини nn. Z-функція цього рядка — це масив довжини nn, де ii-й елемент дорівнює найбільшій кількості символів, починаючи з позиції ii, які збігаються з першими символами ss.

Іншими словами, z[i]z[i] — це довжина найдовшого рядка, який одночасно є префіксом ss і префіксом суфікса ss, що починається з позиції ii.

Зауваження. У цій статті, щоб уникнути двозначності, ми вважаємо, що індексація починається з 00; тобто перший символ ss має індекс 00, а останній — індекс n1n-1.

Перший елемент Z-функції, z[0]z[0], загалом не є чітко визначеним. У цій статті ми вважатимемо його рівним нулю (хоча це нічого не змінює в реалізації алгоритму).

У цій статті наведено алгоритм обчислення Z-функції за час O(n)O(n), а також різні її застосування.

Коли підходить цей алгоритм?
  • Шукаєте один взірець у тексті чи аналізуєте збіги з префіксом одного рядка? (якщо взірців багато одночасно → Ахо-Корасік)
  • Достатньо точного збігу, а не порівняння довільних підрядків за O(1)O(1)? (якщо потрібні саме такі порівняння → Хешування рядків)
  • Вам зручніше міркувати у термінах «довжина збігу з префіксом», ніж бордерів? (якщо зручніша префікс-функція → КМП)

Приклади

Наприклад, ось значення Z-функції, обчислені для різних рядків:

  • "aaaaa" — [0,4,3,2,1][0, 4, 3, 2, 1]
  • "aaabaab" — [0,2,1,0,2,1,0][0, 2, 1, 0, 2, 1, 0]
  • "abacaba" — [0,0,1,0,3,0,1][0, 0, 1, 0, 3, 0, 1]

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

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

vector<int> z_function_trivial(string s) {
int n = s.size();
vector<int> z(n);
for (int i = 1; i < n; i++) {
while (i + z[i] < n && s[z[i]] == s[i + z[i]]) {
z[i]++;
}
}
return z;
}

Ми просто проходимо по кожній позиції ii та оновлюємо z[i]z[i] для кожної з них, починаючи з z[i]=0z[i] = 0 і збільшуючи його, доки не знайдемо розбіжність (і доки не досягнемо кінця рядка).

Звісно, це неефективна реалізація. Тепер ми покажемо побудову ефективної реалізації.

Ефективний алгоритм обчислення Z-функції

Щоб отримати ефективний алгоритм, ми обчислюватимемо значення z[i]z[i] по черзі від i=1i = 1 до n1n - 1, але водночас, обчислюючи нове значення, намагатимемося якнайкраще скористатися вже обчисленими значеннями.

Для стислості назвемо Z-блоками (англ. segment match) ті підрядки, які збігаються з префіксом ss. Наприклад, значення шуканої Z-функції z[i]z[i] — це довжина Z-блоку, що починається з позиції ii (і закінчується на позиції i+z[i]1i + z[i] - 1).

Для цього ми зберігатимемо індекси [l,r)[l, r) найправішого Z-блоку. Тобто серед усіх знайдених блоків ми зберігаємо той, що закінчується найправіше. Певною мірою індекс rr можна розглядати як «межу», до якої наш рядок ss був переглянутий алгоритмом; усе, що далі цієї точки, ще не відоме.

Тоді, якщо поточний індекс (для якого нам треба обчислити наступне значення Z-функції) дорівнює ii, маємо один із двох варіантів:

  • iri \geq r — поточна позиція поза тим, що ми вже обробили.

    Тоді ми обчислимо z[i]z[i] за допомогою тривіального алгоритму (тобто просто порівнюючи значення одне за одним). Зауважимо, що зрештою, якщо z[i]>0z[i] > 0, нам доведеться оновити індекси найправішого блоку, бо гарантовано новий r=i+z[i]r = i + z[i] кращий за попередній rr.

  • i<ri < r — поточна позиція всередині поточного Z-блоку [l,r)[l, r).

    Тоді ми можемо скористатися вже обчисленими значеннями Z-функції, щоб «ініціалізувати» значення z[i]z[i] чимось (це напевно краще, ніж «починати з нуля»), можливо, навіть якимось великим числом.

    Для цього зауважимо, що підрядки s[lr)s[l \dots r) і s[0rl)s[0 \dots r-l) збігаються. Це означає, що як початкове наближення для z[i]z[i] ми можемо взяти значення, вже обчислене для відповідного блоку s[0rl)s[0 \dots r-l), а саме z[il]z[i-l].

    Однак значення z[il]z[i-l] може виявитися завеликим: застосоване до позиції ii, воно може перевищити індекс rr. Це неприпустимо, бо ми нічого не знаємо про символи праворуч від rr: вони можуть відрізнятися від потрібних.

    Ось приклад подібної ситуації:

    s="aaaabaa"s = "aaaabaa"

    Коли ми доходимо до останньої позиції (i=6i = 6), поточним Z-блоком буде [5,7)[5, 7). Позиція 66 тоді відповідатиме позиції 65=16 - 5 = 1, для якої значення Z-функції дорівнює z[1]=3z[1] = 3. Очевидно, ми не можемо ініціалізувати z[6]z[6] значенням 33, це було б цілком неправильно. Максимальне значення, яким ми могли б його ініціалізувати, — це 11, бо це найбільше значення, яке не виводить нас за межі індексу rr Z-блоку [l,r)[l, r).

    Отже, як початкове наближення для z[i]z[i] ми можемо безпечно взяти:

    z0[i]=min(ri,  z[il])z_0[i] = \min(r - i,\; z[i-l])

    Після ініціалізації z[i]z[i] значенням z0[i]z_0[i] ми намагаємося збільшити z[i]z[i], запускаючи тривіальний алгоритм, бо загалом, після межі rr, ми не можемо знати, чи продовжуватиметься збіг, чи ні.

Отже, увесь алгоритм розбивається на два випадки, які відрізняються лише початковим значенням z[i]z[i]: у першому випадку воно вважається рівним нулю, у другому визначається за вже обчисленими значеннями (за наведеною вище формулою). Після цього обидві гілки цього алгоритму зводяться до реалізації тривіального алгоритму, який стартує одразу після задання початкового значення.

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

Реалізація

Реалізація виявляється доволі лаконічною:

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

Коментарі до цієї реалізації

Весь розв'язок подано у вигляді функції, яка повертає масив довжини nn — Z-функцію рядка ss.

Масив zz початково заповнений нулями. Поточним найправішим Z-блоком вважається [0;0)[0; 0) (тобто навмисно малий блок, який не містить жодного ii).

Усередині циклу для i=1n1i = 1 \dots n - 1 ми спершу визначаємо початкове значення z[i]z[i] — воно або залишиться нулем, або буде обчислене за наведеною вище формулою.

Після цього тривіальний алгоритм намагається збільшити значення z[i]z[i] якомога більше.

Зрештою, якщо це потрібно (тобто якщо i+z[i]>ri + z[i] > r), ми оновлюємо найправіший Z-блок [l,r)[l, r).

Асимптотична поведінка алгоритму

Ми доведемо, що наведений вище алгоритм має час роботи, лінійний за довжиною рядка, — тобто O(n)O(n).

Доведення дуже просте.

Нас цікавить вкладений цикл while, оскільки все інше — це лише набір сталих операцій, які в сумі дають O(n)O(n).

Ми покажемо, що кожна ітерація циклу while збільшує праву межу rr Z-блоку.

Для цього розглянемо обидві гілки алгоритму:

  • iri \geq r

    У цьому випадку або цикл while не зробить жодної ітерації (якщо s[0]s[i]s[0] \ne s[i]), або зробить кілька ітерацій, починаючи з позиції ii, щоразу зсуваючись на один символ праворуч. Після цього права межа rr обов'язково оновиться.

    Отже, ми з'ясували, що коли iri \geq r, кожна ітерація циклу while збільшує значення нового індексу rr.

  • i<ri < r

    У цьому випадку ми ініціалізуємо z[i]z[i] певним значенням z0z_0, заданим наведеною вище формулою. Порівняймо це початкове значення z0z_0 зі значенням rir - i. Матимемо три випадки:

    • z0<riz_0 < r - i

      Доведемо, що в цьому випадку не відбудеться жодної ітерації циклу while.

      Це легко довести, наприклад, від супротивного: якби цикл while зробив хоча б одну ітерацію, це означало б, що початкове наближення z[i]=z0z[i] = z_0 було неточним (меншим за фактичну довжину збігу). Але оскільки s[lr)s[l \dots r) і s[0rl)s[0 \dots r-l) однакові, це означало б, що z[il]z[i-l] містить хибне значення (менше, ніж мало б бути).

      Отже, оскільки z[il]z[i-l] правильне і менше за rir - i, звідси випливає, що це значення збігається з шуканим значенням z[i]z[i].

    • z0=riz_0 = r - i

      У цьому випадку цикл while може зробити кілька ітерацій, але кожна з них приведе до збільшення значення індексу rr, бо ми почнемо порівнювати з s[r]s[r], який вийде за межі інтервалу [l,r)[l, r).

    • z0>riz_0 > r - i

      Цей варіант неможливий за означенням z0z_0.

Отже, ми довели, що кожна ітерація внутрішнього циклу просуває вказівник rr праворуч. Оскільки rr не може бути більшим за n1n-1, це означає, що внутрішній цикл зробить не більш ніж n1n-1 ітерацій.

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

Застосування

Тепер розглянемо деякі способи використання Z-функції для конкретних задач.

Ці застосування значною мірою подібні до застосувань префікс-функції.

Пошук підрядка

Щоб уникнути плутанини, назвемо tt рядком тексту, а ppвзірцем. Задача така: знайти всі входження взірця pp у текст tt.

Щоб розв'язати цю задачу, побудуємо новий рядок s=p++ts = p + \diamond + t, тобто конкатенуємо pp і tt, але посередині поставимо символ-роздільник \diamond (ми оберемо \diamond так, щоб він напевно не зустрічався ніде в рядках pp чи tt).

Обчислимо Z-функцію для ss. Тоді для будь-якого ii з інтервалу [0;  length(t)1][0; \; \operatorname{length}(t) - 1] ми розглянемо відповідне значення k=z[i+length(p)+1]k = z[i + \operatorname{length}(p) + 1]. Якщо kk дорівнює length(p)\operatorname{length}(p), то ми знаємо, що є одне входження pp у ii-й позиції tt; інакше входження pp у ii-й позиції tt немає.

Час роботи (і споживання пам'яті) становить O(length(t)+length(p))O(\operatorname{length}(t) + \operatorname{length}(p)).

Кількість різних підрядків у рядку

Заданий рядок ss довжини nn. Підрахувати кількість різних підрядків ss.

Ми розв'яжемо цю задачу ітеративно. Тобто: знаючи поточну кількість різних підрядків, перерахуємо цю кількість після додавання в кінець ss одного символу.

Отже, нехай kk — поточна кількість різних підрядків ss. Додамо новий символ cc до ss. Очевидно, що можуть з'явитися деякі нові підрядки, що закінчуються цим новим символом cc (а саме всі ті рядки, які закінчуються цим символом і яких ми ще не зустрічали).

Візьмемо рядок t=s+ct = s + c і обернемо його (запишемо його символи у зворотному порядку). Тепер наше завдання — підрахувати, скільки префіксів tt не зустрічаються ніде більше в tt. Обчислимо Z-функцію tt і знайдемо її максимальне значення zmaxz_{max}. Очевидно, що префікс tt довжини zmaxz_{max} зустрічається також десь усередині tt. Зрозуміло, що коротші префікси також зустрічаються.

Отже, ми з'ясували, що кількість нових підрядків, які з'являються при додаванні символу cc до ss, дорівнює length(t)zmax\operatorname{length}(t) - z_{max}.

Відповідно, час роботи цього розв'язку становить O(n2)O(n^2) для рядка довжини nn.

Варто зазначити, що точно так само ми можемо перераховувати, все ще за час O(n)O(n), кількість різних підрядків при додаванні символу на початок рядка, а також при його вилученні (з кінця або з початку).

Стиснення рядка

Заданий рядок ss довжини nn. Знайти його найкоротше «стиснене» представлення, тобто: знайти рядок tt найкоротшої довжини такий, що ss можна подати як конкатенацію однієї або кількох копій tt.

Розв'язок такий: обчислити Z-функцію ss, пройти по всіх ii таких, що ii ділить nn. Зупинитися на першому ii такому, що i+z[i]=ni + z[i] = n. Тоді рядок ss можна стиснути до довжини ii.

Доведення цього факту таке саме, як і для розв'язку, що використовує префікс-функцію.

Задачі для практики

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