← Back to DevBytes

Suffix Trees: Implementation and Time Complexity Analysis

Introduction to Suffix Trees

A suffix tree is a compressed trie containing all the suffixes of a given string. It is one of the most powerful data structures in string processing, enabling efficient solutions to a wide range of problems such as substring search, longest repeated substring, longest common substring, and pattern matching. First introduced by Weiner in 1973, suffix trees allow many string operations to be performed in time proportional to the length of the pattern rather than the length of the text.

Unlike a naive trie that stores each suffix character by character, a suffix tree compresses non-branching paths into single edges labeled with substrings. This compression reduces the total number of nodes from O(n²) to O(n), making the structure both space-efficient and fast to traverse.

Why Suffix Trees Matter

Suffix trees matter because they transform many difficult string problems into linear or near-linear time operations. Consider the classic substring search problem: checking whether a pattern P of length m exists in a text T of length n. With a naive approach, this takes O(n·m) time. With a suffix tree built on T, the search takes only O(m) time after an O(n) preprocessing step.

Some of the key problems that suffix trees solve efficiently include:

These capabilities make suffix trees essential in bioinformatics (DNA sequence analysis), data compression, text editors, and search engines.

Structure of a Suffix Tree

A suffix tree for a string S of length n has the following properties:

To ensure that no suffix is a prefix of another suffix (which would prevent proper leaf termination), a special terminal character (often denoted as $) is appended to the string. This guarantees that every suffix ends at a leaf.

Naive Construction Approach

The simplest way to build a suffix tree is to insert each suffix one at a time into a trie-like structure, then compress paths. While this approach is easy to understand, it runs in O(n²) time, which is acceptable for learning purposes but impractical for large strings.

Python Implementation of a Naive Suffix Tree

Below is a complete implementation of a suffix tree using the naive insertion method. The tree stores edge labels as (start, end) index pairs into the original string, which avoids copying substrings and keeps memory usage reasonable.

class SuffixTreeNode:
    def __init__(self):
        self.children = {}
        self.suffix_index = -1

class SuffixTree:
    def __init__(self, text):
        # Append a unique terminal character
        self.text = text + "$"
        self.root = SuffixTreeNode()
        self._build()

    def _build(self):
        n = len(self.text)
        # Insert every suffix into the tree
        for i in range(n):
            self._insert_suffix(i)

    def _insert_suffix(self, suffix_start):
        node = self.root
        n = len(self.text)
        j = suffix_start
        while j < n:
            char = self.text[j]
            if char not in node.children:
                # Create a new leaf storing the remaining suffix as (start, end)
                leaf = SuffixTreeNode()
                leaf.suffix_index = suffix_start
                node.children[char] = (j, n - 1, leaf)
                return
            # Edge exists; compare characters along the edge
            edge_start, edge_end, child = node.children[char]
            k = edge_start
            while k <= edge_end and j < n and self.text[k] == self.text[j]:
                k += 1
                j += 1
            if k > edge_end:
                # We consumed the entire edge; move to child node
                node = child
            else:
                # Split the edge at position k
                mid_node = SuffixTreeNode()
                # Old edge becomes child of mid_node
                mid_node.children[self.text[k]] = (k, edge_end, child)
                # New leaf for the remaining suffix
                new_leaf = SuffixTreeNode()
                new_leaf.suffix_index = suffix_start
                mid_node.children[self.text[j]] = (j, n - 1, new_leaf)
                # Update parent's edge to point to mid_node
                node.children[char] = (edge_start, k - 1, mid_node)
                return

    def search(self, pattern):
        """Return True if pattern exists in the text."""
        node = self.root
        i = 0
        m = len(pattern)
        while i < m:
            char = pattern[i]
            if char not in node.children:
                return False
            edge_start, edge_end, child = node.children[char]
            k = edge_start
            while k <= edge_end and i < m and self.text[k] == pattern[i]:
                k += 1
                i += 1
            if i == m:
                return True
            if k > edge_end:
                node = child
            else:
                return False
        return True

    def _collect_leaves(self, node, results):
        if node.suffix_index != -1:
            results.append(node.suffix_index)
        for key in node.children:
            _, _, child = node.children[key]
            self._collect_leaves(child, results)

    def find_all_occurrences(self, pattern):
        """Return all starting positions of pattern in the text."""
        node = self.root
        i = 0
        m = len(pattern)
        while i < m:
            char = pattern[i]
            if char not in node.children:
                return []
            edge_start, edge_end, child = node.children[char]
            k = edge_start
            while k <= edge_end and i < m and self.text[k] == pattern[i]:
                k += 1
                i += 1
            if i == m:
                results = []
                if k > edge_end:
                    self._collect_leaves(child, results)
                else:
                    # Pattern ends in the middle of an edge
                    self._collect_leaves(child, results)
                return results
            if k > edge_end:
                node = child
            else:
                return []
        # Pattern is empty; return all suffix positions
        results = []
        self._collect_leaves(node, results)
        return results

