Cry_For_theMoon  

开个坑,花点时间学学 SAM。

一些概念

1.

SAM 是个自动机,而且是个 DAG。

SAM 有一个初始节点 u,从 u 出发的任意一条路径都对应原串 S 的一个子串,且原串 S 的任意一个子串唯一对应一条 u 出发的路径。

SAM 的优秀之处在于它压缩了很多相似的后缀信息。

2.

有一个很重要的概念就是 endpos 集合,定义 endpos(T) 是串 TS 中的所有出现位置的结尾下标(从 1 开始标号)。

我们会发现会有一些 T 他们的 endpos$是相同的,比如令 S=ababendpos(ab)=endpos(b)

如果两个不同的串 T1,T2 同时在 x 处结尾出现,那必定有较短的那个是较长的那个的后缀。

所以对于两个串 X,Y,我们假设 |X||Y|,则:如果 XY 的后缀,容易得 endpos(Y)endpos(X),否则 endpos(X)endpos(Y)=

我们把所有 endpos 相同的子串拿出来称为一个等价类:考察一个等价类的所有串,按照长度从大到小排序以后记作 T1,T2,...,Tk

根据上面的内容我们可以发现 TkTk1 的后缀,而 Tk2Tk 的后缀,以此类推。

更一般的,显然所有 Ti 都是 T1 的后缀且 |Ti|=|T1|i+1

所以一个 endpos 等价类实际上就是若干个后缀压缩成了一起,我们用 T1 来代表这个等价类。

3.

SAM 想做到的一点是:每个节点(状态)都代表了一个完整的 endpos 等价类。

在构建 SAM 之前,我们还需要引入一个东西:parent tree。

我们知道一个节点 x 对应一个等价类:T1,T2,...,Tk,我们定义 TTk 去掉开头字符的结果(也就是所谓的 Tk+1)。

定义 link(x)Tk+1 所属的等价类的对应节点。

显然 link(x)x 连边会形成一颗 u 为根的树,这个就是 parent tree。

而且你会发现 endpos(T) 一定是 endpos(T) 的真子集。

另外我们可以发现兄弟节点的 endpos 一定是交集为空的,因为他们没有后缀关系。(比如 baca 的父亲都是 a,那么 endpos(ba)endpos(ca) 显然为空)。

而且可以说明的是不同的等价类只有 O(n) 个,因为这就是一个集合分拆的过程啊。

4.

SAM 就是在线地同时构建出 parent tree 和 DFA 本身。

考虑我们建出了 S 的 SAM,怎么求 S+c 的 SAM。

我们本质上只增加了 |S|+1 个以最后一个字符结尾的子串(也就是此时形成的所有后缀),而且你考虑有些后缀应该是已经出现过的,如果长度为 x 的后缀出现过了那么长度为 yx 的后缀一定出现过了。

这个 xsuffix 其实就是 link(S+c) 啊,因为 endpos(S+c) 显然就是 {|S|+1}

所以考虑先求 link(S+c)

link(S) 不断跳 parent tree(其实就是从大往小枚举后缀,只不过每次会处理一段连续的性质相似的后缀罢了),当跳到一个点 p,它的 nxt(c) 已经有值,我们设 p 所在的等价类,最长的那个串是 a,那么 a+c 就一定就是最长的出现过了的后缀。

p 跳转到了 q,如果 len(q)=len(p)+1 那么直接 link(S+c)=q。这里 len(a) 表示的是节点 a 所代表的等价类里最长的串长。

如果不然,也就是 len(q)>len(p)+1,那么说明 a+c 只是 q 等价类的一个非最长串,你不能让 link(S+c)=q,因为 q 集合的最长串并不是 S+c 的后缀。

此时等价类 q 实质上被分为了两部分:长度在 (len(p)+1,len(q)] 这段的串的 endpos 不变,而 (len(link(q)),len(p)+1] 这些长度的串的 endpos 应该多了一个位置 |S|+1 啊。

所以这就是两个等价类了,我们新建一个节点 r,那么 link(r) 变为原先的 link(q)link(q) 变为 rlen(r)=len(p)+1

然后 nxtr 肯定是照搬 nxtq 的,而且我们继续从 p 跳 parent tree:所有 nxt(c)=q 的都要改成 nxt(c)=r 了,最后 link(S+c)=r 完事。

