📜  门| GATE CS 2019 |问题 23(1)

📅  最后修改于: 2023-12-03 15:42:12.242000             🧑  作者: Mango

门 | GATE CS 2019 |问题 23

这道题目出现在 GATE 2019 年的计算机科学考试中,是一道非常经典的问题。

问题描述

假设有 $n$ 个开关,标号为 $0$ 到 $n-1$,和 $m$ 个门,每个门由若干个开关控制,描述为 [$x_1,x_2,\ldots,x_k$],表示该门由开关 $x_1$、$x_2$、$\ldots$、$x_k$ 控制,当所有开关均为开启状态时,该门才会打开。现在给出 $m$ 个门的控制关系,其中第 $i$ 个门的控制关系为 $d_i=[x_{i1},x_{i2},\ldots,x_{ik}]$。

现在有一个一维的维修机器人,它可以进入编号为 $0$ 的开关房间,进入房间 $i$ 会使其翻转开关 $i$,即原本关闭的开关翻转为打开,原本打开的开关翻转为关闭。维修机器人可以进出任意多次,但是每次进入的代价是 $1$。现在需要维修机器人以最小的代价,使得所有门都被开启。

你的任务是实现一个函数,给定 $n, m$ 和所有门的控制关系 $d_i$,计算出最小代价的维修方案。

解法

这个问题可以使用通用的布尔网络流算法来解决。将所有开关看成一个点集 $V$,所有门看成一个点集 $U$,在 $V$ 和 $U$ 之间连边,表示如果开关为开,那么对应的门打开。如果将一次进入开关 $x$ 看作向 $x$ 所在的点 $v_x$ 连一条费用为 $1$、流量为 $1$ 的边,那么维修机器人最小化对应的网络流即可。

我们来逐步讲解这个算法。

建图

首先需要将问题抽象成图。公式化来说,如果门 $i$ 由开关 $j_1,j_2,\ldots,j_k$ 控制,我们将开关 $j_1,j_2,\ldots,j_k$ 对应的点和门 $i$ 对应的点连一条流量为 $\infty$ 的边。建完图之后就是下面这个样子,其中红色的部分表示所有的门(后面我们会将所有门的点单独拎出来)。

图1

注意,每个开关对应的点只与一个门相连。这是因为当其中一个为开启状态时,对应的门就已经被打开了,所以不需要再次遍历。这个建图过程的时间复杂度是 $O(nm)$。

网络流

我们使用 Dijkstra 和堆优化的 Dijkstra 求最小费用流。 每次找到一个 $s\to t$ 的增广路(边的流量只能为1,因此增广路只需找到一个点 $t$ 可达的点 $s$),下面的图是一个经典的残量图和 $s\to t$ 的增广路。其中蓝色路径表示用哪些边形成流。图中的最小代价为 $5$,即1次进入开关房间、3次进入开关房间、1次进入开关房间。

图2

对于一个增广路(一个点 $t$ 可以到达的点 $s$),我们更新残存网络中的边的流量和费用,其中流量只能为1,费用为进入该点(也就是把对应的开关翻转)所需的代价。这样不断地找增广路、更新残余网络,直到网络中不存在增广路为止,这个算法就结束了。时间复杂度为 $O(nm\log_2{n})$。

模板

下面是这个算法的模板。

const int N = ...;  // 点的最大数量
const int M = ...;  // 边的最大数量

int n,m,tot,head[N],nxt[M],to[M],flow[M],cost[M];
int dis[N],pre[N],heap[N],pos[N],totpos;

void add(int u,int v,int f,int c) { ... }  // 往图里添加一条边

void dijkstra() { ... }  // Dijkstra 求网络流

int min_cost_flow() { ... }  // 模板代码

下面是具体实现。

#include <algorithm>
#include <cstring>

// 洛谷 P4716
// https://www.luogu.com.cn/problem/P4716
// 题目描述:http://zhangxiaoya.xyz/gate-cs2019-problem-23/

const int N = 3e3 + 5, M = 3e4 + 5, INF = 0x3f3f3f3f;

int n,m,tot,head[N],nxt[M],to[M],flow[M],cost[M];
int dis[N],pre[N],heap[N],pos[N],totpos;

void add(int u,int v,int f,int c) {
    to[++tot]=v, nxt[tot]=head[u], head[u]=tot, flow[tot]=f, cost[tot]= c;
    to[++tot]=u, nxt[tot]=head[v], head[v]=tot, flow[tot]=0, cost[tot]=-c;
}

bool dijkstra(int s,int t) {
    memset(dis,0x3f,sizeof(dis));
    memset(pre,-1,sizeof(pre));
    dis[s]=0, pre[s]=0, heap[++totpos]=s, pos[s]=totpos;
    while (totpos) {
        int u=heap[1]; std::swap(heap[1],heap[totpos]), --totpos, pos[heap[1]]=1;
        for (int i=head[u];i;i=nxt[i]) {
            int v=to[i], w=dis[u]+cost[i];
            if (flow[i]>0 && dis[v]>w) {
                dis[v]=w, pre[v]=i, heap[++totpos]=v, pos[v]=totpos;
                for (int p=totpos;p>1;p>>=1) if (dis[heap[p]]<dis[heap[p>>1]]) std::swap(heap[p],heap[p>>1]), std::swap(pos[heap[p]],pos[heap[p>>1]]);
            }
        }
    }
    for (int i=0;i<=n;i++) for (int j=head[i];j;j=nxt[j]) if (flow[j]>0 && dis[to[j]]+cost[j]<dis[i]) return false;
    return pre[t]!=-1;
}

int min_cost_flow(int s,int t) {
    int res1=0, res2=0;
    while (dijkstra(s,t)) {
        int inc=INF;
        for (int i=pre[t];i!=-1;i=pre[to[i^1]]) inc=std::min(inc,flow[i]);
        for (int i=pre[t];i!=-1;i=pre[to[i^1]]) flow[i]-=inc, flow[i^1]+=inc, res1+=inc, res2+=inc*cost[i];
    }
    return res2;
}

int main() {
    scanf("%d%d",&n,&m);
    for (int i=0;i<m;i++) {
        int k; scanf("%d",&k);
        std::vector<int> vec;
        for (int j=0;j<k;j++) {
            int x; scanf("%d",&x), vec.push_back(x);
            if (vec.size()>1)
                add(vec[vec.size()-2],vec.back(),INF,1);
        }
        add(n+i+1, vec.back(), INF, 1);
    }
    for (int i=0;i<n;i++) {
        add(i,i+1,1,0);
        add(n+m+1,i,1,0), add(i+n,n+m+2,1,1);
    }
    printf("%d\n",min_cost_flow(n+m+1,n+m+2));
    return 0;
}

上述代码构建了一个有向图,包括 $n+m+2$ 个节点,$n+m$ 条边。其中:

  1. 节点 $i$ 和节点 $i+1$ 之间有一条流量为1,费用为0的边,表示维修机器人可以从开关 $i$ 到开关 $i+1$。

  2. 节点 $n+m+1$ 指向 $[0,1,2,\ldots,n-1]$ 中所有节点(共 $n$ 个节点),有一条流量为1,费用为0的边。表示维修机器人第一次进入开关房间,并保证所有开关都处于关闭状态。

  3. $[0,1,2,\ldots,n-1]$ 中所有节点,都指向节点 $n+m+2$,有一条流量为1,费用为1的边。表示如果进入开关 $i$,那么需要从机器人的总代价中增加1的代价,并进入下一步的搜索。