搜索专题 1 解析

前置声明,本题单代码基本基于 C++98 。

作者尽量自查了两三遍,但篇幅太长并未曾有精力详细审查。如果发现可疑性错误,极有可能是作者放飞自我导致的笔误,请私信作者。

作者对关键结论基本做了证明。如有不理解请私信作者。

作者对题解中的代码尽量进行了重测。如有不能 ac 请私信作者。

以后可能有时间(可能没有)会写更亲民更严谨也更符合人类的重置版题解。

以后可能会把能用位运算实现的搜索都加一份位运算代码(可能不会)。

A POJ-1321

题意:
给一个 \(n \times n\) 的棋盘,有若干个位置可以摆放棋子,标记上 # 。询问摆放 \(k\) 个相同的非攻击型直车的方案数。

\(k \leq n \leq 8\)

解析:
因为一行只能摆放一个车,观测数据规模,我们尝试按行对棋盘进行 \(dfs\)

为了保证复杂度正确,分析 \(dfs\) 树。(\(dfs\) 的状态空间不占据实际空间,而用于分析 \(dfs\) 的复杂度。)

考虑每行只能放一辆车,最多有 \(N+1\) 种放法(放第 \(1 \sim N\) 行或不放)。最坏能考虑放 \(N\) 行(中间可能有一些行选择了不放)。
状态个数的一个上界是 \(9^{8} \leq 4 \times 10^{7}\) (可以分析更紧的上界,但是这个上界已经足够)。

对于每个状态,放棋子的时候并没有多余的打标机或者状态维护,时间都是 \(O(1)\) 的。那么时间复杂度和状态空间大小同级。

实现:
我们考虑状态 \(dfs(dep, cnt)\) ,当前正在处理第 \(dep\) 层,总共放了 \(cnt\) 个车。

一行要么放,\(dfs(dep + 1, cnt + 1)\) ,要么不放 \(dfs(dep + 1, cnt)\) 。并跳到下一行。

行可以放了就换行,列可以打上标记,\(col(i)\) 表示第 \(i\) 列是否放过。回溯的时候回收标记。

代码:


view code
#include<iostream>
#include<cstdio>
#include<vector>
using namespace std;

typedef long long i64;

#define L(i,a,b) for(int i=(a);i<=(b);i++)
#define R(i,a,b) for(int i=(a);i>=(b);--i)
#define SZ(a) int((a).size())
#define VI vector<int>
#define VL vector<i64>

const int MAXN=10,MAXM=1000001;

int n,m;
char gr[MAXN][MAXN];
bool col[MAXN];

int dfs(int dep,int cnt){
  if(cnt==m)return 1; // 返回答案的出口先于退出递归的出口
  else if(dep==n+1)return 0;
  int ans=0;
  // 1. choose
  L(i,1,n)if(!col[i]){
    if(gr[dep][i]=='.')continue;
    col[i]=true;
    ans+=dfs(dep+1,cnt+1);
    col[i]=false;
  }
  // 2. not choose
  ans+=dfs(dep+1,cnt);
  return ans;
}

int SOLVE(){
  L(i,1,n)scanf("%s",gr[i]+1);
  L(i,1,n)col[i]=false;
  return dfs(1,0);
  return 0;
}

signed main(){
  for(scanf("%d%d",&n,&m);n!=-1||m!=-1;scanf("%d%d",&n,&m)){
    printf("%d\n",SOLVE());
  }
  return 0;
}

B POJ-2251

题意:
输入一个三维空间,存在一些障碍,询问从 \(s\) 到达 \(t\) 的最短路径长度。

解析:
考虑 \(x, y, z\)\(1\) 个赋值 \(-1\)\(1\) ,另外 \(2\) 个为 \(0\) 。建立出大小为 \(\binom{3}{1} \times 2 = 6\) 的三维方向数组。
添加边界处理,从 \(s\) 点出发,每个点入队之前打上标记,从队列末尾的叶子生长,构造 \(bfs\) 树。当访问到 \(t\) 点,\(t\) 点的深度就是最短路径长度。

时间复杂度可以宽松地估计为三维空间的大小 \(O(LRC) \leq 30^{3} \leq 3 \times 10^{4}\) ,可以满足时间限制。

代码:


view code
#include<iostream>
#include<cstdio>
#include<vector>
#include<queue>
#include<cassert>
using namespace std;

typedef long long i64;

#define L(i,a,b) for(int i=(a);i<=(b);i++)
#define R(i,a,b) for(int i=(a);i>=(b);--i)
#define SZ(a) int((a).size())
#define VI vector<int>
#define VL vector<i64>

const int MAXN=32;
struct Node{
  int x,y,z,v;
  Node(){}
  Node(int x_,int y_,int z_,int v_):x(x_),y(y_),z(z_),v(v_){}
};

int n,m,h,sx,sy,sz,ans;
char g[MAXN][MAXN][MAXN];
bool vis[MAXN][MAXN][MAXN];

bool out(int x,int y,int z){return x<1||n<x||y<1||m<y||z<1||h<z;}

int SOLVE(){
  L(i,1,n)L(j,1,m){
    scanf("%s", g[i][j]+1);
  }
  L(i,1,n)L(j,1,m)L(k,1,h)if(g[i][j][k]=='S')sx=i,sy=j,sz=k;
  static int dx[]={0,1,0,-1,0,0},dy[]={1,0,-1,0,0,0},dz[]={0,0,0,0,1,-1};
  vis[sx][sy][sz]=true;
  queue<Node> qe;
  qe.push(Node(sx,sy,sz,0));
  ans=1<<30;
  while(!qe.empty()){
    Node cur=qe.front();
    int x=cur.x,y=cur.y,z=cur.z,v=cur.v;
    if(g[x][y][z]=='E'){
      ans=v;
      break;
    }
    qe.pop();
    L(i,0,5){
      int nx=x+dx[i],ny=y+dy[i],nz=z+dz[i];
      if(out(nx,ny,nz))continue;
      if(g[nx][ny][nz]=='#')continue;
      if(vis[nx][ny][nz])continue;
      vis[nx][ny][nz]=true;
      qe.push(Node(nx,ny,nz,v+1));
    }
  }
  L(i,1,n)L(j,1,m)L(k,1,h)vis[i][j][k]=false;
  return ans;
}

signed main(){
  for(scanf("%d%d%d",&n,&m,&h);n!=0||m!=0||h!=0;scanf("%d%d%d",&n,&m,&h)){
    int ac=SOLVE();
    if(ac==1<<30)puts("Trapped!");
    else printf("Escaped in %d minute(s).\n",ac);
  }
  return 0;
}

C POJ-3278

题意:
给定 \(0 \leq N,K \leq 10^{5}\) ,询问从 \(N\)\(K\) 的最短时间。
每个时间可以选择两种操作之一:

  • \(X\) 走到 \(X - 1\)\(X + 1\)
  • \(X\) 走到 \(2X\)

解析:
显然问题有解,因为可以单步走。
\(K \leq N\) ,只有执行第一种操作的可能。否则两种操作都会选择性执行。

我们可以细分成三种操作:\(X-1,X+1,2X\) 。估计 \(bfs\) 树的节点为 \(O(10^{5})\) 级别,尝试进行 \(bfs\) ,在不严格保证算法时间的情况下能得到最短时间。

可以证明 \(bfs\) 树的节点个数不超过 \(10^{5}+1\) 个。


证明:

后退只有 \(X-1\) ,中间状态能走到的下界不会越过 \(0\)

前进存在 \(2X\) 。假设 \(K=10^{5}\) ,倍增跳到了 \(K=10^{5} + 2\) ,多花费 \(2\) 步能到 \(K\) 。也可以在倍增之前退一步,然后 \(2X\) 跳到 \(K\) ,只多花费了一步。

对前进的情况稍微归纳一下,可以分析出对于 \(K \leq 10^{5}\) ,倍增跳到了 \(10^{5} + 2 t \ s.t. \ 1 \leq t\) 都会更坏。所以中间状能走到的上界不会越过 \(10^{5}\)


于是 \(bfs\) 节点的状态空间的约束范围严格是 \(0 \sim 10^{5}\)\(10^{5} + 1\) 个点。可以保证 \(bfs\) 时间复杂度。

代码:


view code
#include<iostream>
#include<cstdio>
#include<vector>
#include<queue>
#include<cassert>
using namespace std;

typedef long long i64;

#define L(i,a,b) for(int i=(a);i<=(b);i++)
#define R(i,a,b) for(int i=(a);i>=(b);--i)
#define SZ(a) int((a).size())
#define VI vector<int>
#define VL vector<i64>

