首页 > 其他分享 >「学习笔记」后缀数组

「学习笔记」后缀数组

时间:2023-07-11 22:11:06浏览次数:42  
标签:ch 后缀 笔记 -- int 数组 sa 排序 rk

感谢 LB 学长的博文!

前置知识

后缀是指从某个位置 \(i\) 开始到整个串末尾结束的一个特殊子串,也就是 \(S[i \dots|S|-1]\)。

计数排序 - OI Wiki (oi-wiki.org)

基数排序 - OI Wiki (oi-wiki.org)

变量

后缀数组最主要的两个数组是 sark

sa 表示将所有后缀排序后第 \(i\) 小的后缀的编号,即编号数组。

rk 表示后缀 \(i\) 的排名,即排名数组。

这两个数组满足一个重要性质: sa[rk[i]] = rk[sa[i]] = i

示例:

这个图很好理解。

做法

暴力的 \(O_{n^2 \log n}\) 做法

将所有的后缀数组都 sort 一遍,sort 复杂度为 \(O_{n \log n}\),字符串比较复杂度为 \(O_{n}\),总的复杂度 \(O_{n^2 \log n}\)。

/*
  The code was written by yifan, and yifan is neutral!!!
 */

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

template<typename T>
inline T read() {
    T x = 0;
    bool fg = 0;
    char ch = getchar();
    while (ch < '0' || ch > '9') {
        fg |= (ch == '-');
        ch = getchar();
    }
    while (ch >= '0' && ch <= '9') {
        x = (x << 3) + (x << 1) + (ch ^ 48);
        ch = getchar();
    }
    return fg ? ~x + 1 : x;
}

const int N = 1e6 + 5;

int n;
char s[N];
string h[N];
pair<string, int> ans[N];

int main() {
    scanf("%s", s + 1);
    n = strlen(s + 1);
    for (int i = 1; i <= n; ++ i) {
        for (int j = i; j <= n; ++ j) {
            h[i] += s[j];
        }
        ans[i] = {h[i], i};
    }
    sort(ans + 1, ans + n + 1);
    for (int i = 1; i <= n; ++ i) {
        cout << ans[i].second << ' ';
    }
    return 0;
}

倍增优化的 \(O_{n \log^2 n}\) 做法

先对长度为 \(1\) 的所有子串,即每个字符排序,得到排序后的 sa1rk1 数组。

之后倍增:

  1. 用两个长度为 \(1\) 的子串的排名,即 rk1[i]rk1[i + 1],作为排序的第一关键词和第二关键词,这样就可以对每个长度为 \(2\) 的子串进行排序,得到 sa2rk2

  2. 之后用两个长度为 \(2\) 的子串的排名,即 rk2[i]rk2[i + 2],来作为排序的第一关键词和第二关键词。(为什么是 \(i + 2\) 呢,因为 rk2[i]rk2[i + 1] 重复了 \(S_{i + 1}\))这样就可以对每个长度为 \(4\) 的子串进行排序,得到 sa4rk4

  3. 重复上面的操作,用两个长度为 \(\dfrac{w}{2}\) 的子串的排名,即 rk[i]rk[i + (w / 2)],来作为排序的第一关键词和第二关键词,直到 \(w \ge n\),最终得到的 sa 数组就是我们的答案数组。

示意图:

倍增的复杂度为 \(O_{\log n}\),sort 复杂度为 \(O_{n \log n}\),总的复杂度 \(O_{n \log ^ 2 n}\)。

排序优化的 \(O_{n \log n}\) 的做法

发现后缀数组值域即为 \(n\),又是多关键字排序,考虑基数排序。
上面已经给出一个用于比较的式子:(A[i] < A[j] or (A[i] = A[j] and B[i] < B[j])),倍增过程中 A[i], B[i] 大小关系已知,先将 B[i] 作为第二关键字排序,再将 A[i] 作为第一关键字排序,两次计数排序实现即可。
单次计数排序复杂度 \(O_{n+w}\)(\(w\) 为值域,显然最大与 \(n\) 同阶),总时间复杂度变为 \(O_{n \log n}\)。

