模拟费用流小记

模拟费用流小记

介绍

费用流作为一种应用面非常广的算法,其可怜的 \(O(nmf)\) 时间复杂度,让我们面对 \(10^5\) 及以上数量级的问题感到心有余而力不足。
但是作为网络流高手是不能容忍这样的事情发生的。
怎么办呢?
不难发现,如果在算法选择正确实在想不到其他的做法的情况下,可以通过观察题目的特殊性质进行模拟,我们称之为,模拟费用流。
这里需要注意的是,模拟费用流并不是真正的对费用流进行模拟,其实是借用了费用流源汇点的思想。或者说,模拟费用流 \(\approx\) 数据结构+反悔贪心。
更加值得注意的是,模拟费用流并不是一个固定的算法,需要对于题目进行一对一的设计,因此并不一定有着像费用流一样的普适性。

A例题

P4694 [PA2013] Raper

你需要生产 \(k\) 张光盘。每张光盘都要经过两道工序:先在 A 工厂进行挤压,再送到 B 工厂涂上反光层。

你知道每天 A、B 工厂分别加工一张光盘的花费。你现在有 \(n\) 天时间,每天可以先送一张光盘到 A 工厂(或者不送),然后再送一张已经在 A 工厂加工过的光盘到 B 工厂(或者不送),每家工厂一天只能对一张光盘进行操作,同一张光盘在一天内生产出来是允许的。我们假定将未加工的或半成品的光盘保存起来不需要费用。

求生产出 \(k\) 张光盘的最小花费。

\(n,k\le5\times10^5\)

A题解

首先主动降低难度,考虑如果这道题的 \(n\le300\),是不是就是简单的费用流了。
具体而言,建立源点 \(S\) 和汇点 \(T\)、超级汇点 \(TT\)
然后连接如下边:

  • 连接 \(T\to TT\),容量是 \(k\),费用是 \(0\)
  • 将所有的 \(S\to i\),容量是 \(1\),费用是 \(a_i\)
  • 将所有的 \(i\to T\),容量是 \(1\),费用是 \(b_i\)
  • 将所有的 \(i\to i + 1\),容量是 \(INF\),费用是 \(0\)

然后跑最小费用最大流即可。

但是这个时候的 \(n\) 已经到 \(5\times10^5\) 的数量级了,再用费用流就有点异想天开了。
所以这个时候需要想想这张图的特殊性质了。
一个显然的性质是,在原图中我们将一个流量看做一张光盘,而一张光盘显然需要选择一个点对 \((i,j),i\le j\),让这张光盘在 \(i\) 时刻从源点流入图中,在 \(j\) 时刻流出。
再转化下题意,设 \(x_i\) 表示在 \(i\to i + 1\) 这条边的流量,那么有:

  • 选择一个光盘在 \(i\) 时刻流入图中,等价于 \(x_i\gets x_i+1\),同时费用增加 \(a_i\)
  • 选择一个光盘在 \(j\) 时刻流出图中,等价于 \(x_i\gets x_i-1\),同时费用增加 \(b_i\)
  • 同时需要保证任意时刻 \(x_i\ge 0\)

