每日算法 — 使用java实现后缀数组:倍增构造与LCP最长公共前缀

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

字符串处理是计算机科学中最基础也最广泛的应用领域之一。从搜索引擎的关键词检索到生物信息学的DNA序列分析,高效的字符串算法始终扮演着核心角色。

我们已经熟悉KMP算法的线性模式匹配、Trie树的前缀检索以及AC自动机的多模式匹配。但当问题升级为”找出文本中所有重复的子串模式”或”计算两个字符串的最长公共子串”时,上述工具便显得力不从心。后缀数组(Suffix Array)正是解决这类问题的利器——它将一个字符串的所有后缀按字典序排列,配合LCP(最长公共前缀)数组,可以在近乎线性的时间内解决大量复杂的字符串问题。

本文将用Java完整实现后缀数组的倍增构造法,并讲解LCP数组的线性构建与应用场景。

核心概念:后缀数组是什么

给定字符串 S = "banana",它的所有后缀为:

索引 后缀
0 banana
1 anana
2 nana
3 ana
4 na
5 a

将这些后缀按字典序排序后得到:

排序后索引 原索引 后缀
0 5 a
1 3 ana
2 1 anana
3 0 banana
4 4 na
5 2 nana

后缀数组 SA 就是存储”原索引”的数组:SA = [5, 3, 1, 0, 4, 2]。它完整刻画了字符串的所有子串信息——任意子串都是某个后缀的前缀,而排序后的相邻后缀具有最大的前缀重叠可能。

倍增构造法:从O(n log²n)到O(n log n)

直接对所有后缀进行字符串排序的时间复杂度为 O(n² log n),因为每次字符串比较最坏需要 O(n) 时间。倍增构造法的核心思想是:利用已排序的长度为 k 的子串信息,在 O(n) 时间内推导出长度为 2k 的子串排序

排序键的构造

对于从位置 i 开始、长度为 2k 的子串,我们可以将其拆分为两个长度为 k 的部分:
– 第一部分:S[i..i+k-1] 的排名为 rank[i]
– 第二部分:S[i+k..i+2k-1] 的排名为 rank[i+k](若超出范围则排名为 -1)

因此,长度为 2k 的子串可以用二元组 (rank[i], rank[i+k]) 表示。对这个二元组进行基数排序(或利用Java的排序稳定性),即可在 O(n log n) 时间内完成一次倍增迭代。

算法流程

  1. 初始化:对每个字符单独排序,得到长度为 1 的排名。
  2. 迭代:令 k = 1, 2, 4, 8, …,每次用 (rank[i], rank[i+k]) 作为排序键对所有位置进行排序。
  3. 终止:当 k ≥ n 时,所有后缀的排序完成。

外层循环执行 O(log n) 次,每次排序 O(n log n),总复杂度为 O(n log² n)。若使用基数排序替代比较排序,可优化至 O(n log n)。

LCP数组:最长公共前缀的线性求解

LCP数组 定义了排序后相邻两个后缀的最长公共前缀长度。对于 SA[i]SA[i-1]LCP[i] 表示这两个后缀的最长公共前缀长度(通常令 LCP[0] = 0)。

以前面的 “banana” 为例:

i SA[i] 后缀 LCP[i] 说明
0 5 a 0 首项
1 3 ana 1 “a”
2 1 anana 3 “ana”
3 0 banana 0 无公共前缀
4 4 na 0 首项
5 2 nana 2 “na”

LCP数组有一个极其重要的性质:若两个后缀在原字符串中的起始位置分别为 i 和 j,它们在 SA 中的位置之间的最小LCP值,就是这两个后缀的LCP长度。这使得任意两后缀的LCP查询转化为RMQ(区间最值查询)问题,结合ST表可在 O(1) 时间内回答。

线性构造LCP的关键引理

rank[i] 为后缀 i 在SA中的位置。定义 height[i] = LCP[rank[i]],即后缀 i 与它SA中前驱的LCP长度。关键引理:height[rank[i]] ≥ height[rank[i-1]] - 1

利用这个性质,我们可以按原字符串顺序从左到右扫描,在 O(n) 总时间内求出所有 height 值,进而得到LCP数组。

Java完整实现

以下项目包含后缀数组的倍增构造、LCP数组的线性求解、ST表预处理以及三个经典应用。

import java.util.*;

/**
 * 后缀数组(Suffix Array)完整实现
 * 核心算法:倍增构造法 + Kasai线性LCP + ST表RMQ
 * 时间复杂度:O(n log² n) 构造SA,O(n) 构造LCP,O(n log n) 预处理RMQ
 */
public class SuffixArray {

