题解 P5470 【[NOI2019]序列】

· · 题解

论在看不懂题解的时候如何化悲愤为力量……

本篇题解会尽可能细致具体地解释思路和写法,并顺带提一下我自己调试过程发现的一些代码细节,希望能帮到大家。(希望亲爱的管理员通过一下哈)

算法:贪心模拟费用流

费用流做法

这个题的费用流做法已经有两篇题解都讲到了,最关键的思路点就在于把 “必须至少有 L 对下标相同” 这个条件转化成 “最多有 K-L 对下标不相同”。这应该也是本题最关键的突破点了。想到了这一点,相信能去参加NOI的选手们都不难想到建图变成类似于二分图的匹配模型,然后用费用流解决。

然而很显然时间复杂度过高,你的费用流是不大可能跑过所有数据的。实际上普通的spfa求最短路的MCMF算法就可以轻松拿到64分(当然如果你在luogu评测机上跑就别想了QAQ)。所以这道题的部分分给的还是挺友善的了。

LOJ 64分代码(如果你去LOJ提交要记得加上文件输入输出哦):

#include <bits/stdc++.h>
using namespace std;

typedef long long ll; 

template<typename T> inline void read(T &x){
    char c=getchar();x=0;
    while(!isdigit(c))c=getchar();
    while(isdigit(c))x=x*10+c-'0',c=getchar();
}

const int MAXN=2e5+5;
int T, n, m1, m2;
ll a[MAXN], b[MAXN];

namespace NF{
    const int N=4e3+5, V=N<<1, E=V*20;
    int s, t, head[V], e, to[E], fl[E], dis[E], nxt[E];
    int flow[E], pv[V], pe[V];
    ll d[E], MC, MF;

    inline void init(int _s, int _t){
        e=1;
        s=_s, t=_t;
        memset(head, 0, sizeof(head));
    }

    inline void add_path(int u, int v, int w, ll c){
        to[++e]=v, fl[e]=w, dis[e]=c;
        nxt[e]=head[u], head[u]=e;
    }

    inline void add_edge(int u, int v, int w, ll c){
        add_path(u, v, w, c);
        add_path(v, u, 0, -c);
    }

    inline bool spfa(){
        static bool inq[N];
        static queue<int> q;
        memset(d, 0x3f, sizeof(d));
        d[s]=pv[s]=pv[t]=0;
        flow[s]=0x3f3f3f3f;
        q.push(s);
        inq[s]=true;
        while(!q.empty()){
            int u=q.front(); q.pop(), inq[u]=false;
            for(int i=head[u]; i; i=nxt[i]) if(fl[i]){
                int v=to[i], w=fl[i], c=dis[i];
                if(d[v]>d[u]+c){
                    d[v]=d[u]+c;
                    flow[v]=min(flow[u], w);
                    pv[v]=u, pe[v]=i;
                    if(!inq[v]){
                        q.push(v);
                        inq[v]=true;
                    }
                }
            }
        }
        return pv[t];
    }

    inline ll MCMF(){
        MF=MC=0;
        while(spfa()){
            int tmp=flow[t];
            MF+=tmp;
            MC+=1ll*tmp*d[t];
            for(int u=t; pv[u]; u=pv[u]){
                int i=pe[u];
                fl[i]-=tmp;
                fl[i^1]+=tmp;
            }
        }
        return MC;
    }
}

namespace bruteforce{
    int main(){
        int s=n*2+1, t=n*2+2, _s=n*2+3, p1=n*2+4, p2=n*2+5;
        NF::init(s, t);
        NF::add_edge(s, _s, m1, 0);
        NF::add_edge(p1, p2, m1-m2, 0);
        for(int i=1; i<=n; ++i){
            NF::add_edge(_s, i, 1, -a[i]);
            NF::add_edge(i, p1, 1, 0);
            NF::add_edge(i+n, t, 1, -b[i]);
            NF::add_edge(p2, i+n, 1, 0);
            NF::add_edge(i, i+n, 1, 0);
        }
        printf("%lld\n", -NF::MCMF());
        return 0;
    }
}

int main(){
//  freopen("sequence.in", "r", stdin);
//  freopen("sequence.out", "w", stdout);
    read(T);
    while(T--){
        read(n);read(m1);read(m2);
        for(int i=1; i<=n; ++i)
            read(a[i]);
        for(int i=1; i<=n; ++i)
            read(b[i]);
        if(n<=2000) bruteforce::main();
    }
    return 0;
}

贪心模拟费用流

如何提高费用流效率?我们有个叫“模拟费用流”的东西。说白了,我对模拟费用流的理解就是,在特殊条件允许下,用贪心来取代spfa找最短路,用各种手段(代价取反、分类讨论等等)来模拟退流过程

那么来看一下这道题如何模拟费用流。

如何模拟流自由流?显然在还未被配对的 ab 里头各选一个最大值就好了。

