蒟蒻 OIer の 联赛考前模板速记

· · 算法·理论

说明

小朋友没写过文,心灵还很脆弱,求求别蛐蛐了,有问题告诉我我会改的……

也许在考前打板子的时候你觉得板子写挂是小问题,很容易调。但上了考场后,你的代码里可不只有板子,此时调起来的难度就大了很多。所以把板子一遍写对的能力是十分重要的。

所以,本文旨在简要地概括各联赛级模板的算法思想以辅助记忆。这里默认你会这些板子。

借楼预告今年的山东省 NOIP 迷惑行为大赏。

更新日志:

进度:完成。

文中给出代码的是作者惯用的写法,您有自己习惯的写法完全可以(而且最好这样做,因为考前改变一些习惯很危险)按照自己的习惯。

数据结构

数据结构模板尽管看上去很长,但其记忆难度其实是最低的。如果你还不能熟练记住,建议跟着下面的文字说明一步步画图,只要脑子里有图就能准确编写。

注:

Trie

适用于维护多个串。

算法流程:

时间复杂度:单次修改/查询 O(|s|)s 是操作的串的长度。

注意点:

:::success[代码(P2580)]

struct node{
    ll trans[30],is,vis;
};
ll n,q,p=1;
string s;
node a[5000010];
void add(ll now,ll dep){
    if(dep==s.length()){
        a[now].is=1;
        return ;
    }
    ll c=s[dep]-'a';
    if(!a[now].trans[c]){
        a[now].trans[c]=(p++);
    }
    add(a[now].trans[c],dep+1);
}
ll query(ll now,ll dep){
    if(dep==s.length()){
        if(!a[now].is){
            return 0;
        }
        if(!a[now].vis){
            a[now].vis=1;
            return 1;
        }
        return 2;;
    }
    ll c=s[dep]-'a';
    if(!a[now].trans[c]){
        return 0;
    }
    return query(a[now].trans[c],dep+1);
}
const string ans[]={"WRONG","OK","REPEAT"};
int main(){
    ios::sync_with_stdio(0);
    cin>>n;
    for(int i=0;i<n;i++){
        cin>>s;
        add(0,0);
    }
    cin>>q;
    while(q--){
        cin>>s;
        cout<<ans[query(0,0)]<<endl;
    }
    return 0;
}

:::

ST 表

问题:多次区间查询,且单个位置贡献多次不影响查询的答案(例如最值)。

算法流程:

复杂度:预处理 O(n\log n),查询单次 O(1)

:::success[代码(P3865)]

for(int i=1;i<17;i++){
    for(int j=0;j<n;j++){
        st[j][i]=max(st[j][i-1],st[min(j+(1ll<<i-1),n-1)][i-1]);
    } 
}
while(q--){
    cin>>l>>r;
    r--;l--;
    cout<<max(st[l][lg[r-l]],st[r-(1<<lg[r-l])+1][lg[r-l]])<<endl;
}

:::

线段树

如果你对 zkw 不是很熟悉就别写,zkw 也不是就那么完美。这里写最有可读性且不亏空间(因为其他值开 ll 而左右儿子只需要 int 所以空间利用率最低 50\%,但数组只需要二倍所以必然不亏空间)的结构体版本。

问题:多次单点或区间操作,维护容易进行区间合并的信息。

算法流程:

复杂度:建树 O(n\log n),单次操作 O(\log n),其中 n 是叶子数。前两者均需要乘区间合并的复杂度。

:::success[代码(P3372)]

struct node{
    int ls,rs;
    ll s,tag;
};
node t[200010];
ll n,q,a[100010],op,u,v,w,p=2;
void build(ll now,ll l,ll r){
    if(l==r){
        t[now].s=a[l];
        return ;
    }
    ll mid=(l+r>>1);
    t[now].ls=(p++);
    build(t[now].ls,l,mid);
    t[now].rs=(p++);
    build(t[now].rs,mid+1,r);
    t[now].s=t[t[now].ls].s+t[t[now].rs].s;
}
void pushdown(ll now,ll l,ll r){
    ll mid=(l+r>>1);
    t[t[now].ls].s+=t[now].tag*(mid-l+1);
    t[t[now].ls].tag+=t[now].tag;
    t[t[now].rs].s+=t[now].tag*(r-mid);
    t[t[now].rs].tag+=t[now].tag;
    t[now].tag=0;
}
void upd(ll now,ll l,ll r,ll L,ll R,ll c){
    pushdown(now,l,r);
    if(l>=L&&r<=R){
        t[now].tag+=c;
        t[now].s+=(r-l+1)*c;
        return ;
    }
    ll mid=(l+r>>1);
    if(L<=mid){
        upd(t[now].ls,l,mid,L,R,c);
    } 
    if(R>mid){
        upd(t[now].rs,mid+1,r,L,R,c);
    }
    t[now].s=t[t[now].ls].s+t[t[now].rs].s;
}
ll query(ll now,ll l,ll r,ll L,ll R){
    pushdown(now,l,r);
    if(l>=L&&r<=R){
        return t[now].s;
    }
    ll mid=(l+r>>1),ans=0;
    if(L<=mid){
        ans+=query(t[now].ls,l,mid,L,R);
    } 
    if(R>mid){
        ans+=query(t[now].rs,mid+1,r,L,R);
    }
    return ans;
}

:::

例题:

单调数据结构

单调队列

问题(例):求序列上所有长度为 k 的子区间的最值。

算法流程:

复杂度:O(n)

:::success[代码(P1886)] deque 非常慢。考场上不要用。