感觉描述起来挺烦的但理解以后并不难记难写。

另外关于求 endpos 啊。我们计算完 S 对应的节点后,在该节点的 endpos 集合中加入 |S| 这个数,然后一个节点的真实 endpos 应该是 parent tree 中,所有子树的并,所以可以建出 SAM 以后线段树合并。

一些例题

Luogu 3804 【模板】后缀自动机

求一个串 S 中所有出现次数大于 1 的子串的:出现次数 * 长度的最大值。

|S|106


出现次数大于 1 也就是 |endpos|>1 且我们注意到对于一个 endpos 等价类我们其实只关注 len(x),也就是里面最长的那个串。

代码
#include<bits/stdc++.h>
#define rep(i,a,b) for(int i=(a);i<=(b);i++)
#define per(i,a,b) for(int i=(a);i>=(b);i--)
#define op(x) ((x&1)?x+1:x-1)
#define odd(x) (x&1)
#define even(x) (!odd(x))
#define lc(x) (x<<1)
#define rc(x) (lc(x)|1)
#define lowbit(x) (x&-x)
#define mp(x,y) make_pair(x,y)
typedef long long ll;
typedef unsigned long long ull;
typedef double db;
using namespace std;
const int MAXN=1e6+10,MAXC=30;
char s[MAXN];
int n;
ll ans;

struct Node{int fa,len,nxt[MAXC];};
namespace SAM{
    Node t[MAXN*2];
    vector<int>e[MAXN*2];
    int sz,lst,siz[MAXN*2];
    void init(){
        rep(i,0,sz)e[i].clear();
        sz=lst=0;
        memset(t[0].nxt,0,sizeof t[0].nxt);
        t[0].fa=-1;t[0].len=0;
    }
    int newnode(){
        sz++;
        memset(t[sz].nxt,0,sizeof t[sz].nxt);
        return sz;
    }
    void extend(int ch){
        int cur=newnode();t[cur].len=t[lst].len+1;siz[cur]=1;
        int p=lst;
        while(p!=-1 && !t[p].nxt[ch]){
            t[p].nxt[ch]=cur;
            p=t[p].fa;
        }
        if(p==-1){
            t[cur].fa=0;
        }else{
            int q=t[p].nxt[ch];
            if(t[q].len==t[p].len+1)t[cur].fa=q;
            else{
                int r=newnode();
                rep(j,0,25)t[r].nxt[j]=t[q].nxt[j];
                t[r].len=t[p].len+1;
                t[r].fa=t[q].fa;

                while(p!=-1 && t[p].nxt[ch]==q){
                    t[p].nxt[ch]=r;
                    p=t[p].fa;
                }

                t[q].fa=t[cur].fa=r;
            }
        }
        lst=cur;
    }
    void build(){
        rep(i,1,sz)e[t[i].fa].push_back(i);
    }
    void dfs(int u){
        for(auto v:e[u])dfs(v),siz[u]+=siz[v];
        if(siz[u]>1)ans=max(ans,1ll*siz[u]*t[u].len);
    }
};
int sz[MAXN*2];
vector<int>res;
int main(){
    
    cin>>(s+1);
    n=strlen(s+1);

    SAM::init();
    rep(i,1,n){
        SAM::extend(s[i]-'a');
    }
    SAM::build();
    SAM::dfs(0);

    cout<<ans;
    return 0;
}

TJOI2015 弦论

SAM 上任意一个子串都唯一对应了一条从初始节点出发的路径,且把路径上的转移依次写出就得到了这个串。

又因为 SAM 是 DAG 所以我们可以设 f(u) 是从节点 u 出发的路径条数,然后变成了一个经典问题。

t=1 的时候转移从 f(u)=f(v)+1 变成 f(v)+|endposu| 即可。

事实上 DAG 上字典序第 k 小路径是一个可以加强的问题,使用 DAG 链剖分,本题可以出成多次询问,在 O(nΣ+qlog2n) 的时间内解决。

另解:考虑 SA;t=0 的话就是每次产生 leniheighti 个新的子串而且还是按照大小排好序的;t=1 的话考虑逐位确定,任何时刻待选的串都会在一段区间 [sal,sar] 里,如果确定了前 x 位,这里所有 height=x 的地方会把区间划分成若干个子区间,每个子区间的第 x+1 位都是不同的,我们看一下选择哪个子区间即可。时间复杂度 O(nlogn) 瓶颈在构建 SA,可以线性。

