第五章 字符串专题 ---------------- 字符串匹配(三)----后缀数组算法

后缀数组、倍增法与高度数组介绍
博客介绍了字符串后缀数组,它是字符串所有后缀按字典序排序后记录起始下标的数组,还可通过二分查找匹配。为降低求后缀数组的时间复杂度,引入倍增法。此外,还讲解了后缀数组伴生的高度数组,即相邻后缀的最大公共前缀,并提及高度数组的重要规律。

一、什么是后缀数组:

  字符串后缀Suffix 指的是从字符串的某个位置开始到其末尾的字符串子串。后缀数组 Suffix Array(sa) 指的是将某个字符串的所有后缀按字典序排序之后得到的数组,不过数组中不直接保存所有的后缀子串,只要记录后缀的起始下标就好了。

  比如下面在下面这张图中,sa[8] = 7,表示在字典序中排第9的是起始下标为7的后缀子串,这里还有一个比较重要的数组rank,rank[i] : sa[i]在所有后缀中的排名 ,比如rk[5]=0,表示后缀下标为5的子串在后缀数组中排第0个; rank数组与sa数组互为逆运算,rk[sa[i]]=i。

  现在假如我们已经求出来了后缀数组,然后直接对已经排好序的后缀数组进行二分查找,这样就能匹配成功了,下面贴出代码:

import java.util.Arrays;

public class SuffixArrayTest {
    public static void main(String[] args) {
        match();  // 得到结果是5
    }
    
    static void match(){
        String s = "ABABABABB";
        String p = "BABB";
        Suff[] sa = getSa(s); // 后缀数组
        int l = 0;
        int r = s.length()-1;
        // 二分查找 ,nlog(m)
        while(r>=l){
            int mid = l + ((r-l)>>1);
            // 居中的后缀
            Suff midSuff = sa[mid];
            String suffStr = midSuff.str;
            int compareRes;
            // 将后缀和模式串比较,O(n);
            if (suffStr.length()>=p.length()) {
                compareRes = suffStr.substring(0, p.length()).compareTo(p);
            }else {
                compareRes = suffStr.compareTo(p);
            }
            // 相等了 输出后缀的起始位置
            if(compareRes == 0){
                System.out.println(midSuff.index);
                break;
            }else if (compareRes<0) {
                l = mid + 1;
            }else {
                r = mid - 1;
            }
        }
    }
    /**
     * 直接对所有后缀排序,因为字符串的比较消耗O(N),所以整体为N²log(N)
     * @param src
     * @return
     */
    public static Suff[] getSa(String src){
        int strLength = src.length();
        // sa 即SuffixArray,后缀数组  
        // sa 是排名到下标的映射,即sa[i]=k说明排名为i的后缀是从k开始的
        Suff[] suffixArray = new Suff[strLength];
        for (int i = 0; i < strLength; i++) {
            String suffI = src.substring(i);   //截取后缀
            suffixArray[i] = new Suff(suffI, i);
        }
        Arrays.sort(suffixArray);   //依据Suff的比较规则进行排序
        return suffixArray;
    }
    
    static class Suff implements Comparable<Suff>{

        String str;  //后缀内容
        int index;   //后缀的起始下标
        
        public Suff(String str, int index) {
            super();
            this.str = str;
            this.index = index;
        }

        @Override
        public int compareTo(Suff o2) {
            return this.str.compareTo(o2.str);
        }
        @Override
        public String toString() {
            return "Suff{"+"str='"+str+"\'"+",index="+index+"}";
        }
        
    }
}

 二、倍增法:

       上面求后缀数组的方式时间复杂度为n²log(n),一般来说,时间复杂度只要达到了n平方级别都要想办法降低,于是就有一种叫做倍增法的方法来求后缀数组,基本思想就是:

   1、先将每个字符排序 得到sa,rank数组,

   2、然后给每个字符增添一个字符,这样就变成了两个字符,最后一个字符无法增添字符,就需要处理好边界问题。然后就是排序,排序规则的话就需要自定义规则

   3、然后再在两个字符的基础上添加两个字符,就变成四个字符,然后再在上一次排序的规则上进一步排序。然后八个字符......

  最主要的降低时间复杂度的方式就是根据每一步更新后的rank数组来进行下一步的排序,这样前面已经排好序的就不用比较了。嗯。。。具体的倍增法的思想的话只有自己在具体应用代码的时候慢慢琢磨,通过不断地调试慢慢理解。这里的代码直接都是封装好了方法直接调用即可。下面贴出代码:

import java.util.Arrays;

public class SuffixArray {
    public static void main(String[] args) {
        match();  // 得到结果是5
    }
    