deque<ll> q;
ll n,k,a[1000010];
int main(){
    cin>>n>>k;
    for(int i=0;i<n;i++){
        cin>>a[i];
    }
    for(int i=0;i<n;i++){
        while(q.size()&&i-q.front()+1>k){
            q.pop_front();
        }
        while(q.size()&&a[q.back()]>a[i]){
            q.pop_back();
        }
        q.push_back(i);
        if(i>=k-1){
            cout<<a[q.front()]<<' ';
        }
    }
    cout<<endl;
    q.clear();
    for(int i=0;i<n;i++){
        while(q.size()&&i-q.front()+1>k){
            q.pop_front();
        }
        while(q.size()&&a[q.back()]<a[i]){
            q.pop_back();
        }
        q.push_back(i);
        if(i>=k-1){
            cout<<a[q.front()]<<' ';
        }
    }
    return 0;
}

:::

单调栈

问题(例):求序列上每个元素后第一个比它大的元素。

算法流程:

复杂度:O(n)

:::success[代码(P5788)] stack 非常慢。考场上不要用。

ll n,a[3000010],ans[3000010];
stack<ll> st;
int main(){
    ios::sync_with_stdio(0);
    cin>>n;
    for(int i=1;i<=n;i++){
        cin>>a[i];
    }
    for(int i=n;i;i--){
        while(st.size()&&a[st.top()]<=a[i]){
            st.pop();
        }
        if(st.size()){
            ans[i]=st.top();
        }
        st.push(i);
    }
    for(int i=1;i<=n;i++){
        cout<<ans[i]<<' '; 
    }
    return 0;
}

:::

例题:

Treap

问题:维护平衡的二叉搜索树。

算法流程:

图论

图论部分的理解记忆同样重要,不过此外需要注意一些常犯错的小细节。

树的直径

问题:给定一棵无边权树,求其最长的简单路径。

算法流程:

复杂度:O(n)

注意点:

:::success[代码(B4016)]

ll n,u,v,dep[100010],l[100010],r[100010],len[100010];
vector<ll> a[100010];
void dfs(ll now,ll lst){
    dep[now]=dep[lst]+1;
    l[now]=now;r[now]=now;len[now]=0;
    for(int i=0;i<a[now].size();i++){
        if(a[now][i]!=lst){
            dfs(a[now][i],now);
            if(len[now]<max(dep[l[now]],dep[r[now]])+max(dep[l[a[now][i]]],dep[r[a[now][i]]])-(dep[now]<<1)){
                if(dep[l[now]]<dep[r[now]]){
                    swap(l[now],r[now]);
                }
                if(dep[l[a[now][i]]]<dep[r[a[now][i]]]){
                    r[now]=r[a[now][i]];
                }
                else{
                    r[now]=l[a[now][i]];
                }
                len[now]=dep[l[now]]+dep[r[now]]-(dep[now]<<1);
            }
            if(len[now]<len[a[now][i]]){
                l[now]=l[a[now][i]];
                r[now]=r[a[now][i]];
                len[now]=len[a[now][i]];
            }
        }
    }
}
int main(){
    ios::sync_with_stdio(0);
    cin>>n;
    for(int i=1;i<n;i++){
        cin>>u>>v;
        a[u].push_back(v);
        a[v].push_back(u);
    }
    dfs(1,1);
    cout<<len[1];
    return 0;
}

:::

例题:

倍增 LCA

问题:给定两个点,求这两个点深度最大的公共祖先。

算法流程:

复杂度:O(n\log n)-O(\log n)

注意点:

:::success[代码(P3379)] 需要快读快写才能通过,此时单点用时不超过 1.2 秒。

ll n,q,u,v,rt,fa[500010][30],dep[500010];
vector<ll> a[500010];
void dfs(ll now,ll lst){
    fa[now][0]=lst;
    dep[now]=dep[lst]+1;
    for(int i=0;i<a[now].size();i++){
        if(a[now][i]!=lst){
            dfs(a[now][i],now);
        }
    }
}
ll lca(ll x,ll y){
    if(dep[x]<dep[y]){
        swap(x,y);
    }
    for(int i=19;~i;i--){
        if(dep[fa[x][i]]>=dep[y]){
            x=fa[x][i];
        }
    }
    if(x==y){
        return x;
    }
    for(int i=19;~i;i--){
        if(fa[x][i]!=fa[y][i]){
            x=fa[x][i];
            y=fa[y][i];
        }
    }
    return fa[x][0];
}
int main(){
    cin>>n>>q>>rt;
    for(int i=1;i<n;i++){
        cin>>u>>v;
        a[u].push_back(v);
        a[v].push_back(u);
    }
    dfs(rt,rt);
    for(int i=1;i<20;i++){
        for(int j=1;j<=n;j++){
            fa[j][i]=fa[fa[j][i-1]][i-1];
        }
    }
    while(q--){
        cin>>u>>v;
        cout<<lca(u,v)<<endl;
    }
    return 0;
}

:::

拓扑排序

问题:给定一个 DAG,将结点排序使得所有边的终点都在起点后面。

算法流程:

复杂度:O(n)

:::success[代码(B3644)]

ll n,u,deg[110];
vector<ll> a[110];
queue<ll> q;
int main(){
    ios::sync_with_stdio(0);
    cin>>n;
    for(int i=1;i<=n;i++){
        cin>>u;
        while(u){
            a[i].push_back(u);
            deg[u]++;
            cin>>u;
        }
    }
    for(int i=1;i<=n;i++){
        if(deg[i]==0){
            q.push(i);
        }
    }
    while(q.size()){
        ll now=q.front();
        q.pop();
        cout<<now<<' ';
        for(int i=0;i<a[now].size();i++){
            deg[a[now][i]]--;
            if(!deg[a[now][i]]){
                q.push(a[now][i]);
            }
        }
    }
    return 0;
}

:::

例题:

最短路

单源最短路:Dijkstra

问题:在边权非负的有向图上求从一个起点出发到其他点的最短路。

算法流程:

复杂度:O(m\log n)

注意点:

:::success[代码(P3371/P4779)] 看上去在状态里面维护一遍 dis 是冗余的,实则是 C++ 不允许给两个非自定义的类型重载运算符。

