后缀自动机 (SAM)
一些记号
:字符集。字符集大小 。 :字符串。字符串长 ,标号自 开始。 :初始状态。 :字符串 中子串 的结束位置的集合。 :状态 的后缀链接。 :状态 对应的最长子串的长度。 :状态 对应的最长子串。 :状态 对应的最短子串的长度。 :状态 对应的最短子串。
后缀自动机概述
后缀自动机(suffix automaton, SAM)是一个能解决许多字符串相关问题的有力的数据结构。
举个例子,以下的字符串问题都可以在线性时间内通过 SAM 解决:
- 在另一个字符串中搜索一个字符串的所有出现位置;
- 计算给定的字符串中有多少个不同的子串。
直观上,字符串的 SAM 可以理解为给定字符串的 所有子串 的压缩形式。值得注意的事实是,SAM 将所有的这些信息以高度压缩的形式储存。对于一个长度为
定义
字符串
换句话说:
- SAM 是一张有向无环图。结点被称作 状态,边被称作状态间的 转移。
- 图存在一个源点
,称作 初始状态,其它各结点均可从 出发到达。 - 每个 转移 都标有某个字符。从一个结点出发的所有转移均 不同。
- 存在一个或多个 终止状态。如果我们从初始状态
出发,最终转移到了一个终止状态,则路径上的所有转移的标号连接起来一定是字符串 的一个后缀。反过来, 的每个后缀均可用一条从 到某个终止状态的路径构成。 - 在所有满足上述条件的自动机中,SAM 的结点数是最少的。
子串和路径
SAM 最简单、也最重要的性质是,它包含关于字符串
为了简化表达,我们称子串 对应 这条(从
到达某个状态的路径可能不止一条,因此我们说一个状态对应一些字符串的集合,这个集合中的字符串分别对应着这些路径。
简单例子
我们将会在这里展示一些简单的字符串的后缀自动机。
我们用蓝色表示初始状态,用绿色表示终止状态。
对于字符串
对于字符串
对于字符串
对于字符串
对于字符串
对于字符串
线性复杂度的构造算法
在我们描述线性时间内构造 SAM 的算法之前,我们需要引入几个对理解构造过程非常重要的概念,并对其性质进行简单证明。
结束位置 endpos
考虑字符串
两个子串
一个事实是,每个这样的等价类都对应 SAM 的一个状态1。也就是说,SAM 中的每个非初始状态都对应一个或多个
暂且接受这个事实,我们将基于它介绍构造 SAM 的算法。我们还将说明,SAM 需要满足的所有性质,除了最小性以外都满足了;而最小性可以由 Nerode 定理得出(不会在这篇文章中证明)。
由
引理 1
字符串
证明
引理显然成立。如果
引理 2
考虑两个非空子串
证明
如果集合
引理 3
考虑一个
证明
如果等价类中只包含一个子串,引理显然成立。现在我们来讨论子串元素个数大于
由引理 1,
记
后缀链接 link
考虑 SAM 中某个状态
我们还知道字符串
换句话说,
为方便讨论,我们规定初始状态
引理 4
所有后缀链接构成一棵根节点为
证明
考虑任意状态
引理 5
以
证明
由引理 2,任意一个 SAM 的
我们现在考虑任意状态
注意这里应该是
结合前面的引理有:后缀链接构成的树本质上是
以下是对字符串
对图示的解释
结合图示,如果能够形成一些对于后缀自动机的认识,将对下文理解其构造算法和应用都有所帮助。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
小结
在讨论算法本身前,我们总结一下之前的内容,并引入一些辅助记号。
-
的子串可以根据它们的结束位置集合 划分为多个等价类; -
SAM 由初始状态
和与每一个(非空子串的) 等价类对应的每个状态组成; -
每一个状态
都匹配一个或多个子串。我们记 为其中最长的一个字符串,记 为它的长度。类似地,记 为最短的子串,它的长度为 。那么对应这个状态的所有字符串都是字符串 的不同的后缀,且所有字符串的长度恰好取遍区间 中的每一个整数。 -
对于任意状态
,定义后缀链接为连接到对应字符串 的长度为 的后缀的一条边。从根节点 出发的后缀链接可以形成一棵树。这棵树也表示 集合间的包含关系。 -
对于任意状态
,可用后缀链接 表达 : -
如果我们从任意状态
开始顺着后缀链接遍历,总会到达初始状态 。这种情况下我们可以得到一个互不相交的区间 的序列,且它们的并集形成了连续的区间 。
算法
现在我们可以讨论构造 SAM 的算法了。这个算法是 在线 算法,我们可以逐个加入字符串中的每个字符,并且在每一步中对应地维护 SAM。
为了保证线性的空间复杂度,我们将只保存
一开始 SAM 只包含一个状态
过程
现在,只需要实现给当前字符串添加一个字符
-
令
为添加字符 之前,整个字符串对应的状态(一开始我们设 ,算法的最后一步更新 )。 -
创建一个新的状态
,并将 赋值为 ,在这时 的值还未知。 -
现在我们进行如下流程:从状态
开始,如果当前状态还没有标号为字符 的转移,我们就添加一个经字符 到状态 的转移,并将当前状态沿后缀链接移动。如果过程中遇到某个状态已经存在到字符 的转移,我们就停下来,并将这个状态标记为 。 -
情况一:如果没有找到这样的状态
,我们就到达了虚拟状态 ,我们将 赋值为 并退出。 -
假设现在我们找到了一个状态
,它可以通过字符 转移。我们将转移到的状态标记为 。此时,要么 ,要么 。 -
情况二:如果
,我们只要将 赋值为 并退出。 -
情况三:否则就会有些复杂,需要 复制 状态
:我们创建一个新的状态 ,复制 的除了 的值以外的所有信息(后缀链接和转移)。我们将 赋值为 。复制之后,我们将后缀链接从
指向 ,也从 指向 。最终我们需要沿着后缀链接从状态
往回走,只要经过的状态存在指向状态 的转移,就将该转移重新连接到状态 。 -
处理完以上三种情况后,我们都需要将
的值更新为状态 。
如果我们还想知道哪些状态是 终止状态 而哪些不是,我们可以在为字符串
因为我们只为
解释
我们详细解释算法每一步的细节,并说明它的 正确性。
对算法的详细解释
-
若一个转移
满足 ,则我们称这个转移是 连续的。否则,即当 时,这个转移被称为 不连续的。从算法描述中可以看出,连续的和不连续的转移,在算法中的处理也并不相同。连续的转移是固定的,我们不会再改变了。与此相反,当向字符串中插入一个新的字符时,不连续的转移可能会改变(转移边的端点可能会改变)。
-
为了避免引起歧义,我们记向 SAM 中插入当前字符
之前的字符串为 。 -
算法从创建一个新状态
开始,对应于整个字符串 。我们创建一个新的节点的原因很清楚。与此同时我们也创建了一个新的字符和一个新的等价类。 -
在创建一个新的状态之后,我们会从对应整个字符串
的状态 沿着后缀链接进行移动。对于经过的每一个状态,我们尝试添加一个通过字符 到新状态 的转移。然而我们只能添加与原有转移不冲突的转移。因此我们只要找到已存在的
的转移,我们就必须停止。 -
最简单的情况是我们到达了虚拟状态
,这意味着我们为所有 的后缀添加了 的转移。这也意味着,字符 从未在字符串 中出现过。因此 的后缀链接为状态 。 -
第二种情况下,我们找到了现有的转移
。这意味着我们尝试向自动机内添加一个 已经存在的 字符串 (其中 为 的一个后缀,且字符串 已经作为 的一个子串出现过了)。因为我们假设字符串 的自动机的构造是正确的,我们不应该在这里添加一个新的转移。然而,难点在于,从状态
出发的后缀链接应该连接到哪个状态呢?我们要把后缀链接连到一个状态上,且对应的最长的字符串恰好是 ,即这个状态的 应该是 。然而这样的状态有可能并不存在,即 。这种情况下,我们必须通过拆开状态 来创建一个这样的状态。 -
当然,如果转移
是连续的,那么 。在这种情况下一切都很简单。我们只需要将 的后缀链接指向状态 。 -
否则转移是不连续的,即
,这意味着状态 不只对应于长度为 的后缀 ,还对应于 的更长的子串。除了将状态 拆成两个子状态以外我们别无他法,所以第一个子状态的长度就是 了。我们如何拆开一个状态呢?我们 复制 状态
,产生一个状态 ,我们将 赋值为 。由于我们不想改变经过 的路径,我们将 的所有转移复制到 。我们也将从 出发的后缀链接设置为 的后缀链接的目标,并设置 的后缀链接为 。在拆开状态后,我们将从
出发的后缀链接设置为 。最后一步我们将一些原本指向
的转移重新连接到 。我们需要修改哪些转移呢?只重新连接相当于所有字符串 (其中 是状态 对应的最长字符串)的后缀就够了。也就是说,我们需要继续沿着后缀链接移动,从结点 直到虚拟状态 ,或者当前状态经 的转移不再指向状态 。
为了进一步理解算法的操作,可以从
基于 
集合理解该算法
-
设字符串
长度为 ,在添加新的字符 后, 集合可能发生的变化就只有增加了新位置 这一种可能。(下标从 开始)而且,这些发生变化的状态一定对应
的形式的字符串,其中的 是 的某个后缀。所以,只需要令 初始为 ,沿着后缀链接移动,就能找到 的所有后缀对应的状态,再考察状态 沿着字符 的转移即可。这样一定能处理到所有可能发生变化的状态。 -
如果字符串
原来的 集合为空集,即不存在这样的状态,则该集合变成 就相当于新建了状态 。状态 对应的字符串 只在位置 处出现过,这就意味着字符串 对应的状态,没有经由字符 的转移。这就是算法最开始的操作:将
沿着后缀链接移动,如果没有经由 的转移,就建立它,并指向新的状态 。 -
如果字符串
原来的 集合不为空集,就说明 之前已经在 中出现过。设字符串 在 的 SAM 中对应的状态是 。因为 可能对应着多个字符串,而添加了字符 后,未必每个字符串的结束位置都增加了新位置 。也就是说,同一个旧的状态 中对应的字符串的 集合的变化未必是一致的。当然,变化只有两种可能,即增加新位置
和不增加新位置 ,因此单个旧状态至多分裂成两个新状态。而且,只要字符串
可以结束在新位置 ,那么对于所有 的后缀 ,字符串 都一定可以结束在新的位置 。这说明,从字符串 对应的状态出发,沿着后缀链接移动找到的所有状态,它们对应的所有字符串的 集合都同样地增加了一个新位置 。因此,这些状态都不会分裂。前面已经说明,发生变化的字符串
一定都是 的后缀,因此都可以通过后缀链接找到。如果过程中,有某个状态分裂,就说明它对应的一部分字符串的结束位置增加,也就说明继续沿着后缀链接移动,经过的状态都将不再分裂。因此,分裂至多发生一次。 -
算法之前将
沿着后缀链接移动,直到找到当前状态经由 的转移为止。如果没有这样的状态,就是算法中的 情况一,最为简单。否则,转移指向的状态
就是可能分裂的旧状态。因为 经过的状态对应的字符串 都是 的后缀,所以由 经 转移到的状态 一定对应着 ,而字符串 的 集合一定会新增位置 。这就说明, 对应的字符串一定有一部分,结束位置变多了。因此,要么它不分裂,所有字符串结束位置都变多;要么它分裂,一部分字符串结束位置变多,一部分字符串结束位置不变。而且,无论它分不分裂,之后再沿着后缀链接移动,找到的状态
经由 转移到的状态,就都不用分裂了。 -
那怎么判断是否需要分裂呢?因为对于
对应的字符串 ,字符串 结束位置都增加了。所以,只需要看 中有没有字符串不具有 的形式即可,其中, 必须对应状态 。怎么看呢?因为结束位置相同,所以只需要比较长度即可。状态 对应的字符串 最长为 ,故而它们相应的 最长就是 ;同时,状态 对应的字符串最长为 。所以,状态 存在结束位置不会增加的字符串,当且仅当 。如果
,则状态 不会分裂,此即 情况二;否则,状态 会分裂,需要为结束位置没有增加的元素和结束位置增加的元素分别创建一个节点,此即 情况三。情况二容易处理,因为无需调整经由
指向 的转移;情况三则稍显复杂。因为,我们为那些结束位置增加的字符串复制了新状态 。设 为 ,也就是说, 是 对应的字符串中结束位置增加的最长的那个。现在,根据上面的讨论,需要把状态 中 的后缀都挪到新状态 中。为此,只需要继续将 沿着后缀链接移动,就能找到所有这样的 的后缀,再将那些原本指向 的调整到 即可。
线性时间复杂度
我们假设字符集大小为 常数,即每次对一个字符搜索转移、添加转移、查找下一个转移这些操作的时间复杂度都为
证明
如果我们考虑算法的各个部分,算法中有三处时间复杂度不明显是线性的:
- 第一处是遍历所有状态
的后缀链接,添加字符 的转移。 - 第二处是当状态
被复制到一个新的状态 时复制转移的过程。 - 第三处是修改指向
的转移,将它们重新连接到 的过程。
我们使用 SAM 的大小(状态数和转移数)为 线性的 的事实(对状态数是线性的的证明就是算法本身,对转移数为线性的的证明将在稍后实现算法后给出)。
因此上述 第一处和第二处 的总复杂度显然为线性的,因为单次操作均摊只为自动机添加了一个新转移。
还需为 第三处 估计总复杂度,我们将最初指向
因为作为当前字符串后缀的字符串
当然,如果字符集大小不是常数,SAM 的时间复杂度就不是线性的。从一个结点出发的转移需要存储在支持快速查询和插入的平衡树中。因此如果我们记
实现
首先,我们实现一种存储一个转移的全部信息的数据结构。如果需要的话,你可以在这里加入一个终止标记,也可以是一些其它信息。我们将用一个 map
存储转移的列表,允许我们在总计 next
声明为 int[K]
更方便。
1 2 3 4 |
|
SAM 本身将会存储在一个 state
结构体数组中。我们记录当前自动机的大小 sz
和变量 last
,当前整个字符串对应的状态。
1 2 3 |
|
我们定义一个函数来初始化 SAM(创建一个只有初始状态的 SAM)。
1 2 3 4 5 6 |
|
最终我们给出主函数的实现:给当前行末增加一个字符,对应地在之前的基础上建造自动机。
实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
|
正如之前提到的一样,如果你用内存换时间(空间复杂度为
更多性质
状态数
对于一个长度为
证明
算法本身即可证明该结论。一开始,自动机含有一个状态,第一次和第二次迭代中只会创建一个节点,剩余的
然而我们也能在 不借助这个算法 的情况下 证明 这个估计值。我们回忆一下状态数等于不同的
字符串
转移数
对于一个长度为
证明
我们首先估计连续的转移的数量。考虑自动机中由从状态
现在我们来估计不连续的转移的数量。令当前不连续转移为
将以上两个估计值相加,我们可以得到上界
因此我们可以获得更为紧确的 SAM 的转移数的上界:
后缀链接树
尽管构造 SAM 是为了得到它的状态和转移的信息,但是构造过程中记录的后缀链接
在构建 SAM 的过程中,需要更新
引理 4 中提及,所有状态和所有后缀链接构成根为
后缀链接树有如下性质:
- 祖先节点对应的字符串总是子孙节点对应的字符串的后缀。
- 每个节点处的
集合就是它的子树内的所有「前缀节点」 的下标 的集合。 - 后缀链接树的祖先节点的
集合总是严格包含子孙节点的 集合。 - 每个节点处的
的值就是它的子树内的所有「前缀节点」 对应前缀的最长公共后缀的长度。 - 除根节点
外,每个节点对应的不同子串的数目,就是它的 值,减去它的父节点的 值,即 。
这些性质有很多应用。比如,第
最后,对字符串
应用
下面我们来看一些可以用 SAM 解决的问题。简单起见,假设字符集的大小
检查字符串是否出现
问题
给一个文本串
解法
我们在
对于每个字符串
不同子串个数
问题
给一个字符串
解法一
对字符串
每个
考虑到 SAM 为有向无环图,不同路径的条数可以通过动态规划计算。即令
即,
所以不同子串的个数为
总时间复杂度为:
解法二
另一种方法是在构造完后缀自动机后,利用得到的后缀链接树的信息。每个节点对应的子串数量是
总时间复杂度仍然为:
所有不同子串的总长度
问题
给定一个字符串
解法一
本题做法与上一题类似,只是现在我们需要考虑分两部分进行动态规划:不同子串的数量
我们已经在上一题中介绍了如何计算
我们取每个邻接结点
算法的时间复杂度仍然是
解法二
同样可以利用后缀链接树的信息。每个节点对应的最长子串的所有后缀长度是
减去其
总时间复杂度仍然为:
字典序第 k 大子串
问题
给定一个字符串
解法
解决这个问题的思路可以从解决前两个问题的思路发展而来。字典序第
预处理的时间复杂度为
另注
虽然该题是后缀自动机的经典题,但实际上这题由于涉及字典序,用后缀数组做最方便。
最小循环移位
问题
给定一个字符串
解法
容易发现字符串
所以问题简化为在
总的时间复杂度为
出现次数
问题
对于一个给定的文本串
解法一
利用后缀链接树的信息,进行 dfs 即可预处理每个节点的
所有「前缀节点」的初始集合大小为
查询时,在自动机上查找模式串
预处理时间复杂度为
解法二
对文本串
接下来做预处理:对于自动机中的每个状态
然而我们不能明确的构造集合
为了计算这些值,我们进行以下操作。对于每个状态,如果它不是通过复制创建的(且它不是初始状态
这样做每个状态的答案都是正确的。
为什么这是正确的?不是通过复制获得的状态,恰好有
接下来我们对每一个
为什么我们在这个过程中不会重复计数(即把某些位置数了两次)呢?因为我们只将一个状态的位置添加到 一个 其它的状态上,所以一个状态不可能以两种不同的方式将其位置重复地指向另一个状态。
因此,我们可以在
最后回答询问只需要查找值
第一次出现的位置
问题
给定一个文本串
解法一
利用后缀链接树的信息,进行 dfs 即可预处理每个节点的
所有「前缀节点」
查询时,在自动机上查找模式串
预处理时间复杂度为
解法二
我们构造一个后缀自动机。我们对 SAM 中的所有状态预处理位置
为了维护 sam_extend()
进行扩展。当我们创建新状态
当我们将结点
(因为值的唯一的其它选项
那么查询的答案就是
所有出现的位置
问题
问题同上,这一次需要查询文本串
解法一
找到模式串
单次查询复杂度为
解法二
我们还是对文本串
如果
因此为了解决这个问题,我们需要为每一个状态保存一个指向它的后缀连接列表。查询的答案就包含了对于每个我们能从状态
预处理的复杂度为
我们不会重复访问一个状态(因为对于仅有一个后缀链接指向一个状态,所以不存在两条不同的路径指向同一状态)。
我们只需要考虑两个可能有相同
此外,我们可以通过不考虑复制而来的节点的 is_clone
来代表这个状态是不是被复制出来的,我们就可以简单地忽略掉被复制的状态,只输出其它所有状态的
以下是大致的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
最短的没有出现的字符串
问题
给定一个字符串
解法
我们在字符串
假定我们已经处理完了子串的一部分,当前在状态
计算
问题的答案就是
两个字符串的最长公共子串
问题
给定两个字符串
解法
我们对字符串
我们现在处理字符串
为了达到这一目的,我们使用两个变量,当前状态
一开始
现在我们来描述如何添加一个字符
- 如果存在一个从
到字符 的转移,我们只需要转移并让 自增一。 -
如果不存在这样的转移,我们需要缩短当前匹配的部分,这意味着我们需要按照后缀链接进行转移:
与此同时,需要缩短当前长度。显然我们需要将
赋值为 ,因为经过这个后缀链接后我们到达的状态所对应的最长字符串是一个子串。 -
如果仍然没有使用这一字符的转移,我们继续重复经过后缀链接并减小
,直到我们找到一个转移或到达虚拟状态 (这意味着字符 根本没有在 中出现过,所以我们设置 )。
显然问题的答案就是所有
这一部分的时间复杂度为
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
例题:SPOJ Longest Common Substring
多个字符串间的最长公共子串
问题
给定
解法一
我们将所有的子串连接成一个较长的字符串
然后对字符串
现在我们需要在自动机中找到存在于所有字符串
因此我们需要计算可达性,即对于自动机中的每个状态和每个字符
解法二
不妨设 最短 的字符串为
因为匹配过程中,每次匹配到 SAM 的一个状态时,必然同时匹配到了它在后缀链接树上的所有祖先节点,但是祖先节点的匹配长度的信息并没有更新。所以,在完成对字符串
最后,只需要对每个
算法时间复杂度是
例题:SPOJ Longest Common Substring II
例题
- HihoCoder #1441 : 后缀自动机一·基本概念
- 【模板】后缀自动机
- SDOI2016 生成魔咒
- SPOJ - SUBLEX
- TJOI2015 弦论
- SPOJ Longest Common Substring
- SPOJ Longest Common Substring II
- Codeforces 1037H Security
- Codeforces 666E Forensic Examination
- HDU4416 Good Article Good sentence
- HDU4436 str2int
- HDU6583 Typewriter
- Codeforces 235C Cyclical Quest
- CTSC2012 熟悉的文章
- NOI2018 你的名字
相关资料
我们先给出与 SAM 有关的最初的一些文献:
- A. Blumer, J. Blumer, A. Ehrenfeucht, D. Haussler, R. McConnell. Linear Size Finite Automata for the Set of All Subwords of a Word. An Outline of Results. [1983]
- A. Blumer, J. Blumer, A. Ehrenfeucht, D. Haussler. The Smallest Automaton Recognizing the Subwords of a Text. [1984]
- Maxime Crochemore. Optimal Factor Transducers. [1985]
- Maxime Crochemore. Transducers and Repetitions. [1986]
- A. Nerode. Linear automaton transformations. [1958]
另外,在更新的一些资源以及很多关于字符串算法的书中,都能找到这个主题:
- Maxime Crochemore, Rytter Wowjcieh. Jewels of Stringology. [2002]
- Bill Smyth. Computing Patterns in Strings. [2003]
- Bill Smith. Methods and algorithms of calculations on lines. [2006]
另外,还有一些资料:
- 《后缀自动机》,陈立杰。
- 《后缀自动机在字典树上的拓展》,刘研绎。
- 《后缀自动机及其应用》,张天扬。
- https://www.cnblogs.com/zinthos/p/3899679.html
- https://codeforces.com/blog/entry/20861
- https://zhuanlan.zhihu.com/p/25948077
本页面主要译自博文 Суффиксный автомат 与其英文翻译版 Suffix Automaton。其中俄文版版权协议为 Public Domain + Leave a Link;英文版版权协议为 CC-BY-SA 4.0。
-
需要将每个状态都取作一个
等价类的原因,其实就是本段提到的 Nerode 定理。简单来说,如果两个字符串 和 的 集合不同,那么它们不能对应于 SAM 的同一个状态:同一个状态到达终止状态的路径总是一样的,这意味着在 和 末尾添加字符到达 的结尾的方式也是一样的,而这正说明 和 在字符串 中的结束位置一样。反过来,只要两个字符串 和 的 集合相同,就可以将它们对应到 SAM 的同一个状态。这样做可行,就是 Nerode 定理的证明的内容,在此不多讨论。但是,此处的讨论至少可以相信,将 集合相同的字符串放到同一个状态,这样得到的 SAM 一定是最小的,因为进一步合并节点是不可能的。 ↩ -
如果不额外使用列表记录当前状态的可用转移,只用数组存储所有可能的转移(无论是否存在)并在复制节点时直接复制,那么时间复杂度也是
的。 ↩↩ -
此处正文没有解释的是,在第一种和第二种情况中,
的位置是否也是单调(弱)递增的。第一种情况容易验证,因为更新后 是空串,起止位置在字符串 的末尾。第二种情况,转移是连续的,说明 。然而,向子串的末尾添加新的字符只会使得该子串更难以出现在字符串中,也就是说,当字符串 的长度为 的后缀的结束位置集合严格包含 时,字符串 的长度为 的后缀的结束位置集合可能仍然与 相同。故而, ,亦即 作为 的后缀的起始位置必然不大于 作为 的后缀的起始位置。而当一次找到状态 使得存在经由 的转移时,必定移动了至少一次,这说明 作为 的后缀的起始位置不小于 作为 的后缀的起始位置。最后, 。这就说明,在第二种情况中, 的位置也是单调递增的。 ↩
本页面最近更新:2025/3/31 16:46:25,更新历史
发现错误?想一起完善? 在 GitHub 上编辑此页!
本页面贡献者:Chrogeek, GoodCoder666, ksyx, abc1763613206, Backl1ght, c-forrest, CCXXXI, ChungZH, countercurrent-time, Dev-XYS, Enter-tainer, FFjet, frank-xjh, fstqwq, GeZiyue, Ghastlcon, H-J-Granger, Heartfirey, Henry-ZHR, iamtwz, interestingLSY, Ir1d, Kinandra, LeoJacob, longlongzhu123, Luckyblock233, mao1t, Marcythm, mgt, MingqiHuang, opsiff, ouuan, ranwen, shuzhouliu, sshwy, SukkaW, Suyun514, Tiphereth-A, vincent-163, WhenMelancholy, Xeonacid, yjl9903, ZXyaang
本页面的全部内容在 CC BY-SA 4.0 和 SATA 协议之条款下提供,附加条款亦可能应用