题解 P3956 【棋盘】
ZigZagKmp
2018-09-10 21:11:53
### update on 2020.2.16
看到评论区有同学对排版的建议,故对文章排版进行一些优化,使本题解符合现行题解规范。
## 写在前面
这是本蒟蒻一年前发的题解,因此有些地方讲的不是特别清楚,代码也比较冗长。其实这一题没有必要将其转换为图论模型,在上面跑最短路,可以直接使用优先队列优化的 `BFS` 。
`NOIP2017`普及组比赛,是我参加的第一次复赛,当时我太菜了(虽然说现在的我也还是很菜),这一题最暴力的 `dfs` 都写不对,不知怎么拿到了`20`分,其余的测试点 `WA`+`TLE` ,在考场上时间几乎全用来氪这一题,结果最后一题想出`50`分 `DP` 没调完,`220`滚粗。考场上主要纠结于如何处理魔法,因此考后和[同学](https://www.luogu.com.cn/user/28910)想到了这样一种代替魔法的算法。
这一次修改,将对原题解中解释的不是很清楚的地方作较为清晰的解释,并且修改了最后的参考程序,提供本题2种做法。
至于本题的主流算法( `dfs` +记忆化优化),其实从最短路的角度上来讲是基于 `dfs` 实现的 `SPFA` (没错,就是那个~~死了又活了的`SPFA`~~),如果这一题数据范围扩大,并且做一些特殊构造,是可以被卡掉的。
另外,本蒟蒻不建议使用基于 `dfs` 实现的 `SPFA` ,因为这其实是最好卡掉了 `SPFA` 。
----
### 题意分析与转化
> 给你一个棋盘,有一些格子被染成了两种颜色。你可以从一个有颜色的格子走向另一个有颜色的格子,若两个格子颜色相同,则不付出代价,否则付出 $1$ 点代价。
如果题目就到这里,相当于代价为 $0$ 或者 $1$ 的四个方向的走迷宫,可以使用双端队列 `BFS` 实现,时间复杂度$O(m^2)$
> 另外, 你可以花费 $2$ 个金币施展魔法让下一个无色格子暂时变为你指定的颜色。但**这个魔法不能连续使用**, 而且这个魔法的持续时间很短,也就是说,如果你使用了这个魔法,走到了这个暂时有颜色的格子上,你就不能继续使用魔法; 只有当你离开这个位置,走到一个本来就有颜色的格子上的时候,你才能继续使用这个魔法,而当你离开了这个位置(施展魔法使得变为有颜色的格子)时,这个格子恢复为无色。
如果就按照题意来**直接搜索**的话会比较麻烦(本蒟蒻当年就是这样写挂了的),但是我们可以稍微转化一下。
(注:下面的分析过程不妨**只考虑一种颜色**,因为颜色不同带来的代价的变化和魔法的转化没有太大关系)
我们现在站在一个施了魔法的格子上(称为 `via` ),我们上一步的格子称为 `now` ,下面我们思考下一步能走到的格子有哪些(称为 `next` )。
因为魔法不能连用,并且如果我们走回上一步的格子,结果一定不是最优的,所以我们可以选择的只有如下的情况:
![](https://cdn.luogu.com.cn/upload/pic/71445.png)
其中蓝色的格子可能成为 `next` (**前提,在未使用魔法的情况下这些格子本来有色**)。
知道了 `next` 可能的决策集合,我们下面来看如何把魔法**等价转换**掉。
![](https://cdn.luogu.com.cn/upload/pic/32552.png)
如图,可以将格子 `via` 施加魔法,从 `now` 走向 `next` ,实现了向**右下方**的转移。
![](https://cdn.luogu.com.cn/upload/pic/32553.png)
如图,可以将格子 `via` 施加魔法,从 `now` 走向 `next` ,实现了向**左下方**的转移。
![](https://cdn.luogu.com.cn/upload/pic/32554.png)
如图,可以将格子 `via` 施加魔法,从 `now` 走向 `next` ,实现了向**下连跳$2$格**的转移。
![](https://cdn.luogu.com.cn/upload/pic/32555.png)
如图,可以将格子 `via` 施加魔法,从 `now` 走向 `next` ,实现了向**右连$2$格**的转移。
同理,我们也可以实现向**左上方**,向**右上方**,向**上连跳$2$格**,向**左连跳$2$格**的转移。
综上,我们得到**使用魔法**可以转移到的情况:
![](https://cdn.luogu.com.cn/upload/pic/32556.png)
综合一下,我们可以得到如下结论:从 `now` 这个格子出发,使用或不使用魔法可以走到的**有色格子**的情况如下图:
![](https://cdn.luogu.com.cn/upload/pic/32558.png)
其中绿色格子因为使用魔法,不需要花费代价,蓝色的格子因为使用一次魔法,需要额外花费 $2$ 点代价。(同样,这里不考虑格子实际颜色对代价的影响)
也许有同学会问了,如果出现下图中的这种情况呢?
![](https://cdn.luogu.com.cn/upload/pic/71453.png )
显然从 `now` 出发先向右走后向下走是更优的,但是按照我们刚刚所述的方法,对 `now` 这个格子讨论,右下角的有色格子 `B` 会被认为要使用魔法,凭空多出了$2$点代价,我们刚刚的方法能得到最优解吗?
试问: `now` 右面的格子 `A` 是不是有色格子?
既然 `A` 是有色格子,那么我们也会对 `A` 讨论, `now` 可以不花费代价到达 `A` , `A` 又可以不花费代价到达 `B` ,因此 `now` 到 `B` 格子的代价最终会被 `now->A->B` 的路径取代,不会出现丢失最优解的情况。
这时我们发现一个问题:普通的 `BFS` 似乎实现不了这种答案更新,具体的分析与解法将在下文讲解。
----
#### 现在我们先把颜色加上去:
1. 与魔法无关的$4$种转移,同色代价不变,异色代价加 $1$ 。
2. 涉及魔法的$8$种转换,如果颜色相同,如图:
![](https://cdn.luogu.com.cn/upload/pic/32560.png)
当格子 `via` 变成黄色的格子的时候,付出的代价最小,一共为$2$个代价。
如果颜色不同,如图:
![](https://cdn.luogu.com.cn/upload/pic/32561.png)
当格子 `via` 变成黄色或者红色的时候,付出的代价都是一样的,一共为$3$个代价。
----
综上所述,我们将题目转化为如下:
> 你需要从$(1,1)$走到$(m,m)$,你只能走在有颜色的格子中,并且使得你所花费的代价最小。
> 当你站在一个有颜色的格子上的时候,你可以进行如下$2$种操作:
> 1. 向上、下、左、右前行。
> 2. 向左上、左下、右上、右下、向上连跳$2$格、向下连跳$2$格、向左连跳$2$格、向右连跳$2$格前行。
> 如果你使用的是操作 $2$ ,你将额外付出 $2$ 点代价。
> 在一次操作中,如果你**这次操作的起点格子与终点格子**颜色不同,你将付出$1$点代价。
完了吗?
**显然没有!**
如果$(m,m)$没有颜色怎么办?
因为魔法不能连续使用,所以只可能从$(m,m-1)$,$(m-1,m)$转移。
1. 如果都没有颜色,就不可能到达$(m,m)$。
2. 如果任何一个有颜色,相当于$(m,m)$变化为有颜色的那个格子的颜色,总代价为$2$。
3. 如果$2$个都有颜色,转移总代价都是$2$,最后做一下比较就可以了。
----
## 算法分析
下面我们主要研究 `BFS` 的实现。
现在我们已经将题意转化为经典的走迷宫模型,解决的经典方法是 `BFS` ,但是又有所不同。每一次拓展的代价不一定相同,此时可能出现如下的情况:
![](https://cdn.luogu.com.cn/upload/pic/39190.png)
~~因为不满足三角形不等式,所以从实际来讲这里不应该画成三角形。但因为这张图简(wo)明(bi)直(jiao)观(lan),所以这张图就不重画了。~~
显然,最短路径应该是 `1->2->3` ,而如果我们用一般的队列实现宽搜,就会得到错误的答案。
我们先思考一个问题:为什么经典的走迷宫模型可以直接普通队列 `BFS` ?
不难得出:在这样的 `BFS` 中,每一次扩展的代价都相等**且为正数**,**后进入队列的状态一定不如先进入队列的状态优**(先进入队列的状态的代价 $\le$ 后进入队列的状态的代价)。
基于这样的单调性,我们可以得出:第一次访问到某一个状态时,一定是这个状态的最优情况。
这是一个贪心思想。我们把这种思想应用到更具有普遍性的情况中:代价不一定相等。
现在我们有$2$种解决方法:
- 普通队列+迭代思想(~~已经死掉的`SPFA`~~)
我们无法保证第一次访问到某一个状态的最优性,但我们可以将**被优化的状态**不断压入队列中,直到所有状态都是最优的。这种算法最坏情况下可以达到 $O(n^2)$ (n为状态种数),一般情况下为 $O(n)$ 。
- 优先队列( `Dijkstra` )
我们只要满足每一次都从状态队列中取出最小代价的状态,即可满足第一次访问最优性。优先队列可以实现这种操作。时间复杂度 $O(n\log_2n)$ 。
有一种比较形象地解释,我们可以把 `BFS` 的棋盘看作图,即 `Dijkstra` 用来解决最短路。把这个图 **“拎起来 ”**,最初拎着起点,然后找当前最高的且没有被拎过的结点,作为下一次拎起来的结点,如此不断去拎,直到把终点结点拎起来,这样拎就可以找到起点和终点的最短距离。
注意,使用优先队列优化的 `BFS` 不能处理**有负数代价**的情况,反例如下:
![](https://cdn.luogu.com.cn/upload/pic/71455.png)
我们要从 `S` 走到 `T` ,第一步就可以找到所谓的最小代价$2$, `A` 虽然也在队列中,但是$10$的代价比$2$大,因此不会被取出。
但实际上, `S` 走到 `T` 的最小代价是$-90$,路径为 `S->A->T` ,我们得到了错误的答案。
----
至此,我们成功跳出了命题人挖给我们的的思维陷阱,没有直接按照题意搜索,而是将题意转化,简化问题,大大降低了代码实现复杂度。(听说直接按照题意 `dfs` 会炸栈空间,虽然 `NOIP` 开大栈空间,但是你在跑大样例的时候看到程序报 `RE` 还找不到哪儿错了,对接下来做题的心态会有很大影响)
update on 2020.2.16. `CSP-S 2019` 的时候,我的几个同学因为不会开大栈空间 `Day1T2` 没法测大样例,丢分严重,且影响到 `Day2` 的发挥。
----
### 代码实现
1. `BFS` +优先队列优化
```cpp
#include<bits/stdc++.h>
using namespace std;
#define inf 0x3f3f3f3f
template <typename Tp>
void read(Tp &x){
char c=getchar();x=0;int f=1;
while(c<'0'||c>'9'){if(c=='-')f=-1;c=getchar();}
while(c>='0'&&c<='9'){x=x*10+c-'0';c=getchar();}x*=f;
}//快速读入,不解释
struct node{
int x,y,c,w;
bool operator <(node b)const{//const不可丢
return w>b.w;
}//因为STL中优先队列默认取出最大的元素
//所以这里重载运算符,保证每次取出的是最小代价的
};
priority_queue<node>q;//node类型必须定义<
int dx[]={0,1,0,-1,1,1,-1,-1,0,2,0,-2};//12方向及魔法代价
int dy[]={1,0,-1,0,1,-1,1,-1,2,0,-2,0};
int dw[]={0,0,0,0,2,2,2,2,2,2,2,2};
int a[105][105],dis[105][105];//a存储棋盘上格子的颜色
int n,m;
void bfs(){
memset(dis,0x3f,sizeof(dis));dis[1][1]=0;
q.push((node){1,1,a[1][1],dis[1][1]});
node cur,nxt;
while(!q.empty()){
cur=q.top();q.pop();
if(dis[cur.x][cur.y]<cur.w)continue;
for(int i=0;i<12;i++){//懒惰删除
nxt.x=cur.x+dx[i];
nxt.y=cur.y+dy[i];
nxt.w=cur.w+dw[i];
if(nxt.x<=0||nxt.x>m||nxt.y<=0||nxt.y>m)continue;//保证在棋盘范围内
nxt.c=a[nxt.x][nxt.y];
if(!nxt.c)continue;
if(cur.c!=nxt.c)nxt.w++;//确定下一步的信息
if(dis[nxt.x][nxt.y]>nxt.w){
dis[nxt.x][nxt.y]=nxt.w;
q.push(nxt);
}
}
}
}
int main(){
int x,y,c;
read(m);read(n);
for(int i=1;i<=n;i++){
read(x);read(y);read(c);
a[x][y]=c+1;
}//这里c+1,为了方便区分无色格子
bfs();
if(!a[m][m]){//处理(m,m)无色情况
int ans=min(dis[m][m-1],dis[m-1][m])+2;
if(ans>=inf)puts("-1");
else printf("%d\n",ans);
}
else{
if(dis[m][m]==inf)puts("-1");
else printf("%d\n",dis[m][m]);
}
return 0;
}
```
2. 最短路( `Dijkstra` )
注:下面的参考程序使用 `zkw线段树` 代替优先队列, `zkw线段树` 短小精悍,常数较小,并且支持直接单点修改,不需要优先队列的懒惰删除法。
```cpp
#include<bits/stdc++.h>
using namespace std;
#define maxn 100005
#define maxm 2000005
#define inf 0x3f3f3f3f
template <typename Tp>
void read(Tp &x){
char c=getchar();x=0;int f=1;
while(c<'0'||c>'9'){if(c=='-')f=-1;c=getchar();}
while(c>='0'&&c<='9'){x=x*10+c-'0';c=getchar();}x*=f;
}//快速读入,不解释
struct Edge{
int f,t,w,nxt;
}edge[maxm];
int head[maxn],etot=1;//这里有一个小技巧,存图时边数初值设为一个奇数
void add_edge(int f,int t,int w){//这样可以利用位运算成对变化找到反向边
edge[++etot]=(Edge){f,t,w,head[f]};
head[f]=etot;
}//链式前向星存图
//--------以下内容为 zkw线段树
//主要思路,用线段树维护dis
//dis1数组表示在线段树中的dis
//tr数组记录当前最小dis对应的节点编号
//有关zkw线段树,可以参考洛谷日报的讲解,这里不多说
int tr[maxn<<2],dis1[maxn<<2],bt;
int n,m,S,T;
void build(){
for(bt=1;bt<=n+1;bt<<=1);//bt初始化,zkw线段树的初始操作
for(int i=1;i<=n;i++)tr[i+bt]=i;//tr数组初始化
memset(dis1,0x3f,sizeof(dis1));//dis1数组初始化
//因为这里dis初值都是inf,所以可以这样直接赋值
}
void modify(int x,int val){
dis1[x]=val;x+=bt;//单点修改
for(x>>=1;x;x>>=1){//以下是zkw线段树常规操作
if(dis1[tr[x<<1]]<dis1[tr[(x<<1)|1]])tr[x]=tr[x<<1];
else tr[x]=tr[(x<<1)|1];
}
}//其实上面的内容并不是很长,只是注释比较多
int dis[maxn];
void dijkstra(){
memset(dis,0x3f,sizeof(dis));
build();//build()不可忘
dis[S]=0;modify(S,0);//源点更新
int x,y,w;
for(int j=1;j<=n;j++){//这里tr[1]维护的是[1,n]dis的最小值的节点编号,所以直接调用
x=tr[1];modify(x,inf);//这里将x设为极大值,来取代删除操作
for(int i=head[x];i;i=edge[i].nxt){
y=edge[i].t;w=edge[i].w;
if(dis[y]>dis[x]+w){//dijkstra松弛操作
dis[y]=dis[x]+w;
modify(y,dis[y]);//直接更新
}
}
}
}
int dx[]={0,1,0,-1,1,1,-1,-1,0,2,0,-2};//12方向及魔法代价
int dy[]={1,0,-1,0,1,-1,1,-1,2,0,-2,0};
int dw[]={0,0,0,0,2,2,2,2,2,2,2,2};
int a[105][105],cnt[105][105];
struct node{
int x,y,c;
}b[maxn];
//a存储棋盘上格子的颜色
//cnt表示棋盘上的格子对应的节点编号
void preprocess(){//建图
int x,y,c,xx,yy,ww;
for(int i=1;i<=n;i++){
x=b[i].x;y=b[i].y;c=b[i].c;
for(int j=0;j<12;j++){
xx=x+dx[j];yy=y+dy[j];ww=dw[j];
if(a[xx][yy]){
if(a[xx][yy]!=c)ww++;
add_edge(i,cnt[xx][yy],ww);
}
}
}//这一段在上文题解中讲的比较详细,这里不再多说
S=cnt[1][1];
}
int main(){
int mm,x,y,c;
read(mm);read(n);
for(int i=1;i<=n;i++){
read(x);read(y);read(c);
a[x][y]=c+1;cnt[x][y]=i;
b[i]=(node){x,y,c+1};
}//这里c+1,为了方便区分无色格子
preprocess();
dijkstra();//因为在图论中m常代表的含义是边数
if(!a[mm][mm]){//所以用mm取代原题目中的m,即棋盘大小
int ans=min(dis[cnt[mm][mm-1]],dis[cnt[mm-1][mm]])+2;
if(ans>=inf)puts("-1");
else printf("%d\n",ans);
}//(m,m)没有颜色的特判
else{
if(dis[cnt[mm][mm]]==inf)puts("-1");
else printf("%d\n",dis[cnt[mm][mm]]);
}
return 0;
}
```
附:卡掉本题 `dfs` +记忆化优化(即 `SPFA` )的做法:数据范围修改为 $m\le 1000,n\le 100000$ ,因为本题是棋盘,本身就是一种类似于网格图的东西,再加一些比较靠近的有色格子(只能说接近菊花图,因为这一题一个点出度最多为$12$),再加一些链状有色格子(次优解长链),理论上来讲大部分可以卡掉了。