贪心?动态规划?都试试!

Sweetlemon

2019-10-29 16:48:57

Solution

#### LGR-062, C, 小 C 与桌游 这是一道有意思的题目。 题意简述:求 DAG 的一个拓扑序,使得拓扑序列的前缀最大值的变化次数尽可能多 / 少。 首先看**尽可能多**的情况。考虑贪心,把拓扑排序的队列改为优先队列,每次都取当前入度为零(称之为“可访问”)的点中最小的一个进行访问。可以用调整的方法证明这个贪心策略的最优性——如果这一步不访问最小的那一个点 $u$,而是访问另一个点 $v$,那么把访问 $u$ 的位置提到访问 $v$ 以前,这个拓扑序仍合法,并且答案会变大。 那么**尽可能少**的情况呢?我们是不是也可以用类似的贪心,每次都取当前可访问点中最大的一个进行访问呢? 考虑这个例子: ![简单贪心反例](https://cdn.luogu.com.cn/upload/image_hosting/cyph3wmo.png) 如果每次取可访问点中最大的一个来访问,那么自然会依次选中 $1,3,5,4,2,6$,总代价是 $4$;但是如果选择拓扑序 $1,2,6,3,5,4$,总代价则只有 $3$。也就是说,大的点可能会被掩盖在小的点后面,使得我们的贪心不正确! 或者可以从贪心证明的角度来思考。尽可能多的情况下,访问了较小的不会影响访问较大者的贡献;但在尽可能少的情况下,访问较大者会影响访问较小者的贡献;因此,这两个存在本质的不同,不能直接生搬硬套。 怎么办呢?如果贪心不行,我们不妨考虑 dp? 设 $f[i]$ 表示访问到的最大的点是 $i$ 时的最小代价,那么 $f[0]=0$,答案就是 $f[n]$。这个状态其实是有冗余的——比如上图中 $f[4]$ 就没有意义,任何时刻访问到的最大的点都不可能是 $4$。不过这个状态对于表示问题的当前情况来说也算是足够了,就姑且这么使用吧。 设 $\mathrm{pre}[i]$ 表示 $i$ 的所有前置点 $j$(即存在路径 $j\rightarrow i$ 的点 $j$)的最大标号,例如上图中 $\mathrm{pre}[1]=0,\mathrm{pre}[6]=2,\mathrm{pre}[4]=5$。不难发现,$f$ 数组仅对于 $\mathrm{pre}[i]<i$ 的点有意义,我们把这些点称为“关键点”;并且对于任意点 $i$,$\mathrm{pre}[i]$ 都是关键点。$\mathrm{pre}$ 数组可以通过 DAG 上 dp 简单地求出。 考虑所有关键点 $i$,$f[i]=f[\mathrm{pre}[i]]+1$ ……吗? 再考虑这个例子。 ![转移方程反例](https://cdn.luogu.com.cn/upload/image_hosting/scizi86l.png) 如果 $f[i]=f[\mathrm{pre}[i]]+1$,那么 $f[1]=1,f[2]=2,f[3]=3,f[4]=4,f[5]=2,f[6]=5$。 但是事实上,访问到 $6$ 的最小代价实际上是 $3$——访问顺序可以是 $1,5,2,3,4,6$! 访问点 $i$ 的条件是 $i$ 的前置点都已经被访问,那么只要“当前已经访问的点的最大值”**不小于** $\mathrm{pre}[i]$,我们就已经可以把 $i$ 的前置点访问完毕了!可以这么理解:假如我们已经买好了 $k$ 级票,买好后可以免费访问 $k$ 级及以下的点,那么只要 $k\ge \mathrm{pre}[i]$,$i$ 的前置点就都在可免费访问的范围内。 因此,事实上 $f[i]=\min(f[k])+1,\mathrm{pre}[i]\le k<i$。这里我们严格按照定义,对于 $k>i$ 的情况不在 $f[i]$ 处考虑而是在 $f[k]$ 处考虑。 这个 dp 怎么算呢? 我会动态区间最小值!不就是棵线段树嘛!或者也可以用树状数组,代码好写,常数较小,但是多一个 $\log$。树状数组方面的实现可以详见[Chanis 的日报](https://www.luogu.org/blog/Chanis/super-BIT)。 那么有没有别的做法呢? 看这个转移方程,似乎有一点“单调”的意思。 我们要求的东西是一个“后缀最小值”,因此“标号大、代价小”的状态会覆盖“标号小、代价大”的状态,我们的数组应该是单调递增的。 查找时在数组中寻找标号不小于 $\mathrm{pre}[i]$ 的第一个元素,就是我们寻找的最小代价,加上 1 就是 $f[i]$ 的值。 插入时,如果数组中存在着“标号比 $i$ 小、代价还比 $f[i]$ 大”的元素,就会被 $f[i]$ 覆盖,从而可以把被覆盖的状态删除。这些状态分布在 $f[1...i]$ 的后半部分,因此从 $f[i]$ 往前找,一直找到不能删就停下。 数组中删除元素?当然不行! 根据斜率优化的经验,这里我们使用一个 set 来维护,不断 `lower_bound` 和 `erase` 即可。于是这题就解决了。 ---- 可是!真的不能贪心吗? 我们换个思路。仍然使用“买 $k$ 级票”的情景,即必须按照拓扑序访问节点,访问节点时要先买票;买一次票的代价是 $1$,买 $k$ 级票可以访问所有标号不大于 $k$ 的节点;买票时只能买当前可以访问的节点,即当前入度为 0 的节点。 算法刚开始时,我们只买了 $0$ 级票(也就是还没有买票),什么节点都不能访问。这时候当然必须买票了,不交钱不行啊!于是我们只能交些车船费(花费 $1$ 的代价),买一张票。 买票的时候你看到了这几张票:J 级票,$1$ 元;S 级票,$1$ 元;N 级票,$1$ 元;I 级票,$1$ 元。假如这四张票都能买,那么你选择买哪一张呢?当然是最高级的 I 级票啦,毕竟车船费都是一样的,肯定要买适用范围最广的 I 级啦。 这就是我们的贪心策略——在必须买票的时候,买可以买的最高级的票。 翻译成原题语言,就是当 目前所有可访问的节点标号 都大于 访问过的节点的最大标号,也就是必须要花费 $1$ 的代价时,访问 可访问点 中 标号最大 的那一个。 等一下!这和原来的贪心策略有什么区别?当然是在前提条件上啦。 原来的策略是,**在访问一个新的点时**,选择可访问的标号最大的点。 现在的策略是,**在必须花费代价才能访问新的点时**,选择可访问的标号最大的点。 现在的策略的好处就是“物尽其用”,利用原来消耗过的代价,尽可能地访问更多的点,争取暴露出“更高级的票”,也就是尽量地把标号大的点加入到可访问点的集合中。这样就解决了原来贪心策略中“大的点被小的点掩盖”的问题。 于是我们的算法就是: 1. (此时必须买票)选择可访问的点中标号最大的进行访问,代价加 1 2. (此时利用刚买的票)访问可访问点中所有标号小于“最大标号”的点 3. 重复上述两个步骤,直到所有的点都被访问 我们用什么来维护“可访问点”的集合呢?要支持取最大值,取所有“不超过 $x$”的数值…… 既然是集合,那就用 `set` 吧。取最值用 `rbegin`,取“不超过 $x$ 的数值”用 `lower_bound`。 当然,还可以用两个堆。我们把可访问点同时加入最大堆和最小堆中,第 1 步时取最大堆堆顶,第 2 步不断从最小堆堆顶取元素,直到堆顶的标号大于“最大标号”。注意,需要用标记数组防止重复访问同一个点。这道题的查询是一个“两端”型的,也就是要从值域的两端取东西,那么两个堆拼起来不就像一个两端开口的容器吗?正符合我们的要求。 于是这题终于完美解决了。这道题启示我们,贪心和动态规划常常相互配合,动态规划能处理贪心难以解决的问题,贪心能优化动态规划的状态设计,降低时间复杂度。当然,我们也要考虑从贪心和动态规划本身进行优化,尤其是优化贪心策略、优化状态表示和状态转移。 其实,尽可能多的情况也可以用 dp 解决(这个 dp 应该是比“尽可能少的情况”简单的),也不妨思考一下。 下面附各种做法(贪心、树状数组优化 dp、set 单调优化 dp)的代码。 速度的话,可能我的常数比较大,测试结果是 (所有测试点时间之和) 贪心 1.33s, set 1.92s, 树状数组 941 ms(均无 -O2,加 -O2 后差异不大)。可以看到,虽然树状数组的复杂度多一个 $\log$,但是由于这个 $\log$ 是最坏情况,并不常能取到;且常数较小,因此实际速度还是较快的。而 set 天生比较慢;至于我的贪心为什么这么慢……还是人傻常数大吧? ##### 贪心 ```cpp #include <cstdio> #include <cctype> #include <functional> #include <algorithm> #include <queue> #define MAXIOLG 25 #define MAXN 500005 #define MAXM 1000005 #define INf 114514 #define INF 1919810 using namespace std; typedef long long ll; typedef long double ld; typedef ll io_t; io_t shin[MAXIOLG]; io_t seto(void); //快读 void ayano(io_t x,char spliter='\n'); //快写 priority_queue<int,vector<int>,greater<int> > q; //小根堆 priority_queue<int,vector<int>,less<int> > q2; //大根堆 int fst[MAXN],nxt[MAXM],edges=0; //存图 int g[MAXM]; int ind[MAXN],visited[MAXN]; //入度, 访问标记 void addedge(int u,int v); //加边 int main(void){ int n,m; n=seto(),m=seto(); while (m--){ int u,v; u=seto(),v=seto(); addedge(u,v),ind[v]++; } int ans1=0,ans2=0; //先计算尽可能大情况的答案 ans1 for (int i=1;i<=n;i++) (!ind[i])?(q.push(i)):(void()); int tx=0; //当前访问过的最大的节点 while (!q.empty()){ int nowv=q.top(); //要访问的节点 q.pop(); if (nowv>tx) tx=nowv,ans1++; //更新答案 for (int ei=fst[nowv];ei;ei=nxt[ei]){ int v=g[ei]; ind[v]--; (!ind[v])?(q.push(v)):(void()); } } ayano(ans1); //输出第一个答案 //再计算尽可能小情况的答案 ans2 for (int i=1;i<=n;i++) for (int ei=fst[i];ei;ei=nxt[ei]) ind[g[ei]]++; //重新统计入度 for (int i=1;i<=n;i++) (!ind[i])?(q.push(i),q2.push(i)):(void()), visited[i]=0; //注意要同时插入到两个堆里 tx=0; while (!q2.empty()){ //取大根堆堆顶, 买票 int nowv=q2.top(); q2.pop(); if (visited[nowv]) continue; visited[nowv]=1; (nowv>tx)?(ans2++,tx=nowv):(0); //访问这个节点 for (int ei=fst[nowv];ei;ei=nxt[ei]){ int v=g[ei]; ind[v]--; (!ind[v])?(q.push(v),q2.push(v)):(void()); } while ((!q.empty()) && (nowv=q.top())<=tx){ //取小根堆堆顶进行访问, 充分利用票 q.pop(); if (visited[nowv]) continue; visited[nowv]=1; for (int ei=fst[nowv];ei;ei=nxt[ei]){ int v=g[ei]; ind[v]--; (!ind[v])?(q.push(v),q2.push(v)):(void()); } } } ayano(ans2); //输出第二个答案 return 0; } void addedge(int u,int v){ edges++; g[edges]=v; nxt[edges]=fst[u],fst[u]=edges; } io_t seto(void){ io_t x=0; char ch=getchar(); int symbol=0; while (!isdigit(ch)) (ch=='-')?(symbol=1):(0),ch=getchar(); while (isdigit(ch)) x=(x*10)+(ch-'0'),ch=getchar(); return (symbol)?(-x):(x); } void ayano(io_t x,char spliter){ if (!x){ putchar('0'),putchar(spliter); return; } if (x<0) putchar('-'),x=-x; int len=0; while (x){ io_t d=x/10; shin[len++]=x-d*10; x=d; } while (len--) putchar(shin[len]+'0'); putchar(spliter); } ``` ##### 树状数组求区间最值的 dp ```cpp #include <cstdio> #include <cctype> #include <functional> #include <algorithm> #include <queue> #define MAXIOLG 25 #define LOWBIT(x) ((x)&(-(x))) #define MAXN 500005 #define MAXM 1000005 #define INf 114514 #define INF 1919810 using namespace std; typedef long long ll; typedef long double ld; typedef ll io_t; io_t shin[MAXIOLG]; io_t seto(void); void ayano(io_t x,char spliter='\n'); priority_queue<int,vector<int>,greater<int> > q; int n; int fst[MAXN],nxt[MAXM],edges=0; int g[MAXM]; int ind[MAXN]; int pre[MAXN]; //上文所述的 pre 数组 int f[MAXN]; //上文所述的动态规划 f 数组 int ftarr[MAXN]; //用于存储 f 最小值的树状数组 int query(int l,int r); //树状数组查询区间最小值, O(log n log n) void update(int x,int v); //简化版的树状数组更新区间最小值 void addedge(int u,int v); //加边 int main(void){ int m; n=seto(),m=seto(); while (m--){ int u,v; u=seto(),v=seto(); addedge(u,v),ind[v]++; } for (int i=1;i<=n;i++) (!ind[i])?(q.push(i),pre[i]=0):(0), f[i]=ftarr[i]=INF; //初始化, f 和树状数组都初始化为 INF int ans1=0; //第一行输出的答案 int tx=0; //当前访问的最大节点(在计算第一个答案时用) //由于 dp 可以按照任意拓扑序计算 //因此本程序中两个答案的计算同时进行 while (!q.empty()){ int nowv=q.top(); q.pop(); //下面一部分用于 dp if (nowv>pre[nowv]){ //关键点 f[nowv]=query(pre[nowv],nowv-1)+1; //查询区间最值, 加1 update(nowv,f[nowv]); //插入到树状数组中进行更新 } int tpre=max(pre[nowv],nowv); //下面一部分用于贪心 if (nowv>tx) tx=nowv,ans1++; for (int ei=fst[nowv];ei;ei=nxt[ei]){ int v=g[ei]; pre[v]=max(pre[v],tpre); //在访问的同时更新 pre ind[v]--; (!ind[v])?(q.push(v)):(void()); } } //输出两个答案 ayano(ans1); ayano(f[n]); return 0; } int query(int l,int r){ //查询区间 [l,r] 的最小值 if (!l) return 0; //注意树状数组遇到下标 0 会出事, 所以提前处理; f[0]=0 int ans=INF; while (r>=l){ int t=r^LOWBIT(r); //相当于 t=r-LOWBIT(r) if (t>=l) ans=min(ans,ftarr[r]),r=t; else ans=min(ans,f[r]),r--; } return ans; } void update(int x,int v){ //注意,这是简化版的更新函数 //由于本题中树状数组中的值只会变小不会变大 //所以可以像求和一样进行更新, 更新复杂度 O(log n) //但是一般的 值有可能变大的问题不能这么写 //必须对所有有影响的点重新计算最值, 更新复杂度 O(log n log n) while (x<=n) ftarr[x]=min(ftarr[x],v), x+=LOWBIT(x); } //以下略去了 addedge, seto, ayano 三个函数的实现 ``` #### set 优化 dp ```cpp #include <cstdio> #include <cctype> #include <functional> #include <algorithm> #include <queue> #include <map> #include <set> #define MAXIOLG 25 #define MAXN 500005 #define MAXM 1000005 #define INf 114514 #define INF 1919810 using namespace std; typedef long long ll; typedef long double ld; typedef ll io_t; io_t shin[MAXIOLG]; io_t seto(void); void ayano(io_t x,char spliter='\n'); priority_queue<int,vector<int>,greater<int> > q; //用于贪心的小根堆 set<pair<int,int> > st; //pair 自带比较函数 int fst[MAXN],nxt[MAXM],edges=0; int g[MAXM]; int ind[MAXN]; int pre[MAXN]; int f[MAXN]; void addedge(int u,int v); int main(void){ int n,m; n=seto(),m=seto(); while (m--){ int u,v; u=seto(),v=seto(); addedge(u,v),ind[v]++; } for (int i=1;i<=n;i++) (!ind[i])?(q.push(i),pre[i]=0):(0), f[i]=INF; int ans1=0; int tx=0; f[0]=0; st.insert(make_pair(0,0)); while (!q.empty()){ int nowv=q.top(); q.pop(); int tpre=pre[nowv]; //以下是 dp if (nowv>tpre){ //计算 f 值; first 保存"值"——标号,second 保存"下标"——代价 f[nowv]=st.lower_bound(make_pair(tpre,0))->second+1; int tf=f[nowv]; //下面将把 f[nowv] 插入到 set 中 //找到标号小于 nowv 且代价大于 nowv 的状态 auto it=st.lower_bound(make_pair(nowv,0)); // 不断删除 while ((it!=st.begin() && (it--,1)) && (it->second > tf)) st.erase(it),it=st.lower_bound(make_pair(nowv,0)); //插入 st.insert(make_pair(nowv,tf)); } tpre=max(tpre,nowv); //以下是贪心 if (nowv>tx) tx=nowv,ans1++; for (int ei=fst[nowv];ei;ei=nxt[ei]){ int v=g[ei]; pre[v]=max(pre[v],tpre); ind[v]--; (!ind[v])?(q.push(v)):(void()); } } ayano(ans1); ayano(f[n]); return 0; } //以下略去了 addedge, seto, ayano 三个函数的实现 ```