# Example usage
if __name__ == "__main__":
    text = "banana"
    tree = SuffixTree(text)

    print(tree.search("nan"))       # True
    print(tree.search("apple"))     # False
    print(tree.find_all_occurrences("ana"))  # [1, 3]
    print(tree.find_all_occurrences("a"))    # [1, 3, 5]

This implementation demonstrates the core mechanics: inserting suffixes, splitting edges when partial matches occur, and traversing the tree for pattern searches. The search method runs in O(m) time, while find_all_occurrences runs in O(m + k) time where k is the number of occurrences.

Ukkonen's Algorithm: Linear Time Construction

The naive approach builds the tree in O(n²) time. Ukkonen's algorithm, published in 1995, constructs a suffix tree in O(n) time for constant alphabets (or O(n log |Σ|) for general alphabets). It is an online algorithm, meaning it processes the string left to right and maintains a suffix tree of the current prefix at each step.

Key Concepts in Ukkonen's Algorithm

Ukkonen's algorithm relies on several important ideas:

Simplified Ukkonen Implementation

Below is a working implementation of Ukkonen's algorithm in Python. It uses edge labels represented as mutable lists so that end positions can be updated in O(1) time during leaf extension.

class UkkonenNode:
    def __init__(self):
        self.children = {}
        self.suffix_link = None
        self.start = None
        self.end = None
        self.suffix_index = -1

class UkkonenSuffixTree:
    def __init__(self, text):
        self.text = text + "$"
        self.n = len(self.text)
        self.root = UkkonenNode()
        self.root.suffix_link = self.root
        self.active_node = self.root
        self.active_edge = -1
        self.active_length = 0
        self.remaining = 0
        self.leaf_end = [-1]  # mutable end for all leaves
        self._build()

    def _new_node(self, start, end):
        node = UkkonenNode()
        node.start = start
        node.end = end
        node.suffix_link = self.root
        return node

    def _edge_length(self, node):
        if node == self.root:
            return 0
        return node.end[0] - node.start + 1

    def _walk_down(self, node):
        length = self._edge_length(node)
        if self.active_length >= length:
            self.active_edge += length
            self.active_length -= length
            self.active_node = node
            return True
        return False

    def _build(self):
        for i in range(self.n):
            self._extend(i)

    def _extend(self, pos):
        self.leaf_end[0] = pos
        self.remaining += 1
        last_new_node = None

        while self.remaining > 0:
            if self.active_length == 0:
                self.active_edge = pos

            char = self.text[self.active_edge]
            if char not in self.active_node.children:
                # Rule 2: No edge starting with this character
                leaf = self._new_node(pos, self.leaf_end)
                leaf.suffix_index = pos - self.remaining + 1
                self.active_node.children[char] = leaf
                if last_new_node is not None:
                    last_new_node.suffix_link = self.active_node
                    last_new_node = None
            else:
                next_node = self.active_node.children[char]
                if self._walk_down(next_node):
                    continue
                if self.text[next_node.start + self.active_length] == self.text[pos]:
                    # Rule 3: Character already present
                    if last_new_node is not None and self.active_node != self.root:
                        last_new_node.suffix_link = self.active_node
                        last_new_node = None
                    self.active_length += 1
                    break

                # Rule 2: Split the edge
                split_end = [next_node.start + self.active_length - 1]
                split = self._new_node(next_node.start, split_end)
                self.active_node.children[char] = split

                leaf = self._new_node(pos, self.leaf_end)
                leaf.suffix_index = pos - self.remaining + 1
                split.children[self.text[pos]] = leaf
                next_node.start += self.active_length
                split.children[self.text[next_node.start]] = next_node

                if last_new_node is not None:
                    last_new_node.suffix_link = split
                last_new_node = split

            self.remaining -= 1
            if self.active_node == self.root and self.active_length > 0:
                self.active_length -= 1
                self.active_edge = pos - self.remaining + 1
            elif self.active_node != self.root:
                self.active_node = self.active_node.suffix_link

    def search(self, pattern):
        node = self.root
        i = 0
        m = len(pattern)
        while i < m:
            char = pattern[i]
            if char not in node.children:
                return False
            child = node.children[char]
            length = self._edge_length(child)
            k = 0
            while k < length and i < m:
                if self.text[child.start + k] != pattern[i]:
                    return False
                k += 1
                i += 1
            if i < m:
                node = child
        return True

