Skip to main content

Що таке суфіксний масив?

Коротка відповідь

Суфіксний масив (Suffix Array, SA) - це масив індексів усіх суфіксів рядка, відсортованих у лексикографічному порядку. Він дає змогу ефективно шукати підрядки (через двійковий пошук), обчислювати LCP-масив, кількість різних підрядків, найдовші повторювані підрядки, і застосовується у стисненні даних (наприклад, BWT). Будується за O(n log n) (або O(n) просунутими алгоритмами), пошук підрядка - O(m log n).

Детальне пояснення

Що це і навіщо потрібно

Нехай дано рядок S довжини n. Розглянемо всі його суфікси S[i..n-1]. Якщо відсортувати ці суфікси лексикографічно й запам'ятати лише їхні стартові позиції, отримаємо суфіксний масив SA. Маючи SA, можна виконувати швидкий пошук шаблону P в S двійковим пошуком по відсортованих суфіксах, а також розв'язувати безліч рядкових задач ефективніше, ніж наївними методами.

Формальне визначення

  • Рядок S[0..n-1], зазвичай до нього додають унікальний мінімальний символ-вартовий (sentinel), наприклад '$', якого немає в алфавіті S і який лексикографічно менший за будь-який інший символ. Це спрощує граничні випадки.
  • Суфіксний масив SA - перестановка індексів [0..n-1], така що суфікси S[SA[0]..], S[SA[1]..], ..., S[SA[n-1]..] впорядковані лексикографічно неспадно.
  • LCP-масив (Longest Common Prefix) доповнює SA: LCP[i] = довжина найбільшого спільного префікса між суфіксами з індексами SA[i] і SA[i-1] (для i=0 зазвичай 0). Його можна побудувати за O(n) за S і SA (алгоритм Касаі).

Приклад: "banana$"

Розглянемо рядок S = "banana$" (індекси 0..6). Список суфіксів і їхнє сортування:

Індекси і суфікси: 0: banana$ 1: anana$ 2: nana$ 3: ana$ 4: na$ 5: a$ 6: $ Відсортовані суфікси (лексикографічно): 6: $ 5: a$ 3: ana$ 1: anana$ 0: banana$ 4: na$ 2: nana$ SA = [6, 5, 3, 1, 0, 4, 2] LCP = [0, 0, 1, 3, 0, 0, 2]

Тут LCP[3] = 3, бо ana$ і anana$ мають спільний префікс "ana" довжини 3; максимальний LCP дорівнює 3, тому найдовший повторюваний підрядок у "banana" має довжину 3 ("ana").

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

  • Швидкий пошук підрядка P в S: двійковий пошук по SA за O(m log n), де m - довжина P.
  • Підрахунок кількості різних підрядків: n(n+1)/2 − sum(LCP).
  • Пошук найдовшого повторюваного підрядка: max(LCP).
  • Стиснення даних та індексування: оборотне перетворення Барроуза-Вілера (BWT) будується на базі SA.

Алгоритми побудови (ідея і складність)

  • Наївний: відсортувати всі суфікси як рядки - O(n^2 log n) за часом, O(n) за пам'яттю, часто занадто повільний.
  • Подвоєння довжини (prefix-doubling): сортуємо пари рангів (k і k-зсув), подвоюючи k: O(n log n) сортувань; на практиці часто O(n log n). Реалізація проста.
  • DC3/Skew, SA-IS: лінійний час O(n), складніше в реалізації, використовуються в промислових бібліотеках.
  • LCP (Касаі): O(n) по вже побудованому SA.

Пошук підрядка через суфіксний масив

  1. Будуємо SA для S (і, опційно, LCP).
  2. Робимо двійковий пошук по масиву суфіксів, порівнюючи P із суфіксом S[SA[mid]..].
  3. Знаходимо діапазон [lo..hi) суфіксів, що починаються з P. Це і є всі входження P в S.
