DSA DSA In Depth
0%
lesson suffix-treeukkonenstrings

Suffix Trees & Ukkonen's Online O(N) Construction: Active Point & Suffix Links

7 min read Module 4.2: Suffix Structures, Sequence Alignment & Data Compression Updated Oct 23, 2026
📄 Cheat Sheet
Ukkonen's Suffix Tree Construction in O(N) Time
Tushar Roy - Coding Made Simple Ukkonen's Suffix Tree Construction in O(N) Time Watch on YouTube ↗

Walks through Ukkonen's three extension rules, the (active_node, active_edge, active_length) tuple, suffix links, and open-ended leaf index pointers.

0:00 / --:--

1. Structural Anatomy of a Compressed Suffix Tree

Given $S[0..N-1]$ terminated by a unique sentinel $S[N-1] = \texttt{'\$'}$ ($\texttt{'\$'} \notin \Sigma$), the Suffix Tree $\mathcal{T}(S)$ is the compressed Radix Tree of all $N$ non-empty suffixes $S[0..N-1], S[1..N-1], \dots, S[N-1..N-1]$:

  • Bounded Vertex Count: Because $\texttt{'\$'}$ is unique to the end of $S$, no non-empty suffix is a prefix of any other suffix. Thus all $N$ suffixes end at $N$ distinct leaves. Every internal node (except possibly the root if $N=1$) has branching degree $\ge 2$, which implies $\le N - 1$ internal nodes and $\le 2N - 1$ total vertices.
  • $O(1)$-Space Edge Slices: Instead of copying substring characters onto edges, each edge $u \to v$ stores two integers $(l, r)$ representing the half-open or closed substring $S[l..r]$. Thus the entire tree takes $O(N)$ words of memory.
  • Universal Substring Property: Every substring of $S$ is a prefix of some suffix of $S$. Therefore, a pattern $P[0..M-1]$ occurs in $S$ if and only if $P$ can be spelled out from the root of $\mathcal{T}(S)$ in $O(M)$ time; the leaves in the subtree below the locus of $P$ give all occurrence start positions!

2. Ukkonen's Three Extension Rules & Amortized Linearity

In Phase $i$ ($i = 0..N-1$), Ukkonen's algorithm implicitly maintains the count $\text{rem}$ (remainder) of suffixes waiting to be explicitly branched, alongside the Active Point $(\text{active\_node}, \text{active\_edge}, \text{active\_len})$ identifying the locus of the longest suffix of $S[0..i-1]$ that already appeared earlier in $S$ (so it sits inside the tree rather than at a leaf):

  1. Rule 1 (Leaf Extension — Free): Any suffix $S[j..i-1]$ that already ended at a leaf simply appends $S[i]$. By storing leaf right endpoints as $\infty$ (implicitly the current phase index $i$), all existing leaves extend simultaneously in $O(1)$ time!
  2. Rule 2 (Branch Creation — New Leaf): While $\text{rem} > 0$, if the character $S[i]$ does NOT exist next at the Active Point, we split the active edge (if $\text{active\_len} > 0$) to create a new internal node $w$, attach a new leaf $[i, \infty)$ for $S[i]$, wire the previous internal node created in this phase to $w$ via a Suffix Link $\text{link}[u] = w$, decrement $\text{rem}$, and advance the Active Point to the next shorter suffix via $\text{link}[\text{active\_node}]$.
  3. Rule 3 (Already Present — Showstopper): If character $S[i]$ ALREADY exists at the Active Point, we simply advance $\text{active\_len}$ by $1$ (canonicalizing across internal nodes via the skip/count trick) and break out of phase $i$ immediately! Why? Because if the longest unbranched suffix $S[j..i]$ is already in the tree, every shorter suffix $S[j+1..i], \dots, S[i..i]$ is also already in the tree.