const int MAXN=100002;
const int DWL=0,UWL=100001;
int N,K;
int vis[MAXN];
bool out(int x){return x<DWL||UWL<x;}

signed main(){
  scanf("%d%d",&N,&K);
  queue<pair<int,int> > qe;
  qe.push({N,0});
  vis[N]=true;
  while(!qe.empty()){
    int u=qe.front().first,v=qe.front().second;
    // printf("cur %d %d\n",u, v);
    if(u==K){
      return 0*printf("%d\n",v);
    }
    qe.pop();
    if(!out(u-1)&&!vis[u-1])qe.push({u-1,v+1}),vis[u-1]=true;
    if(!out(u+1)&&!vis[u+1])qe.push({u+1,v+1}),vis[u+1]=true;
    if(!out(u*2)&&!vis[u*2])qe.push({u*2,v+1}),vis[u*2]=true;
  }
  return 0;
}

D POJ-3279

图论分析+二进制枚举(搜索)汇点状态强制定向。

题意:
给一个 \(N \times M (1 \leq N,M \leq 15)\) 的网格图,\(1\) 代表黑色,\(0\) 代表白色。踩一个格子会翻转这个格子和它上下左右四个格子(如果存在)。
输出一个矩阵,表示每个格子最少需要踩多少下。要求踩的次数总和最低,如果还有次数总和相同,则输出字典序最小的矩阵。

解析:


前言

首先翻转问题,显然是每个格子要么翻要么不翻。因为 \(2\) 次翻转会回到原来的状态,等于抵消。

一般我们遇到的,一维情况如踩一个格子影响它的后缀;二维情况如踩一个格子,影响以他为左端点的 \(C \times C\) 方格。我们从产生影响的格子往被影响的格子连边,那么能得到一个有向无环图 \(DAG\)

众所周知有向无环图(\(DAG\))能从入度为 \(0\) 源点开始,一边删除被使用过的源点,一边在新源点上 \(dp\) 或贪心。这个理论保证了上述情况直接贪心的正确性。

但是这题,一个格子如果影响其他格子,会像上下左右都连边,显然得不到一个 \(DAG\)

方向很乱怎么办?枚举汇点状态,强制定向。


钦定某条边界上的点状态固定,那么从这条边开始可以逆推到前一层(每个前驱“是否需要翻转以使已经固定的后继成立”都是固定的)。对于非边界上的某一层,这一层的点只能影响到当前一层和已被固定的后一层。于是我们构造出了一个 \(DAG\)

这里我们可以选择枚举上下左右底边上的所有点作为汇点,不妨是上底边(因为从这里开始二进制枚举,最先找到的答案字典序最小,减少代码量。否则还需存储所有答案再判)。

那么时间复杂度就是 \(O(2^{M} N)\)

  • 当然如果其他题里 \(M, N\) 的数量级不同,\(O(2^{M} N) \neq O(2^{N} M)\) 。即使遇到这种情况,换个底边枚举依旧一样。

实际上图分析基本用于分析算法的时间复杂度和正确性,具体实现中并不会用到图这种数据结构。


例子

二进制枚举第 \(1\) 行的状态(每个位置翻还是不翻),使得这个状态固定。

从第 \(2\) 行开始,每个节点必须明确翻还是不翻,使得第 \(1\) 行成立(在翻转操作状态固定的情况下,整行是白色)。这种明确性使得第 \(2\) 行固定。

从第 \(3\) 行开始,每个节点必须明确翻还是不翻,使得第 \(2\) 行成立。这种明确性使得第 \(3\) 行固定。

……

从第 \(n\) 行开始,每个节点必须明确翻还是不翻,使得第 \(n-1\) 行成立。这种明确性使得第 \(n\) 行固定。

最后 \(1 \sim n\) 的翻转状态都被固定了,而只有第 \(n\) 行不确定颜色,我们检查第 \(n\) 行的颜色情况。


代码:


view code
#include<iostream>
#include<cstdio>
#include<vector>
#include<queue>
#include<cassert>
using namespace std;

typedef long long i64;

#define L(i,a,b) for(int i=(a);i<=(b);i++)
#define R(i,a,b) for(int i=(a);i>=(b);--i)
#define SZ(a) int((a).size())
#define VI vector<int>
#define VL vector<i64>

const int MAXN=16;
const int dx[]={0,1,0,-1},dy[]={1,0,-1,0};
int N,M,mi;
int gr[MAXN][MAXN],op[MAXN][MAXN],cgr[MAXN][MAXN],acop[MAXN][MAXN];
bool out(int i,int j){return i<1||N<i||j<1||M<j;}
void work(int x,int y){
  op[x][y]^=1;
  cgr[x][y]^=1;
  L(k,0,3){
    int nx=x+dx[k],ny=y+dy[k];
    if(out(nx,ny))continue;
    cgr[nx][ny]^=1;
  }
}

signed main(){
  scanf("%d%d",&N,&M);
  L(i,1,N)L(j,1,M)scanf("%d",&gr[i][j]);
  mi=1<<30;
  L(mask,0,(1<<M)-1){
    L(i,1,N)L(j,1,M)op[i][j]=0,cgr[i][j]=gr[i][j];
    int copcnt=0;
    L(i,0,M-1)if(mask>>i&1)work(1,i+1),copcnt++;
    L(i,2,N)L(j,1,M)if(cgr[i-1][j]==1)work(i,j),copcnt++;
    int cnt0=0;
    L(j,1,M)cnt0+=cgr[N][j]==0;
    if(cnt0==M&&copcnt<mi){
      mi=copcnt;
      L(i,1,N)L(j,1,M)acop[i][j]=op[i][j];
    }
  }
  if(mi==1<<30)return 0*puts("IMPOSSIBLE");
  else L(i,1,N)L(j,1,M)printf("%d%c",acop[i][j]," \n"[j==M]);
  return 0;
}

E POJ-1426

这题在网上的绝大部分搜索题解均为无法保证复杂度的解,只是在恰好能通过这题。 遇到变种问题会超时。

题意:
给定一个 \(n(n \leq 200)\) ,构造一个只由 \(0,1\) 组成的 \(10\) 进制数 \(x\) ,满足 \(n \mid x\) 。要求 \(x\) 不能超过 \(100\) 位,且可以证明不会超过。

解析:


解的存在性?

尝试构造性证明,用到一个数竞里的常见技巧 \(R_{i} = (10^{i} - 1) / 9\)\(i\)\(1\) 的十进制数)。

由鸽笼原理,\(R_1 \sim R_{n + 1}\) 一定存在两个数 \(\bmod n\) 同余。不妨是 \(R_{1} \leq R_{i} < R_{j} \leq R_{n + 1}\) ,那么 \(n \mid (x = R_{j} - R_{i})\) 就是一个高位前缀是 \(1\) ,低位后缀是 \(0\) 的解。

是不是直接枚举 \(R_i\) 就秒了?显然不是。因为理论最坏能达到 \(200\) 位数,显然违反了题目要求(题目应该是在故意卡数学构造做法)。但我们至少证明了解的存在性。


下面考虑构造一个最小解(如果解最小,则最短,则满足题目要求的不超过 \(100\) 位)。


不被保证的解法

考虑从 \(1\) (保证没有前导 \(0\) )开始暴力搜索每一位填充 \(0\) 还是 \(1\) 并逐渐增加十进制多项式的长度,理论上能搜到解,但并不保证时间复杂度(是指数级 \(O(2^{n})\) ),但恰好这题可以通过。而在变种问题(比如洛谷 P2841)中使用不保证时间复杂度的暴力算法将会超时。


考虑从 \(1\) 开始构造 \(bfs\) 树(这题 \(dfs, bfs\) 均可,因为不需要回溯)。

  • 如果过程中某个节点 \(v\) 满足被 \(n\) 整除,即 \(v \equiv 0 (\bmod n)\) ,则 \(v\) 就是解。
  • 如果过程中某个节点 \(v\) 满足和之前构造出的节点 \(u\)\(n\) 同余,即 \(v \equiv u (\bmod n)\) 。那么从 \(u\) 出发使得 \(u \equiv 0 (\bmod n)\) 的后缀和从 \(u\) 出发使得 \(v \equiv 0 (\bmod n)\) 的后缀是一样的,一样的后缀意味着 bfs 树上 \(u,v\) 具有一样的子树,我们可以直接剪掉重复子树。
    • 剪掉重复子树的具体做法是,如果 \(v\) 和之前一个 \(u\)\(n\) 同余,那么 \(u\) 就不继续往后生长。