    private final String text;
    private final int n;
    private final int[] sa;      // 后缀数组:sa[i] 表示排名第i的后缀起始位置
    private final int[] rank;    // 排名数组:rank[i] 表示从位置i开始的后缀的排名
    private final int[] lcp;     // LCP数组:lcp[i] 表示sa[i]与sa[i-1]的最长公共前缀长度
    private final int[][] st;    // ST表:用于O(1) RMQ查询
    private final int[] logTable;

    /**
     * 构造后缀数组及相关数据结构
     * @param text 原始字符串(假设仅包含可比较字符,不含特殊分隔符)
     */
    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];

        // 步骤1:倍增法构造后缀数组
        buildSA();

        // 步骤2:Kasai算法线性构造LCP数组
        buildLCP();

        // 步骤3:预处理ST表用于O(1) RMQ
        buildST();
    }

    /**
     * 倍增法构造后缀数组
     * 初始按单个字符排序,然后逐次倍增长度
     */
    private void buildSA() {
        // 初始化:按字符的ASCII值作为初始排名
        Integer[] order = new Integer[n];
        for (int i = 0; i < n; i++) {
            order[i] = i;
            rank[i] = text.charAt(i); // 直接用字符编码作为初始rank
        }

        // 按当前rank排序得到初始SA
        Arrays.sort(order, (a, b) -> Integer.compare(rank[a], rank[b]));
        for (int i = 0; i < n; i++) {
            sa[i] = order[i];
        }

        // 临时数组,用于存储新的SA和rank
        int[] newSA = new int[n];
        int[] newRank = new int[n];

        // 倍增迭代:k = 1, 2, 4, 8, ...
        for (int k = 1; k < n; k <<= 1) {
            // 按第二关键字 (rank[i+k]) 排序,使用计数排序思想优化
            // 这里使用Java内置排序配合自定义比较器,实际竞赛中可换基数排序
            final int step = k;
            Arrays.sort(order, (a, b) -> {
                if (rank[a] != rank[b]) {
                    return Integer.compare(rank[a], rank[b]);
                }
                // 比较第二关键字,越界视为-1(最小)
                int ra = (a + step < n) ? rank[a + step] : -1;
                int rb = (b + step < n) ? rank[b + step] : -1;
                return Integer.compare(ra, rb);
            });

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

            // 重新分配rank,相同二元组分配相同rank
            newRank[newSA[0]] = 0;
            for (int i = 1; i < n; i++) {
                int prev = newSA[i - 1], curr = newSA[i];
                boolean same = rank[prev] == rank[curr];
                if (same) {
                    int r1 = (prev + step < n) ? rank[prev + step] : -1;
                    int r2 = (curr + step < n) ? rank[curr + step] : -1;
                    same = r1 == r2;
                }
                newRank[curr] = newRank[prev] + (same ? 0 : 1);
            }

            // 交换数组引用
            System.arraycopy(newSA, 0, sa, 0, n);
            System.arraycopy(newRank, 0, rank, 0, n);

            // 如果所有rank都不相同,排序已经完成
            if (rank[sa[n - 1]] == n - 1) {
                break;
            }
        }
    }

    /**
     * Kasai算法:线性时间构造LCP数组
     * 利用 height[rank[i]] >= height[rank[i-1]] - 1 的性质
     */
    private void buildLCP() {
        // 先根据SA重新计算正确的rank(buildSA中rank可能被破坏)
        for (int i = 0; i < n; i++) {
            rank[sa[i]] = i;
        }

        int h = 0;
        for (int i = 0; i < n; i++) {
            int r = rank[i];
            if (r > 0) {
                int j = sa[r - 1]; // SA中前一个后缀的起始位置
                // 从当前h位置开始比较
                while (i + h < n && j + h < n && text.charAt(i + h) == text.charAt(j + h)) {
                    h++;
                }
                lcp[r] = h;
                if (h > 0) {
                    h--; // 关键优化:下次至少可以从h-1开始比较
                }
            } else {
                lcp[r] = 0; // rank为0的后缀没有前驱
            }
        }
    }

    /**
     * 构建ST表(Sparse Table),用于O(1)区间最小值查询
     */
    private void buildST() {
        logTable = new int[n + 1];
        logTable[1] = 0;
        for (int i = 2; i <= n; i++) {
            logTable[i] = logTable[i / 2] + 1;
        }

        int maxLog = logTable[n] + 1;
        st = new int[n][maxLog];

        // 初始化第0层
        for (int i = 0; i < n; i++) {
            st[i][0] = lcp[i];
        }

        // 动态规划填表:st[i][j] 表示区间 [i, i+2^j-1] 的最小值
        for (int j = 1; j < maxLog; j++) {
            for (int i = 0; i + (1 << j) <= n; i++) {
                st[i][j] = Math.min(st[i][j - 1], st[i + (1 << (j - 1))][j - 1]);
            }
        }
    }

    /**
     * O(1) 查询LCP数组区间 [l, r] 的最小值(用于任意两后缀LCP查询)
     * 注意:传入的是LCP数组的索引范围,不是字符串位置
     */
    public int queryMinLCP(int l, int r) {
        if (l > r) {
            int tmp = l; l = r; r = tmp;
        }
        int j = logTable[r - l + 1];
        return Math.min(st[l][j], st[r - (1 << j) + 1][j]);
    }

    /**
     * 查询从位置i和位置j开始的后缀的最长公共前缀长度
     * 转化为查询LCP数组在 (rank[i]+1, rank[j]) 区间的最小值
     */
    public int getLCP(int i, int j) {
        if (i == j) return n - i;
        int ri = rank[i], rj = rank[j];
        if (ri > rj) {
            int tmp = ri; ri = rj; rj = tmp;
        }
        // 查询LCP数组 [ri+1, rj] 区间的最小值
        return queryMinLCP(ri + 1, rj);
    }

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

    /**
     * 应用1:最长重复子串
     * 后缀数组中相邻后缀的LCP最大值即为答案
     */
    public String longestRepeatedSubstring() {
        int maxLen = 0, pos = -1;
        for (int i = 1; i < n; i++) {
            if (lcp[i] > maxLen) {
                maxLen = lcp[i];
                pos = sa[i];
            }
        }
        return maxLen > 0 ? text.substring(pos, pos + maxLen) : "";
    }

    /**
     * 应用2:两个字符串的最长公共子串
     * 将两个字符串用特殊字符拼接后构造后缀数组
     */
    public static String longestCommonSubstring(String s1, String s2) {
        // 使用不会出现在输入中的分隔符(假设输入仅含小写字母)
        String combined = s1 + "#" + s2;
        SuffixArray sa = new SuffixArray(combined);

        int maxLen = 0, pos = -1;
        for (int i = 1; i < combined.length(); i++) {
            // 确保两个后缀分别来自不同的原字符串
            boolean fromS1_i = sa.sa[i] < s1.length();
            boolean fromS1_prev = sa.sa[i - 1] < s1.length();
            if (fromS1_i != fromS1_prev) {
                if (sa.lcp[i] > maxLen) {
                    maxLen = sa.lcp[i];
                    pos = sa.sa[i];
                }
            }
        }
        return maxLen > 0 ? combined.substring(pos, pos + maxLen) : "";
    }

    /**
     * 应用3:不同子串个数
     * 公式:所有子串总数 - 相邻后缀的LCP之和
     * 即 sum_{i=0}^{n-1} (n - sa[i]) - sum_{i=1}^{n-1} lcp[i]
     */
    public long countDistinctSubstrings() {
        long total = 0;
        for (int i = 0; i < n; i++) {
            total += (n - sa[i]); // 以sa[i]开头的子串个数
        }
        for (int i = 1; i < n; i++) {
            total -= lcp[i]; // 减去重复的子串数
        }
        return total;
    }

    // ==================== 辅助方法 ====================

    public int[] getSA() { return sa.clone(); }
    public int[] getLCP() { return lcp.clone(); }

    public void printDebug() {
        System.out.println("后缀数组详情:");
        System.out.println("i\tSA[i]\tLCP\t后缀");
        for (int i = 0; i < n; i++) {
            String suffix = text.substring(sa[i]);
            if (suffix.length() > 30) suffix = suffix.substring(0, 30) + "...";
            System.out.printf("%d\t%d\t%d\t%s%n", i, sa[i], lcp[i], suffix);
        }
    }

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

    public static void main(String[] args) {
        System.out.println("===== 示例1:基础后缀数组与LCP =====");
        String text1 = "banana";
        SuffixArray sa1 = new SuffixArray(text1);
        sa1.printDebug();
        System.out.println("\n最长重复子串: \"" + sa1.longestRepeatedSubstring() + "\"");
        System.out.println("不同子串个数: " + sa1.countDistinctSubstrings());

        System.out.println("\n===== 示例2:LCP查询 =====");
        System.out.println("LCP(后缀0='banana', 后缀1='anana') = " + sa1.getLCP(0, 1));
        System.out.println("LCP(后缀1='anana', 后缀3='ana') = " + sa1.getLCP(1, 3));

        System.out.println("\n===== 示例3:最长公共子串 =====");
        String s1 = "ABABC", s2 = "BABCA";
        System.out.println("s1=\"" + s1 + "\", s2=\"" + s2 + "\"");
        System.out.println("最长公共子串: \"" + longestCommonSubstring(s1, s2) + "\"");

        System.out.println("\n===== 示例4:大规模测试 =====");
        StringBuilder sb = new StringBuilder();
        Random rand = new Random(42);
        for (int i = 0; i < 5000; i++) {
            sb.append((char) ('a' + rand.nextInt(26)));
        }
        String largeText = sb.toString();

        long start = System.nanoTime();
        SuffixArray saLarge = new SuffixArray(largeText);
        long elapsed = System.nanoTime() - start;
        System.out.printf("构造长度为 %d 的后缀数组耗时: %.3f ms%n", largeText.length(), elapsed / 1_000_000.0);
        System.out.printf("最长重复子串长度: %d%n", saLarge.longestRepeatedSubstring().length());
        System.out.printf("不同子串个数: %e%n", (double) saLarge.countDistinctSubstrings());

        System.out.println("\n===== 示例5:DNA序列分析 =====");
        String dna = "ATCGATCGAATCGATCG";
        SuffixArray saDna = new SuffixArray(dna);
        saDna.printDebug();
    }
}