代码
#include<bits/stdc++.h>
#define rep(i,a,b) for(int i=(a);i<=(b);i++)
#define per(i,a,b) for(int i=(a);i>=(b);i--)
#define op(x) ((x&1)?x+1:x-1)
#define odd(x) (x&1)
#define even(x) (!odd(x))
#define lc(x) (x<<1)
#define rc(x) (lc(x)|1)
#define lowbit(x) (x&-x)
#define mp(x,y) make_pair(x,y)
typedef long long ll;
typedef unsigned long long ull;
typedef double db;
using namespace std;
const ll MAXN=5e5+10,MAXC=26;
int n,k,t;
char s[MAXN];
string res;

namespace SAM{
    int fa[MAXN*2],len[MAXN*2],nxt[MAXN*2][MAXC],last,tot;
    int sz[MAXN*2],vis[MAXN*2];
    ll f[MAXN*2];
    vector<int>e[MAXN*2];
    void init(){
        rep(i,0,tot)e[i].clear();
        last=tot=0;
        fa[0]=-1;len[0]=0;    
        memset(sz,0,sizeof sz);
        memset(nxt[0],0,sizeof nxt[0]);
        memset(f,0,sizeof f);
        memset(vis,0,sizeof vis);
    }
    int newnode(){
        tot++;
        memset(nxt[tot],0,sizeof nxt[tot]);
        return tot;
    }
    void extend(int ch){
        int cur=newnode();
        int p=last;len[cur]=len[p]+1;
        while(p!=-1 && !nxt[p][ch]){
            nxt[p][ch]=cur;
            p=fa[p];
        }
        if(p==-1)fa[cur]=0;
        else{
            int q=nxt[p][ch];
            if(len[q]==len[p]+1)fa[cur]=q;
            else{
                int r=newnode();
                len[r]=len[p]+1;
                fa[r]=fa[q];
                rep(j,0,25)nxt[r][j]=nxt[q][j];
                while(p!=-1 && nxt[p][ch]==q){
                    nxt[p][ch]=r;
                    p=fa[p];
                }
                fa[q]=fa[cur]=r;
            }
        }
        last=cur;sz[last]=1;
    }
    void build(){rep(i,1,tot)e[fa[i]].push_back(i);}
    void dfs(int u){for(auto v:e[u])dfs(v),sz[u]+=sz[v];}
    void dp(int u){
        if(vis[u])return;
        vis[u]=1;
        rep(j,0,25)if(nxt[u][j]){
            dp(nxt[u][j]);
            f[u]+=f[nxt[u][j]];
        }
        if(u){
            if(t==0)f[u]++;
            else f[u]+=sz[u];
        }
    }
    void search(int u,int k){
        if(f[u]<k){
            cout<<"-1\n";
            return;
        }
        int cnt=(u)?((t==0)?(1):(sz[u])):(0);
        if(k<=cnt){
            cout<<res<<"\n";
            return;
        }
        k-=cnt;
        rep(j,0,25)if(nxt[u][j]){
            if(k<=f[nxt[u][j]]){
                char ch='a'+j;
                res.append(1,ch);
                search(nxt[u][j],k);
                return;
            }
            k-=f[nxt[u][j]];
        }
    }
};
int main(){
    ios::sync_with_stdio(false);
    cin>>(s+1)>>t>>k;
    n=strlen(s+1);
    SAM::init();
    rep(i,1,n)SAM::extend(s[i]-'a');
    //
    SAM::build();
    SAM::dfs(0);
    SAM::dp(0);
    //
    SAM::search(0,k);

    return 0;
}

SDOI2016 生成魔咒

也就是对所有除了初始节点以外的点实时地求 lenilen(linki),SAM 加入字符的时候就可以直接维护。基础题。