\(1\) 出发构造 \(bfs\) 树(也是二叉树), 会往后生长的节点只有 \(O(n)\) 个(模 \(n\) 的模数最多 \(n\) 个),停止生长的节点有两个叶子(往后补一个 \(0\)\(1\)),状态空间最大 \(O(n) ,\)bfs$ 树的节点最多有 \(O(n)\) 个。

涉及到存储高精度数和高精度对低精度取模的操作。每个节点可以使用一个 \(O(n)\) 长度的字符串存储高精度数,而高精度取模时间为 \(O(n)\)


高精度取模

高精度取模基于 \((x \times y + z) \bmod m = ((x \times y \bmod m) + y) \bmod m\) ,实际代码很简单:

N=0; for(int i=0;i<(int)SZ(str)-1;i++) N=(N*10+(str[i]-'0'))%MOD;

状态空间 \(O(n)\) ,状态空间中节点的转移时间 \(O(n)\) (每次转移需要进行一次高精度取模),总时间复杂度 \(O(n^{2})\)

代码:


view code
#include<iostream>
#include<cstdio>
#include<vector>
#include<queue>
#include<cassert>
using namespace std;

typedef long long i64;

#define L(i,a,b) for(int i=(a);i<=(b);i++)
#define R(i,a,b) for(int i=(a);i>=(b);--i)
#define SZ(a) int((a).size())
#define VI vector<int>
#define VL vector<i64>

int operator%(const string &v,const int m){
  int ans=0;
  for (int i=0;i<SZ(v);i++)ans=(ans*10+v[i]-'0')%m;
  return ans;
}
int PutStr(string str){
  for (int i=0;i<SZ(str);i++)printf("%c",str[i]);
  return 0;
}
int PutStrl(string str){
  return PutStr(str)*puts("");
}

int N;
int vis[201];

int SOLVE(){
  L(i,0,N-1)vis[i]=false;
  queue<string> qe;
  qe.push("1");
  vis[1]=true;
  while(!qe.empty()){
    string x=qe.front();
    qe.pop();
    for(char ch='0';ch<='1';ch++){
      string cur=x+ch;
      int r=cur%N;
      if(r==0) {
        PutStrl(cur);
        return 0;
      }
      else if(!vis[r])vis[r]=true,qe.push(cur);
    }
  }
  return 0;
}

signed main(){
  // PutStr("azfA1");
  L(i,1,8)assert(string("8")%i==8%i);
  
  for(scanf("%d",&N);N;scanf("%d",&N)){
    int ac=SOLVE();
  }

  return 0;
}

F POJ-3126

题意:
\(100\) 次询问,每次给定两个四位素数 \(N, M\) 。要求\(N\) 每次只能改变一个数位变成另一个素数,询问 \(N\) 最少花多少步变成 \(M\)

解析:
注意到 \(4\) 位数的素数并不多。我们可以用我们爱用的办法,随便什么素数筛或者暴力预处理每个四位数是否是素数,都不会超时。

实际上,根据素数定理,设 \(\phi(n)\) 为不超过 \(n\) 的素数个数,有 \(\phi(n) \sim \frac{n}{\ln n}\) 。即 \(\leq 10^{5}\) 的素数大致满足 \(\frac{10^{5}}{11} \leq 10^{4}\) 。这 \(10^{4}\) 是状态空间的上限。

\(N\) 构造 \(bfs\) 树,树的节点不超过 \(10^{4}\)
考虑每个节点的转移,每次允许一个数位变化,每个数位能变成其他 \(9\) 个数(除了最高位)。那么每个节点的分支个数是 \(\binom{4}{1} \times 9 = 36 \leq 40\) 。状态个数乘以转移时间为 \(10^{4} \times 40 = 4 \times 10^{5}\)

对于一百次询问,总共用时 \(4 \times 10^{5} \times 100 = 4 \times 10^{7}\) ,不会超时。

实现:
只需要额外注意数位的枚举。

比如四位数 \(X=\overline{a_3 a_2 a_1 a_0}=a_3 \times 10^3 + a_2 \times 10^2 + a_1 \times 10^1 + a_0\)

枚举 \(i \in [0, 3]\)\(a_i = \lfloor \frac{X}{10^{i}} \rfloor \bmod 10\) 。然后让 \(v\) 替代 \(a_i\) ,即 \(\left ( \lfloor \frac{X}{10^{i}} \rfloor + (v - a_i) \right ) \times 10^{i} + X \bmod 10^{i}\)

由于不能有前导 \(0\) ,需要保证最高位为 \(1\) 。当 \(i=3\) 是最高位,\(v \in [1, 9]\) ,否则 \(v \in [0, 9]\)
理论上应该严格满足 \(v \neq d\) 。但是这里 \(v = d\) 已经被标记过了,稍微乱写一点也不会导致错误。

int p=1;
L(i,0,3){
  int d=X/p%10;
  L(j,i==3?1:0,9)if(d!=j){
    int Y=(X/p+(j-d))*p+X%p;
  }
  p*=10;
}

线性筛筛出的 \(pmi(x) = x\) 表示 \(x\) 是素数,否则不是。当然可以用其他筛法,甚至直接 \(O(\frac{10^{5}}{\ln 10^{5}} \times \sqrt{10^{5}}) \leq 10^{8}\) 暴力预处理。

代码:


view coe
#include<iostream>
#include<cstdio>
#include<vector>
#include<queue>
#include<cassert>
using namespace std;

typedef long long i64;

#define L(i,a,b) for(int i=(a);i<=(b);i++)
#define R(i,a,b) for(int i=(a);i>=(b);--i)
#define SZ(a) int((a).size())
#define VI vector<int>
#define VL vector<i64>

const int MAXN=100000;
int pmi[MAXN],pr[MAXN/5],tot;
int vis[MAXN];
int N,M;

struct Node{
  int x,step;
  Node(){}
  Node(int _x,int _step):x(_x),step(_step){}
};

int SOLVE(){
  L(i,0,MAXN-1)vis[i]=false;
  scanf("%d%d",&N,&M);
  if(N>M)swap(N,M);
  queue<Node> qe;
  qe.push(Node(N,0));
  vis[N]=true;
  while(!qe.empty()){
    int X=qe.front().x,step=qe.front().step;
    if(X==M){
      return step;
    }
    qe.pop();
    int p=1;
    L(i,0,3){
      int d=X/p%10;
      L(j,i==3?1:0,9)if(d!=j){
        int Y=(X/p+(j-d))*p+X%p;
        if(pmi[Y]!=Y)continue;
        if(vis[Y])continue;
        vis[Y]=true;
        qe.push(Node(Y,step+1));
      }
      p*=10;
    }
  }
  return -1;
}

signed main(){
  tot=0;
  pmi[1]=1;
  L(i,2,MAXN-1){
    if(!pmi[i])pmi[i]=i,pr[++tot]=i;
    L(j,1,tot){
      if(pr[j]*i>=MAXN)break;
      pmi[pr[j]*i]=pr[j];
      if(pmi[i]==pr[j])break;
    }
  }
  assert(pr[5]==11);
  for(int T,tc=1*scanf("%d",&T);tc<=T;tc++){
    int ac=SOLVE();
    if(ac==-1)puts("Impossible");
    else printf("%d\n",ac);
  }

  return 0;
}

G POJ-3087

题意:
可以抽象成这样。

  1. \(a_1, a_2, \cdots, a_n\)\(b_1, b_2, \cdots, b_n\)
  2. 合并成 \(b_1, a_1, b_2, a_2, \cdots, b_n, a_n\)
  3. 询问 \(b_1, a_1, b_2, a_2, \cdots, b_n, a_n\) 是否和 \(c_1, c_2, \cdots, c_{2n}\) 在有序数值上相同。如果是则停止,否则执行 \(3\)
  4. 平分,前半部分重新标号成 \(a_1^{'}, a_2^{'}, \cdots, a_n^{'}\) ,后半部分重新标号 \(b_1^{'}, b_2^{'}, \cdots, b_n^{'}\) 。执行 \(2\)

问最快执行“拆开、合并”操作多少次,给定的序列能够和已知的序列颜色一样。

解析:
因为一开始给定的 \(a, b\) 已经是拆开的,为了一般话,我们不妨把他们拼接成 \(a_1, a_2, \cdots, a_n, b_1, b_2, \cdot, b_n\) 当作是第 \(0\) 个序列。

然后我们从第 \(1\) 个序列开始判断答案。

