目录

题目描述

399. 除法求值

题意分析

输入是若干条形如 a / b = value 的已知比值,以及若干条询问 x / y 的请求。要求对每条询问给出答案,无法从已知条件推出时返回 -1.0

已知的比值是可以传递的:知道 a / bb / c,就能推出 a / c;同一条等式反过来读也成立,b / a 等于 value 的倒数。所以「能否回答」取决于两个变量之间是否存在一条由已知等式串起来的推导链。

约束里有几个关键信号:所有 value 严格大于 0,因此不会出现除以零,倒数总是合法;变量用字符串标识,数量不大(等式和询问各不超过 20 条);题目保证给出的等式之间不矛盾,也就是同一对变量沿不同推导链算出的比值一定相同,不需要做一致性校验。

边界上最容易漏的是「变量根本没出现过」的情况:询问 x / x 时,若 x 从未在任何等式里出现,答案是 -1.0 而不是 1.0——题目要求的是「能否推出」,一个没被定义过的量不能凭自反性直接给 1。

解法:建图加 BFS

核心思路

直接对每条询问做符号推导很难落地:等式可以任意组合、正用反用,穷举组合方式没有收敛的方向。

换个表示:把每个变量看成一个点,把等式 a / b = value 看成一条从 a 指向 b、权为 value 的边,同时补一条从 b 指向 a、权为 1 / value 的反向边。这样「先用 a / b、再用 b / c」就是沿着 a → b → c 走两步,而推导结果恰好是路径上边权的乘积。

这一步的收益在于把「组合等式」变成了「找路径」,而找路径有现成的线性做法。此时的不变量是:从起点走到任意点 v 的路径边权乘积,等于 start / v 的值,且与选哪条路径无关(题目保证等式无矛盾,所以不同路径给出同一个值)。

有了这个不变量,就可以对每条询问从起点做一次广度优先搜索,队列里除了变量名还要携带「从起点到它的累积比值」。用一张 ratio 表同时充当访问标记和累积值记录:某个变量一旦被写进 ratio,说明它已入队且比值已确定,不必再从别的路径重复扩展,这也顺带避免了环上的死循环。

搜索开始前先做两处特判:任一端点不在图中,说明它从未被定义,直接返回 -1.0;两端点相同且都已定义,返回 1.0。搜索结束仍未触及终点,说明两者不连通,返回 -1.0

这里使用 BFS 不是为了找“最短路径”:题目保证等式无矛盾,同一对变量之间任意路径的乘积都相同,找到一条可达路径即可。选择 BFS 只是因为迭代写法直观,也不会遇到递归栈过深的问题。

解题步骤

  • 遍历 equationsvalues,建立邻接表 graph,对第 i 条等式写入 graph[a][b] = values[i]graph[b][a] = 1 / values[i]。反向边必须一起建,否则只能沿等式书写方向推导。
  • 逐条处理询问。先判断起点和终点是否都作为键出现在 graph 中,只要有一个没出现就返回 -1.0。这一步要放在自反判断之前,否则未定义变量会被误判成 1.0
  • 若起点与终点相同(且都已定义),返回 1.0,不必进入搜索。
  • 初始化队列只含起点,ratio[start] = 1.0ratio 里有键就代表已访问,省掉一个额外的集合。
  • 循环取出队首变量 cur,遍历它的所有邻居 nextnext 已在 ratio 中就跳过;否则算出 nextValue = ratio[cur] * graph[cur][next],若 next 就是终点立刻返回 nextValue,否则写入 ratio 并入队。提前返回是因为无矛盾保证下,先到达的路径与其他路径答案一致。
  • 队列耗尽仍未命中终点,返回 -1.0

a / b = 2.0b / c = 3.0 为例:从 a 出发时先得到 a / b = 1.0 × 2.0 = 2.0,再得到 a / c = 2.0 × 3.0 = 6.0;反向查询 b / a 直接使用反向边得到 0.5。查询 a / ee 未定义,查询 x / xx 也未定义,两者都返回 -1.0。这组例子同时覆盖了路径乘积、反向边和未知变量自查询三个关键边界。

代码实现

import java.util.*;

class Solution {
    public double[] calcEquation(List<List<String>> equations, double[] values, List<List<String>> queries) {
        Map<String, Map<String, Double>> graph = new HashMap<>();
        for (int i = 0; i < equations.size(); i++) {
            String a = equations.get(i).get(0);
            String b = equations.get(i).get(1);
            double value = values[i];
            graph.computeIfAbsent(a, k -> new HashMap<>()).put(b, value);
            graph.computeIfAbsent(b, k -> new HashMap<>()).put(a, 1.0 / value);
        }

        double[] ans = new double[queries.size()];
        for (int i = 0; i < queries.size(); i++) {
            ans[i] = bfs(graph, queries.get(i).get(0), queries.get(i).get(1));
        }
        return ans;
    }