代码
#include<bits/stdc++.h>
#define rep(i,a,b) for(int i=(a);i<=(b);i++)
#define per(i,a,b) for(int i=(a);i>=(b);i--)
#define op(x) ((x&1)?x+1:x-1)
#define odd(x) (x&1)
#define even(x) (!odd(x))
#define lc(x) (x<<1)
#define rc(x) (lc(x)|1)
#define lowbit(x) (x&-x)
#define mp(x,y) make_pair(x,y)
typedef long long ll;
typedef unsigned long long ull;
typedef double db;
using namespace std;
const int MAXN=1e5+10;
int n;
ll ans;
namespace SAM{
    int fa[MAXN*2],len[MAXN*2],tot,last;
    map<int,int>nxt[MAXN*2];
    int calc(int x){return len[x]-len[fa[x]];}
    void init(){
        rep(i,0,tot)nxt[i].clear();
        last=tot=0;
        fa[0]=-1;len[0]=0;
    }
    int newnode(){return ++tot;}
    //
    void extend(int ch){
        int cur=newnode(),p=last;
        len[cur]=len[p]+1;
        while(p!=-1 && nxt[p].find(ch)==nxt[p].end()){
            nxt[p][ch]=cur;
            p=fa[p];
        }
        if(p==-1)fa[cur]=0;
        else{
            int q=nxt[p][ch];
            if(len[q]==len[p]+1)fa[cur]=q;
            else{
                int r=newnode();
                ans-=calc(q);
                nxt[r]=nxt[q];
                len[r]=len[p]+1;
                fa[r]=fa[q];
                while(p!=-1 && nxt[p].find(ch)!=nxt[p].end() && nxt[p][ch]==q){
                    nxt[p][ch]=r;
                    p=fa[p];    
                }
                fa[q]=fa[cur]=r;
                ans+=calc(q),ans+=calc(r);
            }
        }

        ans+=calc(cur);
        last=cur;
    }
};
int main(){
    ios::sync_with_stdio(false);
    cin>>n;
    SAM::init();
    rep(i,1,n){
        int ch;cin>>ch;
        SAM::extend(ch);
        cout<<ans<<"\n";
    }
    
    return 0;
}

SPOJ Longest Common Substring II

考虑两个串的 LCS 怎么做,先建出一个串 S 的 SAM,然后我们对于另一个串 T,假设我们已经知道了 fi 表示 Ti 前缀的最长在 S 出现过后缀的长度,如果能求 f 那么 maxf 就是答案。

我们考虑 fi 对应的前缀在 SAM 中属于节点 x 的 endpos 等价类,如果 nxtx(ch) 不为 0,则直接 fi+1=fix:=nxtx(ch) 即可:否则我们不断跳 link 去匹配就行:其实很类似 ACAM 的跳 fail?

考虑实际上我们得到的是一个 gx 表示第 x 个 endpos 等价类里的若干个串,最长的在 T 中出现过的串的长度。

由于 endpos 等价类里的字符串有着连续性所以我们对除了第一个串以外的所有串都求一下这个东西然后取 min,就是答案了。

但我们每次求的并不是正确的 gx,原因就是当你在 x 处成功拓展的时候要考虑到 parent tree 上所有的祖先也会被更新到(而且这个时候就是 gy=leny 了)。

所以每次做完以后要对 SAM 上的所有点去更新一下。这里的复杂度是 O(k×|S1|) 的。

代码
#include<bits/stdc++.h>
#define rep(i,a,b) for(int i=(a);i<=(b);i++)
#define per(i,a,b) for(int i=(a);i>=(b);i--)
#define op(x) ((x&1)?x+1:x-1)
#define odd(x) (x&1)
#define even(x) (!odd(x))
#define lc(x) (x<<1)
#define rc(x) (lc(x)|1)
#define lowbit(x) (x&-x)
#define mp(x,y) make_pair(x,y)
typedef long long ll;
typedef unsigned long long ull;
typedef double db;
using namespace std;
const int MAXN=1e5+10;
void tomin(int& x,int y){x=min(x,y);}
void tomax(int& x,int y){x=max(x,y);}
int n;
char s[MAXN];