为了方便分析,我们把 \(a_1, a_2, \cdots, a_n, b_1, b_2, \cdot, b_n\)\(b_1, a_1, b_2, a_2, \cdots, b_n, a_n\) ,看成 \(A_1, A_2, \cdots, A_n, A_{n + 1}, A_{n + 2}, \cdots, A_{2n}\)\(A_{n+1}, A_{1}, A_{n + 2}, A_{2}, \cdots, A_{2n}, A_{n}\)

这显然是一个置换

\[f = \left ( \begin{matrix} 1 & 2 & 3 & 4 & \cdots & 2n-1 & 2n \\ p_{1} & p_{2} & p_{3} & p_{4} & \cdots & p_{2n-1} & p_{2n} \\ \end{matrix} \right ) = \left ( \begin{matrix} 1 & 2 & 3 & \cdots & n & n + 1 & n + 2 & n + 3 & \cdots & 2n \\ 2 & 4 & 6 & \cdots & 2n & 1 & 3 & 5 & \cdots & 2n - 1 \\ \end{matrix} \right ) \]

一个 \(N\) 排列的置换 \(f\)\(f(i) = p_i \quad s.t. \quad i = 1,2,\cdots,N\)

显然 \(f\) 看起来是将前 \(n\) 个元素顺序映射到偶数位置,后 \(n\) 个元素顺序映射到奇数位置。

实际上,这个 \(f\) 满足 \(f(i) = 2 i \bmod (2n + 1)\)

问题即给定 \(A,B\) 两个排列,每个数染上一个颜色。询问最小的 \(k\) ,使得 \(f^{k} \circ A\) 的颜色和 \(B\) 的颜色一样。

考虑最快让 \(A\)\(B\) 的颜色相同。因为 \(A\) 的每次变换都是固定的,所以是在问第一次 \(A\)\(B\) 的颜色相同是什么时候。

\(l\) 为最小的正整数满足 \(f^{l} \circ A = A\) ,我们称作 \(l\)\(f\) 的阶。

  • 换句话说就是最小的 \(l\) 满足 \(f^{l} (i) = i \quad s.t. \quad i=1,2,\cdots,N\)