/*
  The code was written by yifan, and yifan is neutral!!!
 */

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

template<typename T>
inline T read() {
    T x = 0;
    bool fg = 0;
    char ch = getchar();
    while (ch < '0' || ch > '9') {
        fg |= (ch == '-');
        ch = getchar();
    }
    while (ch >= '0' && ch <= '9') {
        x = (x << 3) + (x << 1) + (ch ^ 48);
        ch = getchar();
    }
    return fg ? ~x + 1 : x;
}

const int N = 1e6 + 5;

int n, m;
int sa[N], oldsa[N], rk[N << 1], oldrk[N << 1], cnt[N];
// rk 第 i 个后缀的排名,sa 第 i 小的后缀的编号
char s[N];

int main() {
    scanf("%s", s + 1);
    n = strlen(s + 1);
    m = 127;

    /*--------------------------------*/

    // 计数排序

    for (int i = 1; i <= n; ++ i) {
        ++ cnt[rk[i] = s[i]];
    }
    for (int i = 1; i <= m; ++ i) {
        cnt[i] += cnt[i - 1];
    }
    for (int i = n; i >= 1; -- i) {
        sa[cnt[rk[i]] --] = i;
    }
    memcpy(oldrk + 1, rk + 1, n * sizeof(int));

    /*--------------------------------*/

    // 判重

    for (int cur = 0, i = 1; i <= n; ++ i) {
        if (oldrk[sa[i]] == oldrk[sa[i - 1]]) {
            rk[sa[i]] = cur;
        }
        else {
            rk[sa[i]] = ++ cur;
        }
    }

    /*--------------------------------*/

    for (int w = 1; w < n; w <<= 1, m = n) {

        // 先按照第二关键词计数排序

        memset(cnt, 0, sizeof cnt);
        memcpy(oldsa + 1, sa + 1, n * sizeof(int));
        for (int i = 1; i <= n; ++ i) {
            ++ cnt[rk[oldsa[i] + w]];
        }
        for (int i = 1; i <= m; ++ i) {
            cnt[i] += cnt[i - 1];
        }
        for (int i = n; i >= 1; -- i) {
            sa[cnt[rk[oldsa[i] + w]] --] = oldsa[i];
        }

        /*--------------------------------*/

        // 再按照第一关键词计数排序

        memset(cnt, 0, sizeof cnt);
        memcpy(oldsa + 1, sa + 1, n * sizeof(int));
        for (int i = 1; i <= n; ++ i) {
            ++ cnt[rk[oldsa[i]]];
        }
        for (int i = 1; i <= m; ++ i) {
            cnt[i] += cnt[i - 1];
        }
        for (int i = n; i >= 1; -- i) {
            sa[cnt[rk[oldsa[i]]] --] = oldsa[i];
        }

        /*--------------------------------*/

        // 更新数组

        memcpy(oldrk + 1, rk + 1, n * sizeof(int));
        for (int cur = 0, i = 1; i <= n; ++ i) {
            if (oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]) {
                rk[sa[i]] = cur;
            }
            else {
                rk[sa[i]] = ++ cur;
            }
        }
    }
    for (int i = 1; i <= n; ++ i) {
        printf("%d ", sa[i]);
    }
    return 0;
}

各种常数优化

  1. 考虑我们按照第二关键词排序的实质,就是将超出 \(n\) 范围的空字符串放在 sa 的最前面,在本次排序中,\(S[sa_i \dots sa_i+2^k−1]\) 是长度为 \(2^k\) 的子串 \(S[sai−2^k−1 \dots sai+2^k−1]\) 的后半截,sa[i] 的排名将作为排序的关键字。
    \(S[sa_i,sa_i+2^k−1]\) 的排名为 \(i\),则第一次计排后 \(S[sa_i−2^k−1 \dots sa_i+2^k−1]\) 的排名必为 \(i\),考虑直接赋值。