namespace SAM{
    const int MAXN=2e5+10,MAXC=26;
    int fa[MAXN],len[MAXN],nxt[MAXN][MAXC],last,tot;
    int f[MAXN],g[MAXN];
    vector<int>e[MAXN];
    void init(){fa[0]=-1;len[0]=0;}
    int newnode(){return ++tot;}
    void extend(int ch){
        int cur=newnode(),p=last;len[cur]=len[p]+1;
        while(p!=-1 && !nxt[p][ch]){
            nxt[p][ch]=cur,p=fa[p];
        }
        if(p==-1)fa[cur]=0;
        else{
            int q=nxt[p][ch];
            if(len[q]==len[p]+1)fa[cur]=q;
            else{
                int r=newnode();
                rep(j,0,25)nxt[r][j]=nxt[q][j];
                len[r]=len[p]+1;fa[r]=fa[q];
                while(p!=-1 && nxt[p][ch]==q){
                    nxt[p][ch]=r,p=fa[p];
                }
                fa[q]=fa[cur]=r;        
            }
        }
        last=cur;
    }
    void build(){rep(i,1,tot)f[i]=len[i],e[fa[i]].push_back(i);}
    void pre(){rep(i,0,tot)g[i]=0;}
    void spread(int u){for(auto v:e[u])spread(v),tomax(g[u],g[v]);}
    void upd(){rep(i,0,tot)tomin(f[i],g[i]);}
    int conclude(){
        int res=0;
        rep(i,0,tot)tomax(res,f[i]);
        return res;
    }
    void add(char* s,int n){
        int p=0,res=0;
        rep(i,1,n){
            int ch=s[i]-'a';
            while(p!=-1 && !nxt[p][ch])p=fa[p],res=(p==-1)?(0):(len[p]);
            if(p==-1){p=0;continue;}
            int q=nxt[p][ch];
            res++;
            tomax(g[q],res);
            p=q;
        }
    }
};
int main(){
    cin>>(s+1);
    n=strlen(s+1);
    SAM::init();
    rep(i,1,n)SAM::extend(s[i]-'a');
    SAM::build();
    while(cin>>(s+1)){
        n=strlen(s+1);
        SAM::pre();
        SAM::add(s,n);
        SAM::spread(0);
        SAM::upd();
    }
    cout<<SAM::conclude();
    return 0;
}

另解:考虑 SA 解决这个问题,首先我们把所有串拼在一起中间加入 k1 个独一无二的字符隔开然后二分长度。

现在问题变成了若干个连续段,每个元素都有一个属性 i,问是否有一段,属性 [1,k] 都出现过了。这个随便做了。

既然是 SAM 专题就不放 SA 代码了。

另解2:SAM其实还有一个解法,但我先别急,等我写完再急。

CF1037H Security

首先答案串一定是 x 的一段前缀加一个更大的字符,所以可能的方案也就 26×|x| 这个级别。

考虑在 SAM 上容易找到所有的节点,现在变成了问哪些节点的 endpos 中存在一个元素在一个区间范围内。

线段树合并以后即可。

这里注意,如果我们想合并完了再查询,而不是把查询挂在每个点,那么合并的时候需要新开点,而不是合并到原有的点上。

代码

CF666E Forensic Examination

1+m 个串拼接在一起,中间用两两不同的特殊字符分隔,最后建立 SAM,这是常用套路。

我们把询问离线下来,然后依次在 SAM 上沿着 s1,s2,...,sn 走。

走过 sr 后我们设当前节点位于 u,我们考虑所有关于 s[l,r] 的询问,则其在 SAM 上的对应位置一定是 u 在 parent tree 上的祖先且可以通过倍增找到。

现在相当于我们每次询问 SAM 上的一个等价类 u,其 endpos 是一个 [1,m] 的多重集,然后询问值域在 [L,R] 里的拿出来以后的众数。

这个就是经典的线段树合并维护 endpos 集合啊就做完了,时间复杂度 O((|Si|+q)×logn)

代码

CTSC2012 熟悉的文章

以下设 Σ 为串长总和。

分段这个事情让我们想到 dp,设 dp(i) 表示对 1i 划分得到的最优答案,则有:

f(i)=max{f(i1),f(j)+ij}

其中要满足 [i+1,j] 这一段长度大于等于 L 且在字典中出现过。

我们二分 L,变成了做 logΣ 次判定。

把字典拼接在一起中间用字符 2 分隔得到字典串 D,建立 SAM。我们考虑求出 gi 表示 S1,iD 出现过的最长后缀,显然可以在 O(|S|) 的时间内求出所有的 g

考虑 igi 显然是单调不降的也就是 j 的取值 [L,R] 是个滑动窗口,单调队列维护即可。

时间复杂度 O(ΣlogΣ)

