深入解析:如何用广度优先搜索 (BFS) 解决水壶问题

在算法和逻辑谜题的世界里,有一个经典的问题经常被用来测试我们的搜索算法能力,那就是著名的“水壶问题”。今天,我们不仅要理解这个问题的本质,还要深入探讨如何利用 广度优先搜索(Breadth-First Search, 简称 BFS) 来找到最优解。这篇文章将带你从零开始,一步步构建出完整的解决方案,并优化代码以确保高效运行。

问题陈述:从直觉到算法

想象一下,你站在河边,手里有两个没有任何刻度标记的水壶。第一个水壶的容量是 m 升,第二个是 n 升。你的任务是利用这两个水壶,准确量出 d 升水。听起来很简单,对吧?但问题在于,你无法直接测量水量,你只能执行以下几种基本操作:

  • 装满:将一个水壶装满水直到其最大容量。
  • 清空:将一个水壶里的水全部倒掉。
  • 倒水:将一个水壶的水倒入另一个水壶,直到源水壶空了或者目标水壶满了。

我们的目标是找到实现 d 升水所需的最少操作次数。如果在任何一个水壶中最终正好有 d 升水,我们就成功了。如果无解,我们就返回 -1。

实例演示

让我们通过一个具体的例子来感受一下。假设 INLINECODE40138363,INLINECODEc64b32e1,d = 4。我们该如何得到 4 升水呢?

初始状态是 (0, 0),表示两个水壶都是空的。我们可以这样操作:

  • 装满 5 升水壶:(0, 5)
  • 将 5 升水壶的水倒入 3 升水壶,直到 3 升水壶满:(3, 2)
  • 清空 3 升水壶:(0, 2)
  • 将 5 升水壶中剩余的 2 升水倒入 3 升水壶:(2, 0)(这里注意:原5L壶剩2L,倒入3L壶,5L壶空,3L壶有2L)。
  • 再次装满 5 升水壶:(2, 5)
  • 从 5 升水壶倒入 1 升水到 3 升水壶(因为 3 升水壶已有 2 升,只能再装 1 升):(3, 4)

此时,5 升水壶里正好装了 4 升水。我们用了 6 步解决了问题。而我们的算法,就是要自动寻找出这一条最短的路径。

算法核心:为什么选择广度优先搜索 (BFS)?

在这个问题中,每一个时刻两个水壶中的水量 INLINECODE71d89c51 构成了系统的一个“状态”。我们要解决的核心问题,其实就是在这些状态之间寻找一条路径,从初始状态 INLINECODE030b5f22 到达任意一个 jug 等于 d 的状态。

你可能听说过深度优先搜索(DFS),但在寻找“最少操作次数”或“最短路径”的问题上,BFS 是绝对的王者

通俗理解 BFS

想象一棵决策树。树的根节点是初始状态 (0, 0)。从根节点出发,每一次合法的操作都会生长出一个新的子节点。

  • DFS(深度优先) 会一条路走到黑。它可能先尝试装满 Jug1,然后一直倒来倒去,直到无法操作才回头。如果它运气不好,第一条路可能非常长,甚至绕了很大一圈才找到解,这样效率就很低,而且不能保证这是最短的。
  • BFS(广度优先) 则像水波纹扩散一样。它先检查所有只需要 1 步 就能到达的状态。如果在第 1 层没找到解,它再检查所有需要 2 步 才能到达的状态。

这意味着,只要 BFS 第一次找到了目标状态 d,它所用的步数一定是最少的。因为如果存在更短的路径,BFS 在之前的层级中早就已经找到了。

解决方案设计与代码实现

1. 状态表示与防止死循环

我们用一对整数 INLINECODE62a1dcec 来表示状态。由于水壶的容量是有限的,可能的状态数量也是有限的。具体来说,Jug1 的取值范围是 INLINECODE1cde873c 到 INLINECODE882268df,Jug2 的取值范围是 INLINECODEb0654bcc 到 n

为了防止我们在相同的状态之间无限循环(比如装满一个壶又立刻倒掉),我们需要一个 visited[m+1][n+1] 的布尔矩阵来记录已经访问过的状态。如果某个状态已经访问过,我们就不再将其加入队列。

2. 操作的详细定义

从任意状态 (u, v) 出发,我们有 6 种可能的转移状态:

  • Fill Jug 1: (m, v) —— 把 Jug1 装满。
  • Fill Jug 2: (u, n) —— 把 Jug2 装满。
  • Empty Jug 1: (0, v) —— 把 Jug1 倒空。
  • Empty Jug 2: (u, 0) —— 把 Jug2 倒空。
  • Pour Jug 1 -> Jug 2: 将 Jug1 的水倒入 Jug2。能倒多少?这取决于 Jug1 还有多少水(INLINECODE4ee4eac6)以及 Jug2 还有多少空位(INLINECODE05f194aa)。我们将倒入 INLINECODEbd016cca 升水。新状态为 INLINECODE3c609175。
  • Pour Jug 2 -> Jug 1: 将 Jug2 的水倒入 Jug1。同理,倒入 INLINECODE546bea61 升水。新状态为 INLINECODE376c4c95。

3. 完整代码实现 (C++)

让我们把这些思路转化为实际的代码。我们将使用一个队列来辅助进行 BFS,队列中不仅存储状态,还存储到达该状态所需的步数。

#include 
using namespace std;

// 打印路径的辅助函数,用于调试或显示过程
void printPath(map<pair, pair> &parent, pair target) {
    stack<pair> path;
    pair curr = target;
    
    // 不断回溯直到初始状态 (0,0)
    while (curr != make_pair(0, 0)) {
        path.push(curr);
        curr = parent[curr];
    }
    path.push({0, 0});

    cout << "
操作路径追踪 (倒序): " << endl;
    while (!path.empty()) {
        auto state = path.top();
        path.pop();
        cout << "(" << state.first << ", " << state.second << ")" < ");
    }
    cout < max(m, n)) return -1;

    // 特殊情况:如果目标和正好是某个壶的容量
    if (d == m || d == n) return 1; // 只需要一步:装满它

    // 特殊情况:如果 d 是 m 或 n 的倍数且满足最大公约数条件,BFS 也能处理,但这里先用 BFS

    // BFS 队列:每个元素包含 {jug1, jug2, steps}
    queue<tuple> q;

    // 访问标记矩阵:visited[i][j] 表示 (i, j) 状态是否已经出现过
    vector<vector> visited(m + 1, vector(n + 1, false));

    // 记录父节点用于路径回溯(可选,但这对于调试非常有用)
    map<pair, pair> parent;

    // 初始状态:两个壶都是空的,步数为 0
    q.push({0, 0, 0});
    visited[0][0] = true;
    parent[{0, 0}] = {-1, -1}; // 标记根节点

    while (!q.empty()) {
        auto curr = q.front();
        q.pop();

        int u = get(curr); // jug1 当前水量
        int v = get(curr); // jug2 当前水量
        int steps = get(curr); // 当前步数

        // 检查是否满足条件:任意一个壶中有 d 升水
        if (u == d || v == d) {
            // printPath(parent, {u, v}); // 如果你想看具体路径,取消注释
            return steps;
        }

        // 下面是六种状态转移的逻辑
        // 我们使用 lambda 表达式或者直接写逻辑来处理状态更新
        // 为了清晰,这里我们将所有逻辑展开

        // 1. Fill Jug 1
        if (!visited[m][v]) {
            visited[m][v] = true;
            q.push({m, v, steps + 1});
            parent[{m, v}] = {u, v};
        }

        // 2. Fill Jug 2
        if (!visited[u][n]) {
            visited[u][n] = true;
            q.push({u, n, steps + 1});
            parent[{u, n}] = {u, v};
        }

        // 3. Empty Jug 1
        if (!visited[0][v]) {
            visited[0][v] = true;
            q.push({0, v, steps + 1});
            parent[{0, v}] = {u, v};
        }

        // 4. Empty Jug 2
        if (!visited[u][0]) {
            visited[u][0] = true;
            q.push({u, 0, steps + 1});
            parent[{u, 0}] = {u, v};
        }

        // 5. Pour Jug 1 -> Jug 2
        // 计算倒水量:Jug1 剩余水量 和 Jug2 剩余空间 中的较小值
        int pour1to2 = min(u, n - v);
        if (!visited[u - pour1to2][v + pour1to2]) {
            visited[u - pour1to2][v + pour1to2] = true;
            q.push({u - pour1to2, v + pour1to2, steps + 1});
            parent[{u - pour1to2, v + pour1to2}] = {u, v};
        }

        // 6. Pour Jug 2 -> Jug 1
        // 计算倒水量:Jug2 剩余水量 和 Jug1 剩余空间 中的较小值
        int pour2to1 = min(v, m - u);
        if (!visited[u + pour2to1][v - pour2to1]) {
            visited[u + pour2to1][v - pour2to1] = true;
            q.push({u + pour2to1, v - pour2to1, steps + 1});
            parent[{u + pour2to1, v - pour2to1}] = {u, v};
        }
    }

    // 如果队列遍历完毕仍未找到解
    return -1;
}

