Sequences & Strings/Suffix Arrays, LCP & Aho–Corasick

Lesson 5.84,964 words

Suffix Arrays, LCP & Aho–Corasick

A suffix array sorts all nn suffixes of a string, indexing every substring at once; built in O(nlogn)O(n\log n), it locates a pattern by binary search in O(mlogn)O(m\log n). Its companion LCP array (Kasai's O(n)O(n) algorithm) counts distinct substrings and finds the longest repeated substring.

╌╌╌╌

The string-matching lessons matched one pattern against a text, preprocessing the pattern. Two complementary needs remain. The first is to preprocess the text instead, so that afterwards any of many patterns can be queried fast — exactly the situation of a search engine or a genome browser, where the text is fixed and the patterns arrive online. The second is to match many patterns against one text in a single pass. This lesson builds the two canonical structures for these jobs: the suffix array (with its LCP array) for the first, and the Aho–Corasick automaton for the second. A short coda gives Manacher's algorithm, which finds every palindrome in linear time by the same Z-box bookkeeping we have already met.

Both halves keep the soundness / completeness discipline of a matcher: every reported occurrence must be genuine, and every genuine occurrence must be reported. What changes is where the preprocessing cost is paid and what a single index then enables.

Suffix arrays: sorting all the suffixes

Index the text once so that every substring question becomes cheap. A substring of is a prefix of some suffix of ; if we had all suffixes in sorted order, every occurrence of a pattern would form a contiguous block (all the suffixes that start with ), findable by binary search. That is the whole idea.

It is the array of starting positions, not the suffixes themselves — integers, where the suffixes laid out in full would be characters. This compactness is why the suffix array displaced the older suffix tree in practice: same indexing power, a fraction of the memory.1

Suffix array of with an appended sentinel (smaller than every letter, shown as [end]). Left: all suffixes. Right: sorted, giving . Each row's index is its start position in

It is convenient to append a sentinel $ that is smaller than every real character and occurs nowhere else; it makes every suffix uniquely ordered (no suffix is a prefix of another) and is shown above as [end].

Construction by prefix doubling

The naive build — sort the suffixes with a comparison sort — costs comparisons, but each comparison may scan characters, so overall. Prefix doubling (the Manber–Myers method) removes the per-compare blowup by sorting suffixes on growing prefixes of length , carrying a rank for each suffix between rounds.

The invariant is that after the round for length , every suffix carries a rank equal to its position among all suffixes ordered by their first characters (ties shared). To go from to , sort each suffix by the pair: the first component orders by the first characters, the second breaks ties using the next — which is exactly the rank, already computed, of the suffix starting further along.

One prefix-doubling round on bananak=1k=2i\mathrm{rank}_1[i]\mathrm{rank}_1[i+1](\mathrm{rank}_1[i],\,\mathrm{rank}_1[i+1])2\mathrm{rank}_2-113(1,3)24(3,1)$ (both spell na`), so each pair shares a rank

Run to completion on banana$, the build takes two rounds beyond the initial character ranks. Assigning first-character ranks ($ = 0, a = 1, b = 2, n = 3) gives

with the three a-suffixes (starts ) tied at rank and the two n-suffixes (starts ) tied at rank . The round above sorts the pairs and re-ranks, producing

Two ties survive: starts and still share rank (both suffixes begin an) and starts and share rank (both begin na). The next round compares two characters further out, i.e. the pairs . Start gets the pair while start gets , so sorts first: this reproduces the four-character comparison ana$ < anan, decided by the ranks of the suffixes two positions along, with no characters rescanned. Likewise start gets against start 's , putting na$ before nana$. The new ranks

are all distinct, so the loop's early-exit test fires and reading the positions in rank order yields — the sorted column of the first figure.

The complete prefix-doubling trace on `bananak=1(\mathrm{rank}_k[i], \mathrm{rank}_k[i+k])k=2$ round every rank is distinct (green), so sorting the positions by their f/inal rank reads of/f the suffix array

The cost accounting: each round sorts pairs of integers drawn from , which two passes of counting sort (least-significant component first) do in time and space; re-ranking is one linear scan of the sorted order. The prefix length doubles each round, so rounds suffice, and the total is . Swapping the radix sort for a comparison sort costs per round instead, giving the easier-to-code variant in Algorithm 1. Specialised linear-time builds exist — the DC3 / skew algorithm and SA-IS — but doubling is the standard construction and the one to know.

Algorithm 1:Build-SA(T)\textsc{Build-SA}(T) — suffix array by prefix doubling in O(nlog2n)O(n\log^2 n)
  1. 1
    append sentinel; nTn \gets |T|
  2. 2
    rank[i]T[i]\mathit{rank}[i] \gets T[i] for all ii
    round k = 1: rank by single char
  3. 3
    sa(0,1,,n1)\mathit{sa} \gets (0, 1, \dots, n-1)
  4. 4
    k1k \gets 1
  5. 5
    while k<nk < n do
  6. 6
    define key(i)=(rank[i], rank[i+k] or 1)key(i) = (\mathit{rank}[i],\ \mathit{rank}[i+k]\ \text{or}\ {-}1)
  7. 7
    sort sa\mathit{sa} by keykey
    radix sort for O(n)O(n) per round
  8. 8
    tmp[sa[0]]0\mathit{tmp}[\mathit{sa}[0]] \gets 0
  9. 9
    for p1p \gets 1 to n1n - 1 do
    re-rank, ties keep equal rank
  10. 10
    $\mathit{tmp}[\mathit{sa}[p]] \gets \mathit{tmp}[\mathit{sa}[p-1]]
  11. 11
    + (key(\mathit{sa}[p]) \ne key(\mathit{sa}[p-1])\ ?\ 1 : 0)$
  12. 12
    ranktmp\mathit{rank} \gets \mathit{tmp}
  13. 13
    if rank[sa[n1]]=n1\mathit{rank}[\mathit{sa}[n-1]] = n - 1 then break
    all ranks distinct
  14. 14
    k2kk \gets 2k
  15. 15
    return sa\mathit{sa}
suffix_array.pypython
def build_suffix_array(text: str) -> list[int]:
  """
    The suffix array of `text`: a permutation of 0..n-1 listing suffix start\n
    indices in lexicographic order. Built by prefix doubling.\n
    The empty string yields the empty array.\n
  """
  length: int = len(text)
  if length == 0:
    return []

  # round k = 1: rank each suffix by its single starting character.
  ranks: list[int] = [ord(character) for character in text]
  suffix_array: list[int] = list(range(length))
  temporary: list[int] = [0 for _ in range(length)]

  span: int = 1
  while span < length:

    # key orders by the first `span` chars, breaking ties by the next `span`
    # (the rank, already computed, of the suffix `span` further along; -1 past
    # the end so a shorter suffix sorts before a longer one sharing a prefix).
    def key(start: int) -> tuple[int, int]:
      second: int = ranks[start + span] if start + span < length else -1
      return (ranks[start], second)

    suffix_array.sort(key=key)

    # re-rank the sorted suffixes; equal keys keep an equal rank.
    temporary[suffix_array[0]] = 0
    for position in range(1, length):
      previous_start: int = suffix_array[position - 1]
      current_start: int = suffix_array[position]
      step: int = 1 if key(current_start) != key(previous_start) else 0
      temporary[current_start] = temporary[previous_start] + step
    ranks = temporary[:]

    # once every rank is distinct the order is final; else double the prefix.
    if ranks[suffix_array[-1]] == length - 1:
      break
    span *= 2

  return suffix_array

Because the suffixes sit in sorted order, the suffixes that begin with occupy a contiguous range of . Two binary searches — for the lower and upper bounds of that range — locate every occurrence of .

Algorithm 2:SA-Search(T,SA,P)\textsc{SA-Search}(T, \mathit{SA}, P) — all occurrences of PP in O(mlogn)O(m\log n)
  1. 1
    lo0; hinlo \gets 0;\ hi \gets n
    find first suffix P\ge P
  2. 2
    while lo<hilo < hi do
  3. 3
    mid(lo+hi)/2mid \gets (lo + hi) / 2
  4. 4
    if T[SA[mid]..]<PT[\mathit{SA}[mid] \mathinner{\ldotp\ldotp}] < P then lomid+1lo \gets mid + 1 else himidhi \gets mid
  5. 5
    startlo\mathit{start} \gets lo
  6. 6
    hinhi \gets n
    find first suffix >> all PP\cdot
  7. 7
    while lo<hilo < hi do
  8. 8
    mid(lo+hi)/2mid \gets (lo + hi) / 2
  9. 9
    if T[SA[mid]..]T[\mathit{SA}[mid] \mathinner{\ldotp\ldotp}] has prefix P\le P then lomid+1lo \gets mid + 1 else himidhi \gets mid
  10. 10
    report SA[start..lo1]\mathit{SA}[\mathit{start} \mathinner{\ldotp\ldotp} lo - 1] as the occurrence positions
Binary search for pattern in the suffix array of (). The suffixes prefixed by an (rows and , starts and ) form one contiguous green block. A probe at the midpoint compares against that row's suffix and discards the half that cannot contain ; two such searches bracket the block's lower and upper ends

The comparisons each rescanning characters is the looseness here; storing the LCP array (next) lets a refined search shave it to . Soundness holds because a reported index lies in the range only if its suffix has as a prefix, i.e. genuinely occurs there; completeness holds because every occurrence is a suffix prefixed by , hence inside the contiguous range the two searches bracket.

The LCP array: what adjacency reveals

The suffix array alone does not record how much adjacent suffixes share. That shared length is what substring counting and comparison need.

For banana with the adjacent pairs share : e.g. ana and anana (rows and ) share the prefix ana of length .

Suffix array and LCP for (with sentinel). Column SA is the sorted start index; is the longest common prefix of rows and . The peak (highlighted) is ana, the longest repeated substring

Kasai's algorithm: LCP in linear time

A direct LCP computation compares adjacent suffixes character by character, . Kasai's algorithm does it in with one observation: process the suffixes in the order of the text, , and the LCP can drop by at most one each step, so it never has to be rescanned from scratch.

So we keep a running match length , start each suffix's comparison from rather than , and extend. The total of all increments is because rises by at most the number of matched characters and falls by at most one per step.

Algorithm 3:Kasai(T,SA)\textsc{Kasai}(T, \mathit{SA}) — LCP array in O(n)O(n)
  1. 1
    compute rank\mathit{rank} as inverse of SA\mathit{SA}
    rank[SA[r]] = r
  2. 2
    h0h \gets 0
  3. 3
    for i0i \gets 0 to n1n - 1 do
    suffixes in text order
  4. 4
    if rank[i]>0\mathit{rank}[i] > 0 then
  5. 5
    jSA[rank[i]1]j \gets \mathit{SA}[\mathit{rank}[i] - 1]
    predecessor in sorted order
  6. 6
    while i+h<ni + h < n and j+h<nj + h < n and T[i+h]=T[j+h]T[i+h] = T[j+h] do
  7. 7
    hh+1h \gets h + 1
    extend the shared prefix
  8. 8
    LCP[rank[i]]h\mathit{LCP}[\mathit{rank}[i]] \gets h
  9. 9
    if h>0h > 0 then hh1h \gets h - 1
    drop at most one for next suffix
  10. 10
    else
  11. 11
    h0h \gets 0
  12. 12
    return LCP\mathit{LCP}
kasai_lcp.pypython
def kasai_lcp(text: str, suffix_array: list[int]) -> list[int]:
  """
    The LCP array of `text` given its `suffix_array`, by Kasai's algorithm.\n
    LCP[0] is 0; LCP[i] for i >= 1 is the longest common prefix length of the\n
    suffixes at sorted ranks i-1 and i. Runs in O(n).\n
  """
  length: int = len(text)
  if length == 0:
    return []

  # rank is the inverse permutation: rank[start] is that suffix's sorted row.
  rank: list[int] = [0 for _ in range(length)]
  for sorted_row, start in enumerate(suffix_array):
    rank[start] = sorted_row

  # walk suffixes in text order; the match length only ever drops by one.
  longest_common_prefix: list[int] = [0 for _ in range(length)]
  match_length: int = 0
  for start in range(length):
    if rank[start] == 0:
      match_length = 0
      continue

    # extend the shared prefix with the predecessor from the inherited length.
    predecessor_start: int = suffix_array[rank[start] - 1]
    while (
      start + match_length < length
      and predecessor_start + match_length < length
      and text[start + match_length] == text[predecessor_start + match_length]
    ):
      match_length += 1

    # record it, then surrender one character for the next text-order suffix.
    longest_common_prefix[rank[start]] = match_length
    if match_length > 0:
      match_length -= 1

  return longest_common_prefix

What the LCP array enables

Two classic results fall out immediately.

For banana, at the ana/anana pair, so ana is the longest repeated substring — the basis of Longest Duplicate Substring (which combines this with binary search on the answer, or a suffix array).

The same LCP array also lets a range-minimum query answer the longest-common-prefix of any two suffixes in after preprocessing — the basis of many competitive-programming string solutions.2

kasai_lcp.pypython
def longest_repeated_substring(
  text: str, suffix_array: list[int], longest_common_prefix: list[int]
) -> str:
  """
    The longest substring of `text` that occurs at least twice, taken as the\n
    shared prefix at the peak of the LCP array. Empty if no substring repeats.\n
    Ties pick the first peak encountered.\n
  """
  if not longest_common_prefix:
    return ""

  # the peak of the LCP array is the longest prefix two suffixes share.
  best_length: int = 0
  best_row: int = 0
  for row in range(1, len(longest_common_prefix)):
    if longest_common_prefix[row] > best_length:
      best_length = longest_common_prefix[row]
      best_row = row

  if best_length == 0:
    return ""

  start: int = suffix_array[best_row]
  return text[start : start + best_length]


def count_distinct_substrings(
  text: str, suffix_array: list[int], longest_common_prefix: list[int]
) -> int:
  """
    The number of distinct non-empty substrings of `text`, computed as\n
    sum(n - SA[i]) - sum(LCP[i]). Each suffix contributes its own prefixes;\n
    the LCP subtraction removes those already seen in the sorted order.\n
  """
  length: int = len(text)
  total_prefixes: int = sum(length - start for start in suffix_array)
  shared_prefixes: int = sum(longest_common_prefix)
  return total_prefixes - shared_prefixes

Aho–Corasick: matching a whole dictionary at once

Now the second job: find every occurrence of every pattern in a set within a text , in one pass. Running KMP times costs ; Aho–Corasick does it in where is the total number of matches reported, after preprocessing. It is, in one line, KMP generalised from a single string to a trie.

The construction has three layers.

1. The trie of patterns. Insert every pattern into a trie (one node per distinct prefix of some pattern). A node is terminal if its root-path spells a complete pattern. Following text characters down the goto edges of this trie is exactly the naive idea of matching all patterns simultaneously — until a character has no outgoing edge, at which point we are stuck.

2. Failure links. Just as KMP's jumps, on a mismatch, to the longest proper prefix of the pattern that is also a suffix of what we matched, Aho–Corasick's failure link points to the node whose root-path is the longest proper suffix of 's root-path that is also a node in the trie (a prefix of some pattern). When the text has no goto edge from the current node, we follow failure links until an edge exists or we fall back to the root.

3. Output links. A failure link can land on a terminal node, meaning a shorter pattern ends here too. Following the chain of failure links from any node and collecting terminals reports every pattern that ends at the current text position; precomputing these into output links makes that enumeration per reported match.

Aho–Corasick automaton for . Solid black edges are trie goto transitions (labelled by the character); each node shows the pref/ix it spells. Dashed blue edges are the four failure links that point somewhere other than the root (, , , ); every omitted failure link points to the root. Green double-ringed nodes are terminal (a pattern ends there)

In the figure, the failure link from sh points to h (the longest suffix of sh that is a trie node), and from she to he. The link from hers points to s, and s's output chain is empty — but she's failure target he is terminal, so scanning text …she… reports both she and the embedded he at the same position. That overlap is precisely what output links capture.

Scanning the text ushers through the automaton. The active state walks down goto edges spelling she; at that terminal it reports she and (via its failure target, a terminal) the embedded he. Reading the next character r, state she has no goto on r, so the scan follows the blue failure link to he and continues he then her then hers, reporting hers at the end. Green marks the two reported matches; the dashed blue arrow is the failure jump
Algorithm 4:Build-AC(P)\textsc{Build-AC}(\mathcal{P}) — trie + failure/output links via BFS in O(Pi)O(\sum|P_i|)
  1. 1
    build trie of all patterns; mark terminal nodes
  2. 2
    fail(root)root\mathrm{fail}(root) \gets root
  3. 3
    QQ \gets empty queue
  4. 4
    for each child cc of rootroot do
    depth-1 nodes fail to root
  5. 5
    fail(c)root; \mathrm{fail}(c) \gets root;\ enqueue cc
  6. 6
    while QQ nonempty do
  7. 7
    uu \gets dequeue
  8. 8
    for each labelled edge uavu \xrightarrow{a} v do
  9. 9
    enqueue vv
  10. 10
    ffail(u)f \gets \mathrm{fail}(u)
  11. 11
    while frootf \ne root and ff has no edge on aa do
  12. 12
    ffail(f)f \gets \mathrm{fail}(f)
    climb failure links, like KMP
  13. 13
    fail(v)\mathrm{fail}(v) \gets (ff has edge on aa and that target v\ne v) ? target : rootroot
  14. 14
    out(v)out(fail(v))\mathrm{out}(v) \gets \mathrm{out}(\mathrm{fail}(v)) plus (vv terminal ? vv : none)
Algorithm 5:AC-Scan(T)\textsc{AC-Scan}(T) — report all occurrences of all patterns in O(n+z)O(n + z)
  1. 1
    xrootx \gets root
  2. 2
    for i0i \gets 0 to n1n - 1 do
  3. 3
    while xrootx \ne root and xx has no edge on T[i]T[i] do
  4. 4
    xfail(x)x \gets \mathrm{fail}(x)
    no goto: follow failure links
  5. 5
    if xx has edge on T[i]T[i] then xx \gets that target
  6. 6
    for each pattern PP in out(x)\mathrm{out}(x) do
  7. 7
    report PP ending at position ii

The structure is the same soundness-in-verification, completeness-in-coverage split we saw for single-pattern matching, now carried by the automaton's links rather than a single array. Aho–Corasick underlies dictionary scanners, intrusion-detection signature matching, and LeetCode's Stream of Characters (reverse the patterns and feed the stream backward into the automaton).3

aho_corasick.pypython
from collections import deque
from collections.abc import Iterable, Iterator
from typing import NamedTuple, Optional

class Match(NamedTuple):
  """
    One reported occurrence: the pattern and the end position in the text\n
    where it finishes (the index of its last character).\n
  """
  pattern: str
  end: int

class AhoCorasickNode:
  """
    A single automaton node: child `goto` links keyed by character, a failure\n
    link to the longest-proper-suffix node, an output link to the nearest\n
    terminal up the failure chain, and the pattern ending here (if any).\n
  """

  def __init__(self) -> None:
    self.children: dict[str, AhoCorasickNode] = {}
    self.failure: Optional[AhoCorasickNode] = None
    self.output: Optional[AhoCorasickNode] = None
    self.pattern: Optional[str] = None

class AhoCorasick:
  """
    A dictionary-matching automaton over a fixed set of patterns.\n
  """

  def __init__(self, patterns: Iterable[str]) -> None:
    """
      Build the trie of `patterns`, then wire failure and output links by a\n
      breadth-first sweep. Empty patterns are ignored.\n
    """
    self.root: AhoCorasickNode = AhoCorasickNode()
    for pattern in patterns:
      if pattern:
        self._insert(pattern)
    self._build_links()

  def _insert(self, pattern: str) -> None:
    """
      Add `pattern` to the trie, marking its terminal node.\n
    """
    node: AhoCorasickNode = self.root
    for character in pattern:
      node = node.children.setdefault(character, AhoCorasickNode())
    node.pattern = pattern

  def _build_links(self) -> None:
    """
      Compute failure and output links by BFS over the trie. A node's failure\n
      target is found by climbing its parent's failure chain until an edge on\n
      the same character exists; its output link points at the failure target\n
      if that is terminal, else inherits the target's output link.\n
    """
    self.root.failure = self.root
    queue: deque[AhoCorasickNode] = deque()

    # depth-1 nodes fail to the root.
    for child in self.root.children.values():
      child.failure = self.root
      queue.append(child)

    while queue:
      node: AhoCorasickNode = queue.popleft()
      for character, target in node.children.items():
        queue.append(target)

        # climb failure links from the parent until an edge on `character`.
        fallback: AhoCorasickNode = node.failure if node.failure else self.root
        while fallback is not self.root and character not in fallback.children:
          fallback = fallback.failure if fallback.failure else self.root

        # the edge target there is the failure link, unless it loops to itself.
        candidate: Optional[AhoCorasickNode] = fallback.children.get(character)
        target.failure = (
          candidate if candidate is not None and candidate is not target else self.root
        )

        # output: nearest terminal at or above the failure target.
        failure_node: AhoCorasickNode = target.failure
        if failure_node.pattern is not None:
          target.output = failure_node
        else:
          target.output = failure_node.output

  def _advance(self, node: AhoCorasickNode, character: str) -> AhoCorasickNode:
    """
      The state reached from `node` on `character`: follow the edge if it\n
      exists, otherwise climb failure links until one does or fall to root.\n
    """
    current: AhoCorasickNode = node
    while current is not self.root and character not in current.children:
      current = current.failure if current.failure else self.root
    return current.children.get(character, self.root)

  def search(self, text: str) -> Iterator[Match]:
    """
      Yield a `Match` for every occurrence of every pattern in `text`, in\n
      order of end position. A position may report several patterns when one\n
      pattern is a suffix of another's match.\n
    """
    node: AhoCorasickNode = self.root
    for position, character in enumerate(text):
      node = self._advance(node, character)

      # report the pattern ending here, if any.
      if node.pattern is not None:
        yield Match(node.pattern, position)

      # then chase output links for any shorter patterns ending here too.
      output_node: Optional[AhoCorasickNode] = node.output
      while output_node is not None:
        if output_node.pattern is not None:
          yield Match(output_node.pattern, position)
        output_node = output_node.output

Manacher's algorithm: all palindromes in

A short coda on a third linear-time string algorithm, included because it reuses the Z-box idea from the Z-function lesson almost verbatim. We want, for each centre, the radius of the longest palindrome centred there — which yields the longest palindromic substring and, summed, the count of all palindromic substrings.

To unify odd- and even-length palindromes, interleave a separator: transform into (with distinct end sentinels at both ends), so every palindrome of has odd length and a real centre. Then compute , the palindromic radius at each centre of .

The linear-time computation maintains the rightmost palindrome found so far, by centre and right edge (exactly the Z-box, now centred). For a new centre , its mirror gives a free lower bound ; then extend past by direct comparison and slide if the new palindrome reaches further right.

Manacher mirror step. The palindrome centred at reaches right edge ; a new centre inside it copies its mirror , giving the free bound before extending past
Expanding a palindrome at centre on the transformed string . Starting from the mirror bound, the radius grows while the characters at and match (green pairs); it stops at the first mismatch (red pair). The final radius is the count of matched steps; if passes the old right edge, advances to here
Algorithm 6:Manacher(T)\textsc{Manacher}(T) — radius of the longest palindrome at every centre in O(n)O(n)
  1. 1
    SS \gets interleave TT with separators and end sentinels
  2. 2
    C0; R0C \gets 0;\ R \gets 0
  3. 3
    for c1c \gets 1 to S2|S| - 2 do
  4. 4
    if c<Rc < R then
  5. 5
    rad[c]min(Rc, rad[2Cc])\mathit{rad}[c] \gets \min(R - c,\ \mathit{rad}[2C - c])
    mirror bound
  6. 6
    while S[c+rad[c]+1]=S[crad[c]1]S[c + \mathit{rad}[c] + 1] = S[c - \mathit{rad}[c] - 1] do
  7. 7
    rad[c]rad[c]+1\mathit{rad}[c] \gets \mathit{rad}[c] + 1
    extend past RR
  8. 8
    if c+rad[c]>Rc + \mathit{rad}[c] > R then
  9. 9
    Cc; Rc+rad[c]C \gets c;\ R \gets c + \mathit{rad}[c]
    advance the box
  10. 10
    return rad\mathit{rad}

Each character is examined a constant number of times because only ever moves right, monotonically from to — the identical amortised argument as the Z-function. The longest palindromic substring is the centre of maximum radius (), and over real centres counts all palindromic substrings, both in .

manacher.pypython
def manacher_radii(text: str) -> list[int]:
  """
    The palindromic radius at every centre of the separator-interleaved form\n
    of `text`. For transformed string S = `^|a|b|a|$`, entry c is the radius\n
    of the longest palindrome of S centred at c. Runs in O(n).\n
    The empty text yields the radii of just the boundary sentinels.\n
  """
  # interleave with '|' and bracket with distinct end sentinels '^' and '$'
  # so no real comparison ever runs off the ends.
  transformed: str = "^|" + "|".join(text) + "|$"
  length: int = len(transformed)
  radius: list[int] = [0 for _ in range(length)]

  center: int = 0
  right_edge: int = 0
  for position in range(1, length - 1):
    if position < right_edge:

      # the mirror centre across `center` gives a free lower bound.
      mirror: int = 2 * center - position
      radius[position] = min(right_edge - position, radius[mirror])

    # extend past the known edge by direct character comparison.
    while (
      transformed[position + radius[position] + 1]
      == transformed[position - radius[position] - 1]
    ):
      radius[position] += 1

    # slide the rightmost palindrome if this one reaches further.
    if position + radius[position] > right_edge:
      center = position
      right_edge = position + radius[position]

  return radius

def longest_palindromic_substring(text: str) -> str:
  """
    The longest palindromic substring of `text`. The radius in the\n
    transformed string equals the length of the palindrome in the original,\n
    and its centre maps back to a start index. Ties pick the leftmost.\n
  """
  if text == "":
    return ""

  # the widest radius marks the centre of the longest palindrome.
  radius: list[int] = manacher_radii(text)
  best_length: int = 0
  best_center: int = 0
  for center, value in enumerate(radius):
    if value > best_length:
      best_length = value
      best_center = center

  # map the transformed centre back to a start index in the original text.
  start: int = (best_center - best_length) // 2
  return text[start : start + best_length]

def count_palindromic_substrings(text: str) -> int:
  """
    The number of non-empty palindromic substrings of `text`, counting each\n
    distinct (start, end) occurrence. Each centre contributes\n
    ceil(radius / 2) palindromes in the original string.\n
  """
  radius: list[int] = manacher_radii(text)

  # each transformed centre's radius covers (radius + 1) // 2 real palindromes.
  return sum((value + 1) // 2 for value in radius)

Linear construction, bioinformatics, and self-indexes

Suffix arrays became practical when Manber and Myers (Suffix Arrays: A New Method for On-Line String Searches, SIAM J. Comput. 1993) introduced them explicitly as a space-frugal replacement for suffix trees, and the theory sharpened further when Kärkkäinen and Sanders gave the DC3 (skew) algorithm (Simple Linear Work Suffix Array Construction, ICALP 2003), which builds the suffix array in genuine time by a divide step that sorts two thirds of the suffixes recursively and merges in the rest. The prefix-doubling method in this lesson is the one most people implement, but the existence of linear-time construction is what lets suffix arrays index gigabyte-scale texts.

The reason suffix arrays matter far beyond exercises is bioinformatics and compression. The Burrows–Wheeler transform (Burrows & Wheeler, A Block- Sorting Lossless Data Compression Algorithm, 1994) is a permutation of the text read directly off the sorted suffixes, and it is the basis of the bzip2 compressor; layered with the suffix array's rank information it becomes the FM- index (Ferragina & Manzini, Opportunistic Data Structures with Applications, FOCS 2000), a self-index that searches a compressed text without decompressing it. Genome aligners such as bwa and bowtie are FM-indexes over a reference genome — the same suffix-array machinery, made succinct.

Aho–Corasick, for its part, is the multi-pattern matcher (Aho & Corasick, Efficient String Matching: An Aid to Bibliographic Search, CACM 1975) inside classical tools like fgrep and inside intrusion-detection systems such as Snort, which must scan every packet against thousands of signatures at once — precisely the failure-automaton-on-a-trie construction this lesson builds. Manacher's algorithm (Manacher, 1975) rounds out the trio as the linear-time palindrome scanner, and all three share the amortised a pointer only moves right argument that runs through this entire module.

Takeaways

  • A suffix array lists the start indices of all suffixes in sorted order — integers indexing every substring. Prefix doubling builds it in (or with a comparison sort); pattern search is two binary searches in , since occurrences form a contiguous range.
  • The LCP array records the shared prefix length of sorted-adjacent suffixes, computable in by Kasai's algorithm (the running match drops by at most one per text-order step). It gives the longest repeated substring () and the count of distinct substrings ().
  • Aho–Corasick is KMP on a trie: a trie of all patterns plus failure links (longest proper-suffix node) and output links (chained terminals) scans the text once in , reporting every occurrence of every pattern. Soundness lives in the terminal output, completeness in the failure-link invariant; precompiling transitions yields a true DFA.
  • Manacher's algorithm finds the palindromic radius at every centre in by the Z-box mirror-and-extend trick on a separator-interleaved string, giving the longest palindromic substring and the count of all palindromes.

Footnotes

  1. Skiena, § — Suffix Trees and Arrays: suffix arrays as the space-efficient successor to suffix trees, with binary-search substring queries.
  2. Erickson, Ch. — String Matching: the LCP array, Kasai's linear-time construction, and the distinct-substring and longest-repeat corollaries.
  3. CLRS, Ch. 32 — String Matching (§32.3–32.4): finite-automaton matching and the failure-function machinery that Aho–Corasick generalises from one pattern to a dictionary.
Practice

╌╌ END ╌╌