代码
#include<bits/stdc++.h>
#define rep(i,a,b) for(int i=(a);i<=(b);i++)
#define per(i,a,b) for(int i=(a);i>=(b);i--)
#define op(x) ((x&1)?x+1:x-1)
#define odd(x) (x&1)
#define even(x) (!odd(x))
#define lc(x) (x<<1)
#define rc(x) (lc(x)|1)
#define lowbit(x) (x&-x)
#define mp(x,y) make_pair(x,y)
typedef long long ll;
typedef unsigned long long ull;
typedef double db;
using namespace std;
const int MAXN=2.2e6+10;
void tomax(int& x,int y){x=max(x,y);}
//
int n,m,q;
string tmp;
int s[MAXN],t[MAXN],len;
namespace SAM{
    const int MAXN=4.4e6+10;
    int len[MAXN],fa[MAXN],nxt[MAXN][3],last,tot;
    void init(){fa[0]=-1;}
    void extend(int ch){
        int cur=++tot,p=last;
        len[cur]=len[p]+1;
        while(p!=-1 && !nxt[p][ch])nxt[p][ch]=cur,p=fa[p];
        if(p==-1)fa[cur]=0;
        else{
            int q=nxt[p][ch];
            if(len[q]==len[p]+1)fa[cur]=q;
            else{
                int r=++tot;
                len[r]=len[p]+1,fa[r]=fa[q];
                rep(j,0,2)nxt[r][j]=nxt[q][j];
                while(p!=-1 && nxt[p][ch]==q)nxt[p][ch]=r,p=fa[p];
                fa[q]=fa[cur]=r;
            }
        }
        last=cur;
    }
}

void solve();
int main(){
    ios::sync_with_stdio(false);
    cin>>q>>m;
    rep(i,1,m){
        cin>>tmp;
        int len=tmp.length();
        rep(j,1,len)s[++n]=tmp[j-1]-'0';
        s[++n]=2;
    }
    SAM::init();
    rep(i,1,n)SAM::extend(s[i]);
    //
    rep(i,1,q){
        cin>>tmp;
        solve();
    }

    return 0;
}
int qu[MAXN],head,rear;
int dp[MAXN];
int check(int k){
    head=rear=0;
    dp[0]=0;
    int u=0,L=0;
    rep(i,1,len){
        dp[i]=dp[i-1];
        if(i>=k){
            while(head<rear && dp[qu[rear-1]]-qu[rear-1] <= dp[i-k]-(i-k))rear--;
            qu[rear++]=i-k;
        }
        int ch=t[i];
        while(u!=-1 && !SAM::nxt[u][ch]){
            u=SAM::fa[u];
            if(u==-1)L=0;else L=SAM::len[u];
        }
        if(u!=-1 && SAM::nxt[u][t[i]]){
            u=SAM::nxt[u][ch];
            L++;
        }
        while(head<rear && i-qu[head]>L)head++;
        if(head<rear){
            int j=qu[head];
            tomax(dp[i],dp[j]+i-j);
        }
    }
    return 10*dp[len]>=9*len;
}
void solve(){
    len=tmp.length();
    rep(i,1,len)t[i]=tmp[i-1]-'0';
    int L=1,R=len,res=0;
    while(L<=R){
        int mid=(L+R)>>1;
        if(check(mid)){res=mid;L=mid+1;}
        else{R=mid-1;}
    }
    cout<<res<<"\n";
}

NOI2018 你的名字

首先容斥,算出现的串数目。

考虑这个带区间的玩意就不是很适合在 SAM 上跑,所以我们考虑把这个 S 拉出来建立 SAM。

然后每次询问显然可以在 O(|T|) 时间内跑出所有 gi,表示串 T[1...i]S[l...r] 中出现的最长后缀长度。

显然答案就是 gi 去掉重复计算的个数。

去重的话我们考虑对 |T| 也建立 SAM,对于一个 endpos 等价类,我们随便从里面拿一个位置出来,设为 y

则通过 gy 我们可以知道这个等价类里有多少串在 [l,r] 中是出现过的,设为 cnt,则答案修正 cnt×(|endpos|1) 即可。

代码

posted on   Cry_For_theMoon  阅读(108)  评论(1编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 按钮权限的设计及实现
 
点击右上角即可分享
微信分享提示