for (p = 0, i = n; i > n - w; -- i) {
    oldsa[++ p] = i;
}
for (int i = 1; i <= n; ++ i) {
    if (sa[i] > w) { // 保证 sa[i] 是后半截的编号
        oldsa[++ p] = sa[i] - w; // sa[i] 一定是后半截的编号,而我们要存的是前半截的开始编号
    }
}
  1. 减小值域,每次对 rk 进行更新之后,我们都计算了一个 \(p\),这个 \(p\) 即是 rk 的值域,将值域改成它即可。

  2. rk[id[i]] 存下来,减少不连续内存访问。

  3. 用函数 cmp 来计算是否重复。

  4. 若排名都不相同可直接生成后缀数组。

/*
  The code was written by yifan, and yifan is neutral!!!
 */

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

template<typename T>
inline T read() {
    T x = 0;
    bool fg = 0;
    char ch = getchar();
    while (ch < '0' || ch > '9') {
        fg |= (ch == '-');
        ch = getchar();
    }
    while (ch >= '0' && ch <= '9') {
        x = (x << 3) + (x << 1) + (ch ^ 48);
        ch = getchar();
    }
    return fg ? ~x + 1 : x;
}

const int N = 1e6 + 5;

int n, m;
int sa[N], oldsa[N], rk[N << 1], oldrk[N << 1], cnt[N], key[N];
// rk 第 i 个后缀的排名,sa 第 i 小的后缀的编号
char s[N];

inline bool cmp(int x, int y, int w) {
    return oldrk[x] == oldrk[y] && oldrk[x + w] == oldrk[y + w];
}

int main() {
    int i, p = 0;
    scanf("%s", s + 1);
    n = strlen(s + 1);
    m = 127;
    for (int i = 1; i <= n; ++ i) {
        ++ cnt[rk[i] = s[i]];
    }
    for (int i = 1; i <= m; ++ i) {
        cnt[i] += cnt[i - 1];
    }
    for (int i = n; i >= 1; -- i) {
        sa[cnt[rk[i]] --] = i;
    }
    for (int w = 1; ; w <<= 1, m = p) {
        for (p = 0, i = n; i > n - w; -- i) {
            oldsa[++ p] = i;
        }
        for (int i = 1; i <= n; ++ i) {
            if (sa[i] > w) { // 保证 sa[i] 是后半截的编号
                oldsa[++ p] = sa[i] - w; // sa[i] 一定是后半截的编号,而我们要存的是前半截的开始编号
            }
        }
        memset(cnt, 0, sizeof cnt);
        for (i = 1; i <= n; ++ i) {
            ++ cnt[key[i] = rk[oldsa[i]]];
        }
        for (i = 1; i <= m; ++ i) {
            cnt[i] += cnt[i - 1];
        }
        for (i = n; i >= 1; -- i) {
            sa[cnt[key[i]] --] = oldsa[i];
        }
        memcpy(oldrk + 1, rk + 1, n * sizeof(int));
        for (p = 0, i = 1; i <= n; ++ i) {
            rk[sa[i]] = cmp(sa[i], sa[i - 1], w) ? p : ++ p;
        }
        if (p == n) {
            break ;
        }
    }
    for (int i = 1; i <= n; ++ i) {
        printf("%d ", sa[i]);
    }
    return 0;
}

参考资料

后缀数组简介 - OI Wiki (oi-wiki.org)

「笔记」后缀数组 - Luckyblock - 博客园 (cnblogs.com)

标签:ch,后缀,笔记,--,int,数组,sa,排序,rk
From: https://www.cnblogs.com/yifan0305/p/17545898.html

