【题解】P8866 [NOIP2022] 喵了个喵(构造,adhoc)

【题解】P8866 [NOIP2022] 喵了个喵

题目链接

P8866 [NOIP2022] 喵了个喵

题意概述

有一个牌堆和 \(n\) 个可以从栈底删除元素的栈,任务是要通过规则将所有的卡牌消去。

开始时牌堆中有 \(m\) 张卡牌,从上到下的图案分别是 \(a_1, a_2,\dots, a_m\)。所有的卡牌一共有 \(k\) 种图案,从 \(1\)\(k\) 编号。牌堆中每一种图案的卡牌都有偶数张。开始时所有的栈都是空的。这个游戏有两种操作:

  • 选择一个栈,将牌堆顶上的卡牌放入栈的顶部。如果这么操作后,这个栈最上方的两张牌有相同的图案,则会自动将这两张牌消去。
  • 选择两个不同的栈,如果这两个栈栈的卡牌有相同的图案,则可以将这两张牌消去,原来在栈底上方的卡牌会成为新的栈底。如果不同,则什么也不会做。

要求构造一个操作序列,满足进行这些操作之后可以使得操作的牌堆和每个栈均为空。

数据范围

\(S\) 为所有 \(T\) 组数据中 \(m\) 的总和。

对于所有数据,保证 \(S \leq 2 \times 10^6\)\(1 \leq n \leq 300\)\(1 \leq a_i \leq k\)

测试点\(T=\)\(n\)\(k=\)\(m \leq\)
\(1\sim 3\)\(1001\)\(\leq 300\)\(2n-2\)无限制
\(4\sim 6\)\(1002\)\(=2\)\(2n-1\)无限制
\(7\sim 10\)\(3\)\(=3\)\(2n-1\)\(14\)
\(11\sim 14\)\(1004\)\(=3\)\(2n-1\)无限制
\(15\sim 20\)\(1005\)\(\leq 300\)\(2n-1\)无限制

思路分析

注:为了方便起见,我们将题目中的卡牌称之为元素

首先看到这个题目,可以尝试发现一些基本的结论:

我们最终一定进行了 \(m\) 次第一种操作,和至多 \(\frac{m}{2}\) 次第二种操作,那么操作次数 \(op\) 一定满足 \(m \le op \le \frac{3}{2}m\),所以题目中关于 \(op\) 的限制是没用的。

观察数据范围就比较容易能联想到 NOIP2018 的旅行,说明解法一定与 \(k\) 强相关。

所以我们可以考虑先从 \(k\) 的部分分入手。

\(k=2n-2\)

观察 \(k\)\(n\) 的关系,发现 \(k=2(n-1)\),即 \(k\)\(n-1\) 的二倍,那么当我们把每两种元素放到一个堆里,恰好最后会剩下一个空栈。

那么我们就考虑直接钦定将 \(2i\)\(2i-1\) 两种元素放在第 \(i\) 个栈中,然后将空的栈 \(n\) 作为辅助栈。

那么由于一个栈中只会放两种卡牌,所以任意时刻,一个栈中的元素最多\(2\) 个:当栈中元素数量为 \(2\) 时再加进来一个元素,那么这个元素一定与栈顶/栈底相同,一定可以相消使得元素个数 \(\le 2\)

那么可以有以下解法:

假设当前我们要将 \(x\) 放入栈中进行操作,考虑 \(x\) 放入栈 \(s\) 的操作:

  • \(s\) 为空,直接入栈;
  • \(s\) 有一个元素:
    • 该元素和 \(x\) 相同,那么直接进行第一种操作:将 \(x\) 入栈并将两个 \(x\) 相消;
    • 该元素和 \(x\) 不同,那么也直接进行第一种操作:将 \(x\) 入栈。
  • \(s\) 有两个元素:
    • 栈顶与 \(x\) 相同,那么直接进行第一种操作:将 \(x\) 入栈并将两个 \(x\) 相消;
    • 栈底和 \(x\) 相同,那么直接进行第二种操作:将 \(x\) 加入辅助栈并将两个 \(x\) 相消。

那么首先显然最后无论如何辅助栈一定为空:因为三类五种情况下只有最后一种情况会用到辅助栈且每次操作都能讲辅助栈清空。

且由于每个元素出现次数为偶数次,最后一次 \(x\) 入栈也一定能将其它栈清空,所以这样做一定可以有解。

这部分分给了 15pts,虽然相对于正解还差别很大,但已经给了我们很大的启发来思考正解。

\(k=2n-1\)

由于 \(2n-1\) 只比 \(2n-2\) 多了一个,所以我们可以想到:前 \(2n-2\) 种元素还按照上述办法处理,只需要考虑 \(x=2n-1\) 应该如何处理。

