每日算法 — 使用java实现后缀树:Ukkonen在线构造与最长公共子串查找

引言

后缀树(Suffix Tree)是处理字符串问题的强大数据结构,它将一个字符串的所有后缀组织成一棵压缩的字典树。对于一个长度为n的字符串,其后缀树包含n个叶节点(对应n个后缀),且可以在O(n)时间和空间内构造。

后缀树支持多种经典字符串操作:子串存在性查询、最长重复子串、最长公共子串、最长回文子串等,均可在O(n)预处理后以O(m)或更优的时间回答(m为查询模式长度)。

Ukkonen算法是后缀树的经典在线构造算法,它从左到右逐个字符构建后缀树,整个构造过程的时间复杂度为O(n)。本文将用Java完整实现Ukkonen算法,并展示最长公共子串等核心应用。

核心原理

后缀树定义

字符串S的后缀树是一棵满足以下条件的压缩字典树:
1. 每条边标记为S的一个非空子串
2. 从根到叶的路径对应S的一个后缀
3. 每个内部节点至少有两个子节点

Ukkonen算法三要素

  1. 活动点(Active Point):当前处理阶段中,代表所有需要扩展后缀的共同前缀的位置,由三元组(active_node, active_edge, active_length)表示。

  2. 剩余后缀数(Remainder):当前阶段还需要插入的新后缀数量。

  3. 后缀链接(Suffix Link):内部节点间的指针,加速遍历。若节点v代表子串xα,则其后缀链接指向代表α的节点。

在线构造思想

算法逐个处理字符S[i]。对于每个字符,需要将所有尚未结束的后缀(数量为remainder)进行扩展。通过活动点机制,大部分扩展可在O(1)时间内完成。

Java实现

import java.util.*;

/**
 * 后缀树节点
 */
class SuffixTreeNode {
    // 边标签对应的字符串区间 [start, end)
    // end使用特殊值END(如Integer.MAX_VALUE)表示叶子节点的开放边
    int start;
    int end;
    SuffixTreeNode parent;
    Map<Character, SuffixTreeNode> children;
    SuffixTreeNode suffixLink;  // 后缀链接
    int suffixIndex;            // 叶节点对应的后缀起始位置,-1表示非叶节点

    public SuffixTreeNode(int start, int end, SuffixTreeNode parent) {
        this.start = start;
        this.end = end;
        this.parent = parent;
        this.children = new HashMap<>();
        this.suffixLink = null;
        this.suffixIndex = -1;
    }

    /**
     * 获取边长度
     */
    public int edgeLength() {
        return end - start;
    }

    /**
     * 获取边的字符串表示(用于调试)
     */
    public String getEdgeString(String text) {
        return text.substring(start, Math.min(end, text.length()));
    }
}

/**
 * Ukkonen后缀树构造器
 */
class SuffixTree {
    private final String text;
    private final SuffixTreeNode root;
    private SuffixTreeNode activeNode;
    private char activeEdge;
    private int activeLength;
    private int remainder;
    private int leafEnd;  // 全局叶子结束位置,所有叶节点共享

    public SuffixTree(String text) {
        this.text = text + "$";  // 添加唯一结束符
        this.root = new SuffixTreeNode(-1, -1, null);
        this.activeNode = root;
        this.activeEdge = '\0';
        this.activeLength = 0;
        this.remainder = 0;
        this.leafEnd = -1;
        build();
    }

    /**
     * 获取活动边指向的子节点
     */
    private SuffixTreeNode getActiveEdgeNode() {
        if (activeEdge == '\0') return null;
        return activeNode.children.get(activeEdge);
    }

    /**
     * 从活动点沿边向下走,必要时分割边
     */
    private boolean walkDown(SuffixTreeNode node) {
        if (activeLength >= node.edgeLength()) {
            activeEdge = text.charAt(node.start + activeLength);
            activeLength -= node.edgeLength();
            activeNode = node;
            return true;
        }
        return false;
    }

    /**
     * 构建后缀树(Ukkonen算法)
     */
    private void build() {
        for (int i = 0; i < text.length(); i++) {
            extend(i);
        }
        // 设置后缀索引
        setSuffixIndex(root, 0);
    }