# Example usage
if __name__ == "__main__":
    text = "mississippi"
    tree = UkkonenSuffixTree(text)

    print(tree.search("issi"))   # True
    print(tree.search("issip"))  # True
    print(tree.search("xyz"))    # False

This implementation builds the suffix tree in O(n) time for a constant-sized alphabet. The use of suffix links and the active point ensures that each extension does amortized O(1) work, which is the key insight behind the linear time bound.

Time Complexity Analysis

Naive Construction

The naive algorithm inserts n suffixes, each of length up to n. In the worst case (for example, a string of identical characters), each insertion may traverse and split edges along a path of length O(n). This gives a total construction time of O(n²). The space complexity is O(n²) in the worst case if edge labels are stored as explicit substrings, but O(n) if stored as index pairs.

Ukkonen's Algorithm

Ukkonen's algorithm achieves O(n) construction time through several optimizations:

The result is that all n phases together perform O(n) extensions, each taking amortized O(1) time. For a general alphabet, dictionary lookups on children add a logarithmic factor, giving O(n log |Σ|). The space complexity is O(n) since the tree has at most 2n - 1 nodes.

Search Complexity

Once the tree is built, searching for a pattern of length m takes O(m) time. This is because the search follows at most one path from the root, comparing at most m characters along the way. Finding all k occurrences takes O(m + k) time, since each leaf under the matching point must be collected.

Practical Applications

Longest Repeated Substring

The longest repeated substring corresponds to the deepest internal node in the suffix tree, where depth is measured by the total string length from the root. Traversing the tree once to find this node takes O(n) time.

def longest_repeated_substring(tree):
    max_len = [0]
    result = [""]

    def dfs(node, depth):
        is_internal = len(node.children) > 1 or (
            len(node.children) == 1 and node.suffix_index == -1
        )
        has_multiple_leaves = False
        leaf_count = [0]

        def count_leaves(n):
            if n.suffix_index != -1:
                leaf_count[0] += 1
            for key in n.children:
                _, _, child = n.children[key]
                count_leaves(child)

        # Check if this internal node leads to at least 2 leaves
        for key in node.children:
            _, _, child = node.children[key]
            dfs(child, depth + (child.end[0] - child.start + 1) if hasattr(child, 'end') and isinstance(child.end, list) else 0)

    # Simplified version using the naive tree structure
    def find_lrs(node, depth):
        nonlocal max_len, result
        if node.suffix_index == -1 and len(node.children) >= 2:
            if depth > max_len[0]:
                max_len[0] = depth
                # Reconstruct substring from any child path
                result[0] = tree.text[:depth]
        for key in node.children:
            edge_start, edge_end, child = node.children[key]
            find_lrs(child, depth + (edge_end - edge_start + 1))

    find_lrs(tree.root, 0)
    return result[0]

Longest Common Substring of Two Strings

To find the longest common substring of two strings S1 and S2, build a generalized suffix tree containing both strings (separated by unique terminal characters). The deepest internal node that has leaves from both strings represents the longest common substring. This runs in O(|S1| + |S2|) time.

Best Practices

Conclusion

Suffix trees are a foundational data structure in string processing, offering linear-time solutions to problems that would otherwise require quadratic or worse approaches. While the naive construction is straightforward and suitable for educational purposes and small inputs, Ukkonen's algorithm provides the theoretical and practical foundation for scaling to large datasets. By understanding the structure, construction methods, and complexity trade-offs, developers can leverage suffix trees effectively in applications ranging from bioinformatics to text search systems. For memory-constrained environments, suffix arrays offer a compact alternative, but suffix trees remain unmatched in their versatility and the breadth of problems they solve elegantly.

🛠 Tools from DevBytes

Inventory Tracker Pro — Excel inventory system, low-stock alerts · $19
AI Dev Kit for Mac — local AI dev environment templates · $9.99
KeyMapper for Mac — custom keyboard shortcut toolkit · $7.99

← Back to all articles