但如果直接沿用上述 \(k=2n-2\) 的解法将会非常复杂,至于为什么之后再说。

考虑另一种使用上述解法的策略但抛开 \(2i,2i-1\) 和栈 \(i\) 绑定关系的一种做法:

我们同样在初始时将前 \(n-1\) 个栈当做普通栈,第 \(n\) 个栈当做辅助栈。

假设当前我们要将 \(x\) 放入栈中进行操作,考虑 \(x\) 放入栈中的操作:

  • \(x\) 在之前的栈中已经出现过,假设 \(x\) 之前出现在栈 \(s\) 中。则我们可以按照 \(k=2n-2\) 的策略将 \(x\) 与栈 \(s\) 中的另外一个 \(x\) 进行简单相消,具体而言:

    • \(x\)\(s\) 的栈顶时,执行第一种操作;
    • \(x\)\(s\) 的栈底时,执行第二种操作。
  • \(x\) 在之前的栈中从未出现过,则将 \(x\) 加入到一个新的没满两个元素的栈中。若除了辅助栈之外的其它栈已满,那么发现此时已经不能用 \(k=2n-2\) 进行操作了,我们考虑进行特殊操作。

发现此时我们已经基本解决了问题,只有最后一种情况当【所有普通栈已满】时的特殊操作还不知道应该怎么办。

那么我们接下来都来说明如何进行特殊操作。

当所有普通栈已满时,此时局面上一定是前 \(n-1\) 个栈被元素摆满,且下一个入栈序列中的元素 \(P\) 从未在之前的栈中出现过。

贪心的考虑是直接将 \(P\) 放入辅助栈中,但假如 \(P\) 后面跟了一个是前面栈中栈底的元素,那么本来直接将该元素放入辅助栈栈底就可以与前面的栈中元素进行操作 2 相消。但要是将 \(P\) 放入辅助栈,那么这个元素就很难再抵消,不能最优。

由此可以发现我们的选栈策略实际上与 \(P\) 后面的一串数有关。那么我们考虑离线:即分别考虑 \(P\) 后面都有哪些数,然后再进行选栈。

  • 对于 \(P\) 后面在栈顶的元素:这些栈顶的数都很好解决,我们直接将他们放入原来的栈中简单相消即可。
  • 对于 \(P\) 后面本来就是 \(P\) 的元素:显然可以将两个 \(P\) 放在辅助栈然后直接相消,这时候辅助栈还是空的;
  • 对于 \(P\) 后面在栈底的元素:按理来讲,我们应该将它放在辅助栈中,然后与另外一个栈底元素相消;那么这时候操作完辅助栈还是空的

我们发现当 \(P\) 的后面出现 \(P\) 或者在栈底的元素时,我们可以将他们入栈之后直接结束特殊处理,因为无论如何将它们入栈之后都一定会出现至少一个空栈,这个空栈就变成了辅助栈,回到了正常相消的局面。而当 \(P\) 后面在栈顶的元素入栈之后不一定会使得一定存在空栈,那么就不能回到正常相消的局面。

那么我们分类讨论考虑入栈序列中 \(P\) 后面第一个不在栈顶的元素是什么:

  • \(P\) 后面第一个不在栈顶的元素是 \(P\) 时,那么此时的入栈序列一定是 \((P,\cdots,P)\),省略号表示了一段在栈顶的元素,那么我们将 \(P\) 放入辅助栈中,对于省略号中的元素可以直接在栈顶自由相消,再将第二个 \(P\) 加入到辅助栈中,使得辅助栈变空且辅助栈位置不变;

  • \(P\) 后面第一个不在栈顶的元素在栈底时,假设这个元素为 \(X\)\(X\) 原来在栈 \(s\) 的栈底。那么此时的入栈序列一定是 \((P,\cdots,X)\)

    我们当然会尽量让 \(X\) 相抵消,那么有两种情况:

    • 使用操作 1 将入栈序列中的 \(X\) 放入 \(s\) 中,此时一定满足 \(s\) 只有一个元素 \(X\),那么就要把 \(s\) 的栈顶元素全部消掉,我们设 \(s\) 的栈顶元素为 \(Y\),那么说明 \((P,\cdots,X)\) 的省略号中 \(Y\) 的个数一定是奇数个,因为这样它们才能与栈中的一个 \(Y\) 共同全部消掉。即:当 \((P,\cdots,X)\) 的省略号中 \(Y\) 为奇数时,则将 \(P\) 放入辅助栈中,再将省略号中的元素自由相消,然后将 \(X\) 入栈 \(s\),与栈中 \(s\) 相消。此时 \(s\) 为空,变成新的辅助栈;
    • 同理当省略号中 \(Y\) 的个数为偶数个时,只能让两个 \(X\) 通过操作 2 栈底相消,那么我们只能先将 \(P\) 放入 \(s\) 中,然后再将其它非 \(Y\) 的栈顶元素自由相消,偶数个 \(Y\) 放在辅助栈中相消,最后再将 \(X\) 放入辅助栈进行操作 2 与栈底的 \(X\) 相消。此时辅助栈为空且辅助栈位置不变。
  • \(P\) 后面全部跟的都是在栈顶的元素时,直接一直相消到入栈序列为空即可。

