[学习笔记]后缀自动鸡/SAM

〇、关于后缀自动鸡的一些牢骚废话和引入

它取名叫后缀自动鸡,但是实际上在一个自动鸡出炉之后好像和后缀基本上没什么关系,按照 JZM 大佬的话,叫 “子串自动鸡” 可能更恰当.

只不过,它取名叫后缀自动鸡,原因是之一可能是在原本的串 S 后面插入新字符 c 时,将 S 的许多后缀遍历了一遍,因而得名 “后缀自动鸡”.

但是实际上,它在建成之后,自动鸡中似乎并没有彰显 “后缀” 的东西,而更像是把串 S 的所有子串放到一个时空复杂度都很优秀的 DAG 中.

哦,还有句话,后缀自动鸡的简称 SAM/Suffix Automaton,而不是 SAC/Suffix Autochiken.

壹、一些新概念英语

在进行正式说明前,我们有一些新概念需要理清.

一、终止节点等价类(endpos/right 等价类)

首先,对于串 S 中的每个子串 si,我们将其出现的地方全部找出来,然后将这些出现的地方的尾端放到同一个类里面,然后给这个类取一个名字,叫终止节点集合,或者 endpos 集合(在某些文章中称为 right 集合),举个栗子,有这样的串:

abaabab

我们有一个子串 si=aba,发现有:

si=S[1..3]=S[4..6]

那么,endposi 集合就是 {3,6}.

继续,现在我们每个不同的子串都有一个 endpos 集合(显然相同的子串没啥用),对于这些不同的子串,我们endpos 集合相同的子串再放到一个集合中,叫做 endpos 等价类,将这个等价类用 endpos 集合中的元素进行代替.

那么,在这个例子中,有这些等价类(无特定顺序):

endpos=[1,n]Z,string class=endpos={1,3,4,6},string class={a}endpos={2,5,7},string class={b,ab}endpos={3,6},string class={ba,aba}endpos={4},string class={aa,baa,abaa}endpos={7},string class={bab,abab,aabab,baabab,abaabab}

可以看到,有一些 endpos 集合相同的串被归为了同一个类.

下文中,我们将用 代替 endpos 等价类(能少一点就少一点).

二、自动鸡

有一个叫做 AC 自动鸡的东西 但是不是拿来自动过题的,它也叫 “自动鸡”,但是到底什么是 "自动鸡" 呢?

我们认为一个东西是 "自动鸡",有一些基本概念:

  • 这里的自动鸡其实是有限状态自动鸡(FSM);
  • 自动鸡不是 算法/数据结构,只是一种数学模型,对于同一个问题可能有很多只鸡,但是不是所有的鸡都满足我们解决问题时的资源消耗(比如时间和空间);

同时,一只鸡由这几个部分组成(虽然下文不会用到):

  1. 字符集 Σ,表示这只鸡只能输入这些字符;
  2. 状态集合 Q,即在自动鸡建好之后形成的的 DAG 上的顶点;
  3. 起始状态 start/s,不解释;
  4. 接受状态集合 F,有 FQ
  5. 转移函数 δ,即点之间的转移方法;

明白概念即可,下文大概率用不到.

贰、终止节点集合与类的亿些特性及证明

对于上文的类有一些特性 并给它们取了名字,接下来给出并证明:

一、同类即后缀

如果有两个串 S,T(|S||T|)(其实取不到等号) 同属一个类,那么一定有 ST 的后缀.

证明:Obviously.

二、从属或无关

S,T(|S||T|),他们的 endpos 集合只有两个情况:

endpos(S)endpos(T) or endpos(S)endpos(T)=

证明:Obviously.

三、同类长连续

属于同一个类中的所有串,他们的长度一定是连续的,同时,如果按照长度从小到大排序,那么一定有前一个串是后一个串去掉第一个字符的后缀,或者说,后一个串是前一个串在前面加上了一个字符.

证明:Obviously.

所以,如果我们想要储存一个类,只需要存在最短串长度与最长串的长相就可以了.

四、类数为线性

类的个数为 O(n) 级别的.