Operation / Property Naive Suffix Trie Ukkonen Suffix Tree Why Ukkonen Achieves This Bound
Construction Time $\Theta(N^2)$ $O(N \log |\Sigma|)$ or $O(N)$ Each Rule 2 step creates 1 leaf ($\le N$ total); each Rule 3 step ends phase $i$ ($\le N$ total)
Nodes & Edges $\Theta(N^2)$ $\le 2N - 1$ nodes Path compression + $[l, r]$ index slices + $\texttt{'\$'}$ sentinel
Substring Existence $P$ $O(|P|)$ $O(|P|)$ Spell $P$ from root along compressed edge slices
Ukkonen Remainder & Active-Point Potential Invariant

At the start of every step in phase $i$, $\text{rem}$ equals the number of suffixes of $S[0..i]$ that have not yet been given a leaf, and the Active Point spells the longest such suffix $S[i - \text{rem} + 1 .. i - 1]$. Across all $N$ phases, $\text{rem}$ increments by $1$ per phase ($N$ times total) and decrements by $1$ on every Rule 2 split ($\le N$ times total). Similarly, $\text{active\_len}$ increases by at most $1$ per phase and decreases by at most $1$ per Suffix Link traversal, bounding total Suffix Link and skip/count hops to $O(N)$.

Why Production Systems Prefer Suffix Arrays + LCP Over Suffix Trees

Despite its $O(N)$ asymptotic elegance, a Suffix Tree node requires a child map, l, r, parent, and suffix_link—consuming $20\text{N}$ to $48\text{N}$ bytes of RAM with pointer-chasing cache misses at every branch. A Suffix Array (4N bytes) plus Kasai's LCP array (4N bytes) encodes the exact same tree topology in $8\text{N}$ contiguous bytes ($3\times$ to $6\times$ less memory) with vastly superior cache locality.

3. Visual Trace: Suffix Tree & Suffix Links for $S = \texttt{"xabxa\$"}$

root "xa" [0,2) u1:xa "bxa$" [2,6) idx 0 "$" [5,6) idx 3 "a" [1,2) u2:a "bxa$" [2,6) idx 1 "$" [5,6) idx 4 "bxa$" [2,6) idx 2 "$" [5,6) idx 5 --- Suffix Links: link("xa") = "a" link("a") = root
flowchart TD A["Phase i: append S[i], increment rem++"] --> B{"rem > 0?"} B -- "No" --> H["Advance to Phase i+1"] B -- "Yes" --> C{"Does S[i] exist at Active Point?"} C -- "Yes (Rule 3: Showstopper)" --> D["Wire pending suffix link; active_len++; canonicalize"] D --> H C -- "No (Rule 2: Split/Branch)" --> E["Split edge if active_len > 0; attach leaf [i, inf)"] E --> F["Wire pending suffix link to new internal node; rem--"] F --> G["Advance Active Point via suffix link or root edge shift"] G --> B
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: Ukkonen's Online $O(N)$ Suffix Tree

#include <map>
#include <string>
#include <string_view>
#include <vector>

class UkkonenSuffixTree {
public:
    static constexpr int INF = 1e9;
    struct Node {
        int l = 0, r = 0; // Edge label S[l .. min(r, current_end)]
        int link = 0;     // Suffix link to shorter suffix internal node
        std::map<char, int> next;
        int edge_len(int pos) const { return std::min(r, pos + 1) - l; }
    };

    explicit UkkonenSuffixTree(std::string_view str) : s_(str) {
        nodes_.emplace_back(); // Node 0 = root
        for (int i = 0; i < static_cast<int>(s_.size()); ++i) {
            extend(i);
        }
    }

    // Returns true iff pattern P is a substring of S in O(|P| log |Sigma|) time
    bool contains(std::string_view pat) const {
        int u = 0, n = static_cast<int>(s_.size());
        size_t idx = 0;
        while (idx < pat.size()) {
            auto it = nodes_[u].next.find(pat[idx]);
            if (it == nodes_[u].next.end()) return false;
            int v = it->second;
            int len = nodes_[v].edge_len(n - 1);
            for (int k = 0; k < len && idx < pat.size(); ++k, ++idx) {
                if (s_[nodes_[v].l + k] != pat[idx]) return false;
            }
            u = v;
        }
        return true;
    }

private:
    std::string s_;
    std::vector<Node> nodes_;
    int active_node_ = 0, active_edge_ = 0, active_len_ = 0, rem_ = 0;