经过上述操作后,局面总保持:

  • 存在一个空栈,就是辅助栈。
  • 所有元素在栈中最多出现一次;
  • 每个栈最多只有两个元素。

由于每种元素数量均为偶数,相消永远是同种元素两两相消,所以到最后,某种数出现奇数次是不可能的。又由于每种元素在栈中最多只出现一次。综合来看,每种元素到最后只能出现零次,也就是必定不会出现。

实现细节

之所以在这个题题解中突然加一个我之前写题解从来没有过的【实现细节】的环节,是因为这个题代码确实难写也比较巧妙,所以专门在这里说一下。

  1. 如何实现普通局面时,对于 \(x\) 的入栈和简单相消的过程?

    实际上只要维护一个队列 \(stk\) 存储栈的编号,满足:

    • 如果一个栈中已经存满两个元素,那么当前栈不在队列中;
    • 如果一个栈中有一个元素,那么当前栈在队列中出现一次;
    • 如果一个栈中没有元素,那么当前栈在队列中出现两次;
    • 辅助栈的编号不出现在队列中

    初始时,我们将 \(1\)\(n-1\) 的所有元素入栈两次。

    同时,定义一个数组 \(id_x\) 表示 \(x\) 出现的栈的编号,初始时 \(id_x=0\)

    我们用 deque 来存储每个栈的元素和栈内相对位置。并用一个变量 \(spt\) 表示当前辅助栈的编号。

    当一个元素 \(x\) 要入栈时:

    • \(id_x=0\) 时:直接从队列中弹出一个栈,并将 \(x\) 入栈。
    • \(id_x\ne0\) 时:
      • \(x\) 在栈顶,即 \(dq[id_x].back=x\) 时,将 \(x\) 加入 \(id_x\),并与栈顶 \(x\) 进行相消;
      • \(x\) 在栈底,即 \(dq[id_x].front=x\) 时,将 \(x\) 加入 \(spt\),并与栈底 \(x\) 进行相消。

    这块需要注意,及时更新 \(id_x\)(变为 \(0\) 还是变成新的数),及时将每个栈入队出队,及时更新栈内元素。

    code:

    int solve(int x)
    {
    	if(id[x])
    	{
    		int ID=id[x];
    		if(dq[ID].back()==x)
    		{
    			id[x]=0;
    			dq[ID].pop_back();
    			pb(ID);
    			stk.push(ID);
    		}
    		else if(dq[ID].front()==x)
    		{
    			id[x]=0;
    			dq[ID].pop_front();
    			pb(spt);
    			del(ID,spt);
    			stk.push(ID);
    		}
    	}
    	else
    	{
    		if(stk.empty())//特殊处理
    		{
    			return 0;
    		}
    		else//简单插入
    		{
    			int tt=stk.front();
    			dq[tt].push_back(x);
    			stk.pop();
    			id[x]=tt;
    			pb(tt);
    		}
    	}
    	return 1;
    }
  2. 特殊处理中,如何求出 \(P\) 后面第一个不为栈顶的元素是谁?

    我们可以从 \(P\) 开始暴力枚举每一个元素,判断它们是否为栈顶。直到判断到一个元素是 \(P\) 或者是栈底即可。

    由于 \(P\) 一定没有出现在栈中,所以可以直接判断当前元素是否在栈中出现。

    code:

    int t=pos;
    pos++;
    while(id[a[pos]]&&dq[id[a[pos]]].back()==a[pos])pos++;
  3. 特殊处理中有哪些需要注意的细节?

    • 对于 \((P,\cdots,X)\) 中,\(Y\) 为偶数的情况,未来所有不为 \(Y\) 的栈顶自由相消,对于 \(Y\),不能直接自由相消,需要我们把它放到辅助栈相消。
    • 对于 \((P,\cdots,X)\) 中,\(Y\) 为偶数的情况,辅助栈即将变到 \(s\),所以辅助栈不能入栈。也不能直接特判当前栈为 \(s\) 时,直接不入栈,例如:当当前入栈序列为 3 1 2 1 1 1 ...,如果直接不弹入 \(stk\),会造成后面的一串 1 1 1 无法正确入栈。但由于最后暴力扫队列把 \(s\) 删除复杂度存在问题,所以我们可以直接不使用简单相消。
  4. 如何记录最终答案?

    我们可以使用一个 vector<pair<int,int>>ans 来存储答案序列,对于 \(ans\) 中的元素 \(val\),若 \(val.second\)\(0\),则是操作 1,\(val.first\) 表示操作 1 选择的栈;反之则是操作 2,\(val.first\)\(val.second\) 分别表示操作 2 选择的两个栈。那么操作次数直接输出 \(ans.size()\) 即可。

