给你一个变量对数组 equations
和一个实数值数组 values
作为已知条件,其中 equations[i] = [Ai, Bi]
和 values[i]
共同表示等式 Ai / Bi = values[i]
。每个 Ai
或 Bi
是一个表示单个变量的字符串。
另有一些以数组 queries
表示的问题,其中 queries[j] = [Cj, Dj]
表示第 j
个问题,请你根据已知条件找出 Cj / Dj = ?
的结果作为答案。
返回 所有问题的答案 。如果存在某个无法确定的答案,则用 -1.0
替代这个答案。如果问题中出现了给定的已知条件中没有出现的字符串,也需要用 -1.0
替代这个答案。
注意: 输入总是有效的。你可以假设除法运算中不会出现除数为 0 的情况,且不存在任何矛盾的结果。
注意: 未在等式列表中出现的变量是未定义的,因此无法确定它们的答案。
示例 1:
输入: equations = [["a","b"],["b","c"]]
, values = [2.0,3.0]
, queries = [["a","c"],["b","a"],["a","e"],["a","a"],["x","x"]]
输出: [6.00000,0.50000,-1.00000,1.00000,-1.00000]
解释:
条件:a / b = 2.0, b / c = 3.0
问题:a / c = ?, b / a = ?, a / e = ?, a / a = ?, x / x = ?
结果:[6.0, 0.5, -1.0, 1.0, -1.0 ]
注意:x 是未定义的 => -1.0
示例 2:
输入: equations = [["a","b"],["b","c"],["bc","cd"]]
, values = [1.5,2.5,5.0]
, queries = [["a","c"],["c","b"],["bc","cd"],["cd","bc"]]
输出: [3.75000,0.40000,5.00000,0.20000]
示例 3:
输入: equations = [["a","b"]]
, values = [0.5]
, queries = [["a","b"],["b","a"],["a","c"],["x","y"]]
输出: [0.50000,2.00000,-1.00000,-1.00000]
提示:
1 <= equations.length <= 20
equations[i].length == 2
1 <= Ai.length, Bi.length <= 5
values.length == equations.length
0.0 < values[i] <= 20.0
1 <= queries.length <= 20
queries[i].length == 2
1 <= Cj.length, Dj.length <= 5
Ai, Bi, Cj, Dj
由小写英文字母与数字组成
图查询
这题很明显是需要我们把数据建模为图数据结构,然后把边的遍历过程建模为乘法和除法,比如 a → b a \rightarrow b a→b 两个节点之间通过一条边转移就是执行一次除法,然后除法的两个操作数是 a , b a,\ b a, b 。
所以我将数据转换为图,并且存储了有向边的权重,正向权重就是题目给定的 v a l u e s values values 逆向权重就是 1 v a l u e s [ i ] \frac{1}{values[i]} values[i]1 。
class Solution {
private:
unordered_map<string, vector<string>> graph;
unordered_map<string, unordered_map<string, double>> weight;
void dfs(const string& curr, const string& prev, const string& endd, const double& tmp, double& ans) {
if (ans > -0.5) return;
if (curr == endd) {
ans = tmp;
return;
}
for (const string& next : graph[curr]) {
if (next == prev) continue;
dfs(next, curr, endd, tmp * weight[curr][next], ans);
}
}
public:
vector<double> calcEquation(vector<vector<string>>& equations, vector<double>& values, vector<vector<string>>& queries) {
for (int i = 0; i < equations.size(); ++i) {
graph[equations[i][0]].emplace_back(equations[i][1]);
graph[equations[i][1]].emplace_back(equations[i][0]);
weight[equations[i][0]][equations[i][1]] = values[i];
weight[equations[i][1]][equations[i][0]] = 1.0/values[i];
}
vector<double> results;
for (const vector<string>& query : queries) {
if (!graph.count(query[0]) || !graph.count(query[1])) {
results.emplace_back(-1.0);
continue;
}
double ans = -1.0;
dfs(query[0], "$", query[1], 1.0, ans);
results.emplace_back(ans);
}
return results;
}
};
查询的时候如果查询到了终点,则将一条路下来对初始结果执行的连续运算结果存储到答案的引用中。如果出现了不属于图中的节点引用,则直接返回 − 1.0 -1.0 −1.0 无需在图中进行 DFS 了。
不过这题的数据量看起来虽然小,但是直接构建普通的图进行 DFS 是过不了全部用例的,还是会超时……
并查集
我看了一下官方的解答,应该是采用了某位用户的解答,使用了并查集的思想,讲的很好,就是有点繁杂,而且并查集的实现也不是那么优雅,我又自己实现了一版 C++ 的模板类,使得能被哈希的键都能用于并查集。
使用并查集的思想大概如下:
- 如果某个操作数出现在多个等式中,则该操作数可以联系这些等式的其他操作数,使得这些操作数之间可以使用一个基数和系数来进行统一的表达,我们把这些互相联系可以互相表达的数称为图的一个连通分量,数就是里面的顶点,比例就是图的边权
- 不同连通分量之间的点不具备互相表达的能力,因此查询它们之间的比例关系返回 -1 。
所以我们先对提供的等式建立并查集关系,维护好连通分量和边权。比如在图中如果存在 a → b a \rightarrow b a→b ,则说明操作数 a a a 是可以通过 b b b 来表达的,比如给定等式 a ÷ b = 3 a \div b = 3 a÷b=3 则 a = 3 × b a = 3\times b a=3×b ,所以在 a a a 和 b b b 所组成的连通分量中, b b b 是作为基数的,也就是该并查集分量的根,顶点 a a a 去指向 b b b 。
当一个连通分量是多个顶点的连接时,我们要怎么去维护当前顶点与其父顶点之间的比值关系?我们可以在并查集的实现中额外存储一个 w e i g h t weight weight 表用于查询一个顶点与其父顶点之间的比值是多少。但是并查集的一般板子不是会执行路径压缩和按秩合并吗?这个时候我们该如何维护子顶点与父顶点的比值?
- 按秩合并是无法实现的,因为我们的合并具有方向性,比如合并顶点 a a a 到 b b b 意思是使得并查集中 a a a 指向 b b b ,则表示我们在处理关系 a ÷ b a \div b a÷b ,而不是关系 b ÷ a b \div a b÷a ,因此我们不能采用按秩合并,虽然这对后续并查集的查询效率提升有一些帮助
- 路径压缩是可以实现的,因为
find
操作顺带做路径压缩的时候不会破坏关系指向性质,比如链路 a ÷ b ÷ c a \div b \div c a÷b÷c ,表示 a a a 可以被 b b b 表达,也可以进而被 c c c 表达,则我们可以规定并查集连通分量中所有顶点都使用最小的基数来进行表达,比如 a ÷ b = 2 , b ÷ c = 3 a\div b = 2,\ b \div c = 3 a÷b=2, b÷c=3 ,则有 a = 6 c , b = 3 c a=6c,\ b=3c a=6c, b=3c ,此时 a a a 和 b b b 都指向了连通分量的根节点 c c c , a a a 无需再通过 b b b 被表达计算,这就是路径压缩。
执行 压缩 操作的时候,如何更新一条链路上每个节点与其父节点的比值权重关系?由于路径压缩是递归地将每个节点指向它所在连通分量的根节点,因此递归的时候如果回溯到某一层节点可以指向根节点,则证明其父节点以及祖先节点已经完成调整指向了根节点,因此 a → b → ⋯ → r o o t a\rightarrow b \rightarrow \cdots \rightarrow root a→b→⋯→root 已经被压缩为 a → b → r o o t a\rightarrow b \rightarrow root a→b→root , a → r o o t a \rightarrow root a→root 的边权即为 ( a → b ) × ( b → r o o t ) (a\rightarrow b) \times (b \rightarrow root) (a→b)×(b→root) 。
执行 合并 操作的时候,如何更新两个连通分量之间各个节点的比例关系?因为当我们将一个连通分量指向另一个连通分量时,根节点发生了变化,也就是用于表达分量中其他数的基数发生了变化,这必然要求我们更新边的权值(还记得前文所说吗?边权代表了节点与其父节点之间的比值)。回忆一下普通并查集模板的合并是怎么做的:
void mergeRoot(const int& x1, const int& x2) {
if (!sets.count(x1) || !sets.count(x2))
return; // invalid merge
int root1 = findRoot(x1);
int root2 = findRoot(x2);
if (root1 == root2) return;
if (sets[root2] < sets[root1]) {
sets[root2] += sets[root1];
sets[root1] = root2;
} else {
sets[root1] += sets[root2];
sets[root2] = root1;
}
}
可知,必须先得到两个顶点所在的连通分量的根节点,才能开始合并,而在 f i n d find find 执行查询的过程中,一定会执行路径压缩,所以我们可以保证,合并的时候一定是合并两棵高度为 2 的树。
可以举例简化一下合并的过程:
比如默认有连通分量 a → b a\rightarrow b a→b 和 c → d c\rightarrow d c→d ,根节点 b , d b,\ d b, d 下挂了很多其他子节点(此时由于查询到了根节点 b b b 和 d d d ,所以路径压缩已经完成,树高都是 2 ),它们之间的比值关系如上图所示。现在执行合并 m e r g e ( a , c ) merge(a,\ c) merge(a, c) ,则实际执行的操作是将根 b b b 指向 d d d ,我们创建了一条新的边,它的权值是多少?从上图的关系我们可知, a a a 在合并后应该通过 基数 d d d 来表达,根据下半边的权重,我们知道是 a = 24 d a=24d a=24d ,则如果上半边也要能维持这一关系的话,很明显新的边权重必须是 24 a ÷ 4 a = 6 24a \div 4a = 6 24a÷4a=6 ,这样我们就能计算出两个根连接时新的边的权重了。
如果合并的时候直接要求 a a a 指向 d d d 呢(比如上图,形成一个三角形而不是平行四边形)?那也是先执行根的查询,再对根进行合并,所以实际还是执行 b → d b\rightarrow d b→d ,这个时候已知 a d ad ad (合并的时候从 v a l u e s values values 可知新关系的权重)和 a b ab ab ,那么 b d bd bd 也是直接相除可得。
template <typename Tkey>
class DisjointSet {
private:
unordered_map<Tkey, Tkey> G; // graph
unordered_map<Tkey, int> R; // rank
unordered_map<Tkey, double> W; // weight
public:
void insert(Tkey x) {
if (G.count(x)) return;
G[x] = x;
R[x] = 1;
W[x] = 1.0;
}
Tkey find(Tkey x) {
if (!G.count(x))
return x; // invalid root
if (R[x] > 0)
return x; // root
Tkey parent = G[x];
G[x] = find(parent); // path compression
W[x] *= W[parent]; // parent's weight has been changed already
return G[x];
}
void merge(Tkey x, Tkey y, double wx2y) {
// merge makes `x` point to `y`
if (!G.count(x) || !G.count(y)) return;
Tkey rx = find(x); // will perform compression
Tkey ry = find(y); // will perform compression
if (rx == ry) return; // already in one set
G[rx] = ry;
W[rx] = W[y] * wx2y / W[x]; // weight of root x need to be updated
R[ry] += R[rx];
R[rx] = 0; // not a root anymore
}
bool isConnected(Tkey x, Tkey y) {
if (!G.count(x) || !G.count(y)) return false;
return find(x) == find(y);
}
double weight(Tkey x) {
if (!G.count(x)) return -1.0;
return W[x];
}
void print() const {
for (auto [u, v] : G) {
cout << u << " -> " << v << endl;
}
}
};
class Solution {
private:
DisjointSet<string> dsu;
public:
vector<double> calcEquation(vector<vector<string>>& equations, vector<double>& values, vector<vector<string>>& queries) {
// step 1: construct relationship
for (int i = 0; i < equations.size(); ++i) {
string u = equations[i][0];
string v = equations[i][1];
double w = values[i];
dsu.insert(u);
dsu.insert(v);
dsu.merge(u, v, w);
}
// step 2: execute query
vector<double> ans(queries.size(), -1.0);
for (int i = 0; i < queries.size(); ++i) {
string u = queries[i][0];
string v = queries[i][1];
if (dsu.isConnected(u, v)) {
ans[i] = dsu.weight(u) / dsu.weight(v);
}
}
return ans;
}
};