    int new_node(int l, int r) {
        nodes_.push_back(Node{l, r, 0, {}});
        return static_cast<int>(nodes_.size()) - 1;
    }

    void extend(int pos) {
        ++rem_;
        int last_created = 0;
        while (rem_ > 0) {
            if (active_len_ == 0) active_edge_ = pos;
            auto it = nodes_[active_node_].next.find(s_[active_edge_]);
            if (it == nodes_[active_node_].next.end()) {
                // Rule 2 at an explicit node: attach leaf directly
                nodes_[active_node_].next[s_[active_edge_]] = new_node(pos, INF);
                if (last_created != 0) {
                    nodes_[last_created].link = active_node_;
                    last_created = 0;
                }
            } else {
                int nxt = it->second;
                int len = nodes_[nxt].edge_len(pos);
                // Skip/Count Trick: hop across entire edge if active_len_ >= len
                if (active_len_ >= len) {
                    active_edge_ += len;
                    active_len_ -= len;
                    active_node_ = nxt;
                    continue;
                }
                // Rule 3 (Showstopper): character s_[pos] already on edge
                if (s_[nodes_[nxt].l + active_len_] == s_[pos]) {
                    if (last_created != 0 && active_node_ != 0) {
                        nodes_[last_created].link = active_node_;
                    }
                    ++active_len_;
                    break;
                }
                // Rule 2 inside an edge: split edge at split and attach leaf
                int split = new_node(nodes_[nxt].l, nodes_[nxt].l + active_len_);
                nodes_[active_node_].next[s_[active_edge_]] = split;
                nodes_[split].next[s_[pos]] = new_node(pos, INF);
                nodes_[nxt].l += active_len_;
                nodes_[split].next[s_[nodes_[nxt].l]] = nxt;
                if (last_created != 0) nodes_[last_created].link = split;
                last_created = split;
            }
            --rem_;
            if (active_node_ == 0 && active_len_ > 0) {
                --active_len_;
                active_edge_ = pos - rem_ + 1;
            } else if (active_node_ != 0) {
                active_node_ = nodes_[active_node_].link;
            }
        }
    }
};
Always Append a Unique Terminal Sentinel ('$') When Counting Leaves

Without a unique terminal character $\texttt{'\$'}$ at the end of $S$, a suffix of $S$ that is also a prefix of another suffix (for example, suffix $\texttt{"xa"}$ in $\texttt{"xabxa"}$) will end inside an internal node or midway along an edge (an implicit suffix tree) rather than at a dedicated leaf! Appending $\texttt{'\$'}$ forces one final phase where Rule 2 branches every remaining suffix in $\text{rem}$, turning the implicit suffix tree into a true Suffix Tree with exactly $N$ leaves.

Check yourself Question 1 of 4
Concept Check Why Appending a Unique Sentinel `'$'` Is Necessary for an Explicit Suffix Tree recall

Suppose you build a compressed trie of all suffixes of $S = \texttt{"abcab"}$ WITHOUT appending a unique terminal character `'$'`.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

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

If you use the SAME separator character `'#'` between every pair of strings instead of distinct sentinels $\#_1 \neq \#_2 \neq \dots \neq \#_K$ (or clamping edge right-endpoints to the next `'#'`), what bug occurs?

Key Takeaways

A Suffix Tree compresses all N suffixes of S + '$' into at most 2N - 1 nodes by labeling edges with [l, r] index slices into S.
Ukkonen's algorithm achieves O(N) online construction via open leaf intervals [l, inf) (Rule 1), suffix links between internal nodes created by edge splits (Rule 2), and immediate phase termination when S[i] already exists at the Active Point (Rule 3).
Appending a unique sentinel '$' guarantees that no suffix is a prefix of another suffix, converting an implicit suffix tree into an explicit Suffix Tree with N leaves.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue