垃圾ACMer的暑假训练220722
14.5 多源BFS
14.5.1 矩阵距离
题意
给定一个 n × m ( 1 ≤ n , m ≤ 1000 ) n\times m\ \ (1\leq n,m\leq1000) n×m (1≤n,m≤1000)的 01 01 01矩阵 A A A,定义 A [ i ] [ j ] A[i][j] A[i][j]与 A [ k ] [ l ] A[k][l] A[k][l]间的Manhattan距离 d i s ( A [ i ] [ j ] , A [ k ] [ l ] ) = ∣ i − k ∣ + ∣ j − l ∣ dis(A[i][j],A[k][l])=|i-k|+|j-l| dis(A[i][j],A[k][l])=∣i−k∣+∣j−l∣.输出一个 n × m n\times m n×m的整数矩阵 B B B,其中 B [ i ] [ j ] = min 1 ≤ x ≤ n , 1 ≤ y ≤ m , A [ x ] [ y ] = 1 d i s ( A [ i ] [ j ] , A [ x ] [ y ] ) \displaystyle B[i][j]=\min_{1\leq x\leq n,1\leq y\leq m,A[x][y]=1}dis(A[i][j],A[x][y]) B[i][j]=1≤x≤n,1≤y≤m,A[x][y]=1mindis(A[i][j],A[x][y]).
思路
即求每个元素到最近的 1 1 1的距离.
类比图论中有多个起点,求点到其最近的起点的最短路时,可建立与多个起点相连的虚拟源点,再求目标点到虚拟源点的最短路.在本题中,只需先将 B B B中对应的 A A A中所有 1 1 1的位置初始化为 0 0 0并入队即可.
代码
const int MAXN = 1005, MAXM = MAXN * MAXN;
int n, m;
char graph[MAXN][MAXN];
int dis[MAXN][MAXN];
void bfs() {
memset(dis, -1, so(dis));
qii que;
for (int i = 1; i <= n; i++) { // 将A中所有1的位置入队
for (int j = 1; j <= m; j++) {
if (graph[i][j] == '1') {
dis[i][j] = 0;
que.push({ i,j });
}
}
}
while (que.size()) {
pii tmp = que.front(); que.pop();
for (int i = 0; i < 4; i++) {
int curx = tmp.first + dx[i], cury = tmp.second + dy[i];
if (curx < 1 || curx > n || cury < 1 || cury > m) continue;
if (~dis[curx][cury]) continue;
dis[curx][cury] = dis[tmp.first][tmp.second] + 1;
que.push({ curx,cury });
}
}
}
int main() {
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> graph[i] + 1;
bfs();
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++) cout << dis[i][j] << " \n"[j == m];
}
14.6 BFS的最小步数模型
14.6 魔板
题意
1 2 3 4
8 7 6 5
上图是一张有 8 8 8个大小相同的格子的魔板,魔板的每个格子有一种颜色,这 8 8 8种颜色用 1 ∼ 8 1\sim 8 1∼8的整数表示.用颜色序列表示魔板的一种状态,规定从魔板左上角开始,沿顺时针方向取出整数,构成一个颜色序列,如上图的颜色序列为 { 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 } \{1,2,3,4,5,6,7,8\} {1,2,3,4,5,6,7,8},这是基本状态.
现有三种操作,可通过这些操作改变魔板状态:
A:交换上下两行.如初始时的魔板经一次A操作变为:
8 7 6 5
1 2 3 4
B:将最右边的一列插入到最左边.
4 1 2 3
5 8 7 6
C:对中间四个数顺时针旋转.
1 7 2 4
8 6 3 5
现给定魔板的特殊状态,求一个操作序列将其转化为基本状态,数据保证有解.
第一行输入 8 8 8个整数(数的范围 [ 1 , 8 ] [1,8] [1,8]),表示目标状态.
第一行输出一个整数,表示最短操作序列的长度 l e n len len.若 l e n > 0 len>0 len>0,第二行输入字典序最小的操作序列.
思路
可用哈希表存已经搜到过的状态.
BFS入队时,按做操作A、B、C依次入队即可保证答案字典序最小.
代码
char board[2][4]; // 魔板
umap<string, pair<char, string>> pre; // 记录每个状态的前驱和经过的操作
umap<string, int> dis;
void set_board(string state) { // 将魔板更新为state对应的状态
for (int i = 0; i < 4; i++) board[0][i] = state[i];
for (int i = 7, j = 0; j < 4; i--, j++) board[1][j] = state[i];
}
string get_board() {
string res;
for (int i = 0; i < 4; i++) res += board[0][i];
for (int i = 3; i >= 0; i--) res += board[1][i];
return res;
}
string opA(string state) {
set_board(state);
for (int i = 0; i < 4; i++) swap(board[0][i], board[1][i]);
return get_board();
}
string opB(string state) {
set_board(state);
int tmp0 = board[0][3], tmp1 = board[1][3];
for (int i = 3; i >= 0; i--) board[0][i] = board[0][i - 1], board[1][i] = board[1][i - 1];
board[0][0] = tmp0, board[1][0] = tmp1;
return get_board();
}
string opC(string state) {
set_board(state);
int tmp = board[0][1];
board[0][1] = board[1][1], board[1][1] = board[1][2], board[1][2] = board[0][2], board[0][2] = tmp;
return get_board();
}
int bfs(string start, string end) {
if (start == end) return 0;
queue<string> que;
que.push(start);
dis[start] = 0;
while (que.size()) {
auto tmp = que.front(); que.pop();
string cur[3] = { opA(tmp),opB(tmp),opC(tmp) };
for (int i = 0; i < 3; i++) {
if (!dis.count(cur[i])) {
dis[cur[i]] = dis[tmp] + 1;
pre[cur[i]] = { 'A' + i,tmp };
if (cur[i] == end) return dis[end];
que.push(cur[i]);
}
}
}
}
int main() {
string start = "12345678";
string end;
for (int i = 0; i < 8; i++) {
int x; cin >> x;
end.push_back(x + '0');
}
int step = bfs(start, end);
cout << step << endl;
if (step) {
string ans;
while (start != end) {
ans += pre[end].first;
end = pre[end].second;
}
reverse(all(ans));
cout << ans;
}
}
14.7 双端队列BFS
14.7.1 电路维修
题意
如上图,电路板的整体结构是一个 r × c r\times c r×c的网格,每个格点都是电线的接点,每个格子包含一个电子元件.电子元件的主要部分是一个可旋转的、连接一条对角线上的两端点的短电缆,旋转后它可连接另一对角线的两接点.电路板左上角的接点接入直流电源,右下角的接点接入飞行车的发动装置.初始时,电路可能处于断路状态.现需旋转最少的元件使得电源与发动装置接通.注意电流只能走斜向的线段.
有 t ( 1 ≤ t ≤ 5 ) t\ \ (1\leq t\leq 5) t (1≤t≤5)组测试数据.每组测试数据第一行输入整数 r , c ( 1 ≤ r , c ≤ 500 ) r,c\ \ (1\leq r,c\leq 500) r,c (1≤r,c≤500),表示电路板的行数和列数.之后输入一个 r × c r\times c r×c的字符为’/‘或’\'的字符矩阵,表示初始时元件的方向.
对每组测试数据,若能通过旋转使得电路接通,则输出最小的旋转次数;否则输出"NO SOLUTION".
思路
将接点视为图中的节点,无需旋转元件即可相连的节点间边权为 0 0 0,否则边权为 1 1 1.可建图后用Dijkstra跑最短路.
因只能沿斜线走,则每走一步横纵坐标同时变化 1 1 1,则起点的横纵坐标之和与终点的横纵坐标之和的奇偶性不同时无解.
注意到上图中红色圈出的节点不可到达,每个格子内只有对角线上的点能到达,同一条边上的点不能到达(因方案中每个元件的角度固定,不能同时用到两组对角线上的点).
对边权为 0 0 0或 1 1 1的图,可用双端队列BFS,即考察当前节点扩展出来的边的边权,若边权为 0 0 0,则插入队首;否则插入队尾.注意每个点可能不止入队一次,则每个点出队时才能确定最小值.
代码
const int MAXN = 505, MAXM = MAXN * MAXN;
int n, m;
char graph[MAXN][MAXN];
int dis[MAXN][MAXN];
bool vis[MAXN][MAXN];
int bfs() {
memset(vis, 0, so(vis));
memset(dis, INF, so(dis));
dis[0][0] = 0;
deque<pii> que;
que.push_back({ 0,0 });
const char str[] = "\\/\\/"; // 注意\是转义字符,故要2个
const int dx[4] = { -1,-1,1,1 }, dy[4] = { -1,1,1,-1 }; // 左上、左下、右下、右上的偏移量
const int ix[4] = { -1,-1,0,0 }, iy[4] = { -1,0,0,-1 }; // 格点到graph[][]的偏移量
while (que.size()) {
auto tmp = que.front(); que.pop_front();
int x = tmp.first, y = tmp.second;
if (x == n && y == m) return dis[x][y]; // 每个点出队时才能确定最小值
if (vis[x][y]) continue;
vis[x][y] = true;
for (int i = 0; i < 4; i++) {
int curx = x + dx[i], cury = y + dy[i];
if (curx < 0 || curx > n || cury < 0 || cury > m) continue;
int gx = x + ix[i], gy = y + iy[i]; // 对应到graph[][]中的坐标
int w = graph[gx][gy] != str[i]; // 无需旋转边权为0,否则边权为1
int d = dis[x][y] + w;
if (d <= dis[curx][cury]) {
dis[curx][cury] = d;
if (!w) que.push_front({ curx,cury });
else que.push_back({ curx,cury });
}
}
}
}
int main() {
CaseT{
cin >> n >> m;
for (int i = 0; i < n; i++) cin >> graph[i];
if (n + m & 1) cout << "NO SOLUTION" << endl;
else cout << bfs() << endl;
}
}
14.8 双向BFS
双向BFS常用于优化BFS的最小步数模型,一般不用于BFS的最短路模型.
14.8.1 字串变换
题意
已知有两字串 A A A、 B B B和一组(至多 6 6 6个)字串变换规则: A 1 → B 1 , A 2 → B 2 , ⋯ A_1\rightarrow B_1,A_2\rightarrow B_2,\cdots A1→B1,A2→B2,⋯,表示 A A A中子串 A 1 A_1 A1可变换为 B 1 B_1 B1,子串 A 2 A_2 A2可变换为 B 2 , ⋯ B_2,\cdots B2,⋯.
第一行输入两给定字符串 A A A和 B B B.接下来若干行每行输入两个字符串 A i A_i Ai和 B i B_i Bi描述一组字串变换规则.数据保证所有字符串长度不超过 20 20 20.
若在 10 10 10步内(含 10 10 10步)能将 A A A变换为 B B B,则删除最小变换步数;否则输出"NO ANSWER!".
思路
最坏情况下每个字符串长度都为 20 20 20且都能应用 6 6 6个规则,每个规则都是单个字符替换,则每一步有 20 × 6 = 120 20\times 6=120 20×6=120种情况, 10 10 10步以内共 12 0 10 120^{10} 12010种情况.故朴素BFS从起点搜到终点可能会TLE或MLE.
双向BFS同时从起点和终点往中间搜,若在中间相遇,则有解;否则无解.这样从起点和终点各搜 5 5 5步,共 2 × 6 5 2\times 6^5 2×65种情况.
进一步优化,每次选择元素较少的队列的方向扩展.
代码
const int MAXN = 6;
int n; // 规则数
string A, B;
string str1[MAXN], str2[MAXN]; // 变换规则
// 队列、到起点的距离、到终点的距离、变换规则a→b
int extend(queue<string>& que, umap<string, int>& disA, umap<string, int>& disB, string a[MAXN], string b[MAXN]) {
int d = disA[que.front()];
while (que.size() && disA[que.front()] == d) {
auto tmp = que.front(); que.pop();
for (int i = 0; i < n; i++) { // 枚举变换规则
for (int j = 0; j < tmp.size(); j++) { // 枚举应用变化规则的起点
if (tmp.substr(j, a[i].size()) == a[i]) {
string state = tmp.substr(0, j) + b[i] + tmp.substr(j + a[i].size());
if (disB.count(state)) return disA[tmp] + disB[state] + 1; // 相遇
if (disA.count(state)) continue;
disA[state] = disA[tmp] + 1;
que.push(state);
}
}
}
}
return 11; // 返回一个比10大的数
}
int bfs() {
if (A == B) return 0;
queue<string> queA, queB; // 从起点、终点开始搜索的队列
umap<string, int> disA, disB; // 与起点、终点的距离
queA.push(A), disA[A] = 0, queB.push(B), disB[B] = 0;
while (queA.size() && queB.size()) {
int step = queA.size() <= queB.size() ? // 选择元素较少的队列的方向扩展
extend(queA, disA, disB, str1, str2) : extend(queB, disB, disA, str2, str1); // 注意后者规则反着用
if (step <= 10) return step;
if (++step == 10) return 11; // 返回一个比10大的数
}
return 11; // 返回一个比10大的数
}
int main() {
cin >> A >> B;
while (cin >> str1[n] >> str2[n])n++;
int ans = bfs();
cout << (ans <= 10 ? to_string(ans) : "NO ANSWER!");
}
14.9 A*
A*算法类似于Dijkstra算法,是对BFS的优化.朴素BFS中直接从起点搜到终点可能会经过很多状态,而A*算法中加入启发函数(估价函数),使得只需搜较少的状态即可找到从起点到终点的一条最短路.A*算法只在搜索空间很大时才有明显的优化效果.A*算法和Dijkstra算法都能解决边权非负的图中的最短路.Dijkstra算法可看作从每个点到终点的估计距离都是 0 0 0的最短路.
A*算法将BFS中的队列换为小根堆,队列中不仅存起点到当前点的真实距离,还存该点到终点的估计距离.每次选择与终点的估计距离最小的点扩展.
A*算法中每个点可能会被扩展多少次.
①BFS入队时判重;②Dijkstra算法出队时判重;③A*算法不判重.
A*算法的成立条件:估计距离$\leq $真实距离.
A*算法的应用场景:确定有解.若无解时,A*算法会将整个搜索空间都搜索一遍,效率低于朴素BFS.
但实际应用中未必知道是否有解,也可用A*算法,大部分时候比朴素BFS快.
当终点第一次出队时已确定最短距离.
[证] 设终点 u u u当前出队,此时 u u u与起点的距离为 d ( u ) d(u) d(u).
设起点到 u u u的真实距离为 d i s ( u ) dis(u) dis(u), u u u到终点的估计距离和真实距离分别为 f ( u ) f(u) f(u)和 g ( u ) g(u) g(u).
设真实的最短路径为 d d d,则 d = d i s ( u ) + g ( u ) ≥ d i s ( u ) + f ( u ) d=dis(u)+g(u)\geq dis(u)+f(u) d=dis(u)+g(u)≥dis(u)+f(u).
若 u u u出队时不是最短距离,则 d ( u ) > d ≥ d i s ( u ) + f ( u ) d(u)>d\geq dis(u)+f(u) d(u)>d≥dis(u)+f(u),且是最短距离的节点在队列中.
这表明:当前出队的不是队列中的最小值,矛盾.
[注] A*算法只能保证终点出队时是最短距离,不能保证其他节点.
14.9.1 八数码
题意
在 3 × 3 3\times 3 3×3的网格中不重不漏地填入 1 ∼ 8 1\sim 8 1∼8这 8 8 8个数字,空位用’X’表示.游戏过程中,可将X与其上、下、左、右四个方向之一的数字交换(若存在),目标是通过交换将初始网格变为如下形式(称为正确排列):
1 2 3
4 5 6
7 8 X
将X与其上、下、左、右四个方向的数字交换分别记作u、d、l、r.现给定一个初始网格,求通过最少的移动次数将其变为正确排列的交换序列,若有多种方案,输出任一种;若无方案,输出"unsolvable".
用一个字符串描述 3 × 3 3\times 3 3×3的初始网格.若初始网格为:
1 2 3
x 4 6
7 5 8
则输入 1 2 3 x 4 6 7 5 8 1\ 2\ 3\ x\ 4\ 6\ 7\ 5\ 8 1 2 3 x 4 6 7 5 8.
思路
八数码问题有解的充要条件:初始序列的逆序对数量是偶数.
[证] (充) 较难,略.
(必) 显然左右交换不改变逆序对数量,上下交换只会改变两对逆序对,故初始序列的逆序对数与正确排列的逆序对数同奇偶.
注意到最优解中每次交换可使得交换的数与其目标位置的Manhattan距离减 1 1 1,估价函数取每个数码与其目标位置的Manhattan距离之和.
代码
int f(string state) { // 估价函数
int res = 0;
for (int i = 0; i < state.size(); i++) {
if (state[i] != 'x') {
int tmp = state[i] - '1';
res += abs(i / 3 - tmp / 3) + abs(i % 3 - tmp % 3);
}
}
return res;
}
string bfs(string start) {
const int dx[4] = { -1,0,1,0 }, dy[4] = { 0,1,0,-1 };
const char op[] = "urdl"; // 与上面的偏移量对应
string end = "12345678x";
umap<string, int> dis;
umap<string, pair<string, char>> pre; // 记录前驱和操作
priority_queue<pair<int, string>, vector<pair<int, string>>, greater<pair<int, string>>> heap;
heap.push({ f(start),start });
dis[start] = 0;
while (heap.size()) {
auto tmp = heap.top(); heap.pop();
string state = tmp.second;
if (state == end) break;
int x, y; // x的位置
for (int i = 0; i < state.size(); i++) {
if (state[i] == 'x') {
x = i / 3, y = i % 3;
break;
}
}
int step = dis[state];
string backup = state;
for (int i = 0; i < 4; i++) {
int curx = x + dx[i], cury = y + dy[i];
if (curx < 0 || curx >= 3 || cury < 0 || cury >= 3) continue;
swap(state[x * 3 + y], state[curx * 3 + cury]);
if (!dis.count(state) || dis[state] > step + 1) {
dis[state] = step + 1;
pre[state] = { backup,op[i] };
heap.push({ dis[state] + f(state),state });
}
swap(state[x * 3 + y], state[curx * 3 + cury]); // 恢复现场
}
}
string res;
while (end != start) {
res += pre[end].second;
end = pre[end].first;
}
reverse(all(res));
return res;
}
int main() {
string s1, s2; // s1为原序列,s2为去掉x的序列
char c;
while (cin >> c) {
s1.push_back(c);
if (c != 'x') s2.push_back(c);
}
int cnt = 0; // 逆序对个数
for (int i = 0; i < s2.size(); i++)
for (int j = i + 1; j < s2.size(); j++)
if (s2[i] > s2[j]) cnt++;
if (cnt & 1) cout << "unsolvable";
else cout << bfs(s1);
}
14.9.2 第k短路
题意
给定一个包含 n n n个点(编号 1 ∼ n 1\sim n 1∼n)、 m m m条边的有向图,求起点 S S S到终点 T T T的第 k k k短路的长度,路径允许重复经过点或边.要求每条最短路中至少包含一条边,即 S = T S=T S=T时, k + + k++ k++.
第一行输入整数 n , m ( 1 ≤ n ≤ 1000 , 0 ≤ m ≤ 1 e 4 ) n,m\ \ (1\leq n\leq 1000,0\leq m\leq 1\mathrm{e}4) n,m (1≤n≤1000,0≤m≤1e4).接下来 m m m行每行输入三个整数 u , v , w ( 1 ≤ u , v ≤ n , 1 ≤ l ≤ 100 ) u,v,w\ \ (1\leq u,v\leq n,1\leq l\leq 100) u,v,w (1≤u,v≤n,1≤l≤100).最后一行输入三个整数 S , T , k ( 1 ≤ S , T ≤ n , 1 ≤ k ≤ 100 ) S,T,k\ \ (1\leq S,T\leq n,1\leq k\leq 100) S,T,k (1≤S,T≤n,1≤k≤100),表示求起点 S S S到终点 T T T的第 k k k短路.
若最短路存在,输出最短路长度;否则输出 − 1 -1 −1.
思路
与最短路问题不同,扩展时应将当前点能扩展到的所有点都入队.显然路径中存在环时,起点到终点的路径可能有无数条,故搜索空间很大,考虑A*算法.
估价函数可取每个点到终点的最短距离,该距离可通过以终点为起点跑一遍Dijksta算法得到.
A*算法中当终点出队时确定最短路,一直出队至第 k k k次即第 k k k短路.
[证] 仿照终点出队时确定最短路的方法即证.
代码
const int MAXN = 1005, MAXM = 2e5 + 5; // 两倍边
int n, m; // 点数、边数
int S, T, k; // 起点S到终点T的第k短路
int head[MAXN], rhead[MAXN], edges[MAXM], w[MAXM], nxt[MAXM], idx = 0; // head[]为正向边的头节点,rhead[]为反向边的头节点
int dis[MAXN];
bool vis[MAXN];
int cnt[MAXN]; // cnt[u]表示节点u出队的次数
void add(int h[], int a, int b, int c) {
edges[idx] = b, w[idx] = c, nxt[idx] = h[a], h[a] = idx++;
}
void dijkstra() {
priority_queue<pii, vii, greater<pii>> heap;
heap.push({ 0,T }); // 终点
memset(dis, INF, so(dis));
dis[T] = 0;
while (heap.size()) {
auto tmp = heap.top(); heap.pop();
int u = tmp.second;
if (vis[u]) continue;
vis[u] = true;
for (int i = rhead[u]; ~i; i = nxt[i]) {
int v = edges[i];
if (dis[v] > dis[u] + w[i]) {
dis[v] = dis[u] + w[i];
heap.push({ dis[v],v });
}
}
}
}
int Astar() {
priority_queue<tiii, vector<tiii>, greater<tiii>> heap; // 估价距离、真实距离、节点编号
heap.push({ dis[S],0,S });
while (heap.size()) {
auto tmp = heap.top(); heap.pop();
int d, u; // 真实距离、节点编号
tie(ignore, d, u) = tmp;
cnt[u]++;
if (cnt[T] == k) return d; // 终点出队k次即找到第k短路
for (int i = head[u]; ~i; i = nxt[i]) {
int v = edges[i];
if (cnt[v] < k) heap.push({ d + w[i] + dis[v],d + w[i],v }); // 出队不足k次才扩展
}
}
return -1; // 无解
}
int main() {
memset(head, -1, so(head)), memset(rhead, -1, so(rhead));
cin >> n >> m;
for (int i = 0; i < m; i++) {
int a, b, c; cin >> a >> b >> c;
add(head, a, b, c), add(rhead, b, a, c); // 分别建正向边和反向边
}
cin >> S >> T >> k;
if (S == T) k++; // 最短路至少包含一条边
dijkstra(); // 求终点到每个节点的最短距离
cout << Astar();
}