双向搜索

如果问题不但具有“初态”,还具有明确的“终态”,并且从初态开始搜索与从终态开始逆向搜索产生的搜索范围能覆盖整个问题的状态空间,在这种情况下,可以采用双向搜索——从初态和终态出发各搜索一半状态,使得两边的搜索深度减半,在中间交会、组合成最终的答案

image

双向 DFS

例题:P10484 送礼物

这个问题的直接解法就是进行“指数型”的枚举——搜索每个礼物选还是不选,时间复杂度为 \(O(2^N)\)。当然,若搜索过程中已选的礼物重量之和已经大于 \(W\),可以及时剪枝。

但是此题 \(N \le 45\)\(2^{45}\) 的计算量很大。这时就可以利用双向搜索的思想,把礼物分成两半。

首先,搜索出从前一半礼物中选出若干个,可能达到的 \(0 \sim W\) 之间的所有重量值,存放在一个数组中,并对数组进行排序、去重。

然后,进行第二次搜索,尝试从后一半礼物中选出一些。对于每个可能达到的重量值 \(w\),在第一部分得到的数组中二分查找 \(\le W-w\) 的数值中最大的一个,用二者的和更新答案。

这个算法的时间复杂度就只有 \(O(2^{N/2} \log 2^{N/2}) = O(N \cdot 2^{N/2})\) 了,还可以加入一些优化,进一步提高算法的效率,比如把礼物按照重量降序排序后再分半、搜索。

参考代码
#include <cstdio>
#include <algorithm>
#include <functional>
#include <vector>
using namespace std;
int g[50], mid, n, maxw, ans;
vector<int> w;
// 第一阶段 DFS:搜索前 mid 个物品的所有可能重量组合
void dfs1(int u, int sum) {
    if (u == mid) {
        w.push_back(sum);
        return;
    }
    // 不选当前物品
    dfs1(u + 1, sum);
    // 选当前物品(需判断是否超过 maxw)
    if (sum <= maxw - g[u]) {
        dfs1(u + 1, sum + g[u]);
    }
}
// 第二阶段 DFS:搜索剩余物品,并结合第一阶段结果更新最大值
void dfs2(int u, int sum) {
    if (u == n) {
        // 二分查找第一个使得 w[i] + sum <= maxw 的最大 w[i]
        int t = maxw - sum;
        auto it = upper_bound(w.begin(), w.end(), t);
        if (it != w.begin()) {
            it--;
            ans = max(ans, sum + *it);
        }
        return;
    }
    // 不选
    dfs2(u + 1, sum);
    // 选
    if (sum <= maxw - g[u]) {
        dfs2(u + 1, sum + g[u]);
    }
}
int main()
{
    scanf("%d%d", &maxw, &n);
    for (int i = 0; i < n; i++) scanf("%d", &g[i]);
    // 优化:从大到小排序,优化搜索效率
    sort(g, g + n, greater<int>());
    mid = n / 2;
    // 执行第一阶段
    dfs1(0, 0);
    // 对结果排序并去重,方便二分查找
    sort(w.begin(), w.end());
    w.erase(unique(w.begin(), w.end()), w.end());
    ans = 0;
    // 执行第二阶段
    dfs2(mid, 0);
    printf("%d\n", ans);
    return 0;
}

习题:P4799 [CEOI 2015] 世界冰球锦标赛 (Day2)

解题思路

类似于 P10484 送礼物,采用双向搜索,区别是本题求的是方案数,因此对一半比赛的票价组合搜索完成后只排序,不能去重。

参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
using ll = long long;
ll m, p[45];
vector<ll> v1, v2;
/**
 * DFS 搜索函数
 * @param l 当前处理的起始下标
 * @param r 当前处理的结束下标
 * @param sum 当前累计的票价和
 * @param v 存储结果的数组
 */
void dfs(int l, int r, ll sum, vector<ll> &v) {
    if (l > r) {
        v.push_back(sum);
        return;
    }
    // 不选第 l 场
    dfs(l + 1, r, sum, v);
    // 选第 l 场
    if (sum + p[l] <= m) {
        dfs(l + 1, r, sum + p[l], v);
    }
}
int main()
{
    int n;
    scanf("%d%lld", &n, &m);
    for (int i = 1; i <= n; i++) {
        scanf("%lld", &p[i]);
    }
    // 将 n 分为两部分进行双向搜索
    int mid = n / 2;
    // 第一阶段:搜索前一半
    dfs(1, mid, 0, v1);
    sort(v1.begin(), v1.end());
    // 第二阶段:搜索后一半
    dfs(mid + 1, n, 0, v2);
    ll ans = 0;
    // 结合第一阶段结果进行二分统计
    for (ll x : v2) {
        ll r = m - x;
        // 查找 v1 中小于等于 r 的元素个数
        ans += upper_bound(v1.begin(), v1.end(), r) - v1.begin();
    }
    printf("%lld\n", ans);
    return 0;
}

双向 BFS

从起始状态、目标状态分别开始,两边轮流进行,每次各扩展一整层。当两边各自有一个状态在记录数组中发生重复时,就说明这两个搜索过程相遇了,可以合并得出起点到终点的最少步数。

例题:P10487 Nightmare II

使用双向 BFS 算法,建立两个队列,分别从男孩的初始位置、女孩的初始位置开始进行 BFS,两边轮流进行。

在每一轮中,男孩这边 BFS 三层(可以移动三步),女孩这边 BFS 一层(可以移动一步),使用数组记录每个位置对于男孩和女孩的可达性。

当然,在 BFS 的每次扩展时,注意实时计算状态与鬼之间的曼哈顿距离,及时排除不合法的状态。

在 BFS 的过程中,第一次出现某个位置既能被男孩到达,也能被女孩到达时,当前轮数就是两人的最短会合时间。

参考代码
#include <cstdio>
#include <queue>
#include <cmath>
using namespace std;
const int N = 805;
const int dr[] = {-1, 1, 0, 0};
const int dc[] = {0, 0, -1, 1};
struct Node {
    int r, c;
};
int n, m;
char g[N][N];
bool vis[2][N][N]; // 0: Boy (M), 1: Girl (G)
Node gh[2];
bool safe(int r, int c, int t) {
    if (r < 0 || r >= n || c < 0 || c >= m || g[r][c] == 'X') return false;
    for (int i = 0; i < 2; i++) {
        if (abs(r - gh[i].r) + abs(c - gh[i].c) <= 2 * t) return false;
    }
    return true;
}
bool expand(queue<Node>& q, int step, int t, int self, int other) {
    for (int s = 0; s < step; s++) {
        int sz = q.size();
        while (sz--) {
            Node cur = q.front();
            q.pop();
            // 如果当前点已经被鬼魂覆盖,该路径废弃
            if (!safe(cur.r, cur.c, t)) continue;
            for (int i = 0; i < 4; i++) {
                int nr = cur.r + dr[i];
                int nc = cur.c + dc[i];
                if (safe(nr, nc, t) && !vis[self][nr][nc]) {
                    if (vis[other][nr][nc]) return true; // 相遇
                    vis[self][nr][nc] = true;
                    q.push({nr, nc});
                }
            }
        }
    }
    return false;
}
void solve() {
    scanf("%d%d", &n, &m);
    queue<Node> qm, qg;
    int cnt = 0;
    for (int i = 0; i < n; i++) {
        scanf("%s", g[i]);
        for (int j = 0; j < m; j++) {
            vis[0][i][j] = vis[1][i][j] = false;
            if (g[i][j] == 'M') {
                qm.push({i, j});
                vis[0][i][j] = true;
            } else if (g[i][j] == 'G') {
                qg.push({i, j});
                vis[1][i][j] = true;
            } else if (g[i][j] == 'Z') {
                gh[cnt++] = {i, j};
            }
        }
    }
    int t = 0;
    while (!qm.empty() && !qg.empty()) {
        t++;
        // 扩展男孩 3 步,女孩 1 步
        if (expand(qm, 3, t, 0, 1) || expand(qg, 1, t, 1, 0)) {
            printf("%d\n", t);
            return;
        }
    }
    printf("-1\n");
}
int main()
{
    int t; scanf("%d", &t);
    while (t--) {
        solve();
    }
    return 0;
}

习题:P1032 [NOIP 2002 提高组] 字串变换

解题思路

起点 \(A\) 和终点 \(B\) 都是已知的,可以使用双向 BFS。

使用 map<string, int> 来记录已经搜索过的字符串及其对应的步数。

参考代码
#include <iostream>
#include <string>
#include <queue>
#include <map>
using namespace std;
// 存储变换规则,ra 为原串,rb 为目标串
string ra[7], rb[7];
int n; // 规则计数
/**
 * 扩展一层搜索空间
 * @param q 当前搜索方向的队列
 * @param m1 当前方向的访问标记及步数统计
 * @param m2 另一个搜索方向的访问记录
 * @param a 变换规则的原串数组
 * @param b 变换规则的目标串数组
 * @return 如果找到相遇点,返回总步数;否则返回 -1
 */
int expand(queue<string>& q, map<string, int>& m1, map<string, int>& m2, string a[], string b[]) {
    int sz = q.size();
    // 每次处理当前层级的所有状态
    while (sz--) {
        string t = q.front();
        q.pop();
        for (int i = 0; i < n; i++) {
            // 在当前字串中寻找可匹配规则的子串位置
            int pos = t.find(a[i]);
            while (pos != -1) {
                string s = t;
                // 执行字串替换变换
                s.replace(pos, a[i].length(), b[i]);
                // 如果变换后的字串在另一个搜索方向已经出现过,说明路径对接成功
                if (m2.count(s) && m1[t] + 1 + m2[s] <= 10) {
                    return m1[t] + 1 + m2[s];
                }
                // 如果该字串未在当前方向访问过,则记录步数并入队
                if (!m1.count(s)) {
                    m1[s] = m1[t] + 1;
                    q.push(s);
                }
                // 继续寻找该规则在同一字串中的下一个匹配位置
                pos = t.find(a[i], pos + 1);
            }
        }
    }
    return -1;
}
int main()
{
    string a, b;
    // 读入起点串和目标串
    cin >> a >> b;
    n = 0;
    // 循环读入变换规则
    while (cin >> ra[n] >> rb[n]) n++;
    // 特判:起点和终点相同
    if (a == b) {
        cout << "0\n";
        return 0;
    }
    // q1, m1: 从起点向终点搜;q2, m2: 从终点向起点搜
    queue<string> q1, q2;
    map<string, int> m1, m2;
    q1.push(a); m1[a] = 0;
    q2.push(b); m2[b] = 0;
    int step = 0;
    // 双向 BFS 主循环
    while (!q1.empty() && !q2.empty()) {
        int res = expand(q1, m1, m2, ra, rb);
        if (res != -1 && res <= 10) {
            printf("%d\n", res);
            return 0;
        }
        res = expand(q2, m2, m1, rb, ra);
        if (res != -1 && res <= 10) {
            printf("%d\n", res);
            return 0;
        }
        step++;
        if (step >= 5) break;
    } 
    // 无法在 10 步内完成变换
    cout << "NO ANSWER!\n";
    return 0;
}

习题:P2324 [SCOI2005] 骑士精神

解题思路

由于目标状态固定且步数限制较小,使用 双向 BFS 可以显著降低搜索空间。

目标状态开始,进行 BFS 搜索 8 步,并将搜索到的所有状态及其步数存储下来。对于每组输入的初始状态,从初始状态开始正向搜索最多 7 步。在正向搜索过程中,如果某个状态已经存在于反向搜索的记录中,则两个步数和为答案。

由于棋盘只有 25 个格子,且每个格子状态简单,可以将棋盘掩码与空位置坐标组合成一个整数来表示唯一状态,节省空间并提高存取效率。用 25 位二进制数表示骑士颜色(每一位 0/1),用 5 位表示空位索引(0 到 24)。

参考代码
#include <cstdio>
#include <queue>
#include <unordered_map>
using namespace std;
// 骑士(马)的 8 个移动方向
const int dx[] = {-2, -2, -1, -1, 1, 1, 2, 2};
const int dy[] = {-1, 1, -2, 2, -2, 2, -1, 1};
// 存储从目标状态出发搜索到的状态及其步数
// key: 压缩后的状态 (mask + 空位坐标), value: 步数
unordered_map<int, int> dis;
/**
 * 将目标棋盘状态压缩为一个整数
 * 状态表示:(棋盘位掩码 << 5) | 空格索引
 * 12 个黑色骑士为 1,12 个白色骑士为 0,'*' 为空位
 */
int calc() {
    int t[5][5] = {
        {1, 1, 1, 1, 1},
        {0, 1, 1, 1, 1},
        {0, 0, 0, 1, 1}, // 目标状态中心是空位
        {0, 0, 0, 0, 1},
        {0, 0, 0, 0, 0}
    };
    int mask = 0;
    for (int i = 0; i < 5; i++)
        for (int j = 0; j < 5; j++)
            if (t[i][j] == 1) mask |= (1 << (i * 5 + j));
    return (mask << 5) | 12; // 空位在索引 12 (坐标 2,2)
}
// 预处理:从目标状态开始反向 BFS 搜索 8 步
void precompute() {
    int start = calc();
    queue<int> q;
    q.push(start);
    dis[start] = 0;
    while (!q.empty()) {
        int cur = q.front();
        int d = dis[cur];
        q.pop();
        if (d >= 8) continue; // 限制反向搜索深度为 8
        int mask = cur >> 5, pos = cur & 31;
        int x = pos / 5, y = pos % 5;
        for (int i = 0; i < 8; i++) {
            int nx = x + dx[i], ny = y + dy[i];
            if (nx >= 0 && nx < 5 && ny >= 0 && ny < 5) {
                int npos = nx * 5 + ny, nmask = mask;
                // 交换空格和骑士的位置,生成新 mask
                if ((mask >> npos) & 1) {
                    nmask |= (1 << pos);
                    nmask ^= (1 << npos);
                }
                int nxt = (nmask << 5) | npos;
                if (dis.find(nxt) == dis.end()) {
                    dis[nxt] = d + 1;
                    q.push(nxt);
                }
            }
        }
    }
}
// 处理单组测试数据:从初始状态正向搜索 7 步
int solve() {
    int mask = 0, pos = -1;
    char s[6];
    for (int i = 0; i < 5; i++) {
        scanf("%s", s);
        for (int j = 0; j < 5; j++) {
            if (s[j] == '*') pos = i * 5 + j;
            else if (s[j] == '1') mask |= (1 << (i * 5 + j));
        }
    }
    int start = (mask << 5) | pos;
    // 如果初始状态就在预处理的 8 步范围内
    if (dis.count(start)) return dis[start];
    unordered_map<int, int> vis;
    queue<int> q;
    q.push(start);
    vis[start] = 0;
    while (!q.empty()) {
        int cur = q.front();
        int d = vis[cur];
        q.pop();
        if (d >= 7) continue; // 限制正向搜索深度为 7
        int mask = cur >> 5, pos = cur & 31;
        int x = pos / 5, y = pos % 5;
        for (int i = 0; i < 8; i++) {
            int nx = x + dx[i], ny = y + dy[i];
            if (nx >= 0 && nx < 5 && ny >= 0 && ny < 5) {
                int npos = nx * 5 + ny, nmask = mask;
                if ((mask >> npos) & 1) {
                    nmask |= (1 << pos);
                    nmask ^= (1 << npos);
                }
                int nxt = (nmask << 5) | npos;
                if (vis.find(nxt) == vis.end()) {
                    // 检查是否与反向搜索的结果相遇
                    if (dis.count(nxt)) return d + 1 + dis[nxt];
                    q.push(nxt); vis[nxt] = d + 1;
                }
            }
        }
    }
    return -1;
}
int main()
{
    precompute();
    int t; scanf("%d", &t);
    while (t--) printf("%d\n", solve());
    return 0;
}

习题:P5507 机关

12 个旋钮,每个旋钮有 4 个状态。旋转一个旋钮会带动另一个旋钮,目标是求出将所有旋钮调至状态 1 的最少步数及操作序列。

解题思路

由于每个旋钮只有 4 个状态,可以用 2 个二进制位来存储一个旋钮的状态。12 个旋钮总共需要 24 位二进制,可以用一个 int 来存储。

初始状态和目标状态都是已知的,可以使用双向 BFS。

参考代码
#include <cstdio>
#include <queue>
#include <vector>
#include <algorithm>
using namespace std;
// 12个旋钮,每个旋钮4个状态,共 4^12 = 2^24 种状态
const int S = 1 << 24;
int start, r[12][4], d[S], prem[S], pres[S];
// 获取第 i 个旋钮的当前状态 (0-3)
int get(int s, int i) {
    return (s >> (i * 2)) & 3;
}
// 修改第 i 个旋钮的状态为 v
int put(int s, int i, int v) {
    return (s & ~(3 << (i * 2))) | (v << (i * 2));
}
// 正向旋转:旋转旋钮 i,并根据当前状态带动另一个旋钮 t 旋转
int rotate1(int s, int i) {
    int v = get(s, i);
    int t = r[i][v];
    int nv = (v + 1) & 3;
    s = put(s, i, nv);
    int tv = (get(s, t) + 1) & 3;
    s = put(s, t, tv);
    return s;
}
// 逆向旋转:已知旋转后的状态 s,推测旋转旋钮 i 前的状态
int rotate2(int s, int i) {
    int v = (get(s, i) + 3) & 3; // 逆向推导 i 旋转前的状态
    int t = r[i][v];
    int pv = (get(s, t) + 3) & 3; // 逆向推导被带动旋钮 t 旋转前的状态
    s = put(s, i, v);
    s = put(s, t, pv);
    return s;
}
// 输出路径:结合正向和反向搜索的结果
void output(int u, int v, int i) {
    vector<int> res;
    // 从正向相遇点回溯到起点
    int t = u;
    while (t != start) {
        res.push_back(prem[t] + 1);
        t = pres[t];
    }
    reverse(res.begin(), res.end());
    // 加入相遇时的那一步操作
    res.push_back(i + 1);
    // 从反向相遇点回溯到终点(0)
    t = v;
    while (t != 0) {
        res.push_back(prem[t] + 1);
        t = pres[t];
    }
    int step = res.size();
    printf("%d\n", step);
    for (int mv : res) {
        printf("%d ", mv);
    }    
}
int main()
{
    start = 0;
    for (int i = 0; i < 12; i++) {
        int s;
        scanf("%d", &s);
        start |= ((s - 1) << (i * 2));
        for (int j = 0; j < 4; j++) {
            scanf("%d", &r[i][j]);
            r[i][j]--; // 旋钮编号转为 0-11
        }
    }
    if (start == 0) {
        printf("0\n");
        return 0;
    }
    queue<int> q1, q2;
    q1.push(start);
    d[start] = 1; // 正数为正向搜索标识
    q2.push(0);
    d[0] = -1; // 负数为反向搜索标识
    while (!q1.empty() && !q2.empty()) {
        // 扩展正向搜索
        int sz = q1.size();
        while (sz--) {
            int u = q1.front(); q1.pop();
            for (int i = 0; i < 12; i++) {
                int v = rotate1(u, i);
                if (d[v] < 0) { // 与反向搜索相遇
                    output(u, v, i);
                    return 0;
                }
                if (d[v] == 0) {
                    d[v] = d[u] + 1;
                    pres[v] = u;
                    prem[v] = i;
                    q1.push(v);
                }
            }
        }
        // 扩展反向搜索
        sz = q2.size();
        while (sz--) {
            int v = q2.front(); q2.pop();
            for (int i = 0; i < 12; i++) {
                int u = rotate2(v, i);
                if (d[u] > 0) { // 与正向搜索相遇
                    output(u, v, i);
                    return 0;
                }
                if (d[u] == 0) {
                    d[u] = d[v] - 1;
                    pres[u] = v;
                    prem[u] = i;
                    q2.push(u);
                }
            }
        }
    }
    return 0;
}
posted @ 2026-02-08 11:06  RonChen  阅读(10)  评论(0)    收藏  举报