证明:对于两个类 X,Y,由 "从属或无关" 我们可以知道 X,Y 要么包含要么不相交,同时,我们也可以找到同一个集合 Z 满足 X,YZ,那么,我们可以得到 X,Y 其实是 Z 进行了分裂得到的两个类,其实,Z 可以一次性分裂成两个或更多个类,但由于我们最开始的集合 Z={1,2,3,...n},即 |Z|=n,由线段树对于集合最多的不交划分结论可知,总类的数量不会超过 O(2n),实际上最多 2n1 个类.

终于不是显然了.

五,邻类邻长度

对于一个类 X,如果有一个 Y 满足 XY,且不存在 Z 满足 XZY,即 XY 是直接父子关系,那么有 X 中的最短的串 SY 中最长的串 T 满足 |S|=|T|+1.

证明:对于一个集合的分裂,其实就是在最长的串 A 前面加上一个字符得到新的字符串 B,那么,B 的出现的限制条件一定比 A 更强,即 B 所属的类就变成了 A 所属的类 P 的子集了,为甚说 A,B 一定不在同一个类中呢?如果在同一类中,那么 A 就不是最长的串而是 B 了,而得到的 B 就是 P 的某个子集中最短的一个了,那么就有 |A|+1=|B|.

所以,我们只要知道详细父子关系,那么如果我们想要表示一个类只需要存下最长的串的样子就可以了.

叁、如何建自动鸡

其实在证明中已经使用了一些类之间存在父子关系的特性了.

定义直接父子关系:对于一个类 X,如果有一个 Y 满足 XY,且不存在 Z 满足 XZY,那么 XY 是直接父子关系.

我们按照类的直接父子关系,建出一棵树,这棵树叫做 parent tree,这个 parent tree 就是 SAM 的骨架,但是自动鸡只有这棵树似乎什么用都没有,就像 fail 指针基于 trie 树得到 AC 自动鸡一样,我们得在 parent tree 上加些东西.

首先,根据一些证明的说明,沿 parent tree 边就是在串的前面动手脚,但是我们还需要在串的后面做手脚,这个时候就需要加入新的边了,表示在串的后面加些字符,这就是自动鸡上面的边.

给出代码,做出进一步说明:

/** @brief the size should be two times of the origin*/
int tre[maxn * 2 + 5][30];
int lenth[maxn * 2 + 5];
int fa[maxn * 2 + 5];

/** @brief the number of nodes*/
int ncnt = 1;
/** @brief this variable is necessary, it records the id of the node of the origin string*/
int lst = 1;
/**
 * pay attention : node 1 is an empty node, it represent root
 * and the length of this node is 0
*/
inline void add(const int c){
    // the id of the original string
    int p = lst;
    // the id of new node or class
    int u = lst = ++ ncnt;
    // just means the length increases by 1
    lenth[u] = lenth[p] + 1; sz[u] = 1;
    /** @brief
     *  if the suffix of the original string
     *  add a new character @p c hasn't appeared yet, 
     *  then we connect the represent node with the new node, 
     *  it means that we can get a new class if we add @p c to the end
    */
    for(; p && !tre[p][c]; p = fa[p]) tre[p][c] = u;
    /** @brief
     *  if all suffixes haven't appeared before after adding @p c
     *  we'll reach the situation where @p p equals to 0, 
     *  which means the father of the new class is 1(root)
    */
    if(!p) fa[u] = 1;
    /** @brief if the situation is not like this
     *  just means some suffixes have appeared before after adding @p c
     *  which means the new class is a split version of a certain class(actually class @p tre[p][c] )
    */
    else{
        /** @brief
         *  of course @p q is the original version of the new class,
         *  that is, @p q is apparently the father of @p u,
         *  but there are some other questions that
         *  some suffixes of @p q will appear at n,
         *  in other words, some part of @p q will add END POINT @p n (present length),
         *  which will make a different to class @p q and
         *  cause the split of class @p q.
         *  We're going to judge whether @p q should be split or not, 
         *  if the answer is true, we're supposed to handle the split
        */
        int q = tre[p][c];
        /** @brief 
         *  if all suffix have appeared before after adding @p c ,
         *  then there's no need to split class @p q ,
         *  because add END POINT will add n(present length)
         *  we just need to connect the new class @p u with it father
        */
        if(lenth[q] == lenth[p] + 1) fa[u] = q;
        /** @brief
         *  if not, then we're going to find out
         *  which part of class @p q is going to add END POINT @p n (present length),
         *  and we split it from here
        */
        else{
            /** @brief part to add new END POINT
             *  now node @p q represent a class which won't add new END POINT
             *  obviously that class @p q and class @p u are the subset of class @p split_part
            */
            // pay attention : the new node has no real meaning, so the size shouldn't be added.
            int split_part = ++ ncnt;
            rep(i, 0, 25) tre[split_part][i] = tre[q][i];
            fa[split_part] = fa[q];
            lenth[split_part] = lenth[p] + 1;
            fa[q] = fa[u] = split_part;
            /** @brief
             *  because the original node @p q is split to two part,
             *  @p split_part and @p q (another meaning)
             *  then if an edge which connect a certain node with origin @p q ,
             *  it is supposed to be changed
            */
            for(; p && tre[p][c] == q; p = fa[p])
                tre[p][c] = split_part;
        }
    }
}