代码实现

//luoguP8866
#include<iostream>
#include<cstdio>
#include<queue>
#include<cstring>
#include<vector>
#define mk make_pair
#define pii pair<int,int>
using namespace std;
const int maxn=305;
const int maxm=2e6+10;
const int maxk=1005;
int a[maxm],id[maxk];
int n,m,k,spt;

queue<int>stk;

deque<int>dq[maxn];

vector<pii>ans;

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*10+ch-48;ch=getchar();}
	return x*f;
}

void pb(int pos){ans.push_back(mk(pos,0));}

void del(int pos1,int pos2){ans.push_back(mk(pos1,pos2));}

void clear()
{
	while(!stk.empty())stk.pop();
	for(int i=1;i<=k;i++)id[i]=0;
	ans.clear();
	spt=n;
}

int solve(int x)
{
	if(id[x])
	{
		int ID=id[x];
		if(dq[ID].back()==x)
		{
			id[x]=0;
			dq[ID].pop_back();
			pb(ID);
			stk.push(ID);
		}
		else if(dq[ID].front()==x)
		{
			id[x]=0;
			dq[ID].pop_front();
			pb(spt);
			del(ID,spt);
			stk.push(ID);
		}
	}
	else
	{
		if(stk.empty())//特殊处理
		{
			return 0;
		}
		else//简单插入
		{
			int tt=stk.front();
			dq[tt].push_back(x);
			stk.pop();
			id[x]=tt;
			pb(tt);
		}
	}
	return 1;
}

int work(int pos)
{
	int t=pos;
	pos++;
	while(id[a[pos]]&&dq[id[a[pos]]].back()==a[pos])pos++;
	if(a[pos]==a[t])
	{
		pb(spt);
		for(int i=t+1;i<pos;i++)solve(a[i]);
		pb(spt);
		return pos;
	}
	int cnt=0,idx=id[a[pos]];
	for(int i=t;i<pos;i++)if(id[a[i]]==idx)cnt++;
	int ID=id[a[pos]];
	int y=dq[ID].back();
	if(cnt&1)
	{
		pb(spt);
		dq[spt].push_back(a[t]);
		for(int i=t+1;i<pos;i++)
		{
			if(a[i]==y)pb(ID);
			else solve(a[i]);
		}
		pb(ID);
		dq[ID].clear();
		id[a[pos]]=id[y]=0;
		id[a[t]]=spt;
		stk.push(spt);
		spt=ID;
	}
	else
	{
		pb(ID);
		dq[ID].push_back(a[t]);
		for(int i=t+1;i<pos;i++)
		{
			if(a[i]==y)pb(spt);
			else solve(a[i]);
		}
		pb(spt);
		del(ID,spt);
		dq[ID].pop_front();
		id[a[pos]]=0;
		id[a[t]]=ID;
	}
	return pos;
}

int main()
{
	int T=read();
	while(T--)
	{
		n=read();m=read();k=read();
		clear();
		for(int i=1;i<=m;i++)a[i]=read();
		for(int i=1;i<n;i++){stk.push(i);stk.push(i);}
		for(int i=1;i<=m;i++)
		{
			int x=a[i];
			if(solve(x))continue;
			int tt=work(i);
			i=tt;
		}
		cout<<ans.size()<<'\n';
		for(auto val:ans)
		{
			if(val.second){cout<<"2 "<<min(val.first,val.second)<<" "<<max(val.first,val.second)<<'\n';}
			else cout<<"1 "<<val.first<<'\n';
		}
	}
}

写在后面

我是很菜的,遇到构造就没招了。更何况这题比较 adhoc。

后来搞了一天才把这题搞明白(可见我有多菜)。但终究还是弄懂了,所以写篇题解来纪念一下(

特别鸣谢:@dbxxx

不管是他的题解还是本人都对我提供了很大帮助,感谢他专门抽出一天时间从早到晚不厌其烦给我讲解+调代码。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值