    static void match(){
        String s = "ABABABABB";
        String p = "BABB";
//        SuffixArray.Suff[] sa = SuffixArray.getSa(s); // 后缀数组
        Suff[] sa = getSa2(s); // 后缀数组
        int l = 0;
        int r = s.length()-1;
        // 二分查找 ,nlog(m)
        while(r>=l){
            int mid = l + ((r-l)>>1);
            // 居中的后缀
            Suff midSuff = sa[mid];
//            String suffStr = midSuff.str;
            String suffStr = s.substring(midSuff.index);
            int compareRes;
            // 将后缀和模式串比较,O(n);
            if (suffStr.length()>=p.length()) {
                compareRes = suffStr.substring(0, p.length()).compareTo(p);
            }else {
                compareRes = suffStr.compareTo(p);
            }
            // 相等了 输出后缀的起始位置
            if(compareRes == 0){
                System.out.println(midSuff.index);
                break;
            }else if (compareRes<0) {
                l = mid + 1;
            }else {
                r = mid - 1;
            }
        }
    }
    
    
    /**
     * nlg²n 构建后缀数组
     * 
     * @param src
     * @return
     */
    public static Suff[] getSa2(String src) {
        int n = src.length();
        Suff[] sa = new Suff[n];
        for (int i = 0; i < n; i++) {
            sa[i] = new Suff(src.charAt(i), i, src);// 存单个字符,接下来排序
        }
        Arrays.sort(sa);

        /** rk是下标到排名的映射 */
        int[] rk = new int[n];// suffix array
        rk[sa[0].index] = 1;
        for (int i = 1; i < n; i++) {
            rk[sa[i].index] = rk[sa[i - 1].index];
            if (sa[i].c != sa[i - 1].c)
                rk[sa[i].index]++;
        }
        // 倍增法
        for (int k = 2; rk[sa[n - 1].index] < n; k *= 2) {

            final int kk = k;
            Arrays.sort(sa, (o1, o2) -> {
                // 不是基于字符串比较,而是利用之前的rank
                int i = o1.index;
                int j = o2.index;
                if (rk[i] == rk[j]) {// 如果第一关键字相同
                    if (i + kk / 2 >= n || j + kk / 2 >= n)
                        return -(i - j);// 如果某个后缀不具有第二关键字,那肯定较小,索引靠后的更小
                    return rk[i + kk / 2] - rk[j + kk / 2];

                } else {
                    return rk[i] - rk[j];
                }
            });
            /*---排序 end---*/
            // 更新rank
            rk[sa[0].index] = 1;
            for (int i = 1; i < n; i++) {
                int i1 = sa[i].index;
                int i2 = sa[i - 1].index;
                rk[i1] = rk[i2];
                try {
                    if (!src.substring(i1, i1 + kk).equals(src.substring(i2, i2 + kk)))
                        rk[i1]++;
                } catch (Exception e) {
                    rk[i1]++;
                }
            }
        }

        return sa;
    }
    
    public static class Suff implements Comparable<Suff> {
        public char c;// 后缀内容
        private String src;
        public int index;// 后缀的起始下标

        public Suff(char c, int index, String src) {
            this.c = c;
            this.index = index;
            this.src = src;
        }

        @Override
        public int compareTo(Suff o2) {
            return this.c - o2.c;
        }

        @Override
        public String toString() {
            return "Suff{" + "char='" + src.substring(index) + '\'' + ", index=" + index + '}';
        }
    }
}

三、高度数组

  高度数组是后缀数组伴生的一个东西。假设有字符串"ABABABB",那它的所有后缀为,以及后缀数组为:

 

       高度数组为所有后缀排好序之后的相邻两个后缀之间的最大公共前缀(LCP),比如height[1],看下标为1的后缀ABABB与上一个下标0的后缀ABABABB,最大公共前缀为ABAB,四个,那么height[1] = 4其余的也是一样,那么可以得到高度数组为height[] = {0,4,2,0,1,3,1}

  高度数组有一个重要规律就是:上一个下标i假如有k个公共前缀,并且k>0,那么下一个下标至少有一个k-1个公共前缀,那么前k个字符是不用比较的。

static int[] getHeight(String src,Suff[] sa){
        // Suff[] sa = getSa2(src);
        int strLength = src.length();
        int []rk = new int[strLength];
        // 因为原来的sa数组是按照字符串相同排名相同,现在调整排名为不重复的排名,重新排名后得到数组rk。
        // 将rank表示为不重复的排名即0~n-1
        for (int i = 0; i < strLength; i++) {
            rk[sa[i].index] = i;
        }
        int []height = new int[strLength];
        // (存在的规律是上一个下标i假如有k个公共前缀,并且k>0,
        //  那么下一个下标至少有一个k-1个公共前缀,那么前k个字符是不用比较的)
        // 利用这一点就可以O(n)求出高度数组
        int k = 0;
        for(int i=0;i<strLength;i++){
            int rk_i = rk[i];  // i后缀的排名
            if (rk_i==0) {
                height[0] = 0;
                continue;
            }
            int rk_i_1 = rk_i - 1;
            int j = sa[rk_i_1].index;// j是i串字典序靠前的串的下标
            if (k > 0)
                k--;

            for (; j + k < strLength && i + k < strLength; k++) {
                if (src.charAt(j + k) != src.charAt(i + k))
                    break;
            }
            height[rk_i] = k;
            
        }
        return height;
    }

 