再再再转化下题意,相当于维护一个括号序列,设(的权值是 \(1\))的权值是 \(-1\)\(S_i\) 表示从 \(1\to i\) 的权值之和。
那么你需要保证任意时刻都有 \(S_i\ge 0\)
每次的操作有两种:

  • 选择一个点对 \((i,j),i\le j\),在 \(i\) 位置填入(,在 \(j\) 位置填入),花费是 \(a_i+b_j\)。没有任何限制。
  • 选择一个点对 \((i,j),i<j\),在 \(i\) 位置填入),在 \(j\) 位置填入(,花费是 \(b_i+a_j\)。进行这个操作之前,你需要保证 \(\forall k\in[i,j-1],S_k>0\)
  • 需要留意的是,一个 \(a_i\) 或者 \(b_i\) 如果被选择,之后就不能再被选择了。

问你进行 \(k\) 次的最小花费。

不难发现这玩应可以线段树维护。
具体而言,对于线段树的节点 \([l,r]\),我们需要维护以下信息:

  • \(mina\):在 \([l,r]\) 中,\(a\) 的最小值点。
  • \(minb\):在 \([l,r]\) 中,\(b\) 的最小值点。
  • \(la\):满足 \(\forall k\in[l,la-1],S_k>0\) 的所有可能取值中,\(a\) 的最小值点。
  • \(lb\):满足 \(\forall k\in[l,lb-1],S_k>0\) 的所有可能取值中,\(b\) 的最小值点。
  • \(minn\):在 \([l,r]\) 中,最小的 \(S_i\) 值。
  • \(ans1\):对于所有 \((i,j),i\le j\) 中,最小的 \(a_i+b_j\) 的点对 \((i,j)\)
  • \(ans2\):对于所有 \((i,j),i>j\) 中,在一定要求满足 \(\forall k\in[j,i-1],S_k>0\) 的情况下,最小的 \(a_j+b_i\) 的点对 \((i,j)\)
  • \(ans3\):对于所有 \((i,j),i>j\) 中,不要求一定满足 \(\forall k\in[j,i-1],S_k>0\) 的情况下,最小的 \(a_j+b_i\) 的点对 \((i,j)\)

尝试维护这样的信息,细节看代码。
然后每次贪心找到最小的点对 \((i,j)\),进行操作即可。
时间复杂度降到了可观的 \(O(k\log n)\)

后记:需要注意的是,这道题早在2017年就出现在了CF802O上。
而在不久的2018年,在ICPC 2018 World Final 上,这个算法就出现在了题目中(就是接下来的B例题),可惜的是,也许是因为当时科技不够普及,考场上没有切掉这道题的队伍。
在距离 2018 年遥远的今天,模拟费用流已经不再是遥不可及的科技了,更多意义上,这只是个高级数据结构+反悔贪心的题目,有可能会在NOI上出现,所以需要注意。

A代码

#include<bits/stdc++.h>
#define int long long
using namespace std;
inline int read(){
    int x = 0, f = 1;char ch = getchar();
    while(ch < '0' || ch > '9'){if(ch == '-') f = -1;ch = getchar();}
    while(ch >= '0' && ch <= '9'){x = (x << 1) + (x << 3) + (ch ^ 48);ch = getchar();}
    return x * f;
}
const int maxn = 5e5 + 10, INF = 0x3f3f3f3f;

int n, k;
int a[maxn], b[maxn];

struct node1{
    int first, second;
    node1(int f = 0,int s = 0):first(f),second(s){}
    friend bool operator <(node1 x,node1 y){
    return a[x.first] + b[x.second] < a[y.first] + b[y.second];}
};

typedef node1 pii;

struct Segment_Tree{
    struct node{
        int mina, minb, la, lb, minn, tag; pii ans1, ans2,ans3;
        node(int mina = 0,int minb = 0,int la = 0,int lb = 0,int minn = 0,int tag = 0,pii a1 = node1(0,0), pii a2 = node1(0,0),
        pii a3 = node1(0,0)):mina(mina),minb(minb),la(la),lb(lb),minn(minn),tag(tag),ans1(a1),ans2(a2),ans3(a3){}
    }d[maxn << 2];
    node mergenode(node l,node r){
        node ans = node();
        ans.mina = a[l.mina] < a[r.mina] ? l.mina : r.mina;
        ans.minb = b[l.minb] < b[r.minb] ? l.minb : r.minb;
        ans.minn = min(l.minn,r.minn);
        
        ans.ans1 = min(min(l.ans1,r.ans1),node1(l.mina,r.minb));
        ans.ans3 = min(min(l.ans3,r.ans3),node1(r.mina,l.minb));
        ans.ans2 = min(l.ans2,r.ans2);

        if(l.minn > r.minn){
            ans.ans2 = min(ans.ans2,min(node1(r.la,l.minb),l.ans3));
            ans.la = a[l.mina] < a[r.la] ? l.mina : r.la; ans.lb = r.lb;
        }
        else if(l.minn < r.minn){
            ans.ans2 = min(ans.ans2,min(node1(r.mina,l.lb),r.ans3));
            ans.lb = b[r.minb] < b[l.lb] ? r.minb : l.lb; ans.la = l.la;
        }
        else{
            ans.la = l.la;ans.lb = r.lb;
            ans.ans2 = min(ans.ans2,node1(r.la,l.lb));
        }
        return ans;
    }
    void pushdown(int p){
        if(d[p].tag){
            d[p << 1].tag += d[p].tag;d[p << 1].minn += d[p].tag;
            d[p << 1 | 1].tag += d[p].tag;d[p << 1 | 1].minn += d[p].tag;
            d[p].tag = 0;
        }
    }
    void build(int l = 0,int r = n,int p = 1){
        if(l == r){d[p] = node(l,l,l,0,0,0,node1(l,l),node1(0,0),node1(l,l));return;}
        int mid = l + r >> 1;
        build(l,mid,p << 1);build(mid + 1,r,p << 1 | 1);
        d[p] = mergenode(d[p << 1],d[p << 1 | 1]);
    }
    void update(int l,int r,int pos,int p){
        if(l == r && l == pos){return;}
        int mid = l + r >> 1;pushdown(p);
        if(pos <= mid)update(l,mid,pos,p << 1);
        else update(mid + 1,r,pos,p << 1 | 1);
        d[p] = mergenode(d[p << 1],d[p << 1 | 1]);
    }
    void update(int l,int r,int s,int t,int p,int add){
        if(s <= l && r <= t){d[p].tag += add;d[p].minn += add;return;}
        int mid = l + r >> 1;pushdown(p);
        if(s <= mid)update(l,mid,s,t,p << 1,add);
        if(mid < t)update(mid + 1,r,s,t,p << 1 | 1,add);
        d[p] = mergenode(d[p << 1],d[p << 1 | 1]);
    }
    void update(int pos){update(0,n,pos,1);}
    void update(int s,int t,int add){update(0,n,s,t,1,add);}
}tree;
signed main(){
    n = read(); k = read();
    a[0] = b[0] = INF;
    for(int i = 1;i <= n;i++)a[i] = read();
    for(int i = 1;i <= n;i++)b[i] = read();
    tree.build(0,n,1);int ans = 0;
    while(k--){
        int x, y, upd = 0;
        if(tree.d[1].ans1 < tree.d[1].ans2){x = tree.d[1].ans1.first, y = tree.d[1].ans1.second;upd = 1;}
        else {x = tree.d[1].ans2.first, y = tree.d[1].ans2.second;upd = -1;}
        // printf("choose 1 : %lld %lld %lld\n",tree.d[1].ans1.first,tree.d[1].ans1.second,a[tree.d[1].ans1.first] + b[tree.d[1].ans1.second]);
        // printf("choose 2 : %lld %lld %lld\n",tree.d[1].ans2.first,tree.d[1].ans2.second,a[tree.d[1].ans2.first] + b[tree.d[1].ans2.second]);
        ans += a[x] + b[y];a[x] = b[y] = INF;
        tree.update(x);tree.update(y);
        if(x == y)continue;
        tree.update(min(x, y),max(x, y) - 1,upd);
    }
    printf("%lld\n",ans);
    return 0;
}
/*
8 4
3 8 7 9 9 4 6 8
2 5 9 4 3 8 9 1
*/

B例题

P6943 [ICPC2018 WF] Conquer The World

给定一个 \(n\)\(n-1\) 边的无向图,每条边 \((u,v)\) 有边权 \(c\)

现在第 \(i\) 个点有 \(x_i\) 的点权,每个点需要 \(y_i\) 的点权,所以你可以移动点权到不同的点上。移动一条边上的点 \(u\)\(k\) 个单位点权到 \(v\) 要用 \(k \times c\) 的代价。

求满足所有点的需要的最小代价。

\(1 \le n \le 2.5 \times 10^5\)\(1 \le u,v \le n\)\(1 \le c \le 10^6\)\(0 \le \sum y_i\le \sum x_i \le 10^6\)

B题解

这个有着显然的网络流建模方法:
首先将每条边连接双向的网络流边,容量正无穷,费用就是边权。
然后对于每个点,如果剩余点权为正,就向汇点连边,如果剩余点权为负,则从源点连边。
然后跑最小费用最大流即可。

但是现在的 \(n\le2.5\times10^5\),怎么办呢?
考虑一个性质,就是所有的路径一定不会交叉。
考虑反悔贪心,如果设现在所在节点是 \(x\),那么将深度最小的源点和汇点相匹配,尝试流一个流量,费用显然好算。
然后考虑如何反悔,很简单,对于一次匹配 \((i,j)\),就让 \(2\times dep_x-dep_i\)\(2\times dep_x-dep_j\) 加入一个堆中,这样的话只要后来选择了其中一个点,那么这对匹配的贡献就减除了。
然后每次向上进行合并以及进行决策的时候用左偏堆维护即可,合并和查询都是 \(O(\log n)\) 的。
哦对了,因为每个需求点一定需要匹配,那么就让加入的 \(dep_x\) 再减去 \(INF\),这样一定是最小的,就一定能够匹配上。

B代码

#include<bits/stdc++.h>
#include<ext/pb_ds/priority_queue.hpp>
#define ll long long
using namespace std;
inline int read(){
    int x = 0, f = 1;char ch = getchar();
    while(ch < '0' || ch > '9'){if(ch == '-') f = -1;ch = getchar();}
    while(ch >= '0' && ch <= '9'){x = (x << 1) + (x << 3) + (ch ^ 48);ch = getchar();}
    return x * f;
}
const int maxn = 2.5e5 + 10;
const ll INF = 1e13;
int n;
ll x[maxn], y[maxn];

int tot, head[maxn];
struct edge{
    int to, nexte;ll wei;
    edge(int to = 0,int ne = 0,ll we = 0):to(to),nexte(ne),wei(we){}
}e[maxn << 1];
void add(int u,int v,ll w){e[++tot] = edge(v,head[u],w);head[u] = tot;}

ll ans, cnt, dep[maxn];
__gnu_pbds::priority_queue<ll,greater<ll> > A[maxn],B[maxn];

void dfs(int u,int f){
    for(int i = x[u] + 1;i <= y[u];i++)
        A[u].push(dep[u] - INF),cnt++;
    for(int i = y[u] + 1;i <= x[u];i++)
        B[u].push(dep[u]);
    for(int i = head[u];i;i = e[i].nexte){
        int v = e[i].to;ll w = e[i].wei;
        if(v == f)continue;
        dep[v] = dep[u] + w;dfs(v, u);
        A[u].join(A[v]);B[u].join(B[v]);
    }
    while(!A[u].empty() && !B[u].empty() && A[u].top() + B[u].top() < 2LL * dep[u]){
        ll xx = A[u].top(), yy = B[u].top(); A[u].pop(); B[u].pop();
        ans += xx + yy - 2LL * dep[u];
        A[u].push(2 * dep[u] - yy);B[u].push(2 * dep[u] - xx);
    }
}

signed main(){
    n = read();int u, v, w;
    for(int i = 1;i < n;i++){
        u =  read();v = read(); w = read();
        add(u, v, w);add(v, u, w);
    }
    for(int i = 1;i <= n;i++){x[i] = read();y[i] = read();}
    dfs(1 ,1);
    printf("%lld\n",ans + cnt * INF);
    return 0;
}

中场总结

某种意义上来说,模拟费用流虽然在字面上带了一个子串“费用流”,但是其实本质上是数据结构+反悔贪心,从这几道题也能看出来。

C例题

P1484 种树

cyrcyr 今天在种树,他在一条直线上挖了 \(n\) 个坑。这 \(n\) 个坑都可以种树,但为了保证每一棵树都有充足的养料,cyrcyr 不会在相邻的两个坑中种树。而且由于 cyrcyr 的树种不够,他至多会种 \(k\) 棵树。假设 cyrcyr 有某种神能力,能预知自己在某个坑种树的获利会是多少(可能为负),请你帮助他计算出他的最大获利。

C题解

某种意义上来说,这道题看出费用流的人多少有点魔怔了。
但是还是先说一下魔怔的做法(bushi)
我们尝试建立这样一个图:

  • 建立源点 \(S\) 和汇点 \(T\)
  • 对所有 \(2\mid i\) 的点 \(i\),连接 \(S\to i\),费用是 \(0\),容量为 \(1\)
  • 对所有 \(2\nmid i\) 的点 \(i\),连接 \(i\to T\),费用是 \(0\),容量为 \(1\)
  • 对所有间隔 \(i\to i+1\),从奇数点连向偶数点,费用是获利,容量为 \(1\)

然后在图中跑最小费用最大流即可。

然后再来说一说不魔怔的做法。
从费用流的视角出发,不难发现,最优解一定不会退回源边和汇边,被退回的只可能是中间的边,考虑怎么才能提供像费用流一样的反悔机制。
不难发现,出现反悔当且仅当在之前选了 \(x\),但是选择周围的 \(x-1,x+1\) 会更优。
所以每次选择一个点的时侯,加入 \(val_{x+1}+val_{x-1}-val_x\),以提供反悔的机会。
这个直接堆维护一下即可。

C代码

#include<iostream>
#include<stdio.h>
#include<stdlib.h>
#include<queue>
#define ll long long
using namespace std;
int n, k;
const int maxn = 1e6 * 5 + 10;
int w[maxn],leftt[maxn],rightt[maxn];
bool book[maxn];
struct node{
    int idx,w;
}tmp;
bool operator < (node a,node b){
    return a.w < b.w;
}
priority_queue<node> que;
signed main(){
    scanf("%d%d",&n,&k);
    for(int i = 1;i <= n;i++){
        scanf("%d",&w[i]);
        tmp.idx = i;
        tmp.w = w[i];
        leftt[i] = i - 1;
        rightt[i] = i + 1;
        que.push(tmp); 
    }
    leftt[n + 1] = n;
    rightt[0] = 1;
    long long ans = 0;
    for(int i = 1;i <= k;i++){
        while(!que.empty() && book[que.top().idx])que.pop();
        tmp = que.top();que.pop();
        if(tmp.w < 0)break;
        ans += tmp.w;
        book[leftt[tmp.idx]] = book[rightt[tmp.idx]] = 1;
        tmp.w = w[leftt[tmp.idx]] + w[rightt[tmp.idx]] - w[tmp.idx];
        w[tmp.idx] = tmp.w;//由于取点时可能只是局部最优解,而全局最优解可能取得是当前点的左右,因此提供反悔机制
        //如果取了一个点j,则将w[left[j]] + w[right[j]] - w[j]加入到队列中,如果取了新加入的点,就相当于删除了j
        //并取left[j],right[j],间接的解决了问题
        leftt[tmp.idx] = leftt[leftt[tmp.idx]];
        rightt[tmp.idx] = rightt[rightt[tmp.idx]];
        rightt[leftt[tmp.idx]] = tmp.idx;
        leftt[rightt[tmp.idx]] = tmp.idx;
        que.push(tmp);
    }
    printf("%lld\n",ans);
    return 0;
}

D例题

CF865D Buy Low Sell High

已知接下来N天的股票价格,每天你可以买进一股股票,卖出一股股票,或者什么也不做.N天之后你拥有的股票应为0,当然,希望这N天内能够赚足够多的钱。

D题解

不难发现,可以建出这张图:

  • 建立源点 \(S\) 和汇点 \(T\)
  • \(i\) 天连接 \(S\to i\),容量为 \(1\),费用为 \(-c_i\);连接 \(i\to T\),容量为 \(1\),费用为 \(c_i\)

最后答案就是最大费用任意流。

考虑这玩应的策略。
不难发现,每天只有两个策略:

  • 买入一张股票,在之后的某一天卖出。
  • 更改之前某一张股票的卖出时间,在今天卖出。

这东西显然可以堆维护,细节看代码。

D代码

#include<bits/stdc++.h>
using namespace std;
inline int read(){
    int x = 0, f = 1;char ch = getchar();
    while(ch < '0' || ch > '9'){if(ch == '-') f = -1;ch = getchar();}
    while(ch >= '0' && ch <= '9'){x = (x << 1) + (x << 3) + (ch ^ 48);ch = getchar();}
    return x * f;
}
typedef long long ll;
const int maxn = 3e5 + 10;
priority_queue<ll> que;
int n;
signed main(){
    n = read();ll u, ans = 0;
    for(int i = 1;i <= n;i++){
        u = read();que.push(-u);//这个-u指的是买入股票花费
        if(que.empty() || que.top() + u < 0)continue;
        ans += u + que.top();que.pop();que.push(-u);
        //这个-u指的是,更改一个股票的卖出时间,原来的卖出收益就没了,需要减去
    }
    printf("%lld\n",ans);
    return 0;
}

E例题

CF703I Olympiad in Programming and Sports

\(n\) 个学生每人有两种技能,分别是 \(a,b\) 表示编程能力和运动能力。你需要将他们分为两个团队分别参加编程比赛和体育比赛,编程团队有 \(p\) 人,体育团队有 \(s\) 人,一个学生只能参加其中一项。每个团队的力量是其成员在对应领域能力总和,请问如何分配能使得两个团队的实力和最大?

需要给出构造方案。

  • \(2\le n\le 3\times 10^3\)\(p+s\le n\)
  • \(1\le a_i,b_i\le 3000\)

E题解

虽然但是这个数据范围直接上费用流好像能过(
老规矩,先讲费用流怎么做。

按照如下方式建图:

  • 建立源点 \(S\) 和汇点 \(T\)
  • 每个人 \(i\) 连接 \(S\to i\),费用 \(0\),容量 \(1\)
  • 每个人到编程团队(称为集合 \(A\))连接 \(i\to A\),费用 \(a_i\),容量 \(1\)
  • 每个人到体育团队(称为集合 \(B\))连接 \(i\to B\),费用 \(b_i\),容量 \(1\)
  • 连接 \(A\to T\),费用 \(0\),容量 \(p\)
  • 连接 \(B\to T\),费用 \(0\),容量 \(s\)

直接跑最大费用最大流是可过的。

但是但是,如果我把 \(n\) 的范围开到 \(10^5\),阁下又当如何应对呢?
老规矩,考虑贪心策略。
如果我们先将前 \(p\) 大的 \(a_i\) 全都分配给 \(A\),那么我们能够对剩下的人进行的操作只有两个:

  • 将一个人加入 \(B\)
  • 将一个人加入 \(A\),同时将 \(A\) 中的其中一个人调到 \(B\) 中。

每次选择最优的策略,进行 \(s\) 次即可。
这东西搞三个堆就能维护了。

E代码

#include<bits/stdc++.h>
using namespace std;
inline int read(){
    int x = 0, f = 1;char ch = getchar();
    while(ch < '0' || ch > '9'){if(ch == '-') f = -1;ch = getchar();}
    while(ch >= '0' && ch <= '9'){x = (x << 1) + (x << 3) + (ch ^ 48);ch = getchar();}
    return x * f;
}
const int maxn = 3e3 + 10;
struct node{
    int val, id;
    node(int v = 0,int i = 0):val(v),id(i){}
    friend bool operator < (node a,node b){return a.val < b.val;}
};
priority_queue<node,vector<node>,less<node> > q1,q2,q3;
int n, x, y;
int a[maxn], b[maxn];
int opt[maxn];
signed main(){
    n = read();x = read(); y = read();int ans = 0;
    for(int i = 1;i <= n;i++)q1.push(node(a[i] = read(),i));
    for(int i = 1;i <= n;i++)q2.push(node(b[i] = read(),i));
    for(int i = 1;i <= n;i++)q3.push(node(b[i] - a[i],i));
    for(int i = 1;i <= x;i++){
        int pos = q1.top().id; opt[pos] = 1;
        ans += q1.top().val; q1.pop();
    }
    while(!q1.empty())q1.pop();
    for(int i = 1;i <= n;i++)if(opt[i] != 1)q1.push(node(a[i],i));
    for(int i = 1;i <= y;i++){
        int op = 0, j, k,val = 0;
        while(!q2.empty() && opt[q2.top().id] != 0)q2.pop();
        if(!q2.empty()){
            val = q2.top().val;
            j = q2.top().id; op = 1;
        }
        while(!q1.empty() && opt[q1.top().id] != 0)q1.pop();
        while(!q3.empty() && opt[q3.top().id] != 1)q3.pop();
        if(!q1.empty() && !q3.empty()){
            if(q3.top().val + q1.top().val > val){
                val = q3.top().val + q1.top().val;
                j = q3.top().id; k = q1.top().id; op = 2;
            }
        }
        if(op == 1){ans += val;q2.pop();opt[j] = 2;}
        else{ans += val;opt[k] = 1;opt[j] = 2;q1.pop();q3.pop();}
    }
    printf("%d\n",ans);
    for(int i = 1;i <= n;i++)if(opt[i] == 1)printf("%d ",i);puts("");
    for(int i = 1;i <= n;i++)if(opt[i] == 2)printf("%d ",i);puts("");
    return 0;
}
posted @ 2023-12-05 19:47  Call_me_Eric  阅读(364)  评论(0)    收藏  举报
Live2D