首先。考虑某个 \(N\) 的置换 \(f^{'}\) ,每个点只有一个入度一个出度,会构成若干个环。\(f^{'}\) 的阶 \(l^{'}\) 即是所有环长的公倍数。


举个例子

比如

\[f^{'} = \left ( \begin{matrix} 1 & 2 & 3 & 4 & 5 \\ 2 & 3 & 1 & 5 & 4 \\ \end{matrix} \right ) = g_1 \circ g_2 = \left ( \begin{matrix} 1 & 2 & 3 \\ 2 & 3 & 1 \\ \end{matrix} \right ) \circ \left ( \begin{matrix} 4 & 5 \\ 5 & 4 \\ \end{matrix} \right ) \]

置换 \(f^{'}\) 可以分解为环 \(g_1,g_2\) 的直积,即 \(f^{'}\) 能分解出到两个环。
显然一个环是 \(1 \rightarrow 2 \rightarrow 3 \rightarrow 1\) ,另一个环是 \(4 \rightarrow 5 \rightarrow 4\)
\(1, 2, 3, 4, 5\) 通过 \(f_1\) 置换 \(lcm(3, 2) = 6\) 次才能复原。

\[\begin{aligned} &1, 2, 3, 4, 5 \rightarrow \\ &2, 3, 1, 5, 4 \rightarrow \\ &3, 1, 2, 4, 5 \rightarrow \\ &1, 2, 3, 5, 4 \rightarrow \\ &2, 3, 1, 4, 5 \rightarrow \\ &3, 1, 2, 5, 4 \rightarrow \\ &1, 2, 3, 4, 5 \end{aligned} \]


一般来说,随机给定一个置换 \(f\) ,那么 \(l\) 最坏可能是指数级的。(比如最宽松的估计是 \(N!\) ,因为总共有 \(N!\) 种排列)

但考虑这里的置换 \(f(i) = 2 i \bmod (2n + 1)\)\(f^{2}(i) = f(f(i)) = 2 ( 2 \cdot i \bmod (2n + 1) ) \bmod (2n + 1) = 2^{2} \cdot i \bmod (2n + 1)\) ,归纳有 \(f^{x}(i) = 2^{x} \cdot i \bmod (2n + 1)\)

由于 \(gcd(2, 2n+1) = 1\) ,众所周知的欧拉定理保证了

\[f^{\varphi(2n + 1)} (i) = 2^{\varphi(2n + 1)} \cdot i \bmod (2n + 1) = i \bmod (2n + 1) = i \quad s.t. \quad i=1,2,\cdots,2n \]

众所周知最小循环是任意循环的约数,于是也有 \(l \leq \varphi(2n + 1) < 2n + 1\)

\(N = 2n\) ,则最坏 \(O(N)\) 次置换能让 \(A\) 循环,每次置换时间为 \(O(N)\) 。总时间复杂度 \(O(N^{2})\)

于是我们暴力地置换,查询 \(f^{x} \circ A\) 是否和 \(B\) 的颜色相同即可。直接套 \(set\) 查询会多带个 \(O(log N)\) ,手动字符串哈希并手写哈希表可以避免这个 \(log\) 。为了方便,不妨按使用 \(set\) 计算,单组测试的时间复杂度 \(O(N^{2} \log N)\)

测试数据组数 \(T \leq 1000\) 。实际时间不超过 \(1000 \times 200^{2} \times \ln 200 \leq 3 \times 10^{8}\)

看起来 \(3\) 的常数可能会 \(T\) ,实际上我们前面分析的 \(l\)\(\varphi(2n+1)\) 的因子,于是我们就粗暴地按照 \(l=\varphi(2n+1)\) 进行了估算。所以常数会比估出来的小,实际上很难 \(TLE\)

  • \(TLE\) 了把 \(set\) 换成无序 \(set\) 或者手写哈希,常数就无了。

课外知识

实际上 \(l = \delta_{2n+1}(2)\),即 “\(f\) 的阶 \(l\)” 等于 “\(2\)\(2n+1\) 的阶 \(\delta_{2n+1}(2)\) ” 。当且仅当 \(2\)\(2n+1\) 的原根,才存在 \(\varphi(2n+1)=l=\delta_{2n+1}(2)\) ,否则 \(l=\delta_{2n+1}\) 一定严格是 \(\varphi(2n+1)\) 的真因子。这意味着上述估出时间复杂度不仅跑不满,而且大概率常数非常小。


实现:
主打一个爱怎么暴力怎么暴力。
可以直接建出 \(p_i\) 然后用 \(i \rightarrow p_i\) 做映射。也可以只是简单的奇偶交替插入空字符串以得到新字符串。

代码:


view code
#include<iostream>
#include<cstdio>
#include<vector>
#include<queue>
#include<cassert>
#include<string>
#include<set>
using namespace std;

typedef long long i64;

#define L(i,a,b) for(int i=(a);i<=(b);i++)
#define R(i,a,b) for(int i=(a);i>=(b);--i)
#define SZ(a) int((a).size())
#define VI vector<int>
#define VL vector<i64>

const int MAXN=202;
int N;
int p[MAXN];
char s1[MAXN],s2[MAXN],s3[MAXN];
set<string> S;
string str,pat;

char nxs[MAXN];

int SOLVE(){
  S.clear();
  scanf("%d%s%s%s",&N,s1+1,s2+1,s3+1);
  str=pat="";
  L(i,1,2*N){
    if(i<=N)str+=s1[i];
    else str+=s2[i-N];
    pat+=s3[i];
  }
  L(i,1,2*N){
    if(i<=N)p[i]=i*2;
    else p[i]=(i-N)*2-1;
  }
  L(i,1,2*N){
    L(j,1,2*N)nxs[p[j]]=str[j-1];
    L(j,1,2*N)str[j-1]=nxs[j];
    S.insert(str);
    if(S.count(pat)){
      return i;
    }
  }
  return -1;
}

signed main(){
  int T=1;
  for(int tc=1*(scanf("%d",&T));tc<=T;tc++){
    int ac=SOLVE();
    printf("%d %d\n",tc,ac);
  }

  return 0;
}

H POJ-3414

题意:
有容量 \(A\)\(B\) 升的两个水壶,允许以下操作:

  • 将其中一个水壶注满水
  • 将其中一个水壶的水倒掉
  • 将其中一个水壶的水倒到另一个水壶,使得被倒的水壶装满水,或者倒水的水壶倒空。

查询一个最短的操作序列,使得其中一个水壶有恰好 \(C\) 升水。或者无法做到。

\(1 \leq A,B \leq 100, C \leq max(A,B)\)

解析:

考虑状态空间有多大,\(a\) 水壶的可能装 \(1 \sim A\) 的水,\(b\) 水壶可能装 \(1 \sim B\) 的水,精确的状态空间为 \(O(AB) \leq 10^{4}\)

状态空间不大,且要求最短路径,尝试 \(bfs\)

维护 \(x, y\) 分别是 \(a, b\) 水壶当前的水。考虑总共 \(6\) 种转移操作:

  • \(a\) 倒满水:\(x = A\)
  • \(a\) 倒掉水:\(x = 0\)
  • \(a\) 倒水给 \(b\) :设 \(t = min(a, B - y)\)\(a-t,b+t\)
  • \(b\) 倒满水:\(y = B\)
  • \(b\) 倒掉水:\(y = 0\)
  • \(b\) 倒水给 \(a\) :设 \(t = min(b, A - x)\)\(a+t,b-t\)

每次操作 \(O(1)\) ,状态空间上的每个节点要枚举满 \(6\) 个操作(而不是精确的能找到所有还没使用过的后继状态)。

那么直接从 \(x=0,y=0)\) 构造 \(bfs\) 树的时间复杂度就是 \(O(AB \times 6) = O(AB) \leq 10^{4}\) 的。

只是每次转移都要写六段代码,看起来代码会很,但是逻辑不复杂。

实现:

维护操作路径:我们知道 \(bfs\) 标记节点的过程是一棵树的生长过程,这棵树叫做 \(bfs\) 树。

给这棵树标号:维护一个时间戳 \(T\) ,每个节点被标记,就让他的 \(id\)\(++T\)

获取路径:我们记每个节点的父亲,就能通过从目标节点开始一直跳父亲,跳到根节点。小技巧是我们使用栈存入逆的操作,那么取出来就是正的。

维护操作:对 \(6\) 种操作编号 \(1 \sim 6\) ,对每个节点记一个信息 \(x \in [1, 6]\) ,表示从它的父亲到它使用的是什么操作。

代码:
写六段操作,确实有点长,因为每个操作都要单独写。我不太确定有没有逻辑清晰且短小的代码。


view code
#include<iostream>
#include<cstdio>
#include<vector>
#include<queue>
#include<cassert>
#include<string>
#include<set>
#include<stack>
using namespace std;

typedef long long i64;

#define L(i,a,b) for(int i=(a);i<=(b);i++)
#define R(i,a,b) for(int i=(a);i>=(b);--i)
#define SZ(a) int((a).size())
#define VI vector<int>
#define VL vector<i64>

const int MAXN=11000;
struct Node{
  int v1,v2,id,step;
  Node(){}
  Node(int _v1,int _v2,int _id,int _step):v1(_v1),v2(_v2),id(_id),step(_step){}
};
int vis[101][101];
int A,B,C,T,root;
int fa[MAXN];
int op[MAXN];
char s[6][10]={"FILL(1)","FILL(2)","DROP(1)","DROP(2)","POUR(1,2)","POUR(2,1)"};

signed main(){
  scanf("%d%d%d",&A,&B,&C);
  queue<Node> qe;
  root=1;
  fa[root]=-1;
  T=root;
  qe.push(Node(0,0,T,0));
  vis[0][0]=true;
  while(!qe.empty()){
    int v1=qe.front().v1,v2=qe.front().v2,id=qe.front().id,step=qe.front().step;
    qe.pop();

    if(v1==C||v2==C){
      printf("%d\n",step);
      stack<int> stk;
      for (int cur=id;cur!=root;cur=fa[cur]){
        stk.push(cur);
      }
      while(!stk.empty()){
        printf("%s\n",s[op[stk.top()]]);
        stk.pop();
      }
      return 0;
    }

    // FILL(1)
    if(!vis[A][v2]){
      vis[A][v2]=true;
      qe.push(Node(A,v2,++T,step+1));
      fa[T]=id;
      op[T]=0;
    }

    // FILL(2)
    if(!vis[v1][B]){
      vis[v1][B]=true;
      qe.push(Node(v1,B,++T,step+1));
      fa[T]=id;
      op[T]=1;
    }
    
    // DROP(1)
    if(!vis[0][v2]){
      vis[0][v2]=true;
      qe.push(Node(0,v2,++T,step+1));
      fa[T]=id;
      op[T]=2;
    }

    // DROP(2)
    if(!vis[v1][0]){
      vis[v1][0]=true;
      qe.push(Node(v1,0,++T,step+1));
      fa[T]=id;
      op[T]=3;
    }

    // POUR(1,2)
    int tv=min(B-v2,v1);
    if(!vis[v1-tv][v2+tv]){
      vis[v1-tv][v2+tv]=true;
      qe.push(Node(v1-tv,v2+tv,++T,step+1));
      fa[T]=id;
      op[T]=4;
    }

    // POUR(2,1)
    tv=min(A-v1,v2);
    if(!vis[v1+tv][v2-tv]){
      vis[v1+tv][v2-tv]=true;
      qe.push(Node(v1+tv,v2-tv,++T,step+1));
      fa[T]=id;
      op[T]=5;
    }
  }

  puts("impossible");

  return 0;
}

I UVA-11624

题意:
给一个 \(R \times C\) 的二维矩阵,\(1 \leq R, C \leq 1000\) 。每个格子上存在障碍和道路,起点标号为 J ,有若干个点 F 标号为火。

每秒,火向四联通的邻居蔓延,你可以向四联通的邻居走一步。

如果一秒内,人和火都可以走到某个格子,认为火先走到。

询问不被烧到的情况下,最快走出地图的时间。或者不可能。

解析:
考虑 \(bfs\) ,状态空间 \(O(RC)\) ,转移时间 \(O(4 \times 1) = 1\) ,则 \(bfs\) 的时间为 \(O(RC)\)

所谓的多源 \(bfs\) ,本质上是单源 \(bfs\)

假设存在一个超级源点 \(root\) ,向 'J' 和所有 'F' 连一条边。让所有 'J' 先进入队列,'F' 最后进入。

\(bfs\) 树是,按照队列顺序一层一层生长的。那么对于每一层,总是火先行动,人再行动。

实现:
实际上我们并不加入超级源点 \(root\) ,只按顺序(先火后人)加入 \(root\) 的儿子。从根的下一层开始 \(bfs\) 即多源 \(bfs\)

如果人能走到边上花费 \(step\) 步,那么它下一步一定可以走出地图,火已经追不上了,答案则是 \(step + 1\)

代码:


view code
#include<iostream>
#include<cstdio>
#include<vector>
#include<queue>
#include<cassert>
#include<string>
#include<set>
#include<stack>
using namespace std;

typedef long long i64;

#define L(i,a,b) for(int i=(a);i<=(b);i++)
#define R(i,a,b) for(int i=(a);i>=(b);--i)
#define SZ(a) int((a).size())
#define VI vector<int>
#define VL vector<i64>

const int MAXN=1002;
int N,M,ans;
char gr[MAXN][MAXN];
bool vis[MAXN-1][MAXN-1];
int dx[]={0,1,0,-1},dy[]={1,0,-1,0};
bool out(int x,int y){return x<1||N<x||y<1||M<y;}

struct Node{
  int x,y,c,step;
};

int SOLVE(){
  scanf("%d%d",&N,&M);
  L(i,1,N)L(j,1,M)vis[i][j]=false;
  L(i,1,N){
    scanf("%s",gr[i]+1);
  }
  queue<Node> qe;
  L(i,1,N)L(j,1,M)if(gr[i][j]=='F'){
    qe.push({i,j,0,0});
    vis[i][j]=true;
  }
  L(i,1,N)L(j,1,M)if(gr[i][j]=='J'){
    qe.push({i,j,1,0});
    vis[i][j]=true;
  }
  while(!qe.empty()){
    int x=qe.front().x,y=qe.front().y,c=qe.front().c,step=qe.front().step;
    qe.pop();
    if(x==1||x==N||y==1||y==M){
      if(c==1){
        return step+1;
      }
    }
    L(k,0,3){
      int nx=x+dx[k],ny=y+dy[k];
      if(out(nx,ny))continue;
      if(gr[nx][ny]=='#')continue;
      if(vis[nx][ny])continue;
      vis[nx][ny]=true;
      qe.push({nx,ny,c,step+1});
    }
  }
  return -1;
}

signed main(){
  int T=1;
  for(int tc=1*(scanf("%d",&T));tc<=T;tc++){
    int ac=SOLVE();
    if(ac==-1)puts("IMPOSSIBLE");
    else printf("%d\n",ac);
  }
  return 0;
}

J POJ-3984

题意:
给一个 \(5 \times 5\) 的矩阵,有通路、障碍、起点、终点,输出一条起点到终点的最短路径。

题解:

以起点为根构造 \(bfs\) 树,对 \(bfs\) 树生长过程中的节点标号,并记录每个节点的父亲和所在坐标。

走到终点开始往上跳父亲直到根节点,复原逆路径。然后按正路径向下走,输出节点编号对应的二维坐标。

代码:


view code
#include<iostream>
#include<cstdio>
#include<vector>
#include<queue>
#include<cassert>
#include<string>
#include<set>
#include<stack>
using namespace std;

typedef long long i64;

#define L(i,a,b) for(int i=(a);i<=(b);i++)
#define R(i,a,b) for(int i=(a);i>=(b);--i)
#define SZ(a) int((a).size())
#define VI vector<int>
#define VL vector<i64>

const int MAXN=6;
int N,M;
int gr[MAXN][MAXN],T;
bool vis[MAXN][MAXN];
int loc[2][MAXN*MAXN],fa[MAXN*MAXN];
int dx[]={0,1,0,-1},dy[]={1,0,-1,0};
bool out(int x,int y){return x<1||N<x||y<1||M<y;}

struct Node{
  int x,y,id;
};

signed main(){
  N=M=5;
  L(i,1,N)L(j,1,N)scanf("%d",&gr[i][j]);
  queue<Node> qe;
  qe.push({1,1,1});
  vis[1][1]=true;
  T=1;
  fa[T]=-1;
  loc[0][T]=1;
  loc[1][T]=1;
  while(!qe.empty()){
    int x=qe.front().x,y=qe.front().y,id=qe.front().id;
    if(x==5&&y==5){
      stack<int> stk;
      for(int cur=id;cur!=-1;cur=fa[cur]){
        stk.push(cur);
      }
      while(!stk.empty()){
        printf("(%d, %d)\n",loc[0][stk.top()]-1,loc[1][stk.top()]-1);
        stk.pop();
      }
      return 0;
    }
    qe.pop();
    L(k,0,3){
      int nx=x+dx[k],ny=y+dy[k];
      if(out(nx,ny))continue;
      if(gr[nx][ny]==1)continue;
      if(vis[nx][ny])continue;
      vis[nx][ny]=true;
      ++T;
      qe.push({nx,ny,T});
      fa[T]=id;
      loc[0][T]=nx;
      loc[1][T]=ny;
    }
  }
  return 0;
}

K HDU-1241

题意:
给一个 \(m \times n\) 的矩阵,'@' 代表油,'*' 代表空地。如果一个 '@' 的八连通邻居也是 '@' ,则这两块油属于同一块油田。询问总共有多少块油田。

解析:
上古时代这种解决这种问题的方法叫做“洪泛算法”,其实就是用 bfs 染色。其实这个算法以前考得挺难,这只是一道例题。所以我在后面又加了两道稍微进阶点的洪泛算法题。

那么我们暴力遍历整个矩阵,如果当前格子是 '@' ,就从 '@' 开始做八连通的染色,所到之处都染成一个非 '@' 的颜色。这里,甚至不妨是 '*' 。然后我们统计执行力多少次染色算法即可。

每个格子会被遍历到一次。每个 '@' 会被染成其他颜色一次。每个节点的转移时间是 \(O(4) = O(1)\)

单组测试,整个算法的时间复杂度不超过 \(O(n \times m + n \times m \times 4) = O(n \times m) \leq 10^{4}\) 。但题目没有说有多少组测试。

对于网格图:以前如果网格图很大,用 \(dfs\) 可能递归过深导致爆栈。(取决于评测机设置的栈区上限,有素质的出题人都会不卡栈区)这里并不大。

对于 \(XCPC\) 及以上算法竞赛风格的、更复杂多元的洪泛算法题:通常需要用 vis 数组染色,而不能简单的直接在原图上改标号染色染色。

代码:


view code
#include<iostream>
#include<cstdio>
#include<vector>
#include<queue>
#include<cassert>
#include<string>
#include<set>
#include<stack>
using namespace std;

typedef long long i64;

#define L(i,a,b) for(int i=(a);i<=(b);i++)
#define R(i,a,b) for(int i=(a);i>=(b);--i)
#define SZ(a) int((a).size())
#define VI vector<int>
#define VL vector<i64>

const int MAXN=102;
int N,M,cnt;
char gr[MAXN][MAXN];
int dx[]={0,1,0,-1,1,1,-1,-1},dy[]={1,0,-1,0,1,-1,-1,1};
bool out(int x,int y){return x<1||N<x||y<1||M<y;}
struct Node{
  int x,y;
};

void dfs(int x,int y){
  gr[x][y]='*';
  L(k,0,7){
    int nx=x+dx[k],ny=y+dy[k];
    if(out(nx,ny))continue;
    if(gr[nx][ny]=='*')continue;
    gr[nx][ny]='@';
    dfs(nx,ny);
  }
}

int SOLVE(){
  L(i,1,N)scanf("%s",gr[i]+1);
  cnt=0;
  L(i,1,N)L(j,1,M)if(gr[i][j]=='@'){
    dfs(i,j);
    cnt++;
  }
  return cnt;
}

signed main(){
  for(scanf("%d%d",&N,&M);N!=0||M!=0;scanf("%d%d",&N,&M)){
    printf("%d\n",SOLVE());
  }
  return 0;
}

L HDU-1495

题意:
给一瓶可乐 \(S\) ml ,给两个容量为 \(A\) ml 和 \(B\) ml 的杯子,满足 \(S=A+B\) 。这三个容器之间互相倒,询问最快平分可乐的次数,或者说做不到。


题目歧义的地方?

本来我以为平分是指,存在一个容器是 \(S/2\) ml 就行了,因为另外一个人直接拿走另外两个容器,全喝掉的话也喝到了 \(S/2\) ml。

研究了一下,这里的平分指的是,可乐里有 \(S/2\) ml ,容量更大的那个杯子有 \(S/2\) ml 。


解析:
如果 \(A < B\) ,不妨交换 \(A, B\) ,不影响结果。方便我们讨论第一个杯子总 \(\geq S/2\)

稍微做点简单的分析(即使不做这步分析,依旧不影响后面将要进行的对状态空间构建 bfs 树),因为只能倒出整数结果,如果 \(2 \nmid S\) ,显然不存在 \(\frac{S}{2}\)

三个容器的状态任意时刻都可以表示为 \(Ax+By\) 。所以 \(S / 2\) 一定至少是 \(gcd(A,B)\) 的倍数(Bezout 定理)。


证明:

三个容器的状态任意时刻都可以表示为 \(Ax+By\) 的证明:

初始的前几个状态:

  • 初始时容器 \(s\)(可乐瓶)是 \(A \times 1 + B \times 1\)
    • 先倒入容器 \(a\) (容量 \(A \geq S/2\) 的杯子),变成 \(A \times 0 + B \times 1\)
    • 先倒入容器 \(b\) (容量 \(B \leq S/2\) 的杯子),变成 \(A \times 1 + B \times 0\)
  • 容器 \(a\) 装满是 \(A \times 1 + B \times 0\)
  • 容器 \(b\) 装满是 \(A \times 0 + B \times 1\)

考虑任意时刻,容器 \(a, b\) 已经表现成 \(A \times r_{A} + B \times s_{B}\)\(A \times r_{B} + B \times s_{B}\) 。考虑容器 \(a\) 倒入容器 \(b\) (其他情况:任选两个容器,钦定一个倒入另一个,分析同理)。

  • 若 $A \times (r_{A} + r_{A}) + B \times (s_{B} + s_{B}) \leq B $ ,即 $A \times (r_{A} + r_{A}) + B \times (s_{B} + s_{B} - 1) \leq 0 $ 。则容器 \(a\) 变成 \(A \times 0 + B \times 0\) ,容器 \(b\) 变成 \(A \times (r_{A} + r_{A}) + B \times (s_{B} + s_{B})\)
  • 否则容器 \(a\) 变成 \(A \times (r_{A} + r_{A}) + B \times (s_{B} + s_{B} - 1)\) ,容器 \(b\) 变成 \(A \times 0 + B \times 1\)

\(\square\)


那么若 \(2 \nmid S\) 或者 \(gcd(A, B) \nmid S\) 则直接不需要往后考虑,已经是无解。

假设有解。注意到状态空间最大为 \((S+1)(A+1)(B+1)=O(SAB) \approx 10^{6}\) ,我们可以尝试使用遍历整个状态空间的算法。

考虑从状态 \((S,0,0)\) 构建 \(bfs\) 树,总共有 \(\binom{3}{2} \times 2! = 6\) 种操作,每种操作 \(O(1)\) ,则状态之间的转移时间为 \(O(6 \times 1) = O(1)\) 。然后直接 \(bfs\) 就好了。总的时间复杂度是 \(O(SAB)\)

如果寻遍了状态空间,都没有得到目标解,那么确实就是无解。因为我们一开始只分析了无解的两个显然的必要条件,并没有分析无解的充分条件。

  • 实际上,我没办法证明我推导的必要条件已经构成了充分条件(即使他们好像确实是充分条件)。
  • 另外,尽可能提前推导一些必要条件应该是分析问题的习惯。显然这题里,即使不推导这些条件,只要状态空间里搜索不到目标值,那就是无解。

和 POJ-3414 不同的是,前一题状态空间是二维,这一题状态空间是三维。由于要单独写六种操作,依旧会导致代码比较长,但是能保证逻辑清晰。倒水操作参考 POJ-3414 的解析。


view code
#include<iostream>
#include<cstdio>
#include<vector>
#include<queue>
#include<cassert>
#include<string>
#include<set>
#include<stack>
using namespace std;

typedef long long i64;

#define L(i,a,b) for(int i=(a);i<=(b);i++)
#define R(i,a,b) for(int i=(a);i>=(b);--i)
#define SZ(a) int((a).size())
#define VI vector<int>
#define VL vector<i64>

const int MAXN=101;
int S,N,M;
bool vis[MAXN][MAXN][MAXN];
struct Node{
  int x,y,z,step;
};
int SOLVE(){
  L(i,0,S)L(j,0,N)L(k,0,M)vis[i][j][k]=false;
  queue<Node> qe;
  qe.push({S,0,0,0});
  vis[S][0][0]=true;
  while(!qe.empty()){
    int x=qe.front().x,y=qe.front().y,z=qe.front().z,step=qe.front().step;
    if(x==S/2&&y==S/2){
      return step;
    }
    qe.pop();
    int tv;
    // A->B
    tv=min(N-y,x);
    if(!vis[x-tv][y+tv][z]){
      vis[x-tv][y+tv][z]=true;
      qe.push({x-tv,y+tv,z,step+1});
    }
    // A->C
    tv=min(M-z,x);
    if(!vis[x-tv][y][z+tv]){
      vis[x-tv][y][z+tv]=true;
      qe.push({x-tv,y,z+tv,step+1});
    }
    // B->A
    tv=min(S-x,y);
    if(!vis[x+tv][y-tv][z]){
      vis[x+tv][y-tv][z]=true;
      qe.push({x+tv,y-tv,z,step+1});
    }
    // B->C
    tv=min(M-z,y);
    if(!vis[x][y-tv][z+tv]){
      vis[x][y-tv][z+tv]=true;
      qe.push({x,y-tv,z+tv,step+1});
    }
    // C->A
    tv=min(S-x,z);
    if(!vis[x+tv][y][z-tv]){
      vis[x+tv][y][z-tv]=true;
      qe.push({x+tv,y,z-tv,step+1});
    }
    // C->B
    tv=min(N-y,z);
    if(!vis[x][y+tv][z-tv]){
      vis[x][y+tv][z-tv]=true;
      qe.push({x,y+tv,z-tv,step+1});
    }
  }
  return -1;
}

int gcd(int a,int b){return b?gcd(b,a%b):a;}

signed main(){
  for(scanf("%d%d%d",&S,&N,&M);S!=0||N!=0||M!=0;scanf("%d%d%d",&S,&N,&M)){
    if((S&1)||(S/2%gcd(N,M)!=0)){
      puts("NO");
      continue;
    }
    if (N<M)swap(N,M);
    int ac=SOLVE();
    if(ac==-1)puts("NO");
    else printf("%d\n",ac);
  }
  return 0;
}

M HDU-2612

题意:
给一个图,有一些路 '.' 和障碍 '#' ,有两个起点 'Y''M' ,有若干个终点 '@'

询问是否存在一个点 '@' 满足 'Y''M' 到这个位置的最短距离之和最小。

解析:

不难反证法证明两个起点到某个终点的最短距离之和最小,则两个起点分别到这个终点走的是最短距离。否则,这两个起点到这个终点的最短距离之和可以更小。

实际上,从 'Y' 出发构造一次 \(bfs\) 树,处理出 \(f(i,j)\) 表示它到每个点的最短距离,从 'M' 出发构造一次 \(bfs\) 树,处理出 \(g(i,j)\) 表示它到每个点的最短距离。

遍历 \(1 \leq i \leq n, 1 \leq j \leq m\)\(f(i,j) + g(i,j)\) 的最小值就是答案。

注意按题意要求,答案最后要乘以 \(11\)


代码:

view code
#include<iostream>
#include<cstdio>
#include<vector>
#include<queue>
#include<cassert>
#include<string>
#include<set>
#include<stack>
using namespace std;

typedef long long i64;

#define L(i,a,b) for(int i=(a);i<=(b);i++)
#define R(i,a,b) for(int i=(a);i>=(b);--i)
#define SZ(a) int((a).size())
#define VI vector<int>
#define VL vector<i64>

const int MAXN=202,MAXM=202;
struct Node{
  int x,y,step;
};
int N,M,ans;
char gr[MAXN][MAXM];
bool vis[MAXN][MAXM];
int f[MAXN][MAXM],g[MAXN][MAXM];
int dx[]={0,1,0,-1},dy[]={1,0,-1,0};
bool out(int x,int y){return x<1||N<x||y<1||M<y;}

void work(int sx,int sy,int w[][MAXM]){
  L(i,1,N)L(j,1,M)vis[i][j]=false;
  queue<Node> qe;
  qe.push({sx,sy,0});
  while(!qe.empty()){
    int x=qe.front().x,y=qe.front().y,step=qe.front().step;
    if(gr[x][y]=='@'){
      w[x][y]=step;
    }
    qe.pop();
    L(k,0,3){
      int nx=x+dx[k],ny=y+dy[k];
      if(out(nx,ny))continue;
      if(vis[nx][ny])continue;
      if(gr[nx][ny]=='#')continue;
      vis[nx][ny]=true;
      qe.push({nx,ny,step+1});
    }
  }
}

int SOLVE(){
  L(i,1,N)L(j,1,M)f[i][j]=g[i][j]=1<<30;
  L(i,1,N)scanf("%s",gr[i]+1);
  int sx1,sy1,sx2,sy2;
  L(i,1,N)L(j,1,M){
    if(gr[i][j]=='Y')sx1=i,sy1=j;
    if(gr[i][j]=='M')sx2=i,sy2=j;
  }
  work(sx1,sy1,f);
  work(sx2,sy2,g);
  ans=1<<30;
  L(i,1,N)L(j,1,M)if(f[i][j]!=1<<30&&g[i][j]!=1<<30){
    ans=min(ans,f[i][j]+g[i][j]);
  }
  return ans*11;
  return 0;
}

signed main(){
  while(~scanf("%d%d",&N,&M)){
    printf("%d\n",SOLVE());
  }
  return 0;
}

N POJ-1096

题意:
给一个 \(N \times M \times H\) 的三维立方体,立方体的每个坐标格子由一个由小正方体构成,编号为 \(0 \sim N \times M \times H - 1\)

输入若干个格子的编号,表示这些小正方体存在,否则就是不存在。

询问给出的三维模型的外表面积。

解析:
容易想到一个经典套路。从某个小正方形开始用洪泛算法染色,如果下一步会出边界,或者下一步不存在小正方形堵住,则停止搜索,同时总的外表面积加上 \(1 \times 1 \times 1\)

如果枚举每个存在的小正方形,如果能跑洪泛算法,就跑。那么至少会导致:

  1. 显然不仅外表面积会被计算,内表面积也会被计算。
  2. 如果是类似于封闭壳套封闭壳套封闭壳这种三维模型,每个封闭壳的表面积都会被计算。

这里我们用另一个经典套路,在整个三维模型外,围一圈超级封闭壳。

具体算法的一个实例描述是:

让超级封闭壳占据的格子染色为 \(2\) 。而原有的实际存在的小正方形染色为 \(1\) ,不存在小正方形的格子染色为 \(0\) 。我们从超级封闭壳的某个点开始洪泛算法,把 \(0\) 颜色的格子染成 \(2\) 的颜色,一旦下一步的格子颜色是 \(1\) ,则停止往这个方向染色,并让总的外表面积加上 \(1 \times 1 \times 1\)

这样我们就只计算了最外那层壳子的外表面积。(里面染不进去)。

注意我们如何把高维矩阵的编号和坐标之间做互相映射。一个经典的做法是变进制数函数映射(变进制数可以获取每个位数上的具体值。)


将坐标映射到变进制数的例子

以二维 \(N \times M\) 的矩阵为例:

对于 \(0 \leq i < N, 0 \leq j < M\) ,坐标 \((i, j)\) 映射到一阶变进制数 \(x = i \times M + j\)

\(j = x \bmod M\)\(i = \lfloor \frac{x}{M} \rfloor\)

以三维为例:
对于 \(0 \leq i < N, 0 \leq j < M, 0 \leq k < H\) ,坐标 \((i, j, k)\) 映射到一阶变进制数 \(x = i \times M \times H + j \times H + l\)

\(l = x \bmod H\)\(j = \lfloor \frac{x}{H} \rfloor \bmod M\)\(i = \lfloor \frac{x}{M \times H} \rfloor\)

更高维可以类推,但是实际上只会在比较难的数论或组合中考。


注意这种做法是从坐标 \((0, 0, 0)\) 开始映射的,而这个位置我们要那套超级封闭外壳(总不能用负数作数组索引),所以我们要把实际上算出来的坐标加上 \(1\) 的偏移量。

算法是显然的洪泛算法题。时间复杂度是显然的 \(O(N \times M \times H)\)

代码:


view code
#include<iostream>
#include<cstdio>
#include<vector>
#include<queue>
#include<cassert>
#include<string>
#include<set>
#include<stack>
using namespace std;

typedef long long i64;

#define L(i,a,b) for(int i=(a);i<=(b);i++)
#define R(i,a,b) for(int i=(a);i>=(b);--i)
#define SZ(a) int((a).size())
#define VI vector<int>
#define VL vector<i64>

const int MAXN=62;
int N,M,H,n,ans;
int gr[MAXN][MAXN][MAXN];
int vis[MAXN][MAXN][MAXN];
struct P{
  int x,y,z;
  P(){}
  P(int _x,int _y,int _z):x(_x),y(_y),z(_z){}
};
P hs(int v) {
  return P(v/M/H,v/H%M,v%H);
}
bool out(int x,int y,int z){return x<0||N+1<x||y<0||M+1<y||z<0||H+1<z;}
int dx[]={0,1,0,-1,0,0},dy[]={1,0,-1,0,0,0},dz[]={0,0,0,0,1,-1};
int SOLVE(){
  // i*M*H+j*M+k=id(i,j,k)
  // i = id / M / H
  // j = id / H % M
  // k = id % H
  L(i,0,N+1)L(j,0,M+1)L(k,0,H+1)gr[i][j][k]=0;
  ans=0;

  L(i,1,n){
    int v;scanf("%d",&v);
    P cur=hs(v);
    gr[cur.x+1][cur.y+1][cur.z+1]=1;
  }
  
  gr[0][0][0]=2;
  queue<P> qe;
  qe.push(P(0,0,0));
  while(!qe.empty()){
    int x=qe.front().x,y=qe.front().y,z=qe.front().z;
    qe.pop();
    L(k,0,5){
      int nx=x+dx[k],ny=y+dy[k],nz=z+dz[k];
      if(out(nx,ny,nz))continue;
      if(gr[nx][ny][nz]==2)continue;
      if(gr[nx][ny][nz]==1){
        ans++;
      }else if(gr[nx][ny][nz]==0){
        gr[nx][ny][nz]=2;
        qe.push(P(nx,ny,nz));
      }else assert(false);
    }
  }
  return ans;
}

signed main(){
  for(scanf("%d%d%d%d",&N,&M,&H,&n);N!=0||M!=0||H!=0||n!=0;scanf("%d%d%d%d",&N,&M,&H,&n)){
    int ac=SOLVE();
    printf("The number of faces needing shielding is %d.\n",ac);
  }
  return 0;
}

O luogu-P9243

题意:

数据范围不说了,就是 \(O(n \times m)\) 能过的那种。

给一个 \(n \times m\) 的矩阵。上面有染了一种色的方块,如果一个四联通的色图,不被另一个四联通的色圈主,则这个色图就是以恶搞外壳。询问有多少个外壳。

解析:

不妨认为原图无色是 \(0\) ,有色是 \(1\)

显然同 POJ-1096 的思路,我们在最外层套一圈超级外壳,不妨染色成 \(2\) ,然后从超级外壳上的某个点(不妨是 \((0,0)\))开始做洪泛算法,把空无色格子都染成和超级外壳一种颜色。

于是可以圈住所有 \(1\) 色外壳。这时候再跑洪泛算法,把非颜色 \(2\) 的格子全部染成 \(3\) 颜色,计算执行了多少次算法,就有多少外壳。


注意点:


四联通也叫边联通,显然边联通不代表点联通。

所以需要圈住所有边联通的联通块,要用点联通的图去圈。也即我们从超级外壳上跑的洪泛算法是八连通而不是四联通的。


思考:如何圈住点联通的连通块?

边联通图可以圈住。

证明:

显然不能用点联通的图圈,因为会越到原有的点联通色块的内部。

容易证明只用边联通就染满圈点联通的外壳之外的所有格子。

反证法:任何一个再点联通外壳之外的格子,都存在一条到超级外壳的边联通路径,否则现在外壳之外还有外壳,那么现在的外壳实际上就是内壳,矛盾。

\(\square\)



实际上这种圈住外壳的问题,在游戏行业是常见的。

代码:


view code
#include<iostream>
#include<cstdio>
#include<vector>
#include<queue>
#include<cassert>
#include<string>
#include<set>
#include<stack>
using namespace std;

typedef long long i64;

#define L(i,a,b) for(int i=(a);i<=(b);i++)
#define R(i,a,b) for(int i=(a);i>=(b);--i)
#define SZ(a) int((a).size())
#define VI vector<int>
#define VL vector<i64>

const int MAXN=53;
char gr[MAXN][MAXN];
struct Node{
  int x,y;
  Node(){}
  Node(int _x,int _y):x(_x),y(_y){}
};
int N,M,ans;
bool out(int x,int y){return x<0||N+1<x||y<0||M+1<y;}
int dx[]={0,1,0,-1,1,1,-1,-1},dy[]={1,0,-1,0,1,-1,1,-1};
void bfs_init(){
  queue<Node> qe;
  qe.push(Node(0,0));
  gr[0][0]='2';
  while(!qe.empty()){
    int x=qe.front().x,y=qe.front().y;
    qe.pop();
    L(k,0,7){
      int nx=x+dx[k],ny=y+dy[k];
      if(out(nx,ny))continue;
      if(gr[nx][ny]=='1')continue;
      if(gr[nx][ny]=='2')continue;
      gr[nx][ny]='2';
      qe.push(Node(nx,ny));
    }
  }
}
void bfs_solve(int i,int j){
  queue<Node> qe;
  qe.push(Node(i,j));
  gr[i][j]='3';
  while(!qe.empty()){
    int x=qe.front().x,y=qe.front().y;
    qe.pop();
    L(k,0,3){
      int nx=x+dx[k],ny=y+dy[k];
      if(out(nx,ny))continue;
      if(gr[nx][ny]=='2')continue;
      if(gr[nx][ny]=='3')continue;
      gr[nx][ny]='3';
      qe.push(Node(nx,ny));
    }
  }
}
int SOLVE(){
  scanf("%d%d",&N,&M);
  ans=0;
  L(i,0,N+1)L(j,0,M+1)gr[i][j]='#';

  L(i,1,N)scanf("%s",gr[i]+1);
  bfs_init();
  
  L(i,1,N)L(j,1,M)if(gr[i][j]!='2'&&gr[i][j]!='3'){
    bfs_solve(i,j);
    ans++;
  }

  return ans;
}

signed main(){
  for(int T,tc=1*scanf("%d",&T);tc<=T;tc++){
    printf("%d\n",SOLVE());
  }
  return 0;
}

写在最后

集训队倒闭了,所以这个题单也没了。

几乎是纯搜索。

刻意去除了所有搜索+其他算法的题。

刻意去除了所有实为 dp 记忆化实现形式的伪搜索。

posted @ 2025-08-10 06:39  03Goose  阅读(14)  评论(0)    收藏  举报