但是,如果选出来的 a_ib_j 满足 i=j 的话,根据上面的自由流最优定理,这时候换成流“限制流”,而把这个自由流的机会留给后面,一定不会更劣。

如何模拟?显然在 a_ib_i 均还未被配对的下标 i 中选择一个 a_i+b_i 最大的就可以了。

请注意,下面的两种方式涉及到模拟退流了!

此步非常关键,让我们好好分析一下究竟发生了什么。

画个图就很清晰了。最开始是这样的:

然而之后变成了这样:

那么分析易得,这一次转变的结果是增加了一对下标相同的匹配,而对答案的贡献为 a_k+b_i ,那么根据“贪心”的原则,我们只需要分别找到最大的 a_kb_i 即可。

同样还是给个图方便理解。先是这样:

变成了这样:

同情况三,这一次转变的结果也是增加了一对下标相同的匹配,而对答案的贡献为 a_j+b_k ,那么根据“贪心”的原则,我们只需要分别找到最大的 a_jb_k 即可。

这个时候,我们也就考虑周全了实际费用中的几种流情况,模拟出了“退流”过程,达到了不断“优化调整匹配”的目的,故正确性得到保证,可以开始写了。

然而我认为这道题的难度还不少在实现上(否则进不了NOI),让我们一起仔细想一想怎么实现上述算法。

priority_queue< pair<int, int> >  

pair第一维存权值,第二维存下标。

综上,时间复杂度优化到了 O(n\log n)

好,这时候我就兴冲冲地打完了代码,一次过了出题人拿脚出的样例,然后提交……啊,只有4 pts ??!?

这太戏剧性了,但是不要慌,我们可以对拍,我就把我写的费用流拿来对拍,经过半个下午+一个晚上的艰苦奋斗,终于把这份漏洞百出的代码调 AC 了。于是下面讲几个实现的细节。

我们会发现有时候出现这种情况:

显然这样改一下会更优,腾出一个自由流:

这种调整不会改变当前的答案,也不会改变元素在堆中的分布情况,只会还一个一个自由流。

我们把这个操作叫做“adjust”,在代码中就是这个函数。

我们发现这个操作不光需要知道哪些点被配对了,还要知道每个配对的点匹配的是哪个点。于是,我们把上一点说到的打标记用来惰性删除的数组改成一个 int 型的匹配数组就可以了,那么注意在其他的操作中都要对这个数组小心地进行维护,而不是像以前一样只简单的打个标记就行。

在考虑什么时候要 adjust ,很显然,这个操作针对的是被自由匹配的点,那么我们只需要在每出现自由流的时候做这个操作就行了。观察之前画的图,发现情况一直接流自由流和情况三和四会改变原来的自由流,在这几个情况处理后对相应的下标 adjust 一下就好了。

因为我们操作时有一个优先流自由流的决策 所以要开一个变量 Free 来记录当前还有多少个自由流可以流

  1. 每次情况一流一个自由流的时候,判断如果两边选的是不相等的下标就要把 Free 减一。

  2. 每次 adjust 时候,注意 Free 至少会+1,如果 j=k ,就还要再 +1。

  3. 但是,情况三和四,也要注意!分别对应之前讲这两种情况的情景图,当 j=ki=k 的时候,运气非常好地把自由流消掉了,Free也要+1。

好啦,其他就没什么难点了,把代码放出来,里头有很多注释,这也应该是比较好懂的一份代码了。

#include <bits/stdc++.h>
#define mkp make_pair
#define ft first
#define sd second
using namespace std;

typedef long long ll;
typedef pair<int, int> pii;
typedef priority_queue< pair<int, int> > heap;

template<class T> inline void read(T& x){
    char c=getchar();x=0;
    while(!isdigit(c))c=getchar();
    while(isdigit(c))x=x*10+c-'0',c=getchar();
}

const int N=2e5+5;

int T, n, K, L, a[N], b[N], Free, now, type;
ll ans;
heap A0, A1, B0, B1, AB;
//A0, B0: 没被选的的a,b
//A1, B1: 已经被选了对应的b/a但自己没被选的a/b
//AB: a,b都没被选的组合
int mcha[N], mchb[N];
//mcha[i]: a[i]匹配的b[j]的下标j
//mchb[i]: b[i]匹配的a[j]的下标j

inline void del_AB(){//同时根据a,b的匹配情况来删除堆AB中的元素 
    while(!AB.empty()){
        int i=AB.top().sd;
        if(mcha[i]||mchb[i]) AB.pop();
        else break;
    }
}
inline void del_A(heap& A){//根据a的匹配情况来删除堆A0或A1中的元素 
    while(!A.empty()){
        int i=A.top().sd;
        if(mcha[i]) A.pop();
        else break;
    }
}
inline void del_B(heap& B){//根据b的匹配情况来删除堆B0或B1中的元素 
    while(!B.empty()){
        int i=B.top().sd;
        if(mchb[i]) B.pop();
        else break; 
    }
}