int main() {
    int jug1 = 3;
    int jug2 = 5;
    int target = 4;

    cout << "正在计算水壶问题 (" << jug1 << ", " << jug2 < " << target << "..." << endl;
    int result = minSteps(jug1, jug2, target);

    if (result != -1) {
        cout << "最少需要操作次数: " << result << endl;
    } else {
        cout << "无法得到目标水量。" << endl;
    }

    return 0;
}

4. 代码工作原理深度解析

让我们仔细看看上述代码中的关键部分。

  • 队列的初始化:我们从 INLINECODE26d41dd6 开始。注意这里 INLINECODEa3f8234f 被设为 true 是至关重要的,否则 BFS 的第一步就会重新访问初始状态,导致死循环。
  • Pour 操作的计算:这是最容易出错的地方。当我们将 Jug1 倒入 Jug2 时,pourAmount = min(u, n - v)

* u 是 Jug1 当前拥有的水。

* n - v 是 Jug2 剩下的空间。

* 如果 Jug1 的水比 Jug2 的空间少,Jug1 会先变空,倒完 u

* 如果 Jug2 的空间比 Jug1 的水少,Jug2 会先变满,倒完 n - v

这一行逻辑非常简洁地处理了“直到其中一个满或空”的要求。

状态空间复杂度:因为我们使用了一个 (m+1) * (n+1) 的矩阵来记录访问状态,所以空间复杂度是 O(m n)。在最坏的情况下,BFS 也会访问所有可能的状态,因此时间复杂度同样是 O(m * n)。这对于常规的输入规模(比如几百升的壶)来说是非常高效的。

实战应用与最佳实践

无解情况的判断:数论基础

除了盲目地运行 BFS,我们其实可以先做一个数学上的预判,从而节省时间。这个问题有一个著名的 数论结论

> 当且仅当目标水量 dm 和 n 的最大公约数 (GCD) 的倍数,并且 d <= max(m, n) 时,问题才有解。

例如,在上面的例子中 INLINECODEa0f50f5b,INLINECODE35b6ff9c,GCD(3, 5) = 1。因为 1 的倍数覆盖了所有整数,所以只要 d <= 5,理论上都有解。但如果 INLINECODEcb0d5c5e,INLINECODEa2a6c23a,GCD 是 2。那么我们只能量出 2 升、4 升、6 升的水,永远无法量出 1 升或 3 升或 5 升。

优化后的预判代码片段:

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

bool isSolvable(int m, int n, int d) {
    if (d > max(m, n)) return false;
    return (d % gcd(m, n) == 0);
}

性能优化建议

  • 双向 BFS:虽然标准 BFS 很快,但在极大的水壶容量下(比如 m=10000, n=9999),状态空间依然很大。我们可以考虑使用 双向 BFS,即同时从初始状态 INLINECODEa863bdc9 和目标状态 INLINECODE7474b5c1 / (0, d) 进行搜索,直到两边相遇。这能显著减少搜索的层数。
  • 哈希表代替数组:如果 INLINECODE7a5353bf 和 INLINECODE00c87bdc 非常大(比如 10^5 级别),使用 INLINECODE9ac1a87e 会导致内存溢出。此时可以使用 INLINECODE29cf2e70 或哈希表 std::unordered_set 来存储已访问的状态,虽然查找时间常数稍微大一点,但能大幅节省内存空间。
  • 提前终止:在 BFS 循环中,一旦找到解就立即返回,不要继续搜索后续状态。这是 BFS 保证“最少操作次数”的核心。

总结与思考

在这篇文章中,我们深入探讨了水壶问题的解决之道。我们从基本的逻辑推理出发,理解了状态空间的概念,并选择广度优先搜索 (BFS) 作为我们的利剑,因为它天然适合寻找最短路径。

通过构建 C++ 解决方案,我们不仅实现了算法逻辑,还学习了如何处理状态转移、避免死循环以及理解算法的时空复杂度。此外,了解背后的数论知识(最大公约数)能帮助我们更好地理解问题的本质,并在编程前做出快速的判断。

作为一个开发者,你会发现这类搜索算法在很多实际场景中都非常有用,比如:

  • AI 寻路:游戏开发中角色在地图上的移动。
  • 社交网络分析:计算两个人之间最短的关系链。
  • 网络拓扑发现:在网络中寻找最短传输路径。

希望这篇文章不仅帮助你解决了水壶问题,更能让你在面对类似的搜索问题时,能够自信地运用 BFS 和状态空间思维,编写出优雅且高效的代码。下次当你面对一个看似复杂的逻辑谜题时,不妨试着将其建模为状态图,也许 BFS 就是那个“柳暗花明”的答案。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。如需转载,请注明文章出处豆丁博客和来源网址。https://shluqu.cn/47706.html
点赞
0.00 平均评分 (0% 分数) - 0