P10226 [COCI 2023/2024 #3] Restorani 题解
P10226 [COCI 2023/2024 #3] Restorani 题解
知识点
一拖拉库千奇百怪的做法,知识点涉及挺广。
题目分析
给定一棵树,求从节点 \(1\) 开始,交替并不重复地遍历(路径可以重复)集合 \(A\) 与 \(B\) 中的点,并且最后回到节点 \(1\) 的最小距离与方案。
做法分析
Subtask 1
看到 \(m \leq 10\),就应该知道只有两种可能:爆搜或状压 DP。但是由于是有两个大小为 \(m\) 的集合,总大小为 \(2m\),所以先排除爆搜,那么就是状压 DP 了。
可是有人会疑惑了:即使 \(m \leq 10\),\(n\) 可是 $ \leq 5 \times 10^3$,怎么状压呢?
其实只要关注两个集合中的点的距离即可,我们直接预处理出来,就可以不管 \(n\) 的范围了。
输出方案只要 DFS 或栈模拟一遍即可。
那么算法分为以下几个步骤:
-
先跑一遍树的遍历,用各种求法求出集合 \(A\) 与 \(B\) 中点的距离。
-
然后开始 DP:
-
设在 \(A,B\) 被遍历过的点的状态为 \(S,T\),且 \(B\) 遍历的最后一个点位 \(i\) 时的距离最小值为 \(f_{i,S,T}\);
-
那么状态转移方程即为:
\[f_{k,S|1<<j-1,T|1<<k-1} = \min {f_{i,S,T}+dis_{i,j}+dis_{j,k}} \] -
在 DP 的同时,记一个
std::pair数组 \(pa_{i,S,T}\),分别保存这个状态下 \(A,B\) 两个集合中各自被遍历的最后一个点,用于我们最后求方案。
-
-
输出答案并倒序回溯输出方案。
这个算法时间复杂度十分奇怪,有人可能认为它的 DP 复杂度是 \(O(m \cdot 2^{2m})\),但是其实只有 \(O(\sum_{i=1}^{m}{\operatorname{C}_{m}^{i}}^2)\),因为两个集合是交替遍历的。
总时间复杂度 \(O(m^2 \cdot \log_{2}{n}+\sum_{i=1}^{m}{\operatorname{C}_{m}^{i}}^2)\),空间复杂度 \(O(m^2 + n \cdot \log_{2}{n} + m \cdot 2^{2m})\)。
namespace Subtask1{
const int M1=15,S=(1<<10)+5,lV=20,lN=lV-1;
int U,id,s,t,top;
int dep[N],cnt[S],st[M1<<1];
int d[M1][M1],fa[N][lV];
int f[15][S][S];s
vector<int> G[11];
pair<int,int> pa[15][S][S];
void dfs(int u){
dep[u]=dep[fa[u][0]]+1;
FOR(i,1,lN)fa[u][i]=fa[fa[u][i-1]][i-1];
EDGE(g,i,u,v)if(v!=fa[u][0])fa[v][0]=u,dfs(v);
}
int Lca(int u,int v){
if(dep[u]>dep[v])swap(u,v);
DOR(i,lN,0)if((dep[v]-dep[u])&(1<<i))v=fa[v][i];
if(u==v)return v;
DOR(i,lN,0)if(fa[u][i]!=fa[v][i])u=fa[u][i],v=fa[v][i];
return fa[u][0];
}
int dis(int u,int v){
return (dep[u]+dep[v])-(dep[Lca(u,v)]<<1);
}
signed Cmain(){
s=t=U=(1<<m)-1,RCL(f,INF,f,1),ans=INF;
FOR(i,1,U)cnt[i]=cnt[i>>1]+(i&1),G[cnt[i]].push_back(i);
dfs(1);
FOR(i,1,m)FOR(j,1,m)d[i][j]=dis(a[i],a[j+m]);
FOR(i,1,m)FOR(j,1,m)
f[j][1<<i-1][1<<j-1]=dis(1,a[i])+d[i][j],pa[j][1<<i-1][1<<j-1]={0,i};
FOR(t,1,m-1)for(int S:G[t])for(int T:G[t])FOR(i,1,m)if(T&1<<i-1)
FOR(j,1,m)if(!(S&(1<<j-1)))FOR(k,1,m)if(!(T&(1<<k-1))){
int D=f[i][S][T]+d[j][i]+d[j][k];
if(f[k][S|1<<j-1][T|1<<k-1]>D)
f[k][S|1<<j-1][T|1<<k-1]=D,pa[k][S|1<<j-1][T|1<<k-1]={i,j};
}
FOR(i,1,m)if(f[i][U][U]+dis(1,a[i+m])<ans)id=i,ans=f[i][U][U]+dis(1,a[i+m]);
cout<<ans<<endl;
while(id){
int id0=pa[id][s][t].first,s0=pa[id][s][t].second;
st[++top]=id,st[++top]=pa[id][s][t].second,s^=1<<s0-1,t^=1<<id-1,id=id0;
}
while(top)cout<<st[top--]<<' ';
cout<<endl;
return 0;
}
}
(这破部分分还真长……)
Subtask 2
思考一下为什么我们只能想到用 DP?
其实是因为我们既要求答案,也要求方案,我们需要时刻把他们结合在一起,才能保证答案的正确性。
可是,我们能不能把他们分开呢?
答案是可以。分开后,就使得他们可以不相互受限,单个求解。
先考虑求答案:答案其实和方案并没有太大关系,因为一个答案可能有多种方案。我们来思考如何不顾方案求得答案。
我们以节点 \(1\) 为根开始遍历这棵树,走到一棵子树的根,计算连接它与它的父节点的边会被走多少次。我们设这个子树中包含 \(x\) 个 \(A\) 集合的元素, \(y\) 个 \(B\) 集合中的元素,那么分类讨论:
- \(x=y\),我们可以直接在子树内把它们一通遍历完,易证这样肯定比再跑到子树外面给某个节点找配对快,所以只要进去一次并出来一次即可,贡献为 \(2\)。
- \(x<y\),我们在某一次进入时,同第一种情况把子树内能够直接配对的全部配对了,剩下的要出子树匹配,贡献为 \(2(y-x)\)。
- \(x>y\),同情况 2,贡献为 \(2(x-y)\)。
总结一下,我们设以 \(i\) 为根的子树中有 \(x_i\) 个 \(A\) 集合的元素, \(y_i\) 个 \(B\) 集合中的元素,那么总共的贡献即 \(\sum_{i=1}^n \max(1,|x_i-y_i|)\),也就是答案。
之后三个 Subtask 都可以用这种方法求得答案,那么现在只剩下方案了。
观察 Subtask 2,发现他变成了一个序列上的问题,那么我们隐约可以感觉到这个结论:能正着来的就直接正着来处理,不行的话就在折返的时候顺带解决,这样可以减少不必要的折回。
具体怎么做呢?
对于任意序列,设 \(f_A ( i )\) 是长度为 \(i\) 的序列前缀中来自集合 \(A\) 的元素个数。同理,如果长度为 \(k\) 的序列满足 \(f_A ( k ) = f_B ( k )\) 且 \(f_A(i) ≥ f_B(i),\forall i \le k\) ,则该序列为正序列。现在,整个序列可以分为连续的正子序列和负子序列(举例说明最能让你理解这一点)。
观察。任何负序的逆序都是正序。
我们可以从序列的起点开始,将集合 \(A\) 中最小的第 \(i\) 个元素与集合 \(B\) 中最小的第 \(i\) 个元素配对,从而遍历一个正序列。
这段话来自官方题解,他告诉我们可以给它下一个更简洁的定义。
那么思路就有了:先遍历所有的正子序列,再倒序遍历所有负子序列,就可以求出方案。
时间复杂度 \(O(n + m)\),空间复杂度 \(O(n + m)\)。
namespace Subtask2{
bool id[N];
int cnt0[N][2];
deque<int> dq[2];
signed Cmain(){
FOR(i,1,n)cnt0[i][0]=cnt0[i-1][0]+cnt[i][0],cnt0[i][1]=cnt0[i-1][1]+cnt[i][1];
DOR(i,n,2)if(cnt[i][0]|cnt[i][1])
ans+=max(1,abs(cnt[i][0]-cnt[i][1]))<<1,cnt[i-1][0]+=cnt[i][0],cnt[i-1][1]+=cnt[i][1];
cout<<ans<<endl;
id[1]=(cnt0[1][0]>=cnt0[1][1]);
FOR(i,2,n){
if(cnt0[i][0]==cnt0[i][1])id[i]=id[i-1];
else id[i]=(cnt0[i][0]>cnt0[i][1]);
}
FOR(i,1,n)if(id[i]){
if(~num[i][0])dq[0].push_back(num[i][0]);
if(~num[i][1])dq[1].push_back(num[i][1]);
}
while(!dq[0].empty()&&!dq[1].empty())
cout<<dq[0].front()<<" "<<dq[1].front()<<" ",dq[0].pop_front(),dq[1].pop_front();
DOR(i,n,1)if(!id[i]){
if(~num[i][0])dq[0].push_back(num[i][0]);
if(~num[i][1])dq[1].push_back(num[i][1]);
}
while(!dq[0].empty()&&!dq[1].empty())
cout<<dq[0].front()<<" "<<dq[1].front()<<" ",dq[0].pop_front(),dq[1].pop_front();
cout<<endl;
return 0;
}
}
Subtask 3
其实在有了求答案部分和 Subtask 2 的思路,我们就可以进一步拓展出一下思想:
我们设以 \(i\) 为根的子树中有 \(x_i\) 个 \(A\) 集合的元素, \(y_i\) 个 \(B\) 集合中的元素。当 \(x_i \geq y_i\) 时,称以 \(i\) 为根的子树为“正子树”;当 \(x_i \leq y_i\) 时,称以 \(i\) 为根的子树为“负子树”。请注意,当 \(x_i=y_i\) 时以 \(i\) 为根的子树即是“正子树”也是“负子树”。
我们称目前最后访问的节点是 \(A\) 集合中的元素为“处于过渡中”,否则就是“不在过渡中”。
假设我们当前处于 \(i\) 点:
- 状态为“处于过渡中”:
- \(i \in B\),把 \(i\) 加入答案,并在 \(B\) 中去除该颜色。
- \(i \notin B\) 且以其子节点 \(j\) 为根的子树为“负子树”,移动到 \(j\)。
- 状态为“不在过渡中”:
- \(i \in A\),把 \(i\) 加入答案,并在 \(A\) 中去除该颜色。
- \(i \notin A\) 且以其子节点 \(j\) 为根的子树为“正子树”,移动到 \(j\)。
- 若不满足上述任一条件,移动到 \(i\) 的父节点。当此时在 \(1\) 节点,就结束了整个过程。
然后依照上述规则从 \(1\) 开始爆搜即可(注意从集合中去除元素要对整个树进行更新以保证正确性)。
其实这个算法的中心思想也就是能在同一棵子树中配对的就在同一棵子树中配对。
然后有人可能会对这个算法的时间复杂度产生疑惑,认为它是依靠玄学,但其实并不是这样。
我们可以把操作概括为寻找与更新,单次寻找与更新都最多遍历这整棵树,也就是 \(O(n)\),而总共有 \(2m\) 个点,也就是 \(2m\) 次这样的操作,总时间复杂度 \(O(n \cdot m)\)。
时间复杂度 \(O(n \cdot m)\),空间复杂度 \(O(n+m)\)。
namespace Subtask3{
bool cur=1;
int top;
int fa[N],st[M];
void dfs0(int u){
EDGE(g,i,u,v)if(v!=fa[u]){
fa[v]=u,dfs0(v);
if(cnt[v][0]|cnt[v][1])
ans+=max(1,abs(cnt[v][0]-cnt[v][1]))<<1,cnt[u][0]+=cnt[v][0],cnt[u][1]+=cnt[v][1];
}
}
bool del(int u){
if(~num[u][cur^1]){
cur^=1,st[++top]=num[u][cur],num[u][cur]=-1;
for(;u;u=fa[u])--cnt[u][cur];
return 1;
}return 0;
}
void dfs1(int u){
while(del(u));
for(bool flag=1;flag;){
flag=0;
EDGE(g,i,u,v){
while(del(u));
if(v!=fa[u]&&(cnt[v][0]|cnt[v][1])&&(cur&&cnt[v][0]>=cnt[v][1]||!cur&&cnt[v][0]<=cnt[v][1]))
flag=1,dfs1(v);
}
}
}
signed Cmain(){
dfs0(1),dfs1(1);
cout<<ans<<endl;
FOR(i,1,top)cout<<st[i]<<" ";
cout<<endl;
return 0;
}
}
Subtask 4
(啊哈哈,终于来到 大家最爱的 正解部分了。)
我们延续 Subtask 3 的思想,但是我们不能像他那样真正地一直模拟爆搜整个过程,于是我们就可以用一些东西把他们的顺序存下来,然后快速合并,最后得到答案。
用什么比较方便且快速?我们联想到合并(或者说叫“承接”更好)操作只有 \(O(1)\) 的链表,用它来合并答案序列实在是再合适不过了。
但是呢,如何操作又成了另一个大问题。
为了遵循求得答案部分的思路,我们要保证:设以 \(i\) 为根的子树中有 \(x_i\) 个 \(A\) 集合的元素, \(y_i\) 个 \(B\) 集合中的元素,当 \(x_i \neq y_i\),在某一次进入时,把子树内能够直接配对的全部配对了。很多人都忘记了这一点,然后打出了错误代码。
设现在处于 \(i\) 节点,子节点为 \(j\),我们可以这样操作:
先遍历一遍所有子节点:
- \(x_j=y_j\),直接加到链表的前面或后面,这样就可以避免一些多余贡献。
- \(x_j>y_j\),加入集合 \(A\)。
- \(x_j<y_j\),加入集合 \(B\)。
再遍历两集合 \(A,B\):把两集合的元素不断合并再放到一个集合中。
然后就结束啦!!!
时间复杂度 \(O(n+m)\),空间复杂度 \(O(n+m)\)。
namespace Subtask4{
int cur;
int id[M],pre[M],nxt[M];
list<int> st[N][2],res[M];//st[0/1]分别记以a/b结尾的链表的head节点
void update(list<int> &A,list<int> &B,int cur){
if(!A.empty())
res[cur].splice(res[cur].end(),res[id[A.back()]]),A.back()=id[res[cur].back()]=cur;
else
res[B.back()].splice(res[B.back()].end(),res[cur]),id[res[B.back()].back()]=B.back();
}
int dfs(int u,int fa){
if(~num[u][0])st[u][0].push_back(num[u][0]);
if(~num[u][1])st[u][1].push_back(num[u][1]+m);
int cur=-1,nxt=-1;
EDGE(g,i,u,v)if(v!=fa){
int ret=dfs(v,u);
bool ty=st[v][0].empty();
if(cnt[v][0]|cnt[v][1])
ans+=max(1,abs(cnt[v][0]-cnt[v][1]))<<1,cnt[u][0]+=cnt[v][0],cnt[u][1]+=cnt[v][1];
if(~ret)~cur?res[cur].splice(res[cur].end(),res[ret]),id[res[cur].back()]=cur:cur=ret;
st[u][ty].splice(st[u][ty].end(),st[v][ty]);
}
if(st[u][0].empty()&&st[u][1].empty())return cur;
if(~cur)update(st[u][0],st[u][1],cur);
while(!st[u][0].empty()&&!st[u][1].empty()){
cur=st[u][0].back(),nxt=st[u][1].back(),st[u][0].pop_back(),st[u][1].pop_back();
res[cur].splice(res[cur].end(),res[nxt]),id[res[cur].back()]=cur;
if(st[u][0].empty()&&st[u][1].empty())return cur;
update(st[u][0],st[u][1],cur);
}return -1;
}
signed Cmain(){
FOR(i,1,m<<1)id[i]=i,res[i].push_back(i);
cur=dfs(1,0);
cout<<ans<<endl;
for(int x:res[cur])cout<<(x>m?x-m:x)<<" ";
cout<<endl;
return 0;
}
}
提示&吐槽
这道题如果你在洛谷上得到了 622 分,千万不要沾沾自喜,请重新写程序,因为这道题的 Partically Accepted 有误。[(话说怎么现在都没修好啊?)](Specail Judge 有误!!! - 洛谷 | 计算机科学教育新生态 (luogu.com.cn))
链接
[COCI 官方题解中文翻译](云剪贴板 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn))。

浙公网安备 33010602011771号