inline void adjust(int i){//核心:调整操作 
    if(!mcha[i]||!mchb[i]||mcha[i]==i) return;
    int j=mcha[i], k=mchb[i];
    mcha[i]=mchb[i]=i, ++Free;//腾出自由流 
    mcha[k]=j, mchb[j]=k;
    if(k==j) ++Free;
}
inline void free_match(){//自由配对
    del_A(A0), del_B(B0);
    int i=A0.top().sd, j=B0.top().sd;
    ans+=1ll*(a[i]+b[j]);
    mcha[i]=j, mchb[j]=i;
    if(i!=j){
        --Free;//消耗自由流 
        if(!mcha[j]) A1.push(mkp(a[j], j));
        if(!mchb[i]) B1.push(mkp(b[i], i));
    }
    adjust(i), adjust(j);//记得做调整 
}
inline void work1(){//选一对a[i]+b[i]
    del_AB();
    if(!AB.empty()){
        int i=AB.top().sd;
        if(a[i]+b[i]>now){
            now=a[i]+b[i];
            type=1;
        }
    }
}
inline void work2(){//a[i]+b[j] -> a[i]+b[i], a[k]+b[j]
    del_B(B1), del_A(A0);
    if(B1.empty()) return;
    int i=B1.top().sd, k=A0.top().sd;
    if(a[k]+b[i]>now){
        now=a[k]+b[i];
        type=2;
    }
}
inline void work3(){//a[i]+b[j] -> a[j]+b[j], a[i]+b[k]
    del_A(A1), del_B(B0);
    if(A1.empty()) return;
    int j=A1.top().sd, k=B0.top().sd;
    if(a[j]+b[k]>now){
        now=a[j]+b[k];
        type=3;
    }
}
inline void update1(){//选择了情况一后的更新操作 
    int i=AB.top().sd;
    mcha[i]=mchb[i]=i;
}
inline void update2(){//选择了情况二后的更新操作 
    int i=B1.top().sd, k=A0.top().sd, j=mcha[i];
    mcha[i]=mchb[i]=i;
    mcha[k]=j, mchb[j]=k;
    if(j==k) ++Free;//腾出自由流 
    if(!mchb[k]) B1.push(mkp(b[k], k));
    adjust(j), adjust(k); 
}
inline void update3(){//选择了情况三后的更新操作 
    int j=A1.top().sd, k=B0.top().sd, i=mchb[j];
    mcha[j]=mchb[j]=j;
    mcha[i]=k, mchb[k]=i;
    if(i==k) ++Free;
    if(!mcha[k]) A1.push(mkp(a[k], k));
    adjust(i), adjust(k);
}

inline void cls(){//多测清空 
    ans=0;
    while(!A0.empty()) A0.pop();
    while(!A1.empty()) A1.pop();
    while(!B0.empty()) B0.pop();
    while(!B1.empty()) B1.pop();
    while(!AB.empty()) AB.pop();
    memset(mcha, 0, sizeof(mcha));
    memset(mchb, 0, sizeof(mchb));
}

int main(){
//  freopen("sequence.in", "r", stdin);
//  freopen("sequence.out", "w", stdout);

    read(T);
    while(T--){

        cls();

        read(n);read(K);read(L);
        for(int i=1; i<=n; ++i)
            read(a[i]);
        for(int i=1; i<=n; ++i)
            read(b[i]);

        Free=K-L;//初始化自由流数量 
        for(int i=1; i<=n; ++i){
            A0.push(mkp(a[i], i));
            B0.push(mkp(b[i], i));
            AB.push(mkp(a[i]+b[i], i));
        }

        while(K--){
            if(Free){//优先自由配对
                free_match();
                continue;
            }
            now=0, type=0;
            work1();//选一对a[i]+b[i]
            work2();//a[i]+b[j] -> a[i]+b[i], a[k]+b[j]
            work3();//a[i]+b[j] -> a[j]+b[j], a[i]+b[k]
            ans+=1ll*now;
            if(type==1) update1();
            else if(type==2) update2();
            else update3();
        }

        printf("%lld\n", ans);

    }

    return 0;
}

后记

这是我NOI2019的题目中AC的第四道题,虽然在NOI里不能算难题,可能大佬们写起来觉得挺轻松(所以大佬的题解语言简洁代码灵动,蒟蒻看不懂QAQ),但是还是挺有感触的。有时候你一味想着看题解却可能一无所获(逃)。所以写了这篇比较完整而细致的题解,希望能帮到大家。然而如果看了这篇题解你还是没有懂(应该不会吧啊?),那你不妨想:

有时困境不会对你的垂首乞怜心生恻隐,只会因你的迎难而上肃然起敬。

好啦,这篇题解到此结束了,来了就点个赞(或者踩一脚)再走呗。

由于这是我自己胡搞出来的解法,如果您还有什么疑问或是建议,欢迎到评论区留言,我会尽力回复;如果您发现了我的漏洞,也欢迎到评论区指出或 hack ,我会认真改正!