    private double bfs(Map<String, Map<String, Double>> graph, String start, String target) {
        if (!graph.containsKey(start) || !graph.containsKey(target)) {
            return -1.0;
        }
        if (start.equals(target)) {
            return 1.0;
        }

        Queue<String> queue = new ArrayDeque<>();
        Map<String, Double> ratio = new HashMap<>();
        queue.offer(start);
        ratio.put(start, 1.0);

        while (!queue.isEmpty()) {
            String cur = queue.poll();
            double curValue = ratio.get(cur);
            for (Map.Entry<String, Double> entry : graph.get(cur).entrySet()) {
                String next = entry.getKey();
                if (ratio.containsKey(next)) {
                    continue;
                }
                double nextValue = curValue * entry.getValue();
                if (next.equals(target)) {
                    return nextValue;
                }
                ratio.put(next, nextValue);
                queue.offer(next);
            }
        }
        return -1.0;
    }
}
func calcEquation(equations [][]string, values []float64, queries [][]string) []float64 {
    graph := make(map[string]map[string]float64)
    for i, equation := range equations {
        a, b := equation[0], equation[1]
        if graph[a] == nil {
            graph[a] = make(map[string]float64)
        }
        if graph[b] == nil {
            graph[b] = make(map[string]float64)
        }
        graph[a][b] = values[i]
        graph[b][a] = 1.0 / values[i]
    }

    ans := make([]float64, len(queries))
    for i, query := range queries {
        ans[i] = eval(graph, query[0], query[1])
    }
    return ans
}

func eval(graph map[string]map[string]float64, start string, target string) float64 {
    if graph[start] == nil || graph[target] == nil {
        return -1.0
    }
    if start == target {
        return 1.0
    }

    queue := []string{start}
    ratio := map[string]float64{start: 1.0}
    for head := 0; head < len(queue); head++ {
        cur := queue[head]
        for next, value := range graph[cur] {
            if _, ok := ratio[next]; ok {
                continue
            }
            nextValue := ratio[cur] * value
            if next == target {
                return nextValue
            }
            ratio[next] = nextValue
            queue = append(queue, next)
        }
    }
    return -1.0
}

复杂度分析

  • 时间复杂度:$O(e + q(v + e))$,e 为等式数、v 为变量数、q 为询问数;建图一次扫完所有等式,每条询问最坏要遍历整张图的点和边。
  • 空间复杂度:$O(v + e)$,邻接表存下双向边;不计图和答案时,单次 BFS 的队列与 ratio 表额外占用 $O(v)$。

关键点总结

  • 「已知若干两两关系、求任意两者关系」是一类固定的建模信号:把对象当点、关系当边,问题就从代数推导变成路径搜索。
  • 关系可逆时反向边必须显式建出来,权重取逆运算的结果;本题是倒数,若关系是加法差值则取相反数。
  • BFS 状态必须携带从查询起点到当前点的路径乘积;ratio 同时保存这个值并充当访问标记,天然防住环上的重复扩展。
  • 存在性检查必须早于自反性检查,否则未定义变量的自查询会被错答成 1.0。这类「先验条件优先」的顺序问题在面试中很常被追问。
  • BFS 求的是任意可达路径,不是最短路;如果等式和查询规模很大且查询远多于建图,再考虑用带权并查集把查询降到近似常数时间。

易错点总结

  • 错误写法:只建 graph[a][b] = value,不建反向边 → equations = [["a","b"]]values = [2.0],询问 b / a:搜索从 b 出发无路可走,返回 -1.0,正确答案是 0.5
  • 错误写法:先判断 start.equals(target) 再判断变量是否存在 → 询问 x / xx 从未出现:返回 1.0,正确答案是 -1.0
  • 错误写法:搜索时不记录已访问变量 → equations = [["a","b"],["b","c"],["c","a"]] 这类成环的输入,a → b → c → a 会无限循环,程序超时。
  • 错误写法:把累积比值存在一个单独变量里、而不是随节点入队 → 从 a 出发有两条分支时,第二条分支会沿用第一条分支留下的乘积,equations = [["a","b"],["a","c"]] 询问 a / c 会算成 a/b × a/c,结果错误。
  • 错误写法:出队时才标记访问 → 同一个变量可能先被多条边重复入队,虽然不一定算错,却会造成无意义的重复状态;应在入队时立即写入 ratio
  • 错误写法:认为「变量存在」就一定「可达」 → equations = [["a","b"],["c","d"]] 中查询 a / c,两端都存在但分属两个连通块,仍应返回 -1.0

相似题目

题目 难度 考察点
LCR 111. 除法求值 中等 同题换皮,可直接用带权并查集重写一遍
990. 等式方程的可满足性 中等 只有相等与不等,判定矛盾而不求值
547. 省份数量 中等 纯连通块计数,边不带权
684. 冗余连接 中等 用并查集在加边过程中找出成环的那条边
721. 账户合并 中等 字符串节点的连通块合并,重点在映射与结果整理
743. 网络延迟时间 中等 边权参与加法且需取最小,要用最短路而非任意路径