struct node{
    ll now,dis;
};
bool operator <(node x,node y){
    return x.dis>y.dis;
}
ll n,m,s,u,v,w,dis[100010],vis[100010];
vector<ll> a[100010],b[100010];
priority_queue<node> q;
void dij(){
    fill(dis+1,dis+n+1,1ll<<62);
    dis[s]=0;
    q.push({s,0});
    while(!q.empty()){
        node tmp=q.top();
        q.pop();
        if(vis[tmp.now])continue;
        vis[tmp.now]=1;
        for(int i=0;i<a[tmp.now].size();i++){
            if(!vis[a[tmp.now][i]]&&tmp.dis+b[tmp.now][i]<dis[a[tmp.now][i]]){
                dis[a[tmp.now][i]]=tmp.dis+b[tmp.now][i];
                q.push({a[tmp.now][i],tmp.dis+b[tmp.now][i]});
            }
        }
    }
}
int main(){
    ios::sync_with_stdio(0);
    cin>>n>>m>>s;
    for(int i=0;i<m;i++){
        cin>>u>>v>>w;
        a[u].push_back(v);
        b[u].push_back(w);
    }
    dij();
    for(int i=1;i<=n;i++){
        cout<<dis[i]<<' ';
    }
    return 0;
}

:::

单源最短路:SPFA

问题:在有负边权的有向图上求从一个起点出发到其他点的最短路,或者判断图中是否有负环。

算法流程:

复杂度:O(nm)

注意点:

:::success[代码(P3371)] inq 数组表示当前点是否在队列内,cnt 数组记录当前点进队的次数。

queue<ll> q;
void spfa(){
    fill(dis+1,dis+n+1,(1ll<<31)-1);
    dis[s]=0;
    cnt[s]=1;
    inq[s]=1;
    q.push(s);
    while(!q.empty()){
        ll now=q.front();
        q.pop();
        inq[now]=0;
        for(int i=0;i<a[now].size();i++){
            if(dis[now]+b[now][i]<dis[a[now][i]]){
                dis[a[now][i]]=dis[now]+b[now][i];
                if(!inq[a[now][i]]){
                    q.push(a[now][i]);
                    inq[a[now][i]]=1;
                    cnt[a[now][i]]++;
                    if(cnt[a[now][i]]>=n){
            //判负环,板题中不需要
                        cout<<"orz"<<endl;
                        return ;
                    }
                }
            }
        }
    }
}

:::

全源最短路:Floyd

问题:求一个有向带边权图中任两点间的最短路。

算法流程:

复杂度:O(n^3)

注意点:

:::success[代码]

ll n,m,u,v,w,dp[110][110];
int main(){
    ios::sync_with_stdio(0);
    cin>>n>>m;
    memset(dp,0x3f,sizeof(dp));
    for(int i=0;i<m;i++){
        cin>>u>>v>>w;
        dp[u][v]=min(dp[u][v],w);
        dp[v][u]=min(dp[v][u],w);
    }
    for(int i=1;i<=n;i++){
        dp[i][i]=0;
    }
    for(int i=1;i<=n;i++){
        for(int j=1;j<=n;j++){
            for(int k=1;k<=n;k++){
                dp[j][k]=min(dp[j][i]+dp[i][k],dp[j][k]);
            }
        }
    }
    for(int i=1;i<=n;i++){
        for(int j=1;j<=n;j++){
            cout<<dp[i][j]<<' ';
        }
        cout<<endl;
    }
    return 0;
}

:::

最小生成树

Prim 算法

问题:求一个无向图边权和最小的生成树。

算法流程:

复杂度:O(m\log m)

:::success[代码(P3366)]

struct node{
    ll v,w;
};
bool operator <(node x,node y){
    return x.w>y.w;
}
ll n,m,u,v,w,vis[5010];
vector<ll> a[5010],b[5010];
priority_queue<node> q;
int main(){
    ios::sync_with_stdio(0);
    cin>>n>>m;
    for(int i=0;i<m;i++){
        cin>>u>>v>>w;
        a[u].push_back(v);
        b[u].push_back(w);
        a[v].push_back(u);
        b[v].push_back(w);
    }
    ll cnt=0,ans=0;
    vis[1]=1;
    for(int i=0;i<a[1].size();i++){
        q.push({a[1][i],b[1][i]});
    }
    while(!q.empty()){
        node tmp=q.top();
        q.pop();
        if(vis[tmp.v])continue;
        vis[tmp.v]=1;
        ans+=tmp.w;
        cnt++;
        if(cnt==n-1)break;
        for(int i=0;i<a[tmp.v].size();i++){
            if(!vis[a[tmp.v][i]]){
                q.push({a[tmp.v][i],b[tmp.v][i]});
            }
        }
    }
    if(cnt==n-1){
        cout<<ans;
    }
    else{
        cout<<"orz";
    }
    return 0;
}

:::

Kruskal 算法

问题:求一个无向图边权和最小的生成树。

算法流程:

算法流程(并查集):

复杂度:O(m\log n)O(m\alpha(n)),视并查集实现而定。

:::success[代码(P3366)] 这里并查集只进行了路径压缩。

struct node{
    ll u,v,w;
};
bool operator <(node x,node y){
    return x.w<y.w;
}
ll n,m,cnt,fa[200010];
node a[200010];
ll fnd(ll x){
    if(fa[x]!=x)fa[x]=fnd(fa[x]);
    return fa[x];
}
void merg(ll x,ll y){
    fa[fnd(x)]=fa[y];
}
int main(){
    ios::sync_with_stdio(0);
    cin>>n>>m;
    for(int i=0;i<m;i++){
        cin>>a[i].u>>a[i].v>>a[i].w;
    }
    sort(a,a+m);
    ll cnt=0,ans=0;
    for(int i=1;i<=n;i++){
        fa[i]=i;
    }
    for(int i=0;i<m;i++){
        if(fnd(a[i].u)!=fnd(a[i].v)){
            merg(a[i].u,a[i].v);
            ans+=a[i].w;
            cnt++;
            if(cnt==n-1){
                break;
            }
        }
    }
    if(cnt==n-1){
        cout<<ans;
    }
    else{
        cout<<"orz";
    }
    return 0;
}

