Факторизація Ліндона
Факторизація Ліндона
Спершу означимо поняття факторизації Ліндона.
Рядок називається простим (або словом Ліндона), якщо він строго менший за будь-який зі своїх нетривіальних суфіксів. Приклади простих рядків: , , , , , , . Можна показати, що рядок є простим тоді й лише тоді, коли він строго менший за всі свої нетривіальні циклічні зсуви.
Далі нехай задано рядок . Факторизація Ліндона рядка — це розклад , де всі рядки прості й розташовані в незростаючому порядку .
Можна показати, що для будь-якого рядка така факторизація існує і є єдиною.
- Потрібна факторизація Ліндона рядка або найменший циклічний зсув за і пам'яті?
- Задача саме про слова Ліндона / циклічні зсуви, а не про загальний лексикографічний порядок суфіксів? (якщо потрібен порядок усіх суфіксів → Суфіксний масив)
- Не потрібні запити про входження чи підрядки, лише жадібний прохід по рядку? (якщо потрібні підрядкові запити → Суфіксний автомат)
Алгоритм Дюваля
Алгоритм Дюваля будує факторизацію Ліндона за час , використовуючи додаткової пам'яті.
Спершу введемо ще одне поняття: рядок називається передпростим, якщо він має вигляд , де — простий рядок, а — префікс (можливо, порожній). Простий рядок також є передпростим.
Алгоритм Дюваля жадібний. У будь-який момент його виконання рядок фактично поділений на три рядки , де факторизація Ліндона для вже знайдена та зафіксована, рядок є передпростим (і ми знаємо довжину простого рядка в ньому), а зовсім не зачеплений. На кожній ітерації алгоритм Дюваля бере перший символ рядка і намагається дописати його до рядка . Якщо перестає бути передпростим, то факторизація Ліндона для деякої частини стає відомою, і ця частина переходить до .
Опишемо алгоритм детальніше. Вказівник завжди вказуватиме на початок рядка . Зовнішній цикл виконуватиметься, доки . Усередині циклу ми використовуємо два додаткові вказівники: , який вказує на початок , і , який вказує на поточний символ, з яким ми зараз порівнюємо. Ми хочемо додати символ до рядка , що вимагає порівняння із символом . Можливі три різні випадки:
- : якщо це так, то додавання символу до не порушує його передпростоти. Тож ми просто збільшуємо вказівники і .
- : тут рядок стає простим. Ми можемо збільшити і скинути назад на початок , щоб наступний символ можна було порівняти з початком простого слова.
- : рядок більше не є передпростим. Тому ми розіб'ємо передпростий рядок на його прості рядки та залишок, можливо порожній. Простий рядок матиме довжину . На наступній ітерації ми починаємо знову з рештою .
Реалізація
Тут ми наводимо реалізацію алгоритму Дюваля, яка повертає шукану факторизацію Ліндона заданого рядка .
- C++
- Python
- TypeScript
- Go
vector<string> duval(string const& s) {
int n = s.size();
int i = 0;
vector<string> factorization;
while (i < n) {
int j = i + 1, k = i;
while (j < n && s[k] <= s[j]) {
if (s[k] < s[j])
k = i;
else
k++;
j++;
}
while (i <= k) {
factorization.push_back(s.substr(i, j - k));
i += j - k;
}
}
return factorization;
}
def duval(s: str) -> list[str]:
n = len(s)
i = 0
factorization = []
while i < n:
j, k = i + 1, i
while j < n and s[k] <= s[j]:
if s[k] < s[j]:
k = i # знайдено новий менший символ — починаємо просте слово заново
else:
k += 1 # символ повторюється — рухаємось далі
j += 1
# довжина простого слова дорівнює j - k; виокремлюємо всі його копії
while i <= k:
factorization.append(s[i:i + (j - k)])
i += j - k
return factorization
function duval(s: string): string[] {
const n = s.length;
let i = 0;
const factorization: string[] = [];
while (i < n) {
let j = i + 1;
let k = i;
while (j < n && s[k] <= s[j]) {
if (s[k] < s[j]) {
k = i; // знайдено новий менший символ — починаємо просте слово заново
} else {
k++; // символ повторюється — рухаємось далі
}
j++;
}
// довжина простого слова дорівнює j - k; виокремлюємо всі його копії
while (i <= k) {
factorization.push(s.substring(i, i + (j - k)));
i += j - k;
}
}
return factorization;
}
func duval(s string) []string {
n := len(s)
i := 0
var factorization []string
for i < n {
j, k := i+1, i
for j < n && s[k] <= s[j] {
if s[k] < s[j] {
k = i // знайдено новий менший символ — починаємо просте слово заново
} else {
k++ // символ повторюється — рухаємось далі
}
j++
}
// довжина простого слова дорівнює j - k; виокремлюємо всі його копії
for i <= k {
factorization = append(factorization, s[i:i+(j-k)])
i += j - k
}
}
return factorization
}
Складність
Оцінімо час роботи цього алгоритму.
Зовнішній цикл while виконує не більше ніж ітерацій, оскільки наприкінці кожної ітерації збільшується. Другий внутрішній цикл while також працює за , оскільки він лише виводить підсумкову факторизацію.
Тож нас цікавить лише перший внутрішній цикл while. Скільки ітерацій він виконує в найгіршому випадку? Легко бачити, що прості слова, які ми визначаємо на кожній ітерації зовнішнього циклу, довші за залишок, який ми додатково порівнювали. Тому й сума залишків буде меншою за , а це означає, що ми виконуємо щонайбільше ітерацій першого внутрішнього циклу while. Насправді загальна кількість порівнянь символів не перевищить .
Знаходження найменшого циклічного зсуву
Нехай задано рядок . Ми будуємо факторизацію Ліндона для рядка (за час ). Ми шукатимемо в цій факторизації простий рядок, який починається в позиції, меншій за (тобто починається в першому входженні ), і закінчується в позиції, більшій або рівній (тобто в другому входженні ). Стверджується, що позиція початку цього простого рядка буде початком шуканого найменшого циклічного зсуву. Це легко перевірити, скориставшись означенням розкладу Ліндона.
Початок простого блоку можна легко знайти — достатньо запам'ятати вказівник на початку кожної ітерації зовнішнього циклу, який позначав початок поточного передпростого рядка.
Отже, отримуємо таку реалізацію:
- C++
- Python
- TypeScript
- Go
string min_cyclic_string(string s) {
s += s;
int n = s.size();
int i = 0, ans = 0;
while (i < n / 2) {
ans = i;
int j = i + 1, k = i;
while (j < n && s[k] <= s[j]) {
if (s[k] < s[j])
k = i;
else
k++;
j++;
}
while (i <= k)
i += j - k;
}
return s.substr(ans, n / 2);
}
def min_cyclic_string(s: str) -> str:
s += s # подвоюємо рядок, щоб охопити всі циклічні зсуви
n = len(s)
i, ans = 0, 0
while i < n // 2:
ans = i # запам'ятовуємо початок поточного передпростого рядка
j, k = i + 1, i
while j < n and s[k] <= s[j]:
if s[k] < s[j]:
k = i
else:
k += 1
j += 1
while i <= k:
i += j - k
return s[ans:ans + n // 2]
function minCyclicString(s: string): string {
s += s; // подвоюємо рядок, щоб охопити всі циклічні зсуви
const n = s.length;
let i = 0;
let ans = 0;
while (i < Math.floor(n / 2)) {
ans = i; // запам'ятовуємо початок поточного передпростого рядка
let j = i + 1;
let k = i;
while (j < n && s[k] <= s[j]) {
if (s[k] < s[j]) {
k = i;
} else {
k++;
}
j++;
}
while (i <= k) {
i += j - k;
}
}
return s.substring(ans, ans + Math.floor(n / 2));
}
func minCyclicString(s string) string {
s += s // подвоюємо рядок, щоб охопити всі циклічні зсуви
n := len(s)
i, ans := 0, 0
for i < n/2 {
ans = i // запам'ятовуємо початок поточного передпростого рядка
j, k := i+1, i
for j < n && s[k] <= s[j] {
if s[k] < s[j] {
k = i
} else {
k++
}
j++
}
for i <= k {
i += j - k
}
}
return s[ans : ans+n/2]
}