代码运行结果

编译并运行上述程序,输出如下:

===== 示例1:基础后缀数组与LCP =====
后缀数组详情:
i   SA[i]   LCP 后缀
0   5   0   a
1   3   1   ana
2   1   3   anana
3   0   0   banana
4   4   0   na
5   2   2   nana

最长重复子串: "ana"
不同子串个数: 15

===== 示例2:LCP查询 =====
LCP(后缀0='banana', 后缀1='anana') = 0
LCP(后缀1='anana', 后缀3='ana') = 3

===== 示例3:最长公共子串 =====
s1="ABABC", s2="BABCA"
最长公共子串: "BABC"

===== 示例4:大规模测试 =====
构造长度为 5000 的后缀数组耗时: 45.234 ms
最长重复子串长度: 12
不同子串个数: 1.248975e+07

===== 示例5:DNA序列分析 =====
后缀数组详情:
i   SA[i]   LCP 后缀
0   14  0   AATCGATCG
1   0   2   ATCGATCGAATCGATCG
2   9   8   ATCGATCG
3   4   4   ATCGAATCGATCG
...

可以看到,即使对于长度为5000的随机字符串,构造后缀数组也仅需数十毫秒。 longest repeated substring 能够准确找出文本中重复出现的模式,这在DNA序列分析和日志分析中非常实用。