:::

例题(最小生成树):

例题(Kruskal):

连通性问题(Tarjan 算法)

无向图·割边、边双

问题:求无向图中的各割边。

算法流程:

复杂度:O(m)

注意点:

:::success[代码(P8436)] 这里只给出了第一次 DFS 的代码;一条边的 is 数组被标记为 1 表示其为割边,另外因为需要再一次 DFS 求具体的边双,故再将非割边树边标记为 2,方便进行第二次 DFS。

void tarjan(ll now,ll lst,ll lstedge){
    fa[now]=lst;
    dfn[now]=++p;
    low[now]=dfn[now];
    vis[now]=1;
    ll mx=0;
    for(int i=0;i<a[now].size();i++){
        if(!dfn[a[now][i]]){
            tarjan(a[now][i],now,id[now][i]);
            low[now]=min(low[now],low[a[now][i]]);
            is[id[now][i]]=2;
            if(dfn[now]<low[a[now][i]]){
                is[id[now][i]]=1;
            }
        }
        else if(id[now][i]!=lstedge){
            low[now]=min(low[now],dfn[a[now][i]]);
        }
    }
}

:::

无向图·割点、点双

问题:求无向图中的各割点。

算法流程:

复杂度:O(n)

注意点:

:::success[代码(P3388/P8435)] 在 P8435 中为了准确找到各点双,我们还需要额外记录各边是否在点双内,所以仍然保留了 P8436 中的 is 数组,并额外增加 isp 数组记录各点是否为割点。

void tarjan(ll now,ll lst,ll lstedge){
    ll cnt=0;
    fa[now]=lst;
    dfn[now]=++p;
    low[now]=dfn[now];
    vis[now]=1;
    ll mx=0;
    for(int i=0;i<a[now].size();i++){
        if(!dfn[a[now][i]]){
            tarjan(a[now][i],now,id[now][i]);
            low[now]=min(low[now],low[a[now][i]]);
            is[id[now][i]]=1;
            if(dfn[now]<=low[a[now][i]]){
                is[id[now][i]]=2;
                cnt++;
            }
        }
        else if(id[now][i]!=lstedge){
            low[now]=min(low[now],dfn[a[now][i]]);
        }
    }
    if(lstedge==-1){
        isp[now]=(cnt>=2);
    }
    else isp[now]=(cnt>=1);
}

:::

有向图·缩点(强连通分量,SCC)

问题:给定一张有向图,找出其所有的强连通分量。

算法流程:

复杂度:O(n)

注意点:

:::success[代码(P3387)] 题里有一个拓扑排序的部分。

ll n,m,u,v,dfn[10010],low[10010],ins[10010],bel[10010],p,pos,deg[10010],dp[10010],sz[10010],w[10010];
//ins 是否在栈中,bel 属于哪个 scc,sz 存 scc 大小,deg 和 dp 用于拓扑排序 
vector<ll> aa[10010],a[10010];
//aa 存原图,a 存新图
stack<ll> st;
queue<ll> q;
void dfs(ll now,ll lst){
    dfn[now]=(++pos);
    low[now]=pos;
    st.push(now);
    ins[now]=1;
    for(int i=0;i<aa[now].size();i++){
        if(!dfn[aa[now][i]]){
            dfs(aa[now][i],now);
            low[now]=min(low[now],low[aa[now][i]]);
        }
        else if(ins[aa[now][i]]){
            low[now]=min(low[now],dfn[aa[now][i]]);
        }
    }
    if(low[now]==dfn[now]){
        while(st.top()!=now){
            sz[p]+=w[st.top()];
            ins[st.top()]=0;
            bel[st.top()]=p;
            st.pop();
        } 
        sz[p]+=w[st.top()];
        ins[st.top()]=0;
        bel[st.top()]=(p++);
        st.pop();
    }
}
int main(){
    ios::sync_with_stdio(0);
    cin>>n>>m;
    for(int i=1;i<=n;i++){
        cin>>w[i];
    }
    for(int i=0;i<m;i++){
        cin>>u>>v;
        aa[u].push_back(v);
    }
    for(int i=1;i<=n;i++){
        if(!dfn[i]){
            dfs(i,i);
        }
    }
    for(int i=1;i<=n;i++){
        for(int j=0;j<aa[i].size();j++){
            if(bel[i]==bel[aa[i][j]]){
                continue;
            }
            a[bel[i]].push_back(bel[aa[i][j]]);
            deg[bel[aa[i][j]]]++;
        }
    }
    for(int i=0;i<p;i++){
        if(!deg[i]){
            q.push(i);
            dp[i]=sz[i];
        }
    }
    ll ans=0;
    while(q.size()){
        ll now=q.front();
        q.pop();
        ans=max(ans,dp[now]);
        for(int i=0;i<a[now].size();i++){
            dp[a[now][i]]=max(dp[a[now][i]],dp[now]+sz[a[now][i]]);
            deg[a[now][i]]--;
            if(!deg[a[now][i]]){
                q.push(a[now][i]);
            }
        }
    }
    cout<<ans;
    return 0;
}

:::

字符串

2025 版大纲中,联赛范围内需要背模板的字符串算法只有 KMP 和马拉车(显然哈希是不用背的)。这两种算法的代码都不长,但都比较难以理解,所以一定确保背诵的准确性。

为了防止被某个审核打回,特此声明:以下记 s[i,j] 表示 \overline{s_i,s_{i+1},\dots,s_j}

哈希

问题:判断两个集合或序列是否相等。

本身显然没啥可背的,不过可以背几个大模数。当然考场上花三分钟从 10^9 开始枚举跑素数判断也可以。