    /**
     * 执行第i阶段的扩展
     */
    private void extend(int i) {
        char currentChar = text.charAt(i);
        leafEnd = i + 1;
        remainder++;
        SuffixTreeNode lastNewNode = null;

        while (remainder > 0) {
            if (activeLength == 0) {
                activeEdge = currentChar;
            }

            SuffixTreeNode next = getActiveEdgeNode();

            if (next == null) {
                // 规则1:创建新的叶节点
                activeNode.children.put(activeEdge,
                    new SuffixTreeNode(i, Integer.MAX_VALUE, activeNode));

                if (lastNewNode != null) {
                    lastNewNode.suffixLink = activeNode;
                    lastNewNode = null;
                }
            } else {
                if (walkDown(next)) {
                    continue;  // 从新的活动点重新开始
                }

                // 检查当前字符是否已在边上
                char edgeChar = text.charAt(next.start + activeLength);
                if (edgeChar == currentChar) {
                    // 规则3:当前后缀已存在,停止本阶段
                    if (lastNewNode != null && activeNode != root) {
                        lastNewNode.suffixLink = activeNode;
                        lastNewNode = null;
                    }
                    activeLength++;
                    break;
                }

                // 规则2:分裂边
                int splitPos = next.start + activeLength;
                SuffixTreeNode split = new SuffixTreeNode(next.start, splitPos, next.parent);
                activeNode.children.put(activeEdge, split);

                // 新的叶节点
                split.children.put(currentChar,
                    new SuffixTreeNode(i, Integer.MAX_VALUE, split));

                // 原边成为split的子节点
                next.start = splitPos;
                next.parent = split;
                split.children.put(text.charAt(next.start), next);

                if (lastNewNode != null) {
                    lastNewNode.suffixLink = split;
                }
                lastNewNode = split;
            }

            remainder--;

            if (activeNode == root && activeLength > 0) {
                activeLength--;
                activeEdge = text.charAt(i - remainder + 1);
            } else if (activeNode != root) {
                activeNode = activeNode.suffixLink != null ? activeNode.suffixLink : root;
            }
        }
    }

    /**
     * 递归设置叶节点的后缀索引
     */
    private int setSuffixIndex(SuffixTreeNode node, int labelHeight) {
        if (node == null) return 0;

        boolean isLeaf = true;
        for (SuffixTreeNode child : node.children.values()) {
            isLeaf = false;
            setSuffixIndex(child, labelHeight + child.edgeLength());
        }

        if (isLeaf) {
            // 叶节点对应后缀 text[start - labelHeight + edgeLength :]
            // 由于叶节点边开放到无穷,edgeLength = leafEnd - start
            // 但leafEnd在构造完成后等于text.length()
            // 所以 suffixIndex = leafEnd - labelHeight
            node.suffixIndex = text.length() - labelHeight;
            // 去掉结束符$的影响
            if (node.suffixIndex == text.length() - 1) {
                node.suffixIndex = -1;  // 仅包含$的后缀,忽略
            }
        }
        return 0;
    }

    /**
     * 搜索模式是否存在
     */
    public boolean contains(String pattern) {
        SuffixTreeNode current = root;
        int i = 0;
        while (i < pattern.length()) {
            SuffixTreeNode child = current.children.get(pattern.charAt(i));
            if (child == null) return false;

            String edgeStr = text.substring(child.start,
                Math.min(child.end, text.length()));
            String remainingPattern = pattern.substring(i);

            if (remainingPattern.startsWith(edgeStr)) {
                i += edgeStr.length();
                current = child;
            } else if (edgeStr.startsWith(remainingPattern)) {
                return true;
            } else {
                return false;
            }
        }
        return true;
    }

    /**
     * 查找最长重复子串
     */
    public String longestRepeatedSubstring() {
        Result result = new Result();
        findLRS(root, 0, result);
        return result.bestString;
    }

    private void findLRS(SuffixTreeNode node, int depth, Result result) {
        if (node == null) return;

        // 叶节点
        if (node.children.isEmpty()) return;

        // 检查该节点下是否有至少两个不同的后缀
        int leafCount = countLeaves(node);
        if (leafCount >= 2 && depth > result.bestLength) {
            result.bestLength = depth;
            result.bestString = getPathString(node, depth);
        }

        for (SuffixTreeNode child : node.children.values()) {
            findLRS(child, depth + child.edgeLength(), result);
        }
    }

    private int countLeaves(SuffixTreeNode node) {
        if (node.children.isEmpty()) return 1;
        int count = 0;
        for (SuffixTreeNode child : node.children.values()) {
            count += countLeaves(child);
        }
        return count;
    }

    private String getPathString(SuffixTreeNode node, int depth) {
        // 从根到node的路径对应的字符串
        StringBuilder sb = new StringBuilder();
        SuffixTreeNode curr = node;
        List<SuffixTreeNode> path = new ArrayList<>();
        while (curr != root && curr != null) {
            path.add(curr);
            curr = curr.parent;
        }
        Collections.reverse(path);
        for (SuffixTreeNode n : path) {
            sb.append(text, n.start, Math.min(n.end, text.length()));
        }
        return sb.toString();
    }

    static class Result {
        int bestLength = 0;
        String bestString = "";
    }

    /**
     * 查找两个字符串的最长公共子串
     */
    public static String longestCommonSubstring(String s1, String s2) {
        String combined = s1 + "#" + s2 + "$";
        SuffixTree tree = new SuffixTree(combined);
        return tree.findLCS(s1.length(), s1.length() + 1 + s2.length());
    }

    private String findLCS(int s1Len, int totalLen) {
        Result result = new Result();
        findLCSHelper(root, 0, s1Len, result);
        return result.bestString;
    }

