每日算法 — 使用java实现后缀数组:倍增构造与最长重复子串查找

引言:从字符串匹配到后缀数组

在文本处理、生物信息学和数据压缩领域,字符串匹配是最基础也最核心的操作之一。给定一个长文本 T 和模式串 P,如何快速判断 P 是否在 T 中出现?传统方法如 KMP 算法能在 O(n+m) 时间内完成单次匹配,但当需要回答大量不同模式串的查询时,预处理成本便成了瓶颈。

后缀数组(Suffix Array, SA) 正是为解决这一矛盾而生的强大数据结构。它将文本的所有后缀按字典序排序并记录起始位置,使得任意模式串的匹配问题转化为一次二分查找。配合 LCP数组(Longest Common Prefix),后缀数组还能优雅地解决最长重复子串、不同子串计数等复杂问题。

本文将用 Java 完整实现后缀数组的 倍增构造算法,讲解 SA 与 Rank 的双数组协同机制、LCP 的 Kasai 线性算法,以及多个经典应用场景。

核心概念三件套

后缀数组 SA

对于一个长度为 n 的字符串 s,其所有后缀为 s[i..n-1]0 ≤ i < n)。后缀数组 SA 是一个长度为 n 的整数数组,其中 SA[i] 表示按字典序排列后第 i 小的后缀的起始位置。

例如,字符串 "banana" 的所有后缀为:
– 0: "banana"
– 1: "anana"
– 2: "nana"
– 3: "ana"
– 4: "na"
– 5: "a"