模板题:

KMP 算法

问题:给定待匹配的字符串 s 和模式串 t,求 s 中与 t 相等的子串的个数。

算法流程:

复杂度:O(|s|+|t|)

:::success[代码(P3375)]

string s,t;
ll fail[1000010];
int main(){
    ios::sync_with_stdio(0);
    cin>>s>>t;
    ll p;
    for(int i=1;i<t.length();i++){
        p=fail[i];
        while(p&&t[p]!=t[i]){
            p=fail[p];
        }
        if(t[p]==t[i])fail[i+1]=p+1;
        else fail[i+1]=0;
    }
    p=0;
    for(int i=0;i<s.length();i++){
        while(p&&s[i]!=t[p]){
            p=fail[p];
        }
        if(s[i]==t[p]){
            p++;
        }
        if(p==t.length()){
            cout<<i+2-t.length()<<endl;
            p=fail[p];
        }
    }
    for(int i=1;i<=t.length();i++){
        cout<<fail[i]<<' ';
    }
    return 0;
}

:::

Manacher / 马拉车算法

问题:给定字符串 s,求以 s 中每个位置为中心的最长回文串长。

算法流程:

复杂度:O(|s|)

:::success[代码(P3805)] 注意空间开两倍。

ll len[220000010],mx,mxi;
string tmp,s;
int main(){
    ios::sync_with_stdio(0);
    cin>>tmp;
    s='*';
    for(int i=0;i<tmp.length();i++){
        s+='$';
        s+=tmp[i];
    }
    s+='$';
    s+='#';
    for(int i=1;i<s.length()-1;i++){
        if(mx>i){
            len[i]=min(mx-i,len[(mxi<<1)-i]);
        }
        else{
            len[i]=1;
        }
        while(s[i+len[i]]==s[i-len[i]]){
            len[i]++;
        }
        if(i+len[i]>mx){
            mx=i+len[i];
            mxi=i;
        }
    }
    ll ans=0;
    for(int i=0;i<s.length();i++){
        ans=max(ans,len[i]);
    }
    cout<<ans-1;
    return 0;
}

:::

DP

联赛 DP 没有真正需要死记硬背的模板,重点在熟悉并理解各个基本模型的转移思路。

树形 DP 和 DAG 上 DP 已经在图论一节(分别是“树的直径”和“强连通分量”部分)提过了,这里不再浪费篇幅。树形 DP 额外提供一个简单的例题 P14150 不动鸣神,恒常乐土这题跟我没关系哈要攻击攻击出题人去

背包

01 背包

问题:若干个物品,每个物品有价值和代价,在代价不超过限制的前提下选取价值和最大的物品集合。每个物品只允许选择最多一次。

思路:

方程:

dp_{i,j}=\begin{cases}dp_{i-1,j},&j<c_i\\\max\{dp_{i-1,j-c_i}+w_i,dp_{i-1,j}\},&j\ge c_i\end{cases}

其中 c_i 是物品的代价,w_i 是物品的价值。dp_{i,j} 初值为 0

复杂度:O(nt),其中 n 是物品数量,t 是时间。

:::success[代码(P1048)]

for(int i=1;i<=n;i++){
  for(int j=0;j<=t;j++){
    dp[i&1][j]=dp[i-1&1][j];
    if(j>=c[i]){
      dp[i&1][j]=max(dp[i&1][j],dp[i-1&1][j-c[i]]+w[i]);
    }
  }
}
ll ans=0;
for(int i=0;i<=t;i++){
  ans=max(ans,dp[n&1][i]);
}

:::

完全背包

问题:若干个物品,每个物品有价值和代价,在代价不超过限制的前提下选取价值和最大的物品集合。每个物品允许选择任意多次。

思路:

方程:

dp_{i,j}=\begin{cases}dp_{i-1,j},&j<c_i\\\max\{dp_{i,j-c_i}+w_i,dp_{i-1,j}\},&j\ge c_i\end{cases}

其中 c_i 是物品的代价,w_i 是物品的价值。dp_{i,j} 初值为 0

复杂度:O(nt),其中 n 是物品数量,t 是时间。

注意点:

:::success[代码(P1616)]

for(int i=1;i<=n;i++){
  for(int j=0;j<=t;j++){
    dp[i&1][j]=dp[i-1&1][j];
    if(j>=c[i]){
      dp[i&1][j]=max(dp[i&1][j],dp[i&1][j-c[i]]+w[i]);
    }
  }
}
ll ans=0;
for(int i=0;i<=t;i++){
  ans=max(ans,dp[n&1][i]);
}

:::

多重背包与二进制分组

问题:若干个物品,每个物品有价值和代价,在代价不超过限制的前提下选取价值和最大的物品集合。每个物品选择次数有一个上限。

思路:

方程与 01 背包完全一致。

复杂度:O(nt\log V),其中 n 是物品数量,t 是时间,V 是单个物品的最大出现次数。

广为流传的二进制分组题是要使用 AC 自动机的 CF710F,笔者反而没找到二进制分组优化背包的题,所以有空的时候我再把多重背包的题造出来吧(如果有了评论区撅我)。

区间 DP

问题:通常用于子串合并类问题,也存在类似于 P9119 这样每次只向一侧添加一个数的问题。下面以更常见的前一类为例。

思路:

状态数:O(n^3),转移不为 O(1) 则需要乘转移复杂度。

注意点:

:::success[代码(P1880)] 本题是环上的,记得空间开两倍。