// Побудова суфіксного масиву (prefix-doubling) і LCP (Касаі) // Важливо: додайте до рядка унікальний мінімальний символ, наприклад '$', якого немає у вихідному рядку. function buildSuffixArray(s) { const n = s.length; const sa = Array.from({ length: n }, (_, i) => i); // Ранги за символами (використовуємо кодпоінти; для ASCII/UTF-16 достатньо codePointAt) let rank = Array.from(s, ch => ch.codePointAt(0)); let tmp = new Array(n).fill(0); for (let k = 1; k < n; k <<= 1) { sa.sort((i, j) => { if (rank[i] !== rank[j]) return rank[i] - rank[j]; const ri = i + k < n ? rank[i + k] : -1; const rj = j + k < n ? rank[j + k] : -1; return ri - rj; }); tmp[sa[0]] = 0; for (let i = 1; i < n; i++) { const a = sa[i - 1]; const b = sa[i]; const same = rank[a] === rank[b] && (a + k < n ? rank[a + k] : -1) === (b + k < n ? rank[b + k] : -1); tmp[b] = tmp[a] + (same ? 0 : 1); } for (let i = 0; i < n; i++) rank[i] = tmp[i]; if (rank[sa[n - 1]] === n - 1) break; // усі ранги унікальні } return sa; } function buildLCP(s, sa) { const n = s.length; const rank = new Array(n); for (let i = 0; i < n; i++) rank[sa[i]] = i; const lcp = new Array(n).fill(0); let k = 0; for (let i = 0; i < n; i++) { const r = rank[i]; if (r === 0) { k = 0; continue; } const j = sa[r - 1]; while (i + k < n && j + k < n && s[i + k] === s[j + k]) k++; lcp[r] = k; if (k > 0) k--; } return lcp; } // Двійковий пошук підрядка P в S по SA function findOccurrences(s, sa, pat) { const n = s.length; const m = pat.length; const cmpLower = (idx) => { // порівнюємо перші m символів const sub = s.slice(idx, idx + m); if (sub === pat) return 0; return sub < pat ? -1 : 1; // лексикографічне порівняння рядків JS }; // нижня межа (перше місце, де суфікс >= pat-префіксу) let lo = 0, hi = n; while (lo < hi) { const mid = (lo + hi) >> 1; const c = cmpLower(sa[mid]); if (c >= 0) hi = mid; else lo = mid + 1; } const start = lo; // верхня межа (перше місце, де суфікс > pat-префіксу) const cmpUpper = (idx) => { const sub = s.slice(idx, idx + m); return sub <= pat ? -1 : 1; }; lo = 0; hi = n; while (lo < hi) { const mid = (lo + hi) >> 1; const c = cmpUpper(sa[mid]); if (c <= 0) lo = mid + 1; else hi = mid; } const end = lo; const res = []; for (let i = start; i < end; i++) res.push(sa[i]); // Часто зручно повернути відсортовані за позицією входження індекси return res.sort((a, b) => a - b); } // Демонстрація const s = "banana$"; // '$' не трапляється в рядку і мінімальний const sa = buildSuffixArray(s); const lcp = buildLCP(s, sa); console.log("SA:", sa); // [6, 5, 3, 1, 0, 4, 2] console.log("LCP:", lcp); // [0, 0, 1, 3, 0, 0, 2] console.log(findOccurrences(s, sa, "ana")); // [1, 3]

Зв'язок із суфіксним деревом

  • Суфіксне дерево (Suffix Tree) - потужніша структура з більшим сталим коефіцієнтом пам'яті (часто ~O(n), але з більшим коефіцієнтом).
  • Суфіксний масив - компактніший, простіше зберігати й серіалізувати; з LCP і RMQ може імітувати багато операцій дерева.

Часті задачі і формули

  • Кількість різних підрядків рядка S довжини n: n(n+1)/2 − Σ LCP[i]. Інтуїція: усього підрядків n(n+1)/2, спільні префікси між сусідніми суфіксами в SA «дублюють» підрядки і віднімаються сумою LCP.
  • Найдовший повторюваний підрядок: беремо індекс i з максимальним LCP[i], відповідь - S[SA[i] .. SA[i] + LCP[i] - 1].
  • Кількість входжень шаблону P: розмір діапазону [lower_bound(P), upper_bound(P)) по SA.

Підводні камені і поради

  • Завжди додавайте унікальний мінімум-вартовий (наприклад, '$'), щоб коректно обробляти суфікси, уникнути невизначеностей і спростити порівняння на межі рядка.
  • Стежте за лексикографічним порядком в обраному кодуванні. У JS порівняння рядків - лексикографічне за кодовими одиницями UTF-16; для особливостей Unicode може знадобитися нормалізація.
  • Подвоєння (prefix-doubling) легко реалізувати, і воно досить швидке на практиці. Для дуже великих даних використовуйте SA-IS/DC3.

Складність (зведення)

  • Побудова SA: O(n log n) (prefix-doubling) або O(n) (SA-IS/DC3). Пам'ять - O(n).
  • Побудова LCP (Касаі): O(n) по SA.
  • Пошук підрядка P: O(m log n) двійковим пошуком; з LCP і RMQ можна прискорювати окремі порівняння.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.