算法学习笔记(27): 后缀排序

算法,学习,笔记,后缀,排序 · 浏览次数 : 11

小编点评

```C++``` void getHt(int n) { for (int i = 1, k = 0; i <= n; ++i) { if (k > 0) --k; while (s[i + k] == s[sa[rk[i] - 1] + k]) ++k; H[rk[i]] = k; }求一个字符串的本质不同子串个数就可以利用 \\(H\\)。其式子为:\\[\cfrac {n(n+1)}2 - \\sum_{i = 1}^n H[i]\\] } void solve() { int m = p; // 改变值域,最终为n return x; } ``` **Explanation:** * The `getHt` function calculates the longest common prefix for a given string. * It uses a suffix array to find the longest common prefix. * The `solve` function uses the `getHt` function to find the longest common prefix for all strings. * It then prints the longest common prefix for all strings.

正文

后缀排序

本文不作为教学向文章。

开篇膜拜 Pecco:算法学习笔记(84): 后缀数组 - 知乎 (zhihu.com)

有些时候,其实 \(O(n \log^2 n)\) 的排序也挺好。又短又简单。

其中 \(rk[i]\) 表示从第 \(i\) 个字符开始的后缀的排名,\(sa[i]\) 表示排名为 \(i\) 的后缀开始的位置。

#include <iostream>
#include <cstring>
#include <algorithm>
#include <vector>

const int N = 1e6 + 7;

char s[N];
int n;
int SA[N << 1], temp[2][N << 1];

int main() {
    scanf("%s", s);
    n = strlen(s); 

    for (int i(0); i < n; ++i) SA[i] = i, temp[0][i] = s[i];

    for (int w(1), k(0); w < n; w <<= 1, k ^= 1) {
        int *rank = temp[k], *backup = temp[k ^ 1];

        std::sort(SA, SA + n, [&](const int &x, const int &y) {
            return rank[x] ^ rank[y] ? rank[x] < rank[y] : rank[x + w] < rank[y + w];
        });

        for (int p(1), i(0); i < n; ++i) {
            if (rank[SA[i]] == rank[SA[i + 1]] && rank[SA[i] + w] == rank[SA[i + 1] + w])
                backup[SA[i]] = p;
            else backup[SA[i]] = p++;
        }
    }

    for (int i(0); i < n; ++i) printf("%d ", SA[i] + 1);
    return 0;
}

那么接下来考虑利用基数排序优化一个 \(\log n\)

其本质是按照上一次的 \(rk[i]\) 为第一关键字, \(rk[i + w]\) 为第二关键字排序。

而排序之后,其 \(rk\) 本身就是有序的,所以基数排序按照第二关键字排序的部分可以简化。

直接把后 \(w\) 个提到后面就是了。

然后考虑对于第一关键字桶排序,嗯,看代码。

#include <iostream>
#include <algorithm>
#include <cstring>

using namespace std;
const int N = 4e6 + 3;

char s[N];
int sa[N], tmp[2][N];
int cnt[N]; // radix_sort的计数桶

int * getSA(int n) {
    int m = 128; // 值域
    int *x = tmp[0], *y = tmp[1];

    // 第一次排序
    for (int i = 1; i <= n; ++i)
        ++cnt[x[i] = s[i]];
    // 计数前缀和
    for (int i = 1; i <= m; ++i)
        cnt[i] += cnt[i - 1];
    for (int i = n; i; --i)
        sa[cnt[x[i]]--] = i;

    // 开始之后的排序,对于 (rank[x], rank[x + k]) 进行排序。
    for (int p, k = 1; k < n; k <<= 1) {
        p = 0;
        // 由于对于 [n - k + 1, n],其 rank[x + k] 一定为0,故会被放在最前面
        for (int i = n - k + 1; i <= n; ++i) y[++p] = i;
        // 很明显,rk已经是有序的,前k名一定是已经被放入的(rank[x+k] = 0)
        // 所以,我们只需要将后面的 n-k 个按顺序加入即可
        // (对于此次的第二关键词排序即使对上一次的第一关键词排序,已经是有序的,所以直接加进去)
        for (int i = 1; i <= n; ++i) {
            if (sa[i] > k) y[++p] = sa[i] - k;
        }

        // 现在开始对于第一关键字排序
        // 清空计数桶,实际上可能不需要?
        for (int i = 0; i <= m; ++i) cnt[i] = 0;
        // x[i] 实际上就是上一次的rank,也就是第一关键字
        // 实际上这里可以写作 ++cnt[x[y[i]]]; 效果是一样的
        for (int i = 1; i <= n; ++i) ++cnt[x[i]];
        for (int i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
        // i外套了一层y[i], 实际上就是按照第二关键词排好的顺序放回原数组中。
        for (int i = n; i; --i)
            sa[cnt[x[y[i]]]--] = y[i], y[i] = 0;

        // 为了避免memset,类似于滚动数组的思想
        swap(x, y);
        // 此时y也就是之前的rank,那么我们现在要取得此时的rank

        p = 0;
        for (int i = 1; i <= n; ++i) {
            x[sa[i]] = (y[sa[i]] == y[sa[i - 1]] && y[sa[i] + k] == y[sa[i - 1] + k]) ? p : ++p;
        }

        if (p >= n) break; //  完成排序,每一个都不一样了。
        m = p; // 改变值域,最终为n
    }

    return x; // 最终的rank
}

int main() {
    cin.tie(0)->sync_with_stdio(false);
    std::cin >> (s + 1);
    int n = strlen(s + 1);
    int * rk = getSA(n);
    for (int i = 1; i <= n; ++i) {
        std::cout << sa[i] << ' ';
    } std::cout << '\n';
    return 0;
    for (int i = 1; i <= n; ++i) {
        std::cout << rk[i] << ' ';
    } std::cout << '\n';
    return 0;
}

LCP

观察排序后的字符串:

来自 Pecco 大佬。

可以看到,相邻的后缀之间可能有一些共同前缀。

那么我们可以利用后缀数组找出 Longest Common Prefix,也就是所谓的 LCP。

假设 \(H[i] = lcp(sa[i], sa[i - 1])\)

以及 \(h[i] = H[rk[i]]\)

有定理 \(h[i] \ge h[i - 1] - 1\)

其文本理解为:对于后缀 \(i\),与字典序在其前面一个的后缀的的最长公共前缀的长度,大于上一个后缀与其字典序前一个后缀的LCP减一。

还是很抽象……证明来自 OI wiki

于是我们可以 \(O(n)\) 的求出 \(H\) 数组了。

void getHt(int n) {
    for (int i = 1, k = 0; i <= n; ++i) {
        if (k > 0) --k;
        while (s[i + k] == s[sa[rk[i] - 1] + k])
            ++k;
        H[rk[i]] = k;
    }
}

求一个字符串的本质不同子串个数就可以利用 \(H\)

其式子为:

\[\cfrac {n(n+1)}2 - \sum_{i = 1}^n H[i] \]

测试:不同子串个数 - 洛谷


求两个串 \(i, j\)最长公共前缀

利用 \(H\) 转化为 RMQ 问题

\(\to lcp(i, j) = \min_{k = rk[i] + 1}^{rk[j]} H[i]\)

感性理解为字典序上相差越大,相同越小。如此而已。

其实感觉这个没啥用,完全可以被哈希二分水过,而且时空常数还小很多……

题目:最长公共前缀 - 洛谷


哈希第 \(k\) 大。

见:[TJOI2015] 弦论 - 洛谷

现在我还只会不同位置的相同子串算作一个的情况。

按照 \(sa[i]\) 中的东西向后,每一个子串对字典序的贡献为 \(n - sa[i] + 1 - H[i]\)

所以依次遍历就是了。

for (int i = 1; i <= n; ++i) {
    int difc = n - sa[i] + 1 - H[i];
    if (k > difc) {
        k -= difc; continue;
    }

    for (int j = sa[i]; j <= sa[i] + k + H[i] - 1; ++j)
        putchar(A[j - 1]);
    k = 0;
}
if (k > 0) puts("-1");

与算法学习笔记(27): 后缀排序相似的内容:

算法学习笔记(27): 后缀排序

后缀排序 本文不作为教学向文章。 开篇膜拜 Pecco:算法学习笔记(84): 后缀数组 - 知乎 (zhihu.com) 有些时候,其实 \(O(n \log^2 n)\) 的排序也挺好。又短又简单。 其中 \(rk[i]\) 表示从第 \(i\) 个字符开始的后缀的排名,\(sa[i]\) 表示

算法学习笔记(26): 计算几何

# 计算几何 ## 向量 > 高一知识,略讲。 #### 向量外积 若 $\vec x = (x_1, y_1), \vec y = (x_2, y_2)$,则有 $\vec x \times \vec y = x_1 y_2 - y_1 x_2$。 或者表示为 $|\vec x||\vec y|

算法学习笔记(6): 树链剖分

树链剖分 树链剖分是一个很神奇,但是在树上可以完成一些区间操作问题 简单来说,就是把一棵树分成一条条的链,通过维护链上的信息来维护整棵树的信息 基础知识可以参考我的另外一篇博客:算法学习笔记(5): 最近公共祖先(LCA) 这里假设你已经掌握了上述博客中的所有相关知识,并清晰了其背后的原理 性质?发

算法学习笔记(11): 原根

原根 此文相对困难,请读者酌情食用 在定义原根之前,我们先定义其他的一点东西 阶 通俗一点来说,对于 $a$ 在模 $p$ 意义下的阶就是 $a^x \equiv 1 \pmod p$ 的最小正整数解 $x$ 或者说,$a$ 在模 $p$ 意义下生成子群的阶(群的大小) 再或者说,是 $a$ 在模

算法学习笔记(30):Kruskal 重构树

Kruskal 重构树 这是一种用于处理与最大/最小边权相关的一个数据结构。 其与 kruskal 做最小生成树的过程是类似的,我们考虑其过程: 按边权排序,利用并查集维护连通性,进行合并。 如果我们在合并时,新建一个节点,其权值为当前处理的边的权值,并将合并的两个节点都连向新建的节点,那么就可以得

算法学习笔记(3.1): ST算法

ST表 在RMQ(区间最值)问题中,著名的ST算法就是倍增的产物。ST算法可以在 \(O(n \log n)\) 的时间复杂度能预处理后,以 \(O(1)\) 的复杂度在线回答区间 [l, r] 内的最值。 当然,ST表不支持动态修改,如果需要动态修改,线段树是一种良好的解决方案,是 \(O(n)\

C++算法之旅、09 力扣篇 | 常见面试笔试题(上)算法小白专用

算法学习笔记,记录容易忘记的知识点和难题。详解时空复杂度、50道常见面试笔试题,包括数组、单链表、栈、队列、字符串、哈希表、二叉树、递归、迭代、分治类型题目,均带思路与C++题解

C++算法之旅、08 基础篇 | 质数、约数

算法学习笔记,记录容易忘记的知识点和难题。试除法、分解质因数、筛质数、约数个数、约数之和、最大公约数

算法学习笔记(1): 欧几里得算法及其扩展

扩展欧几里得算法详解 在了解扩欧之前我们应该先了解欧几里得算法 欧几里得算法 这是一个递归求最大公约数(greatest common divisor)的方法 $$ gcd(a, b) = gcd(b, a % b) $$ 可以通过一个类似的简单公式推导而来 好像叫做辗转相减法来着? $$ gcd(

算法学习笔记(3): 倍增与ST算法

倍增 目录倍增查找 洛谷P2249重点变式练习快速幂ST表扩展 - 运算扩展 - 区间变式答案倍增更多的用法优化矩形查询优化建图优化 DP作者有话说 倍增,字面意思即”成倍增长“ 他与二分十分类似,都是基于”2“的划分思想 那么具体是怎么样,我们以一个例子来看 查找 洛谷P2249 依据题面,我们知