int main(){
    ios::sync_with_stdio(0);
    cin>>n;
    memset(f,0x3f,sizeof(f));
    for(int i=1;i<=n;i++){
        cin>>a[i];
        a[i+n]=a[i];
        f[i][i]=0;
        f[i+n][i+n]=0;
        g[i][i]=0;
        g[i+n][i+n]=0;
    }
    for(int i=1;i<=(n<<1);i++){
        qzh[i]=qzh[i-1]+a[i];
    }
    for(int i=1;i<n;i++){
        for(int j=1;j<=(n<<1)-i;j++){
            for(int k=j;k<j+i;k++){
                f[j][j+i]=min(f[j][j+i],f[j][k]+f[k+1][j+i]+qzh[j+i]-qzh[j-1]);
                g[j][j+i]=max(g[j][j+i],g[j][k]+g[k+1][j+i]+qzh[j+i]-qzh[j-1]);
            }
        }
    }
    ll mn=999999999999999999ll,mx=0;
    for(int i=1;i<=n;i++){
        mx=max(mx,g[i][i+n-1]);
        mn=min(mn,f[i][i+n-1]);
    }
    cout<<mn<<endl<<mx;
    return 0;
}

:::

例题:

数位 DP

问题:通常是求一定范围内数位上满足一定条件的数有多少,或者一定范围内第 k 个满足条件的数是多少。

思路:

复杂度视实现。

注意点:

:::success[代码(P10958)]

ll T,n,now[20],dp[20][4][2];
ll dfs(ll dep,ll cnt,bool flg,bool lim){
    if(dep<0)return flg;
    if(!lim&&dp[dep][cnt][flg]!=-1){
        //这里记得判,记忆化中只记有答案的情况 
        return dp[dep][cnt][flg];
    }
    ll ans=0;
    for(int i=0;i<=(lim?now[dep]:9);i++){
        ans+=dfs(dep-1,((cnt<<1)&3)+(i==6),flg||cnt==3&&i==6,lim&&i==now[dep]);
    }
    if(!lim){
        dp[dep][cnt][flg]=ans;
    } 
    return ans;
}
bool check(ll mid){
    ll mxd=0,tmp=mid;
    while(tmp){
        now[mxd++]=tmp%10;
        tmp/=10;
    }
    return dfs(mxd-1,0,0,1)>=n;
}
int main(){
    memset(dp,-1,sizeof(dp));
    ios::sync_with_stdio(0);
    cin>>T;
    while(T--){
        cin>>n;
        ll l=665,r=99999999666ll;
        while(l<=r){
            ll mid=(l+r>>1);
            if(check(mid)){
                r=mid-1;
            }
            else{
                l=mid+1;
            }
        }
        cout<<l<<endl;
    }
    return 0;
}

:::

例题:

状压 DP

问题:需要在 DP 下标里维护状态的问题。最明显的特征是存在某一个数的范围极小。

思路:

:::success[代码(P1896)]

int main(){
    ios::sync_with_stdio(0);
    cin>>n>>m;
    for(int i=0;i<(1<<n);i++){
        if(!(((i<<1)|(i>>1))&i)){
            cnt[p]=popcount(i);
            dp[0][cnt[p]][p]=1;
            s[p++]=i;
        }
    } 
    for(int i=1;i<n;i++){
        for(int j=0;j<p;j++){
            for(int k=0;k<p;k++){
                if(((s[k]<<1)|(s[k]>>1)|s[k])&s[j]){
                    continue;
                }
                for(int l=cnt[k];l<=m;l++){
                    dp[i][l+cnt[j]][j]+=dp[i-1][l][k];
                }
            }
        }
    }
    ll ans=0;
    for(int i=0;i<p;i++){
        ans+=dp[n-1][m][i];
    } 
    cout<<ans;
    return 0;
}

:::

例题:

分步 DP

没有找到我之前做的板子,这里先简单说一下吧。

就是例如给定一个无向图,上面有三个标记,每个单位时间内每个标记可以移动至多一条边,且每单位时间后三个标记两两间距离不能超过 d。求将所有标记移到终点 t 的路径长度最小和。