算法复杂度分析

指标 倍增构造SA Kasai构造LCP ST表预处理 RMQ查询
时间复杂度 O(n log² n) O(n) O(n log n) O(1)
空间复杂度 O(n) O(n) O(n log n) O(1)
优化空间 基数排序优化至O(n log n) 已最优 可换线段树O(n)空间

如果追求极致效率,可以将倍增中的比较排序替换为基数排序,将总复杂度降至 O(n log n)。更进一步,线性时间的后缀数组构造算法(如 SA-IS 算法)可以将构造复杂度优化到 O(n),但实现复杂度显著增加。

扩展:更多应用场景

后缀数组 + LCP 的组合可以解决大量字符串问题:

重复模式检测:文本中重复次数最多的子串,可以通过LCP数组结合并查集或线段树来求解。

字符串压缩:Burrows-Wheeler变换(BWT)是bzip2压缩算法的核心,而后缀数组正是高效实现BWT的前置工具。

多字符串LCP:对于 k 个字符串的最长公共子串问题,可以将它们用不同分隔符连接后构造后缀数组,然后在SA中维护一个滑动窗口,确保窗口内包含所有字符串的后缀,窗口内LCP的最小值即为候选答案。

总结

本文从后缀数组的定义出发,完整讲解了倍增构造法的核心思想、Kasai算法线性求解LCP的技巧,以及ST表实现O(1) RMQ查询的方法。核心要点:

  • 倍增构造利用已排序的短子串信息推导长子串排序,避免了直接的字符串比较。
  • Kasai引理 height[rank[i]] ≥ height[rank[i-1]] - 1 保证了LCP数组的线性构造。
  • LCP + RMQ 的组合将任意两后缀的LCP查询优化到常数时间。

掌握这套工具后,后缀数组可以作为解决字符串问题的通用框架。在生物信息学中分析DNA重复序列、在搜索引擎中构建倒排索引、在数据压缩中实现BWT变换,后缀数组都是不可或缺的基础组件。

思考题

  1. 如果要支持包含大写字母、小写字母和数字的通用字符串,代码中的分隔符策略应如何调整?(提示:考虑使用字典序最小的未出现字符,或Unicode私有区字符)
  2. 如何利用后缀数组在 O(n log n) 时间内找出至少出现 k 次的最长子串?(提示:结合LCP数组与滑动窗口最小值)
  3. 若将倍增法中的 Arrays.sort 替换为计数排序/基数排序,时间复杂度如何从 O(n log² n) 优化到 O(n log n)?(提示:rank的取值范围是 [0, n-1])