    private boolean[] findLCSHelper(SuffixTreeNode node, int depth, int s1Len, Result result) {
        if (node == null) return new boolean[]{false, false};

        if (node.children.isEmpty()) {
            boolean inS1 = node.suffixIndex >= 0 && node.suffixIndex < s1Len;
            boolean inS2 = node.suffixIndex > s1Len;
            return new boolean[]{inS1, inS2};
        }

        boolean hasS1 = false, hasS2 = false;
        for (SuffixTreeNode child : node.children.values()) {
            boolean[] childRes = findLCSHelper(child, depth + child.edgeLength(), s1Len, result);
            hasS1 |= childRes[0];
            hasS2 |= childRes[1];
        }

        if (hasS1 && hasS2 && depth > result.bestLength) {
            result.bestLength = depth;
            result.bestString = getPathString(node, depth);
        }

        return new boolean[]{hasS1, hasS2};
    }

    public SuffixTreeNode getRoot() { return root; }
    public String getText() { return text; }
}

/**
 * 后缀树应用演示
 */
public class SuffixTreeDemo {
    public static void main(String[] args) {
        // 基础构造测试
        String text = "banana";
        System.out.println("=== 后缀树构造 ===");
        System.out.println("原始字符串: " + text);
        SuffixTree tree = new SuffixTree(text);
        System.out.println("后缀树构造完成");

        // 子串查询
        System.out.println("\n=== 子串查询 ===");
        String[] queries = {"ana", "na", "baa", "a", "ban"};
        for (String q : queries) {
            System.out.println("包含 '" + q + "': " + tree.contains(q));
        }

        // 最长重复子串
        System.out.println("\n=== 最长重复子串 ===");
        String lrs = tree.longestRepeatedSubstring();
        System.out.println("最长重复子串: '" + lrs + "'");

        // 另一个例子
        String text2 = "abacabadabacabae";
        System.out.println("\n字符串: " + text2);
        SuffixTree tree2 = new SuffixTree(text2);
        System.out.println("最长重复子串: '" + tree2.longestRepeatedSubstring() + "'");

        // 最长公共子串
        System.out.println("\n=== 最长公共子串 ===");
        String s1 = "ABABC";
        String s2 = "BABCA";
        String lcs = SuffixTree.longestCommonSubstring(s1, s2);
        System.out.println("s1: " + s1);
        System.out.println("s2: " + s2);
        System.out.println("最长公共子串: '" + lcs + "'");

        String s3 = "GeeksforGeeks";
        String s4 = "GeeksQuiz";
        System.out.println("\ns3: " + s3);
        System.out.println("s4: " + s4);
        System.out.println("最长公共子串: '" + SuffixTree.longestCommonSubstring(s3, s4) + "'");

        // 性能测试
        System.out.println("\n=== 性能测试 ===");
        int[] sizes = {1000, 5000, 10000, 50000};
        Random rand = new Random(42);
        for (int n : sizes) {
            StringBuilder sb = new StringBuilder(n);
            for (int i = 0; i < n; i++) {
                sb.append((char) ('a' + rand.nextInt(4)));
            }
            String testStr = sb.toString();
            long start = System.nanoTime();
            SuffixTree st = new SuffixTree(testStr);
            long time = System.nanoTime() - start;
            System.out.printf("n=%d: 构造时间=%.2fms%n", n, time / 1e6);
        }
    }
}

算法分析

时间复杂度

Ukkonen算法的每个阶段(处理一个字符)中,规则3(停止扩展)最多执行一次,其余扩展均通过规则1或规则2完成。关键观察:

  • 活动点深度(从根到活动点的路径长度)总增量为O(n)
  • 后缀链接跳转使活动点深度不会无限增加
  • 每阶段均摊扩展次数为O(1)

因此总时间复杂度为 O(n),其中n为字符串长度。

空间复杂度

后缀树最多有2n-1个节点(n个叶节点和最多n-1个内部节点),每个节点存储常数个指针/整数。总空间复杂度为 O(n)。

应用场景对比

问题 使用后缀树 替代方案
子串存在性查询 O(m) KMP: O(n + m)
最长重复子串 O(n) 后缀数组 + LCP: O(n log n)
最长公共子串(两串) O(n + m) 动态规划: O(nm)
不同子串计数 O(n) 后缀自动机: O(n)

扩展思考

  1. 后缀自动机:与后缀树等价但空间更紧凑的数据结构,特别适合处理大量子串计数问题。
  2. 广义后缀树:为多个字符串构建公共后缀树,支持多串最长公共子串等高级查询。
  3. 在线查询:结合LCA(最低公共祖先)和RMQ(区间最值查询),可在O(1)时间内回答最长公共前缀查询。

总结

后缀树是字符串算法领域的核心数据结构,Ukkonen算法以优雅的在线构造策略实现了线性时间复杂度。通过后缀树,大量看似复杂的字符串问题(最长重复子串、最长公共子串等)都能被高效解决。理解后缀树的构造和应用,是掌握高级字符串处理技术的重要基石。