按字典序排序后:"a"(5), "ana"(3), "anana"(1), "banana"(0), "na"(4), "nana"(2),因此SA = [5, 3, 1, 0, 4, 2]`。

Rank 数组

Rank 数组 是 SA 的逆: Rank[i] 表示后缀 s[i..] 在排序后的名次。即 Rank[SA[i]] = i。在上例中,Rank = [3, 2, 5, 1, 4, 0]

SA 与 Rank 的双向映射是后缀数组算法的核心——当我们需要比较两个后缀的大小时,可以通过 Rank 在 O(1) 时间内获取。

LCP 数组

LCP数组 记录排序后相邻后缀的最长公共前缀长度: LCP[i] = lcp(s[SA[i]..], s[SA[i-1]..]),通常约定 LCP[0] = 0

对于 "banana"LCP = [0, 1, 3, 0, 0, 2]。LCP 数组将后缀数组从”静态索引”提升为”动态分析工具”,是诸多高级应用的基础。

倍增构造算法

核心思想

直接对所有后缀进行快速排序的复杂度为 O(n log n · n) = O(n² log n)(每次比较两个后缀需要 O(n))。倍增算法的精妙之处在于:不直接比较整个后缀,而是利用已排序的短前缀信息,在 O(1) 时间内比较长前缀

具体而言,第 k 轮迭代中,我们已知每个位置开始的 2^(k-1) 长度前缀 的 Rank。要比较两个后缀的 2^k 长度前缀,只需比较它们的前半段和后半段的 Rank 对 (rank1, rank2)——这正是一个二元组的比较,可用基数排序在 O(n) 时间内完成。

算法框架

  1. 初始化:对每个字符直接排序,得到长度为 1 的 Rank。
  2. 倍增:令 k = 1, 2, 4, 8, ...,直到 k >= n
  3. 对每个位置 i,构造关键字 (Rank[i], Rank[i+k])(若越界则补 -1)。
  4. 按关键字排序,更新 SA 和 Rank。
  5. 去重:相同关键字的 Rank 值必须相同。

由于每轮排序 O(n log n),共 log n 轮,总复杂度为 O(n log² n)。若使用基数排序(计数排序实现),可优化至 O(n log n)

Kasai 线性 LCP 算法

朴素计算 LCP 需要 O(n²),而 Kasai 算法 能在 O(n) 时间内完成。

关键观察:设 h = LCP[Rank[i]],则 LCP[Rank[i+1]] ≥ h - 1。这是因为去掉首字符后,后缀 s[i+1..]s[SA[Rank[i]-1]+1..] 的公共前缀至少为 h-1,而 LCP 的定义是相邻排序后缀的公共前缀,实际值只会更大。

算法流程:
1. 利用 Rank 数组定位每个后缀在 SA 中的位置。
2. 按原字符串顺序遍历,维护当前高度 h
3. 若当前后缀不是 SA 中第一个,则与 SA 中前一个后缀逐字符比较,更新 h
4. 每次比较后 h 最多增加 n,而总共最多减少 n,故总复杂度 O(n)。

Java 完整实现

import java.util.Arrays;

/**
 * 后缀数组完整实现
 * 包含:倍增构造、Kasai LCP算法、最长重复子串、不同子串计数
 * 时间复杂度:O(n log n) 构造,O(n) 计算LCP
 */
public class SuffixArray {

    private final String text;
    private final int n;
    private final int[] sa;      // 后缀数组
    private final int[] rank;    // Rank数组
    private final int[] lcp;     // LCP数组

    /**
     * 构造后缀数组
     * @param text 原始字符串(假设不含特殊分隔符,内部处理时会补0)
     */
    public SuffixArray(String text) {
        this.text = text;
        this.n = text.length();
        this.sa = new int[n];
        this.rank = new int[n];
        this.lcp = new int[n];
        build();
        buildLCP();
    }

    /**
     * 倍增算法构造后缀数组
     * 使用Java内置排序比较关键字对,复杂度 O(n log² n)
     * 实际应用中可替换为基数排序优化至 O(n log n)
     */
    private void build() {
        // 初始轮:按单个字符排序
        Integer[] order = new Integer[n];
        for (int i = 0; i < n; i++) {
            order[i] = i;
            rank[i] = text.charAt(i); // 用ASCII码作为初始rank
        }

        // 倍增:k 为当前比较的前缀长度
        for (int k = 1; k < n; k <<= 1) {
            final int kk = k;
            final int[] rankRef = rank;

            // 按 (rank[i], rank[i+k]) 排序
            Arrays.sort(order, (a, b) -> {
                if (rankRef[a] != rankRef[b]) {
                    return Integer.compare(rankRef[a], rankRef[b]);
                }
                // 越界处理:后半段不存在时视为 -1(最小)
                int ra = (a + kk < n) ? rankRef[a + kk] : -1;
                int rb = (b + kk < n) ? rankRef[b + kk] : -1;
                return Integer.compare(ra, rb);
            });

            // 临时rank,相同关键字共享同一rank
            int[] tmpRank = new int[n];
            tmpRank[order[0]] = 0;
            for (int i = 1; i < n; i++) {
                int prev = order[i - 1];
                int curr = order[i];
                boolean same = rankRef[prev] == rankRef[curr];
                if (same) {
                    int r1 = (prev + kk < n) ? rankRef[prev + kk] : -1;
                    int r2 = (curr + kk < n) ? rankRef[curr + kk] : -1;
                    same = r1 == r2;
                }
                tmpRank[curr] = tmpRank[prev] + (same ? 0 : 1);
            }

            System.arraycopy(tmpRank, 0, rank, 0, n);

            // 提前终止:所有rank互不相同,已完全排序
            if (rank[order[n - 1]] == n - 1) {
                break;
            }
        }

        for (int i = 0; i < n; i++) {
            sa[i] = order[i];
        }
    }

    /**
     * Kasai 线性算法计算 LCP 数组
     * 关键性质:LCP[Rank[i+1]] >= LCP[Rank[i]] - 1
     */
    private void buildLCP() {
        // 构建 rank 的逆:rank[i] 表示 suffix i 在 SA 中的位置
        // 此时 rank 已经是最终版本
        int[] invSA = new int[n];
        for (int i = 0; i < n; i++) {
            invSA[sa[i]] = i;
        }

        int h = 0;
        for (int i = 0; i < n; i++) {
            int pos = invSA[i];
            if (pos == 0) {
                lcp[pos] = 0; // 第一个元素无左邻居
                continue;
            }
            int j = sa[pos - 1]; // SA中前一个后缀的起始位置
            // 从当前h开始比较,逐步扩展
            while (i + h < n && j + h < n && text.charAt(i + h) == text.charAt(j + h)) {
                h++;
            }
            lcp[pos] = h;
            if (h > 0) h--; // 下一个位置的LCP至少为h-1
        }
    }

    // ==================== 查询接口 ====================

    public int[] getSA() { return sa; }
    public int[] getRank() { return rank; }
    public int[] getLCP() { return lcp; }

    /**
     * 获取第 i 小的后缀字符串(调试用)
     */
    public String getSuffix(int i) {
        return text.substring(sa[i]);
    }

    // ==================== 经典应用 ====================

    /**
     * 应用1:最长重复子串
     * 原理:任意两个后缀的LCP即为它们的公共前缀,LCP数组的最大值即为最长重复子串长度
     * @return [长度, 起始位置1, 起始位置2]
     */
    public int[] longestRepeatedSubstring() {
        int maxLen = 0;
        int pos = -1;
        for (int i = 1; i < n; i++) {
            if (lcp[i] > maxLen) {
                maxLen = lcp[i];
                pos = i;
            }
        }
        if (maxLen == 0) return new int[]{0, -1, -1};
        return new int[]{maxLen, sa[pos], sa[pos - 1]};
    }

    /**
     * 应用2:不同子串计数
     * 原理:以 SA[i] 开始的所有后缀贡献 n - SA[i] 个子串,但其中与前一个后缀重复的
     *      子串数量为 LCP[i]。因此新增子串数为 n - SA[i] - LCP[i]。
     * @return 不同子串的总数
     */
    public long countDistinctSubstrings() {
        long count = 0;
        for (int i = 0; i < n; i++) {
            count += (n - sa[i]) - lcp[i];
        }
        return count;
    }

    /**
     * 应用3:模式串匹配(二分查找)
     * 利用 SA 的有序性,在 O(m log n) 时间内判断模式串是否出现
     * @param pattern 待匹配模式串
     * @return 是否存在
     */
    public boolean contains(String pattern) {
        int m = pattern.length();
        int lo = 0, hi = n;
        while (lo < hi) {
            int mid = (lo + hi) >>> 1;
            String suffix = text.substring(sa[mid], Math.min(sa[mid] + m, n));
            int cmp = suffix.compareTo(pattern);
            if (cmp < 0) {
                lo = mid + 1;
            } else {
                hi = mid;
            }
        }
        if (lo < n) {
            String candidate = text.substring(sa[lo], Math.min(sa[lo] + m, n));
            return candidate.equals(pattern);
        }
        return false;
    }

    // ==================== 主程序与测试 ====================

    public static void main(String[] args) {
        System.out.println("===== 示例1:基础构造与查询 =====");
        String s1 = "banana";
        SuffixArray sa1 = new SuffixArray(s1);
        System.out.println("文本: " + s1);
        System.out.println("SA:   " + Arrays.toString(sa1.getSA()));
        System.out.println("Rank: " + Arrays.toString(sa1.getRank()));
        System.out.println("LCP:  " + Arrays.toString(sa1.getLCP()));
        System.out.println("后缀列表:");
        for (int i = 0; i < s1.length(); i++) {
            System.out.printf("  SA[%d]=%d -> \"%s\" (LCP=%d)%n",
                i, sa1.getSA()[i], sa1.getSuffix(i), sa1.getLCP()[i]);
        }

        System.out.println("\n===== 示例2:最长重复子串 =====");
        String s2 = "abcdabcfabcg";
        SuffixArray sa2 = new SuffixArray(s2);
        int[] lrs = sa2.longestRepeatedSubstring();
        System.out.println("文本: " + s2);
        System.out.printf("最长重复子串长度: %d, 位置: %d 和 %d%n",
            lrs[0], lrs[1], lrs[2]);
        if (lrs[0] > 0) {
            System.out.println("子串内容: \"" + s2.substring(lrs[1], lrs[1] + lrs[0]) + "\"");
        }

        System.out.println("\n===== 示例3:不同子串计数 =====");
        String[] testStrings = {"banana", "aaaa", "abcde", "abcdabcfabcg"};
        for (String ts : testStrings) {
            SuffixArray saTmp = new SuffixArray(ts);
            long distinct = saTmp.countDistinctSubstrings();
            long expected = (long) ts.length() * (ts.length() + 1) / 2;
            System.out.printf("\"%s\": 不同子串=%d, 总子串=%d%n", ts, distinct, expected);
        }

        System.out.println("\n===== 示例4:模式串匹配 =====");
        String s4 = "mississippi";
        SuffixArray sa4 = new SuffixArray(s4);
        String[] patterns = {"issi", "sip", "abc", "ppi"};
        for (String p : patterns) {
            boolean found = sa4.contains(p);
            System.out.printf("contains(\"%s\") = %b%n", p, found);
        }

        System.out.println("\n===== 示例5:大规模性能测试 =====");
        StringBuilder sb = new StringBuilder();
        java.util.Random rand = new java.util.Random(42);
        int scale = 50000;
        for (int i = 0; i < scale; i++) {
            sb.append((char) ('a' + rand.nextInt(26)));
        }
        String large = sb.toString();
        long start = System.nanoTime();
        SuffixArray saLarge = new SuffixArray(large);
        long elapsed = System.nanoTime() - start;
        System.out.printf("规模: n=%d, 构造耗时: %.3f ms%n", scale, elapsed / 1_000_000.0);
        System.out.printf("LCP最大值: %d%n", Arrays.stream(saLarge.getLCP()).max().getAsInt());
    }
}

复杂度分析

操作 时间复杂度 空间复杂度 说明
后缀数组构造 O(n log² n) O(n) Java 内置排序实现;基数排序可优化至 O(n log n)
LCP 数组(Kasai) O(n) O(n) 线性扫描,利用高度单调性
模式串匹配 O(m log n) O(1) m 为模式串长度,基于 SA 二分查找
最长重复子串 O(n) O(1) 遍历 LCP 取最大值
不同子串计数 O(n) O(1) 利用 SA 与 LCP 的线性公式

扩展与变体

后缀自动机(Suffix Automaton)

后缀自动机是后缀数组的”压缩版”,用 O(n) 状态表示所有子串信息,支持在线构造。它在子串出现次数查询、最短未出现子串等问题上表现更优,但实现复杂度也更高。

后缀树(Suffix Tree)

后缀树是所有后缀的 Trie 压缩形式,与后缀数组 + LCP 在信息上等价(通过笛卡尔树可互转)。后缀树的构造(Ukkonen 算法)为 O(n),但常数较大且实现复杂。实际工程中,后缀数组往往是更平衡的选择。

最长公共子串(LCS)

给定两个字符串 AB,将它们用不会出现的分隔符连接为 A#B$,构建后缀数组。属于 AB 的后缀之间的最大 LCP 即为最长公共子串长度。通过遍历 LCP 并判断相邻后缀来源即可在 O(|A|+|B|) 内解决。

总结

后缀数组是字符串算法的基石之一。通过本文你掌握了:

  • 后缀数组、Rank 数组与 LCP 数组的定义与关系
  • 倍增构造算法的核心思想:利用短前缀 Rank 在 O(1) 内比较长前缀
  • Kasai 线性算法的巧妙之处:高度单调递减约束
  • 完整可运行的 Java 实现,涵盖构造、LCP 计算与三大经典应用
  • 时间/空间复杂度分析与算法选型建议

从生物序列比对到代码相似度检测,从搜索引擎索引到数据压缩,后缀数组的思想渗透在现代计算的诸多角落。掌握这一数据结构,将为你打开字符串算法的大门。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注