### CSP-S 复赛备考技巧与策略 CSP-S(Certified Software Professional - Senior)复赛是信息学奥林匹克竞赛中的重要环节,其难度相较于初赛有显著提升。复赛不仅要求参赛者掌握扎实的编程基础,还需要具备较强的算法思维、数据结构应用能力以及对复杂题型的解析能力。以下从多个维度详细分析备考技巧和策略。 #### 一、算法优化技巧 在 CSP-S 复赛中,算法的优化能力是决定得分高低的关键因素之一。常见的优化手段包括时间复杂度的降低、空间复杂度的压缩以及常数优化。 - **时间复杂度优化**:例如,将暴力枚举优化为二分查找、前缀和或差分数组等方法,能显著提升程序效率。对于图论问题,使用堆优化的 Dijkstra 算法可以将复杂度从 $O(N^2)$ 降低到 $O(M \log N)$,适用于大规模数据[^1]。 - **空间复杂度优化**:在动态规划中,如果状态转移仅依赖前一阶段的状态,可以使用滚动数组来节省空间。 - **常数优化**:在 C++ 中,使用 `register` 变量、快速输入输出(如 `getchar` 和 `fwrite`)等手段可以提升程序运行速度。 ```cpp // 快速输入模板 inline int read() { int x = 0, f = 1; char ch = getchar(); while (ch < '0' || ch > '9') { if (ch == '-') f = -1; ch = getchar(); } while (ch >= '0' && ch <= '9') { x = x * 10 + ch - '0'; ch = getchar(); } return x * f; } ``` #### 二、常用数据结构与应用场景 CSP-S 复赛中涉及的数据结构较为复杂,熟练掌握以下结构及其应用场景至关重要: - **线段树(Segment Tree)**:用于处理区间查询与更新问题,如区间和、区间最大值、懒惰标记优化等。 - **树状数组(Fenwick Tree)**:适用于前缀和查询和单点更新操作,代码简洁且常数较小。 - **并查集(Union-Find)**:用于处理集合合并与查询问题,常用于图的连通性判断。 - **堆(Heap)**:用于维护动态集合中的最大值或最小值,常用于贪心算法和优先队列问题。 - **平衡二叉搜索树(如 Treap、Splay Tree)**:在需要高效插入、删除和查找的场景中使用。 #### 、常见题型解析与策略 CSP-S 复赛题目类型多样,主要包括以下几类: 1. **动态规划(DP)**:需要识别状态定义和转移方程,注意优化状态转移过程。例如,斜率优化、单调队列优化等技巧可大幅提升效率。 2. **图论问题**:包括最短路径、最小生成树、网络流、强连通分量等。掌握常用算法如 Dijkstra、Floyd、Kruskal、Tarjan 等是关键。 3. **字符串处理**:涉及 KMP、AC 自动机、后缀数组算法,适用于模式匹配和文本处理问题。 4. **数学问题**:包括数论、组合数学、矩阵快速幂等,需具备较强的数学建模能力。 5. **计算几何**:虽然出现频率不高,但一旦出现,需掌握基础几何知识如向量运算、点线关系、凸包等。 #### 四、实战训练与真题演练 - **历年真题分析**:通过研究历年 CSP-S 复赛真题,了解命题风格和常见考点。例如,2021 年的“括号序列”问题考查了动态规划与贪心思想的结合[^2]。 - **模拟比赛训练**:定期参加模拟赛,模拟真实比赛环境,提升时间管理与心理素质。 - **代码调试能力**:复赛中调试能力尤为重要,需熟练使用调试工具并具备快速定位错误的能力。 #### 五、时间管理与复习计划 - **阶段划分**: - 第一阶段(基础巩固):系统复习算法与数据结构,完成基础题训练。 - 第二阶段(专题突破):针对动态规划、图论、字符串专题进行深入训练。 -阶段(真题冲刺):集中刷历年真题,总结规律与技巧。 - **每日计划**:建议每日安排 4-6 小时的训练时间,包括 2 小时理论学习、2 小时编程训练、1 小时真题分析。 #### 六、心理素质与临场发挥 - **保持冷静**:面对难题时,避免慌乱,尝试从简单情况入手,逐步推导。 - **合理分配时间**:每道题应设定时间上限,避免因某一题耗费过多时间而影响整体得分。 - **代码规范**:良好的代码风格有助于减少错误,提升可读性与调试效率。 ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值