考虑优化,我们设 $dp_{i,j,k,l}$,其中 $l$ 表示当前轮到第 $l$ 个点转移了,此时我们就可以只枚举这一个点上一步所在的位置进行转移了,于是复杂度变为 $O(n^4)$。 这就是分步优化,多用于状态之间不能分开为多个 DP 进行,但又可以分步转移的情况。 # 数学 ## 乘法逆元 **只有 $a,p$ 互质,才有逆元。** ### 快速幂 / 乘法逆元(快速幂法 / 费马小定理) 问题:给定 $a,n,p$,求 $a^n\bmod p$。 思路: - 每次先求出 $a^{\lfloor\frac{n}{2}\rfloor}$,再平方,如果 $n$ 是奇数再乘 $a$。 复杂度:$O(\log n)$。 费马小定理的应用是当 $p$ 为素数时 $\frac{1}{a}\equiv a^{p-2}\pmod{p}$。 注意点: - 取模次数要够。 - 费马小定理**只有 $p$ 是质数时能用**。 :::success[代码(P1226/P2613)] 另外,你也可以写常数更小的非递归版本。不过笔者比较菜所以习惯于递归版本。 ```cpp ll ksm(ll x,ll y){ if(y==0)return 1; if(y==1)return x%mod; ll tmp=ksm(x,y/2); return tmp*tmp%mod*ksm(x,y&1)%mod; } ``` ::: ### 扩展欧几里得算法 / 乘法逆元(exgcd) 问题:求最小的正整数 $x$ 使得 $ax+b\equiv p\pmod{p}$。 思路: - 方程有解当且仅当 $\gcd(a,p)|b$。 - 原方程可化为 $ax-py=b$,此时可以先求出 $ax-py=\gcd(a,p)$,然后有 $x'=x\frac{b}{\gcd(a,m)}$。 - 由于 $\gcd(a,b)=\gcd(b,a\bmod b)$,故先递归求解 $\text{exgcd}(b,a\bmod b)$,然后令 $x'=y,y'=x-\lfloor\frac{a}{b}\rfloor$ 即可。 复杂度 $O(\log V)$。 :::success[代码(P1082)] ```cpp ll x,y; void exgcd(ll a,ll b){ if(b==0){ x=1; y=0; return ; } exgcd(b,a%b); ll tmp=x; x=y; y=tmp-y*(a/b); } int main(){ ios::sync_with_stdio(0); ll a,b; cin>>a>>b; exgcd(a,b); cout<<(x+b)%b; return 0; } ``` ::: ### 预处理乘法逆元(递推法 / 费马小定理) 问题:给定 $a,n,p$,求 $1\dots a$ 在模 $p$ 意义下的逆元。保证 $p$ 是质数。 思路: - 首先求出每个数的阶乘,并求出 $n!$ 的逆元。 - 根据 $\frac{1}{i!}=\frac{i+1}{(i+1)!}$ 可以依次求出 $i!$ 的逆元。 - 最后根据 $\frac{1}{i}=\frac{(i-1)!}{i!}$ 求出 $i$ 的逆元。 复杂度:$O(n)$。 :::success[代码(P3811)] ```cpp fac[0]=1; for(int i=1;i<=n;i++){ fac[i]=fac[i-1]*i%p; } ifac[n]=ksm(fac[n],p-2); for(int i=n-1;i;i--){ ifac[i]=ifac[i+1]*(i+1)%p; } for(int i=1;i<=n;i++){ cout<<ifac[i]*fac[i-1]%p<<endl; } ``` ::: ## 预处理组合数 问题:对所有 $0\le i\le j\le n$ 求 $C_j^i$。 思路: - 公式为 $\text{C}_{n}^{m}=\text{C}_{n-1}^m+\text{C}_{n-1}^{m-1}$。 - 理解:我们考虑是否选最后一个。选则前 $n-1$ 个中要选 $m-1$ 个,不选则前 $n-1$ 个要选 $m$ 个。 复杂度:$O(n^2)$。代码略。 ## 素数筛 ### 埃氏筛 问题:求前 $n$ 个数中的所有素数。 思路: - 从 $2$ 开始枚举每个数,如果其是质数则枚举其在范围内的倍数并标记为非质数。 复杂度:$O(n\log\log n)$。代码略。 ### 欧拉筛 问题:求前 $n$ 个数中的所有素数。 思路: - 在埃氏筛的基础上,通过**枚举每个数时只枚举比它小的质数倍**来确保每个合数只被最小的质因数标记。 复杂度:$O(n)$。 :::success[代码(P3383)] ```cpp for(int i=2;i<=n;i++){ if(vis[i]==0){ prime[x++]=i; } for(int j=1;j<x;j++){ if(i*prime[j]>n)break; vis[i*prime[j]]=1; if(i%prime[j]==0)break; } } ``` ::: 例题: - [P7960 \[NOIP2021\] 报数](https://www.luogu.com.cn/problem/P7960) ## 高斯消元 问题:解多元一次方程组。 思路: - 逐个消元,消一项时先换一个系数不为 $0$ 的到第一个,然后用它消后面的。这里为了保证精度我们选系数最大的。 - 如果消某一项时发现没有系数不为 $0$ 的方程则报告无解。 复杂度:$O(n^3)$。 :::success[代码(P3389)] ```cpp void gauss(){ //高消 for(int i=0;i<n;i++){ //消元部分:消掉第 i 个 for(int j=i+1;j<n;j++){//先换一个系数不为 0 的上来 if(fabs(mp[j][i])>fabs(mp[i][i])){ for(int k=0;k<=n;k++){ swap(mp[i][k],mp[j][k]); } } } if(fabs(mp[i][i])<1e-8){ cout<<"No Solution"; exit(0); } for(int j=i+1;j<n;j++){ ld ratio=mp[j][i]/mp[i][i]; for(int k=0;k<=n;k++){ mp[j][k]-=mp[i][k]*ratio; } } } for(int i=n-1;i>=0;i--){ //回带部分 for(int j=i+1;j<n;j++){ mp[i][n]-=mp[i][j]*x[j]; } x[i]=mp[i][n]/mp[i][i]; } } ``` ::: ## 矩阵运算 思路: - 矩阵的加减法就是对应位置相加减,数乘就是所有位置都乘这个数。 - 矩阵乘法要求第一个的列数等于第二个的行数,然后对于新矩阵的第 $i$ 行第 $j$ 列,就是将第一个矩阵的第 $i$ 行和第二个矩阵的第 $j$ 列一一相乘后求和。 复杂度:$O(nmk)$,其中第一个矩阵 $n$ 行 $m$ 列,第二个矩阵 $m$ 行 $k$ 列。 :::success[代码(P1349/P1939/P1962/P3390)] ```cpp matrix operator*(matrix &m1,matrix &m2){ matrix m3; m3.n=m1.n;m3.m=m2.m; for(int i=0;i<m3.n;i++){ for(int j=0;j<m3.m;j++){ for(int k=0;k<m1.m;k++){ m3.a[i][j]+=m1.a[i][k]*m2.a[k][j]; } } } return m3; } ``` ::: 例题: - [P4159 \[SCOI2009\] 迷路](https://www.luogu.com.cn/problem/P4159) ## 中国剩余定理 / CRT(大数翻倍法) 问题:求同余方程组的最小解,保证所有模数互质(exCRT 不保证,所以理论上大数翻倍法在 exCRT 中复杂度是错的,但洛谷板题可过)。 思路: - 每次求两个方程,枚举模数较大的方程的解并用模数较小的方程判断直至得到最小解,然后合并这两个方程。 复杂度:$O(n\sqrt[n]{V})$。 :::success[代码(P1495/P4777)] ```cpp struct node{ ll a,b; }; ll n,ta,tb; node a[20]; bool cmp(node x,node y){ return x.a>y.a; } int main(){ cin>>n; for(int i=0;i<n;i++){ cin>>a[i].a>>a[i].b; a[i].b%=a[i].a; } sort(a,a+n,cmp); ta=a[0].a; tb=a[0].b; for(int i=1;i<n;i++){ ll tmp=tb; if(tmp%a[i].a==a[i].b){ ta*=a[i].a; continue; } for(int j=0;j<a[i].a;j++){ tmp+=ta; if(tmp%a[i].a==a[i].b){ ta*=a[i].a; tb=tmp; break; } } } cout<<tb; return 0; } ``` ::: 例题: - \*[P2480 \[SDOI2010\] 古代猪文](https://www.luogu.com.cn/problem/P2480) # 其他算法 暂不考虑写高精度。 ## 离散化 问题:给定一个数列,为每个数字提供一个较小值域内的编号,使得编号的大小关系与原数字大小关系一致。 至于为什么强调大小关系一致,因为没有这个要求可以直接写 `map`(或者时间紧的话 `umap`)离散化。 算法思路: - `unique` 函数可以将有序数组中不同的项保持顺序地换到前面来。例如 $\{1,1,2,3,3\}$ 变为 $\{1,2,3,?,?\}$。 - 具体使用方法是传入 头指针 和 尾指针的下一个位置(加入空格防止歧义),函数完成操作后返回包含所有不同项的前缀的尾指针的下一个。 - 离散化时将原数组排序后使用该函数处理,则“问题”中提到的编号就是原数字在处理后数组的排名,可以二分查找得到。 复杂度:$O(n\log n)$。 注意点: - 注意你是否在一些地方正确地 $+1/-1$ 以使结果符合题意了。 :::success[代码(B3694)] `aa` 存的是原数组。 ```cpp sort(a,a+n); nn=unique(a,a+n)-a; for(int i=0;i<n;i++){ cout<<lower_bound(a,a+nn,aa[i])-a+1<<' '; } ``` ::: ## 二分 问题:知道答案后容易判断是否正确,且具有单调性(即存在一个位置,其前所有位置和其后所有位置的合法性相同)的问题。 算法思路: - 每次取中点判断即可。 复杂度:$O(T(n)\log n)$,其中 $T(n)$ 是 `check` 函数的复杂度。 注意点: - 关于边界有多种写法,你需要记住一种对的。 :::success[代码(P9755)] 搭配快读快输使用。重点是主函数和 `check` 函数里的二分答案部分。 ```cpp #define ll int #define LL __int128 #define ull unsigned long long #define lf double #define ld long double using namespace std; struct node{ ll id,ddl; }; inline bool operator <(node x,node y){ return x.ddl<y.ddl; } node v[100010]; LL n,a[100010],b[100010],c[100010]; ll fa[100010],d,ddl[100010]; bool vis[100010]; vector<ll> g[100010]; inline void getfa(ll now,ll lst){ fa[now]=lst; for(register int i=0;i<g[now].size();i++){ if(g[now][i]!=lst){ getfa(g[now][i],now); } } } inline bool dfs(ll now){ if(vis[now]){ return true; } if(now!=1&&!dfs(fa[now])){ return false; } if(d>ddl[now]){ return false; } d++; vis[now]=1; return true; } inline bool check2(ll l,ll r,ll i){ if(c[i]>=0||b[i]+r*c[i]>=1){ return ((b[i]*2+c[i]*(l+r))*(r-l+1)>>1)>=a[i]; } else if(b[i]+l*c[i]<=1){ return (r-l+1)>=a[i]; } else{ LL len=(1-b[i])/c[i]; return ((b[i]*2+c[i]*(l+len))*(len-l+1)>>1)+(r-len)>=a[i]; } } inline bool check(ll now){ memset(vis,0,sizeof(vis)); ll l,r,mid; for(register int i=1;i<=n;i++){ l=1,r=now; while(l<=r){ mid=(l+r>>1); if(check2(mid,now,i)){ l=mid+1; } else{ r=mid-1; } } ddl[i]=l-1; v[i].ddl=l-1; v[i].id=i; } sort(v+1,v+n+1); d=1; for(register int i=1;i<=n;i++){ if(!vis[v[i].id]&&!dfs(v[i].id)){ return false; } } return true; } int main(){ cin>>n; for(register int i=1;i<=n;i++){ cin>>a[i]>>b[i]>>c[i]; } ll u,v; for(register int i=1;i<n;i++){ cin>>u>>v; g[u].push_back(v); g[v].push_back(u); } getfa(1,1); ll l=1,r=1000000000,mid; while(l<=r){ mid=(l+r>>1); if(check(mid)){ r=mid-1; } else{ l=mid+1; } } cout<<r+1; return 0; } ``` ::: ## 归并排序 问题:将数列按一定顺序排序。 算法思路: - 先从中间分开并递归左右两侧,然后将两侧进行有序数组合并。 - 有序数组合并的方法是维护初始位于 $l$ 的指针 $p$ 和初始位于 $mid+1$ 的指针 $q$,每次比较 $a_p$ 和 $a_q$ 的大小,并将较小的加入临时数组、对应的指针移至下一格。最后将临时数组的值放回原数组。 复杂度:$O(n\log n)$。 注意: - 归并排序具有稳定性。即相同元素间相对位置不会改变。对此有需求时可以选择。 - 针对逆序对一题,注意前一半每个位置对答案的贡献到底怎么表示。 :::success[代码(P1908)] ```cpp ll n,a[500010],aa[500010],ans; void msort(ll l,ll r){ if(l==r){ return ; } ll mid=(l+r>>1); ll p=l,q=mid+1,pos=l; msort(l,mid); msort(mid+1,r); while(p<=mid&&q<=r){ if(a[p]<=a[q]){ ans+=q-mid-1; aa[pos++]=a[p++]; } else{ aa[pos++]=a[q++]; } } while(p<=mid){ ans+=q-mid-1; aa[pos++]=a[p++]; } while(q<=r){ aa[pos++]=a[q++]; } for(int i=l;i<=r;i++){ a[i]=aa[i]; } } ``` :::