首先看变量定义:

/** @brief the size should be two times of the origin*/
int tre[maxn * 2 + 5][30];
int lenth[maxn * 2 + 5];
int fa[maxn * 2 + 5];

tre[][] 表示的即为自动鸡上面的边;

fa[] 表示自动鸡上的父子关系,即我们通过保存父亲节点来维护整个 parent tree

lenth[] 表示的每个点代表的类中,最长的串有多长;

接下来进入 add() 函数:

inline void add(const int c){

这个参数 c 表示我们要在串的末尾插入的字符;

// the id of the original string
int p = lst;
// the id of new node or class
int u = lst = ++ ncnt;
// just means the length increases by 1
lenth[u] = lenth[p] + 1;

p 表示我们在插入 c 之前,代表整个串的点的编号是多少;

u 代表我们要将插入 c 之后,代表新的整个的串的点的编号;

lenth[u] 显然就是在未插入之前的长度基础上增加一;

/** @brief
 *  if the suffix of the original string
 *  add a new character @p c hasn't appeared yet, 
 *  then we connect the represent node with the new node, 
 *  it means that we can get a new class if we add @p c to the end
*/
for(; p && !tre[p][c]; p = fa[p]) tre[p][c] = u;

接下来,我们考虑对于原串 S,在增加 c 之后,原来的 |S|1 个后缀全都有了新的样子(在原来的基础上在末尾加上 c),同时,还多出来一个新的后缀——单独一个 c,我们称这些东西为新后缀,这个循环要做的事情就是,这些新后缀如果在之前没有出现过,那么我们要给他们归为一个新的类——endpos={|S|+1},同时,显然,后缀越长,它越不容易出现,所以我们从代表 S 的点 p 开始向上走,表示从长到短枚举原来的后缀,对于没出现过的,即 tre[p][c]=0,我们将他们连接到新的类——代表 endpos={|S|+1} 的点 u 上,表示在这些后缀后加上 c 可以到达新的类;

if(!p) fa[u] = 1;
/** @brief if the situation is not like this
 *  just means some suffixes have appeared before after adding @p c
 *  which means the new class is a split version of a certain class(actually class @p tre[p][c] )
*/

如果上一个循坏直接走到空点,也就是说对于原串 S 的所有后缀,在加上 c 之后,在之前的串中都没见过,那么它的父节点就是 endpos=[1,n]Z 的根节点了.

else{
/** @brief
 *  of course @p q is the original version of the new class,
 *  that is, @p q is apparently the father of @p u,
 *  but there are some other questions that
 *  some suffixes of @p q will appear at n,
 *  in other words, some part of @p q will add END POINT @p n (present length),
 *  which will make a difference to class @p q and
 *  cause the split of class @p q.
 *  We're going to judge whether @p q should be split or not, 
 *  if the answer is true, we're supposed to handle the split
*/
int q = tre[p][c];

否则,则说明有些新后缀在之前出现过,即有些后缀的 endpos 集合不只是 {|S|+1},还有些其他的东西,同时,由于之前全部的后缀的点的类中都加上 |S|+1 这个元素,那么此时,仅仅只代表 |S|+1 这个元素的类的点 u 肯定就是其他点的儿子了,那 u 的父亲究竟是谁?显然就是之前循环中第一个不满足条件的 ptre[p][c],我们称这个点为 q.

同时,对于 q,由于它和 p 并非有实际父子关系,而是由自动鸡连接的 你居然不是我亲生的,也就是说 q 代表的串并非全是原串 S 的后缀(但一定会有一个 S 的后缀,即在 p 所代表的后缀之后加上一个 c 的后缀 ),但是我们找到它的原因是什么——它其中的某些串在 S 加上 c 之后,会在 |S|+1 再出现一次,也就是有些串的 endpos 就会改动,同样的,q 中有些串又不会改动,这下出了个问题——q 所代表的类中的串的 endpos 集合不一样了,根据定义已经不能再将他们归为同一类,所以接下来我们要考虑将 q 分裂——加上 |S|+1 的部分,和没有加上 |S|+1 的部分;

/** @brief 
 *  if all suffix have appeared before after adding @p c ,
 *  then there's no need to split class @p q ,
 *  because add END POINT will add n(present length)
 *  we just need to connect the new class @p u with it father
*/
if(lenth[q] == lenth[p] + 1) fa[u] = q;

上文提及,q 中一定会包含一个 S 的后缀,即在 p 所代表的后缀之后加上一个 c 的后缀,但是,如果我们发现 q 类中最长的就是这个后缀(下文称其为特征串),由 “同类即后缀” 意味着 q 类中所有串的 endpos 集合都同时加入 |S|+1 这个元素,那么这个 q 类就没有分裂的必要了;

/** @brief
 *  if not, then we're going to find out
 *  which part of class @p q is going to add END POINT @p n (present length),
 *  and we split it from here
*/
else{
/** @brief part to add new END POINT
 *  now node @p q represent a class which won't add new END POINT
 *  obviously that class @p q and class @p u are the subset of class @p split_part
*/
int split_part = ++ ncnt;

否则,则意味着 q 逃不掉分裂的命运,接下来我们要考虑的是从哪里裂开,先申请一个新的点 split_part 当作分裂出去的部分(此处认定分裂部分为加上 |S|+1 元素的部分)的编号.

rep(i, 0, 25) tre[split_part][i] = tre[q][i];
fa[split_part] = fa[q];
lenth[split_part] = lenth[p] + 1;

p 的信息继承到 split_part 上,同时,显然我们分裂的点就是特征串(由 “同类即后缀” 同理可得),显然,原 q(没加上元素 |S|+1)就是多出 |S|+1 点的 split_part 类的一个子集,那么可以确定他们有直接父子关系了 (仅仅只多出一个元素,显然不可能找到 Z 满足 qZsplit_part),那么我们将 q 的父亲设置为 split_part,同样,只有一个元素 |S|+1u 类的父亲肯定也是 split_part 类了.

/** @brief
 *  because the original node @p q is split to two part,
 *  @p split_part and @p q (another meaning)
 *  then if an edge which connect a certain node with origin @p q ,
 *  it is supposed to be changed
*/
for(; p && tre[p][c] == q; p = fa[p])
    tre[p][c] = split_part;

但是,我们确实是将 split_part 分裂出去了,但是,这个 split_part 是代表原来的 q 的,但是还有一些点是沿着自动鸡连接在旧的 q 上,我们要将他们改过来,将他们和 split_part 连接.

然后,插入新点结束,构建自动鸡时只需:

rep(i, 1, n) add(s[i] - 'a');

贴一发整合代码:

const int maxn = 1e6;

int tre[maxn * 2 + 5][30];
int lenth[maxn * 2 + 5];
int fa[maxn * 2 + 5];
int sz[maxn * 2 + 5];

int ncnt = 1;
int lst = 1;

char s[maxn + 5];
int n; // the length of string s

inline void input(){
    scanf("%s", s + 1);
    n = strlen(s + 1);
}

inline void add(const int c){
    int p = lst;
    int u = lst = ++ ncnt;
    lenth[u] = lenth[p] + 1;
    for(; p && !tre[p][c]; p = fa[p]) tre[p][c] = u;
    if(!p) fa[u] = 1;
    else{
        int q = tre[p][c];
        if(lenth[q] == lenth[p] + 1) fa[u] = q;
        else{
            int split_part = ++ ncnt;
            rep(i, 0, 25) tre[split_part][i] = tre[q][i];
            fa[split_part] = fa[q];
            lenth[split_part] = lenth[p] + 1;
            fa[q] = fa[u] = split_part;
            for(; p && tre[p][c] == q; p = fa[p])
                tre[p][c] = split_part;
        }
    }
}

signed main(){
    input();
    rep(i, 1, n) add(s[i] - 'a');
}

注意:如果需要统计某个点的子节点大小,在将 q 裂开之后产生的新点不能计算 sz,因为它只是一个裂开的点为了表示一个新类,并不会对总点数产生贡献.

肆、复杂度分析

一、点数线性

点数即类数,上文已证明,最多 2n1 个 类/点.

二、边数线性

考虑自动鸡有以下性质:

  • s 开始,沿不同的路径,最终一定会走到不同的子串;
  • 由 “同类即后缀”,以及 “同类长连续”,可以说明,只要这个类中有一个串是整个串的后缀,那么整个类都是串的后缀(其实从另一个角度说,同一个类中的串,区别只在于他们在前面加字符,但是在后面的字符是不动的)

然后,我们进行一个定义:定义广义环为忽略边的方向,将所有边视为无向边之后形成的环成为广义环.

现在,我们可以开始证明边数不超过 3n4 了.

首先,假设我们有了一个自动鸡,但是他们还没有连边,我们从 s 开始走后缀,现在规定走后缀的规则:

  1. 每次走后缀只有一次花费;
  2. 如果走的边和原来的边没有形成广义环,那么我们走这条边并将这条边添加进自动鸡,并且这条边不会使用花费;
  3. 如果走的边和原来的边形成了广义环,那么我们走这条边并将这条边添加进自动鸡,并且使用花费;

如果我们沿这些规律走后缀,最后最多只会有 3n4 条边,

三、add() 函数复杂度证明

我再贴一个代码:

inline void add(const int c){
    int p = lst;
    int u = lst = ++ ncnt;
    lenth[u] = lenth[p] + 1;
    for(; p && !tre[p][c]; p = fa[p]) tre[p][c] = u;
    if(!p) fa[u] = 1;
    else{
        int q = tre[p][c];
        if(lenth[q] == lenth[p] + 1) fa[u] = q;
        else{
            int split_part = ++ ncnt;
            rep(i, 0, 25) tre[split_part][i] = tre[q][i];
            fa[split_part] = fa[q];
            lenth[split_part] = lenth[p] + 1;
            fa[q] = fa[u] = split_part;
            for(; p && tre[p][c] == q; p = fa[p])
                tre[p][c] = split_part;
        }
    }
}

其实这里就几个循环,而对于前两个,我们都是在加边,但是由于我们边数已经是 3n4 的上界了,那么这俩循环加起来总共也不超过 3n4,但是我们考虑我们的第三个循环:

for(; p && tre[p][c] == q; p = fa[p])
    tre[p][c] = split_part;

这个不好分析,我们考虑整个 add() 在干什么:

  1. lst 向上爬,这个过程会减小可能成为 u fa[u]minlen(就是类里面最小的字符串长度);
  2. 爬到头了,从 p 走到 q,这个过程会让可能成为 u fa[u]minlen 不多不少 +1
  3. u 认父亲,然后让 u 变成 lst(也就是下一次的 u);

我们发现,每次 fa[u]minlen 最多只会增加一,但是减少却可能一次性减少很多,这类似于 SAheit 数组的过程,那么总复杂度大概在 O(n) 级别.

实际上,如果我们将 minlen(fa[u]) 当成势能函数,可能会更好理解.

伍、应用

部分引自 oiwiki,对于部分 我知道的 可以使用 SA 解决的也会大致描述,同样,如果有其它如使用 kmp 的巧妙方法亦会大致描述.

一、检查子串

  • 使用 SAM

检查 T 是否是 S 的某个子串.

后缀自动鸡中实际上保存了 S 的所有子串信息,所以你只需要从开始节点沿自动鸡边走就可以了.

  • 使用 SA

ST 拼在一起,中间用分隔符隔开,判断 heit 即可.

二、本质不同串个数

  • 使用 SAM

SAM 沿自动鸡边走形成的串本来就是本质不同的子串,所以,我们只需要统计从开始节点走的不同路径数,由于是 DAG,可以直接跑拓扑 DP.

  • 使用 SA

每个后缀长度减去 heit 之和.

三、第 k 大子串

这里有板题 OAO

  • 使用 SAM

如果是去重,那么每个点的 sz 定为 1,即表示每个字串都只出现一次而不因 parent tree 树的出现而改变,反之,sz 即为其 parent tree 子树的大小.

然后,我们处理出一个 sum 数组表示经过这个点的子串有多少,那么就有

sum[u]=sz[u]+vtre[u]sum[v]

处理出来之后用类似 trie 树的做法即可.

  • 使用 SA

如果是去重,处理出 SA 之后,由每个后缀都有 nsa[i]+1heit[i] 个本质不同的子串,然后就可以解决.

如果是非去重,这里直接引用 大佬 的解决方案.

因为我们前面的字母是确定的,那么当前面的字母一样的时候,后面一个字母一定是单调不下降的。

那我们就能二分求出这个字母最后一个的位置。

我们建一个后缀长度的前缀和,那我们就能求出以这个字母开头的子串有多少个。

当个数大于 k 时,就确定了这个字母,否则 k 减去,继续枚举下一个字母。

枚举完一位继续下一位,那我们就能把范围缩小,知道求出答案。

实际上本质和 SAM 的做法差不多.

四、二元最长公共子串(LCS)

  • 使用 SAM

对于一个串建立 SAM,拿另一个串在 SAM 上能跑多远跑多远,跑不了了就往父亲跳.

  • 使用 SA

两个串用 SA 经典流氓做法接在一起(注意中间用分隔符隔开),处理出 sa 之后将 heit 排序从大到小枚举长度,直到一个属于两个串的 heit 满足标准后就找到长度(如果要找长什么样子也可以,这里不做赘述).

五、最小循环位移

  • 使用 kmp

先找到 n 位置对应的 nxt,那么最小循环位移就可能是 nnxt[n],检查一下,如果不满足就 GG.

  • 使用 SAM

复制一个 S 并对其建立 SAM,然后在 SAM 上寻找最小的长度为 |S| 的路径即可.找最小的,我们只需要贪心往最小字符走即可.

  • 使用 SA

理论上可以当 kmp 用,我们对于反串排出 sa,然后看 S[1...n] 的位置,那么长度要么就是它和它前一名、要么就是它和它后一名的 LCP,然后我们暴力检验就可以了.

六、子串出现次数

  • 使用 SAM

找到子串对应的点,然后算出这个点的 sz 就可以了.

  • 使用 SA

用流氓做法,把子串接在后面然后处理出 sa,然后看属于两个串的 heit 刚刚好是子串长度的 heit 个数即可.

七、多元最长公共子串(exLCS)

  • 使用 SAM

两种思路:

  1. 对于第一个建立 SAM,然后其他的在这个上跑,对于每个点,记录 f[i] 表示走到 i 能够匹配上的最长公共子串的长度,注意 f[i]longest(i),最后跑拓扑,用 f[i] 更新 f[fa[i]],得到匹配完某个串之后的 f[],然后记录在 ans[i] 中,其中 ans[i] 表示第 i 个点的历史中能匹配的最小长度(因为我们要求的是满足所有的串),最后在 ans[i] 中找最大就可以了.
  2. T=S1+D1+S2+D2+...+Dn1+Sn+Dn,其中 Si 是我们想要找的,Di 是对应的分隔符,我们对于 T 建立 SAM,然后,如果 Si 中有一个子串,那么存在一条路径从 Di 不经过其他的 Dj 的路径. 然后我们处理连通性,使用 BFS 等搜索算法之族以及动规计算,然后,答案就是所有的 Di 都能到达的点中 longest 的最大值.
  • 使用 SA

流氓将所有串暴力接起来,从大到小枚举 heit 长度,并用并查集枚举每一个合并的块中有多少不同的集合,当某个连通块中存在了所有的串,此时枚举的长度就是答案串的长度,寻找就随意了.

posted @   Arextre  阅读(713)  评论(2编辑  收藏  举报
编辑推荐:
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示