相关文章

  • 「July」做题笔记 #2
    CF1783EGameoftheYear我们先排除\(a_i\leqslantb_i\)的点。即\(\foralli,\lfloor\frac{a_i}{i}\rfloor\leqslant\lfloor\frac{b_i}{i}\rfloor\)。考虑一个\(k\)把整个序列分成了\(\fracnk\)块,这样所有点都只需要在一个块。容易想到使用扫描线解决这......
  • DDP学习笔记
    概念DDP,可以理解为转移会发生改变的动态规划。当然这个改变是题目中给的,包括系数,转移位置的改变。显然暴力枚举这些改变是不现实的,我们要把改变体现到其他地方。最经典的,体现到矩阵上。我们把转移写成矩阵,那么改变转移就是改变转移矩阵。具体的改变会落实到具体的题目上。广......
  • Markdown练习笔记
    一级标题二级标题三级标题四级标题五级标题六级标题斜体粗体粗斜体换行引用嵌套cker-博客园(cnblogs.com)https://www.cnblogs.com/ckeri/无序列表有序列表删除下划线code#include<bits/stdc++.h>usingnamespacestd;intmain(){cout<......
  • ASP.NET CORE 框架揭秘读书笔记系列——命令行程序的创建(一)
    一、dotnet--info查看本机开发环境dotnet--info 会显示本机安装的SDK版本、运行时环境、运行时版本二、利用命令行创建.NET项目我们不仅可以利用脚手架模版创建各种类型的应用项目,还可以为项目添加各种组件和配置。换句话说IDE能完成的各项工作全部都可以通过脚手架命令行......
  • [学习笔记] 割点 & 割边 & 双连通分量
    一、定义在无向连通图\(G=(V,E)\)中,若存在一个点\(u(u\inV)\)使得删掉点\(u\)及其相连的边,会使原图不连通,就称\(u\)是原图的一个割点(cutvertex);若存在一条边\((u,v)((u,v)\inE)\)满足删掉\((u,v)\)后会使原图不连通,就称\((u,v)\)是原图的一座桥(或......
  • CSAPP DataLab学习笔记
    1.bitXor/**bitXor-x^yusingonly~and&*Example:bitXor(4,5)=1*Legalops:~&*Maxops:14*Rating:1*/intbitXor(intx,inty){return2;}思路将异或的真值表写出来,再用&|~表示,最后化简代码intbitXor(intx,inty)......
  • es笔记四之中文分词插件安装与使用
    本文首发于公众号:Hunter后端原文链接:es笔记四之中文分词插件安装与使用前面我们介绍的操作及演示都是基于英语单词的分词,但我们大部分使用的肯定都是中文,所以如果需要使用分词的操作肯定也是需要使用中分分词。这里我们介绍一下如何安装中文分词插件。在介绍安装之前,我们可......
  • 我们与高效工作流的距离:使用AI阅读工具ChatDOC+笔记软件Obsidian Slide,直接从 PDF 文
    我们与高效工作流的距离在当今信息化的时代,为了实现高效工作和学习,如何实现快速地输入和输出成为每个人的必修课题。然而,对于输入而言,每一天大量的信息,往往会使我们陷入信息过载和知识爆炸的困境,难以高效处理。与此同时,输出方面的问题也同样令人头痛。对于多数人而言,PPT是主流......
  • 【做题笔记】线性dp——线段树优化
    线段树优化是用来对于\(DP\)数组区间赋值的。主要是区间取最值来优化线性dp真没什么可写的了挂两个题目:P4644[USACO05DEC]CleaningShiftsSP1545[USACO04DEC]DividingthePathGUSACO的小清新线段树优化dp好题......
  • 我们与高效工作流的距离:使用AI阅读工具ChatDOC+笔记软件Obsidian Slide,直接从 PDF 文
    我们与高效工作流的距离在当今信息化的时代,为了实现高效工作和学习,如何实现快速地输入和输出成为每个人的必修课题。然而,对于输入而言,每一天大量的信息,往往会使我们陷入信息过载和知识爆炸的困境,难以高效处理。与此同时,输出方面的问题也同样令人头痛。对于多数人而言,PPT是主流的输......