除法求值 图解题解
这道题到底在问什么
- 输入
- equations=[["a","b"],["b","c"],["c","d"],["e","f"]], values=[2,3,0.5,4], queries=[["a","c"],["b","a"],["a","d"],["d","a"],["c","a"],["x","x"],["a","f"]]
- 输出
- [6, 0.5, 3, 0.3333, 0.1667, -1.0, -1.0]
最优解:为什么这么做
一句话答案:LeetCode 399 除法求值的思路是把等式建成带权有向图:a/b=k 就从 a 到 b 连一条权为 k 的边,同时从 b 到 a 连权为 1/k 的反向边;查询 c/d 时在图上 DFS 找一条从 c 到 d 的路径,沿途边权连乘即答案,找不到路或变量没出现过返回 -1.0。每次查询最坏 O(V+E),整体 O(Q·(V+E))。
除法求值这道题在问什么
输入一批已知等式,比如 a/b=2、b/c=3,再给一批查询,要求推出每个查询的比值,例如 a/c 是多少。变量的具体数值从头到尾都不知道,能依赖的只有比值之间的关系。推不出来的情况一律返回 -1.0,包括两种:查询里出现了等式中从没出现过的变量(哪怕是 x/x 也不行),或者两个变量之间没有任何比值链条能连通。
为什么除法求值要建成带权图
关键观察是除法关系可以传递:a/b 乘以 b/c,分子分母里的 b 约掉,正好得到 a/c。也就是说,只要存在一串首尾相接的已知比值,把它们连乘就能推出新比值。「首尾相接的一串关系」在数据结构里的标准形态就是图上的一条路径——于是把每个变量看作节点,把 a/b=k 看作从 a 到 b、权值为 k 的有向边,「求 c/d」就变成了「在图上找一条 c 到 d 的路径,把沿途边权乘起来」。
一旦完成这步转化,题目就从陌生的代数推理,落回熟悉的图遍历模板,这正是本题被归为图论题的原因。
为什么每个等式必须建正反两条边
a/b=k 蕴含 b/a=1/k,这条反向信息不建进图里就丢了。只加正向边的话,图上从 b 走不到 a,b/a 这类逆着问的查询会被误判成推不出而返回 -1.0。所以建图时每个等式落两条边:a 到 b 权 k,b 到 a 权 1/k。用哈希表套哈希表做邻接表,节点是变量名字符串,取边和查邻居都是平均 O(1)。
DFS 连乘为什么是对的,seen 集合防什么
搜索函数 dfs(x, y, seen) 回答「从 x 到 y 的比值是多少」。它枚举 x 的每个邻居 nxt:x 到 nxt 的边权是 x/nxt 的值,子问题递归解出 nxt/y,两数相乘时中间项 nxt 约掉,正好是 x/y——每一步的正确性都还原成那条约分恒等式。起点与终点相同时返回 1.0,因为任何数除以自己是 1;任一变量不在图里则直接返回 -1.0,这个存在性检查必须放在最前面。
seen 集合记录本次查询已经走过的节点,防的是死循环:正反两条边天然构成来回的环,不挡住回头路,递归会在 a、b 之间无限弹跳直到爆栈。每次新查询都换一个空的 seen,互不干扰。
复杂度与查询很多时的优化
建图 O(E);每个查询一次 DFS,最坏把 V 个点、E 条边都摸一遍,Q 个查询整体 O(Q·(V+E)),空间 O(V+E) 花在邻接表、递归栈和 seen 上。
如果查询量远大于图的规模,可以把「现查」换成「预处理」:用 Floyd 一次算出所有点对的比值,预处理 O(V³) 之后每个查询 O(1);或者用带权并查集,把比值压缩到每个节点相对根的倍率上,同一集合内任意两点的比值近 O(1) 可得。查询少时,朴素 DFS 就是性价比最高的写法。
▶ 动画逐步走查(共 28 步)——想跟着动画一帧帧对照就展开
- 3记住这句,下面每一帧都在套它。
- 4查询 a / c:从 a 出发,把 a/a=1 当起点(product=1),沿边往 c 找路。
- 5走边 a→b(权 2,即 a/b=2):product = 1 × 2 = 2。继续从 b 往下找。
- 6走边 b→c(权 3,即 b/c=3):product = 2 × 3 = 6。到达终点 c!
- 7路径 a→b→c 上的边权连乘 = 6,这就是 a / c = 6。
- 8查询 b / a:从 b 出发,把 b/b=1 当起点(product=1),沿边往 a 找路。
- 9走边 b→a(权 0.5,即 b/a=0.5):product = 1 × 0.5 = 0.5。到达终点 a!
- 10路径 b→a 上的边权连乘 = 0.5,这就是 b / a = 0.5。
- 11查询 a / d:从 a 出发,把 a/a=1 当起点(product=1),沿边往 d 找路。
- 12走边 a→b(权 2,即 a/b=2):product = 1 × 2 = 2。继续从 b 往下找。
- 13走边 b→c(权 3,即 b/c=3):product = 2 × 3 = 6。继续从 c 往下找。
- 14走边 c→d(权 0.5,即 c/d=0.5):product = 6 × 0.5 = 3。到达终点 d!
- 15路径 a→b→c→d 上的边权连乘 = 3,这就是 a / d = 3。
- 16查询 d / a:从 d 出发,把 d/d=1 当起点(product=1),沿边往 a 找路。
- 17走边 d→c(权 2,即 d/c=2):product = 1 × 2 = 2。继续从 c 往下找。
- 18走边 c→b(权 0.3333,即 c/b=0.3333):product = 2 × 0.3333 = 0.6667。继续从 b 往下找。
- 19走边 b→a(权 0.5,即 b/a=0.5):product = 0.6667 × 0.5 = 0.3333。到达终点 a!
- 20路径 d→c→b→a 上的边权连乘 = 0.3333,这就是 d / a = 0.3333。
- 21查询 c / a:从 c 出发,把 c/c=1 当起点(product=1),沿边往 a 找路。
- 22走边 c→b(权 0.3333,即 c/b=0.3333):product = 1 × 0.3333 = 0.3333。继续从 b 往下找。
- 23走边 b→a(权 0.5,即 b/a=0.5):product = 0.3333 × 0.5 = 0.1667。到达终点 a!
- 24路径 c→b→a 上的边权连乘 = 0.1667,这就是 c / a = 0.1667。
- 25查询 x / x:变量 x 根本没在图里出现过(没有任何方程提到它),无从计算 → 答案 -1。
- 26查询 a / f:从 a 出发,把 a/a=1 当起点(product=1),沿边往 f 找路。
- 27走边 a→b(权 2,即 a/b=2):product = 1 × 2 = 2。继续从 b 往下找。
- 28走边 b→c(权 3,即 b/c=3):product = 2 × 3 = 6。继续从 c 往下找。
- 29走边 c→d(权 0.5,即 c/d=0.5):product = 6 × 0.5 = 3。继续从 d 往下找。
- 30查询 a / f:从 a 怎么走都到不了 f(它们不在同一连通块里),无法换算 → 答案 -1。
⚠️ 容易写错的地方
✗ 错:只加正向边 a→b
✓ 对:同时加反向 b→a 权 1/k
少了反向,b/a、d/a 这类查询会算不出
✗ 错:DFS 不记 visited
✓ 对:用 seen 集合挡住回头
正反边成环,不挡会无限递归爆栈
✗ 错:x 或 y 不在图里也硬算
✓ 对:先判存在,缺则直接 -1
x/x 这种没出现过的变量必须返回 -1
完整代码(Python / C++ / Java)
Python
def calcEquation(equations, values, queries):
g = {}
for (a, b), k in zip(equations, values):
g.setdefault(a, {})[b] = k
g.setdefault(b, {})[a] = 1.0 / k
def dfs(x, y, seen):
if x not in g or y not in g: return -1.0
if x == y: return 1.0
seen.add(x)
for nxt, w in g[x].items():
if nxt in seen: continue
r = dfs(nxt, y, seen)
if r != -1.0: return w * r
return -1.0
return [dfs(c, d, set()) for c, d in queries]C++
#include <vector>
#include <string>
#include <unordered_map>
#include <unordered_set>
using namespace std;
unordered_map<string, unordered_map<string,double>> g;
double dfs(const string& x, const string& y, unordered_set<string>& seen){
if(!g.count(x) || !g.count(y)) return -1.0;
if(x == y) return 1.0;
seen.insert(x);
for(auto& [nxt, w] : g[x]){
if(seen.count(nxt)) continue;
double r = dfs(nxt, y, seen);
if(r != -1.0) return w * r;
}
return -1.0;
}
vector<double> calcEquation(vector<vector<string>>& eq, vector<double>& val, vector<vector<string>>& q){
for(int i = 0; i < (int)eq.size(); i++){
g[eq[i][0]][eq[i][1]] = val[i];
g[eq[i][1]][eq[i][0]] = 1.0 / val[i];
}
vector<double> ans;
for(auto& c : q){ unordered_set<string> s; ans.push_back(dfs(c[0], c[1], s)); }
return ans;
}Java
import java.util.*;
class Solution {
Map<String, Map<String, Double>> g = new HashMap<>();
public double[] calcEquation(List<List<String>> equations,
double[] values, List<List<String>> queries) {
for (int i = 0; i < values.length; i++) {
String a = equations.get(i).get(0), b = equations.get(i).get(1);
g.computeIfAbsent(a, z -> new HashMap<>()).put(b, values[i]);
g.computeIfAbsent(b, z -> new HashMap<>()).put(a, 1.0 / values[i]);
}
double[] ans = new double[queries.size()];
for (int i = 0; i < queries.size(); i++) {
String c = queries.get(i).get(0), d = queries.get(i).get(1);
ans[i] = dfs(c, d, new HashSet<>());
}
return ans;
}
double dfs(String x, String y, Set<String> seen) {
if (!g.containsKey(x) || !g.containsKey(y)) return -1.0;
if (x.equals(y)) return 1.0;
seen.add(x);
for (Map.Entry<String, Double> e : g.get(x).entrySet()) {
if (seen.contains(e.getKey())) continue;
double r = dfs(e.getKey(), y, seen);
if (r != -1.0) return e.getValue() * r;
}
return -1.0;
}
}复杂度
时间
O(Q·(V+E))
每个查询一次 DFS,最坏遍历整图
空间
O(V+E)
邻接表 + 递归栈 + visited
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 除法求值 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
查询非常多时怎么优化?+
用 Floyd 把所有点对的比值预处理成 O(V³),或用带权并查集把同一集合里任意两点的比值压到近 O(1)。
为什么沿路径连乘就是答案?+
a/b·b/c·c/d 中间项约掉只剩 a/d;图上一条路径正好对应这串相乘,所以乘积就是首尾的比值。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 除法求值 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。