DSA DSA In Depth
0%
lesson suffix-automatonsamautomata

Suffix Automaton (SAM / DAWG): endpos Equivalence Classes & State Transitions

7 min read Module 4.2: Suffix Structures, Sequence Alignment & Data Compression Updated Oct 23, 2026
📄 Cheat Sheet
LCP Array Construction (Kasai's O(N) Algorithm) & Suffix Array Duality
Sebastian Wild (Lectures) LCP Array Construction (Kasai's O(N) Algorithm) & Suffix Array Duality Watch on YouTube ↗

Derives the endpos equivalence relation, suffix link tree, state cloning rule when len[q] > len[p] + 1, and proves the 2N - 1 state bound and 3N - 4 transition bound.

0:00 / --:--

1. The Four Fundamental Lemmas of $\text{endpos}(t)$

Every property of the Suffix Automaton follows from four elementary facts about $\text{endpos}(t)$:

  1. Nested Suffix Inclusion: If $|u| \le |w|$ and $\text{endpos}(u) \cap \text{endpos}(w) \neq \emptyset$, then $u$ MUST be a suffix of $w$, which implies $\text{endpos}(w) \subseteq \text{endpos}(u)$. Thus any two $\text{endpos}$ sets are either completely disjoint or one is a subset of the other!
  2. Contiguous Suffix Interval per State: All substrings inside a single equivalence class $q$ are suffixes of the longest substring $W(q)$ in $q$ (of length $\text{len}[q]$), and they consist of all suffixes of $W(q)$ of lengths in the contiguous range $[\text{minlen}[q],\; \text{len}[q]]$.
  3. The Suffix Link Tree ($\text{link}[q]$): As you shorten $W(q)$ one character at a time from the left, the set of ending positions stays equal to $\text{endpos}(q)$ down to length $\text{minlen}[q]$, and at length $\text{minlen}[q] - 1$ it strictly expands into a larger $\text{endpos}$ set! That state is defined as $\text{link}[q]$, so: $$\text{minlen}[q] = \text{len}[\text{link}[q]] + 1$$ Because $\text{endpos}$ sets are either disjoint or nested, the links $\text{link}[q]$ form a rooted tree (the Link Tree) with at most $N$ leaves, proving the automaton has at most $2N - 1$ states (and at most $3N - 4$ edges).
  4. Instant Substring Count: Since state $q$ represents exactly $\text{len}[q] - \text{len}[\text{link}[q]]$ distinct substrings and states partition all non-empty substrings of $S$, the total number of distinct substrings of $S$ is simply $\sum_{q > 0} (\text{len}[q] - \text{len}[\text{link}[q]])$.

2. Online $O(N)$ Extension & State Cloning

When we append character $c = S[i]$ to the string processed so far (whose full prefix state is $\text{last}$), we create a new state $\text{cur}$ with $\text{len}[\text{cur}] = \text{len}[\text{last}] + 1$, walk up the suffix link chain $p = \text{last}, \text{link}[\text{last}], \dots$ adding transition $\text{next}[p][c] = \text{cur}$ while $\text{next}[p][c]$ is absent, and then handle three cases:

  • Case 1 (Reached root with no existing $c$-edge): Set $\text{link}[\text{cur}] = 0$ (the root).
  • Case 2 ($q = \text{next}[p][c]$ exists and $\text{len}[q] == \text{len}[p] + 1$): State $q$ contains ONLY suffixes of the new string, so we simply set $\text{link}[\text{cur}] = q$.
  • Case 3 ($q = \text{next}[p][c]$ exists and $\text{len}[q] > \text{len}[p] + 1$): State $q$ contains strings longer than $\text{len}[p] + 1$ that do NOT end at index $i$, so their $\text{endpos}$ set has split from the shorter suffixes of length $\le \text{len}[p] + 1$! We clone $q$ into a new state $\text{clone}$ with $\text{len}[\text{clone}] = \text{len}[p] + 1$, copy $q$'s outgoing transitions and suffix link to $\text{clone}$, redirect transitions $\text{next}[p][c] == q$ along $p$'s link chain to $\text{clone}$, and set $\text{link}[q] = \text{link}[\text{cur}] = \text{clone}$.
SAM Metric / Operation Worst-Case Bound Key Structural Reason
Number of States $|V|$ $\le 2N - 1$ (for $N \ge 2$) Each extend adds $1$ primary state + at most $1$ clone; tree of disjoint $\text{endpos}$ sets
Number of Transitions $|E|$ $\le 3N - 4$ (for $N \ge 3$) Spanning tree of longest paths ($|V| - 1$) + at most $N + 1$ non-tree shortcuts
Distinct Substrings of $S$ $O(N)$ $\sum_{u > 0} (\text{len}[u] - \text{len}[\text{link}[u]])$
Longest Common Substring of $S$ and $T$ $O(|S| + |T|)$ Feed $T$ through SAM of $S$, falling back via $\text{link}[u]$ on mismatch
SAM Partition & Clone-Occurrence Invariant

Every non-empty substring of $S$ belongs to a unique state $u$ of the SAM, and $|\text{endpos}(u)|$ equals the number of non-cloned primary states in the subtree of $u$ in the Suffix Link Tree. When a state $q$ is cloned into $\text{clone}$, the primary state receives initial count $\text{cnt}[\text{cur}] = 1$ while $\text{cnt}[\text{clone}] = 0$ so that bottom-up link-tree aggregation never double-counts occurrences.

Preallocating 2N States & Length-Bucket Topological Sort

Because $|V| \le 2N$, calling st.reserve(2 * n) up front avoids all vector reallocations during construction. Furthermore, to aggregate occurrence counts bottom-up on the Link Tree ($ ext{cnt}[ ext{link}[u]] mathrel{+}= ext{cnt}[u]$), you do NOT need to build adjacency lists or run recursion: simply counting-sort states by $ ext{len}[u]$ in $O(N)$ time and iterate from longest $ ext{len}[u]$ down to $1$!

3. Visual Trace: SAM States & Suffix Links for $S = \texttt{"abcbc"}$

0: "" len=0 1: {"a"} len=1, lk=0 2:{"ab"} len=2, lk=5 3:{"abc"} len=3, lk=7 4:"abcb" len=4, lk=5 6:"..bc" len=5, lk=7 5 (Clone): {"b"} endpos={1,3}, lk=0 7 (Clone): {"bc","c"} endpos={2,4}, lk=0
flowchart TD A["Extend SAM with char c: create cur (len = len[last] + 1)"] --> B["Walk p = last via link[p] while next[p][c] == -1: set next[p][c] = cur"] B --> C{"Did p reach -1?"} C -- "Yes" --> D["link[cur] = 0 (root)"] C -- "No: let q = next[p][c]" --> E{"Is len[q] == len[p] + 1?"} E -- "Yes" --> F["link[cur] = q"] E -- "No (len[q] > len[p] + 1)" --> G["Create clone of q with len = len[p] + 1; redirect next[p][c] == q to clone"] G --> H["link[q] = link[cur] = clone"] D --> I["last = cur"] F --> I H --> I
Suffix Array, Kasai O(N) LCP Array & Suffix Automaton (SAM)
SA + Kasai LCP O(N) · ≤ 2N−1 SAM States

Inspect the sorted Suffix Array and Kasai’s LCP[i] invariant (h ≥ LCP[rank[i]−1] − 1) on S = "banana$", or step through Suffix Automaton (SAM) endpos equivalence classes and state-splitting on "abcbc".

Step 1 / 10
Custom Input:
Timeline

Invariant & State

Kasai Invariant: LCP[rank[i]] ≥ LCP[rank[i−1]] − 1 (h decrements ≤ N times, increments ≤ N times -> O(N) total!)
🌳 Live 2D Hierarchical Tree Topology — Suffix Array, Kasai O(N) LCP Array & Suffix Automaton (SAM) Visited/Active Pivot/Root Resolved
LCP=0 SA=6 … [0] LCP=0 SA=5 … [1] LCP=1 SA=3 … [2] LCP=3 SA=1 … [3] LCP=0 SA=0 … [4] LCP=0 SA=4 … [5] LCP=2 SA=2 … [6]
[0]SA=6 "$"LCP=0
[1]SA=5 "a$"LCP=0
[2]SA=3 "ana$"LCP=1
[3]SA=1 "anana$"LCP=3
[4]SA=0 "banana$"LCP=0
[5]SA=4 "na$"LCP=0
[6]SA=2 "nana$"LCP=2
String S
"banana$" (N = 6)
∑ LCP[i]
0 + 1 + 3 + 0 + 0 + 2 = 6
Distinct Substrings
21 − 6 = 15
Step
1 / 4
[Step 1] Kasai at i=0 ("banana$"): rank=4, predecessor is "anana$" -> LCP = 0.

4. Production Implementation: Suffix Automaton + LCS of Two Strings

#include <algorithm>
#include <array>
#include <cstdint>
#include <string_view>
#include <vector>

class SuffixAutomaton {
public:
    struct State {
        std::array<int, 26> next;
        int len = 0;
        int link = -1;
        State() { next.fill(-1); }
    };

    explicit SuffixAutomaton(std::string_view s) {
        st_.reserve(2 * s.size() + 1);
        st_.emplace_back(); // State 0 = root
        for (char ch : s) extend(ch - 'a');
    }

    void extend(int c) {
        int cur = static_cast<int>(st_.size());
        st_.emplace_back();
        st_[cur].len = st_[last_].len + 1;

        int p = last_;
        while (p != -1 && st_[p].next[c] == -1) {
            st_[p].next[c] = cur;
            p = st_[p].link;
        }
        if (p == -1) {
            st_[cur].link = 0;
        } else {
            int q = st_[p].next[c];
            if (st_[p].len + 1 == st_[q].len) {
                st_[cur].link = q;
            } else {
                int clone = static_cast<int>(st_.size());
                st_.push_back(st_[q]); // Copies next[] and link
                st_[clone].len = st_[p].len + 1;
                while (p != -1 && st_[p].next[c] == q) {
                    st_[p].next[c] = clone;
                    p = st_[p].link;
                }
                st_[q].link = st_[cur].link = clone;
            }
        }
        last_ = cur;
    }

    int64_t count_distinct_substrings() const {
        int64_t total = 0;
        for (size_t u = 1; u < st_.size(); ++u) {
            total += st_[u].len - st_[st_[u].link].len;
        }
        return total;
    }

    // Returns length of Longest Common Substring with T in O(|T|) time
    int longest_common_substring(std::string_view t) const {
        int u = 0, cur_len = 0, best = 0;
        for (char ch : t) {
            int c = ch - 'a';
            while (u != 0 && st_[u].next[c] == -1) {
                u = st_[u].link;
                cur_len = st_[u].len;
            }
            if (st_[u].next[c] != -1) {
                u = st_[u].next[c];
                ++cur_len;
            }
            best = std::max(best, cur_len);
        }
        return best;
    }

private:
    std::vector<State> st_;
    int last_ = 0;
};
Resetting Matched Length cur_len = st_[u].len on SAM Fallback

When matching a query string $T$ against a Suffix Automaton to find the Longest Common Substring, if state $u$ lacks transition $c$ and you follow $u \leftarrow \text{link}[u]$, your matched length cur_len MUST be reset to $\text{len}[u]$ (the maximum length in the new state)! A frequent bug is decrementing cur_len-- by $1$ on each suffix-link hop; because a single suffix-link hop can drop $\text{len}[u]$ by many characters at once, cur_len-- grossly overestimates the match length.

Check yourself Question 1 of 4
Concept Check Structure of Substrings Within a Single SAM State recall

Each state $u > 0$ in a Suffix Automaton (SAM) corresponds to an equivalence class of non-empty substrings of $S$ that have the exact same set of ending positions $\text{endpos}(u)$.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

For any string $S$ of length $N \ge 3$, what are the tightest worst-case upper bounds on the number of states $|V|$ and directed transitions $|E|$ in its Suffix Automaton, and what string families achieve them?

Key Takeaways

A Suffix Automaton (SAM) groups substrings with identical endpos sets into a single state covering suffix lengths [len[link[u]] + 1, len[u]], using at most 2N - 1 states and 3N - 4 transitions.
Whenever extending along p -> q encounters len[q] > len[p] + 1, state q must be cloned into a state of length len[p] + 1 because longer strings in q do not share the new end position.
The total number of distinct substrings in S is simply sum_{u > 0} (len[u] - len[link[u]]), and the Longest Common Substring with T is found in O(|S| + |T|) time.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue