华容道

GitHub跳转原题关系图返回列表

把局面压缩成(棋子格,空白邻格)的状态图,推动与绕行两类边按需缓存最短路后跑 Dijkstra

OJ: roj

题目 ID: 3613

难度:提高+/省选-

标签:搜索BFS最短路网格python

创建: 2026-10-02 10:49

更新: 2026-10-10 11:30

形式化题目

  • 棋盘为 n×mn \times m 网格,每格标记为固定格(永远不动)或可移动格;恰有一个可移动格上是空白,其余格子各有一枚棋子。每秒执行一次操作:把与空白相邻(有公共边)的任意棋子移进空白,二者交换。
  • 固定/可移动的布局在所有询问中保持不变,共 qq 次询问;第 ii 次给定空白初位置 EiE_i、指定棋子初位置 SiS_i、目标格 TiT_i。
  • 对每次询问求:把指定棋子移动到目标格的最少操作次数;不可能完成输出 −1-1。

样例输入与输出:

text
3 4 2
0 1 1 1
0 1 1 0
0 1 0 0
3 2 1 2 2 2
1 2 2 2 3 2
text
2
-1

第一次询问的初始局面(空白 (3,2)(3,2)、目标棋子 (1,2)(1,2)、目标位置 (2,2)(2,2)):

行 \ 列 1 2 3 4
1 ✕ ● ○ ○
2 ✕ ◎ ○ ✕
3 ✕ □ ✕ ✕

图例:✕ 固定格,○ 普通棋子,● 目标棋子(初始在 (1,2)(1,2)),□ 空白,◎ 目标位置((2,2)(2,2),当前由普通棋子占据)。

  • 第一次询问答案 22:空白先向上走一步((2,2)(2,2) 的普通棋子下移填补),目标棋子再向下走一步进入 (2,2)(2,2)。
  • 第二次询问答案 −1-1:目标格 (3,2)(3,2) 是只与 (2,2)(2,2) 相邻的死胡同。空白每次钻进 (3,2)(3,2) 都只能从 (2,2)(2,2) 挤进去,把 (2,2)(2,2) 留给刚从 (3,2)(3,2) 被顶出来的那枚棋子,此后唯一合法操作又只是把它换回去。而目标棋子踏入 (3,2)(3,2) 必须同时满足"自己在 (2,2)(2,2)、空白在 (3,2)(3,2)"——这个局面永远不会出现,因此无解。

正解

思路

朴素思路与瓶颈。 最直接的想法是对"整盘棋子怎么摆"做广搜,状态数是组合级的,完全不可行。注意到除目标棋子外的其它棋子没有任何身份要求——空白走到任意可移动格,把那里的棋子顶出来即可——于是状态只需记录二元组 (p,e)(p, e):目标棋子所在格与空白所在格;转移只有"推动"(空白与棋子交换)和"空白走一步"两种单位代价操作。但这样仍有 K(K−1)=O(K2)K(K-1) = O(K^2) 个状态(K=nm⩽900K = nm \leqslant 900),单次询问 O(K2)O(K^2),q=500q = 500 时总量约 4×1084 \times 10^8,即使 C++ 也很吃力——这就是要消除的瓶颈。

关键观察。

  1. 除第一次推动外,每次推动后空白都恰好落在棋子的原位置,也就是与新棋子位置相邻。空白走到不与棋子相邻的格子时不可能发生推动,它只是在绕向棋子的下一个邻格,中间位置不携带任何信息。
  2. 空白在除棋子格外的所有可移动格上都能自由穿行(顶开普通棋子),每步代价恒为 11;换言之,"空白从 aa 绕到 bb、全程避开棋子格 pp"的代价就是在 F∖{p}F \setminus \{p\} 上的最短路,记作 dp(a,b)d_p(a, b)。
  3. 因此状态只需记录"空白在棋子的哪个邻格上":压缩为 (p,a)(p, a),其中 a∈N(p)a \in N(p)(pp 的四方向邻格中可移动的格子),总数不超过 4K4K。

两类转移。 下图展示压缩状态之间的两条边:

flowchart LR
    st["状态 (p, a)<br/>棋子在 p、空白在邻格 a"]
    st -->|"推动:棋子走进空白 a,代价 +1"| push["状态 (a, p)"]
    st -->|"绕行:空白避开 p 从 a 走到 b,代价 +d_p(a, b)"| circ["状态 (p, b)"]

推动边 (p,a)→(a,p)(p, a) \to (a, p) 是一步真实交换;交换后空白位于 pp,而 pp 与 aa 相邻,状态不变量"空白在棋子邻格上"得以保持。绕行边 (p,a)→(p,b)(p, a) \to (p, b) 把空白 O(K)O(K) 的绕行收成一条边,代价直接取最短路 dp(a,b)d_p(a, b):逐格走和一步到位等价,但避免了在每条状态边上重复展开行走。

初始段。 首次推动前,空白可能离 SS 很远。空白从 EE 出发、全程避开 SS 做一次 BFS,得到到各 a∈N(S)a \in N(S) 的距离,把 {(S,a)}\{(S, a)\} 作为起点即可。

为什么用 Dijkstra。 推动代价为 11,而网格上棋子 pp 的任意两个邻格不共边(四个方向两两错开),绕行代价 dp(a,b)⩾2d_p(a, b) \geqslant 2 或不可达——边权不全为 11,普通 BFS 与 0-1 BFS 都不适用。用堆跑 Dijkstra,某状态首次弹出时若棋子已在 TT,其代价即答案(首次弹出即最短,所以终点在弹出时判定而不是在入堆时)。

预处理代价。 dpd_p 依赖棋子位置 pp,但棋盘固定:用 @cache 以 (p,a)(p, a) 为键,在第一次用到时才 BFS。整个运行至多 4K4K 次 BFS、总计 O(K2)O(K^2),被所有询问摊还共享,单次询问只剩 O(Klog⁡K)O(K \log K) 的 Dijkstra。

正确性。 充分性:压缩图的每条边都能展开成真实操作序列(推动是一步交换;绕行沿 F∖{p}F \setminus \{p\} 上的最短路逐格顶开普通棋子)。必要性:任何真实操作序列按"推动"切段,推动前空白必在棋子邻格,段内行走用 dpd_p 最短路边替换不会变长,首次推动前用初始 BFS 距离同理。所以压缩图上的最短路恰好等于原问题的最少步数。

边界。 S=TS = T 时答案为 00(真实数据中存在此类询问);SS、TT、EE 落在固定格、或空白根本到不了 SS 的邻格时输出 −1-1;样例第二次询问的死锁在压缩图上恰好表现为 TT 不可达。

代码

代码中 blank_dist 是“空白避开棋子格”的单源 BFS,同时服务初始段与绕行距离;near_dist 是 @cache 缓存的 dp(a,⋅)d_p(a, \cdot),返回值与 adj[p] 对齐;play 是单次询问的 Dijkstra 主循环。

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-10 11:30
 * update_at: 2026-10-10 11:30
 */

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int INF = 1000000000;
int n,m,qnum,kcells; vector<int> free_cell; vector<vector<int> > adj; map<ll, vector<int> > memo;
vector<int> blank_dist(int src,int blocked){ vector<int> dist(kcells,INF); if(src<0||src>=kcells) return dist; dist[src]=0; queue<int> q; q.push(src); while(!q.empty()){int u=q.front();q.pop(); for(int i=0;i<(int)adj[u].size();i++){int v=adj[u][i]; if(v!=blocked && dist[v]>dist[u]+1){dist[v]=dist[u]+1; q.push(v);}}} return dist; }
vector<int> near_dist(int p,int a){ ll key=(ll)p*kcells+a; if(memo.count(key)) return memo[key]; vector<int> d=blank_dist(a,p), res; for(int i=0;i<(int)adj[p].size();i++) res.push_back(d[adj[p][i]]); memo[key]=res; return res; }
struct Node{int c,p,a; bool operator<(const Node&o)const{ return c>o.c; }};
int play(int e0,int s,int t){ if(!free_cell[s]||!free_cell[t]||!free_cell[e0]) return -1; if(s==t) return 0; vector<int> first=blank_dist(e0,s); map<pair<int,int>, int> best; priority_queue<Node> pq; for(int i=0;i<(int)adj[s].size();i++){int a=adj[s][i]; if(first[a]<INF){best[{s,a}]=first[a]; pq.push({first[a],s,a});}} while(!pq.empty()){Node cur=pq.top();pq.pop(); pair<int,int> key={cur.p,cur.a}; if(best[key]!=cur.c) continue; if(cur.p==t) return cur.c; pair<int,int> nk={cur.a,cur.p}; if(cur.c+1 < (best.count(nk)?best[nk]:INF)){best[nk]=cur.c+1; pq.push({cur.c+1,cur.a,cur.p});} vector<int> nd=near_dist(cur.p,cur.a); for(int i=0;i<(int)adj[cur.p].size();i++){int b=adj[cur.p][i], step=nd[i]; pair<int,int> nk2={cur.p,b}; int old=best.count(nk2)?best[nk2]:INF; if(step<INF && cur.c+step<old){best[nk2]=cur.c+step; pq.push({cur.c+step,cur.p,b});}} } return -1; }
int main(){ios::sync_with_stdio(false); cin.tie(nullptr); if(!(cin>>n>>m>>qnum)) return 0; kcells=n*m; free_cell.resize(kcells); for(int i=0;i<kcells;i++) cin>>free_cell[i]; adj.assign(kcells, vector<int>()); for(int i=0;i<kcells;i++) if(free_cell[i]){int r=i/m,c=i%m; int js[4]={i-1,i+1,i-m,i+m}; bool ok[4]={c>0,c<m-1,r>0,r<n-1}; for(int z=0;z<4;z++) if(ok[z] && free_cell[js[z]]) adj[i].push_back(js[z]);} for(int qq=0;qq<qnum;qq++){int ex,ey,sx,sy,tx,ty;cin>>ex>>ey>>sx>>sy>>tx>>ty; ex--;ey--;sx--;sy--;tx--;ty--; cout<<play(ex*m+ey,sx*m+sy,tx*m+ty)<<'\n';} return 0;}
python
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-02 10:51
# update_at: 2026-10-02 10:51

import heapq
import sys
from collections import deque
from functools import cache

INF = 10**9  # 不可达哨兵:远超棋盘最大步数 n*m


def blank_dist(src: int, blocked: int, adj: list[list[int]], k: int) -> list[int]:
    """空白从 src 出发、避开 blocked(目标棋子所在格)到每个格子的最短步数。"""
    dist = [INF] * k
    dist[src] = 0
    dq = deque([src])
    while dq:
        u = dq.popleft()
        for v in adj[u]:
            if v != blocked and dist[v] > dist[u] + 1:  # 只在可移动格之间行走
                dist[v] = dist[u] + 1
                dq.append(v)
    return dist


def solve() -> None:
    data = iter(map(int, sys.stdin.buffer.read().split()))
    n, m, q = next(data), next(data), next(data)
    k = n * m
    free = [next(data) for _ in range(k)]  # 1 = 可移动格(含空白),0 = 固定格
    adj: list[list[int]] = [[] for _ in range(k)]
    for i in range(k):
        if free[i]:  # 只有可移动格之间才相邻可达
            r, c = divmod(i, m)
            adj[i] = [
                j
                for j, inside in ((i - 1, c > 0), (i + 1, c < m - 1), (i - m, r > 0), (i + m, r < n - 1))
                if inside and free[j]
            ]

    @cache
    def near_dist(p: int, a: int) -> tuple[int, ...]:
        """空白从邻格 a 绕开棋子 p 后,到 p 各邻格的最短步数(与 adj[p] 对齐)。"""
        dist = blank_dist(a, p, adj, k)
        return tuple(dist[b] for b in adj[p])

    def play(e0: int, s: int, t: int) -> int:
        """一局游戏:在状态 (棋子格 p, 空白邻格 a) 上跑 Dijkstra,返回最少秒数或 -1。"""
        if not free[s] or not free[t] or not free[e0]:
            return -1
        if s == t:  # 棋子已在目标位置
            return 0
        first = blank_dist(e0, s, adj, k)  # 首次推动前,空白绕开 S 的行走距离
        best: dict[tuple[int, int], int] = {}
        pq: list[tuple[int, int, int]] = []
        for a in adj[s]:
            if first[a] < INF:  # 空白走到 S 的某个邻格后才可能推动棋子
                best[(s, a)] = first[a]
                heapq.heappush(pq, (first[a], s, a))
        while pq:
            c, p, a = heapq.heappop(pq)
            if best.get((p, a)) != c:  # 过期堆项
                continue
            if p == t:  # 棋子抵达目标,首次弹出即最短
                return c
            # 棋子走进空白格:空白换到原棋子格 p(p 与 a 相邻,状态不变量保持)
            if c + 1 < best.get((a, p), INF):
                best[(a, p)] = c + 1
                heapq.heappush(pq, (c + 1, a, p))
            # 空白绕开棋子走到 p 的其他邻格,为下一次推动占位
            for b, step in zip(adj[p], near_dist(p, a)):
                if step < INF and c + step < best.get((p, b), INF):
                    best[(p, b)] = c + step
                    heapq.heappush(pq, (c + step, p, b))
        return -1

    out: list[str] = []
    for _ in range(q):
        ex, ey, sx, sy, tx, ty = (next(data) - 1 for _ in range(6))
        out.append(str(play(ex * m + ey, sx * m + sy, tx * m + ty)))
    print('\n'.join(out))


if __name__ == "__main__":
    solve()

复杂度

  • 时间:单次询问为初始 BFS O(K)O(K) 加 Dijkstra O(4Klog⁡4K)O(4K \log 4K),合计 O(Klog⁡K)O(K \log K);绕行距离缓存整个运行至多 4K4K 次 BFS,总计 O(K2)O(K^2)。整体 O(K2+qKlog⁡K)O(K^2 + qK \log K),与 C++ 正解(压缩状态图 + 绕行最短路 + Dijkstra)同阶。
  • 空间:邻接表、缓存(⩽4K\leqslant 4K 个键 ×\times 每键 ⩽4\leqslant 4 个距离)、单询问状态与堆均为 O(K)O(K),峰值远低于 3232 MiB。
  • 实测:官方样例输出 22 / −1-1;verify.sh 全量 20 组真实数据 PASS,单组最慢 0.190.19 s、峰值内存 <16< 16 MB。

总结

  • 其它棋子可互换 → 状态先缩为 (棋子格, 空白格);再观察"推动后空白恒贴棋子" → 最终只记 (棋子格, 空白邻格),状态数从 O(K2)O(K^2) 降到 O(K)O(K)。
  • 空白绕行不逐格展开,收成一条权为 dp(a,b)d_p(a, b) 的边;dpd_p 依赖固定棋盘,用 @cache 按需生成、跨询问共享。
  • 推动 +1+1 与绕行 +dp+d_p 边权不等,故用 Dijkstra;终点在首次弹出时判定。
  • S=TS = T 直接输出 00,不可达输出 −1-1;死锁局面(如样例二)会被图上的不可达自然覆盖。

图示解析

这张图串起本题从"整盘配置"到"最少秒数"的建模主线:

text
整盘配置(组合级,朴素 BFS 不可行)
`- 观察一:其它棋子可互换 → 状态 (棋子格 p, 空白格 e),仍有 O(K²)/询问
   `- 观察二:推动后空白恒贴棋子 → 压缩状态 (p, a),a ∈ N(p),≤ 4K 个
      `- 两类边:推动 +1 / 绕行 +d_p(a,b)(按需 BFS,@cache 跨询问共享)
         `- Dijkstra:首次弹出 p = T 即最少秒数,堆空则 -1

先看每层节点代表的推理结论:它先砍掉无身份的普通棋子,再砍掉空白的中间位置。顺着分支检查两类边如何还原真实操作,就能理解压缩图的最短路为什么等于原问题的最少步数。图中只保留正文已论证的关键关系,样例推演和正确性细节以正文为准。