Minimum Edge Weight Equilibrium Queries in a Tree
HardPrompt
There is an undirected tree with n nodes labeled from 0 to n - 1. You are given the integer n and a 2D integer array edges of length n - 1, where edges[i] = [ui, vi, wi] indicates that there is an edge between nodes ui and vi with weight wi in the tree.
You are also given a 2D integer array queries of length m, where queries[i] = [ai, bi]. For each query, find the minimum number of operations required to make the weight of every edge on the path from ai to bi equal. In one operation, you can choose any edge of the tree and change its weight to any value.
Note that:
- Queries are independent of each other, meaning that the tree returns to its initial state on each new query.
- The path from
aitobiis a sequence of distinct nodes starting with nodeaiand ending with nodebisuch that every two adjacent nodes in the sequence share an edge in the tree.
Return an array answer of length m where answer[i] is the answer to the ith query.
Example 1:
Input: n = 7, edges = [[0,1,1],[1,2,1],[2,3,1],[3,4,2],[4,5,2],[5,6,2]], queries = [[0,3],[3,6],[2,6],[0,6]]
Output: [0,0,1,3]
Explanation: In the first query, all the edges in the path from 0 to 3 have a weight of 1. Hence, the answer is 0.
In the second query, all the edges in the path from 3 to 6 have a weight of 2. Hence, the answer is 0.
In the third query, we change the weight of edge [2,3] to 2. After this operation, all the edges in the path from 2 to 6 have a weight of 2. Hence, the answer is 1.
In the fourth query, we change the weights of edges [0,1], [1,2] and [2,3] to 2. After these operations, all the edges in the path from 0 to 6 have a weight of 2. Hence, the answer is 3.
For each queries[i], it can be shown that answer[i] is the minimum number of operations needed to equalize all the edge weights in the path from ai to bi.Example 2:
Input: n = 8, edges = [[1,2,6],[1,3,4],[2,4,6],[2,5,3],[3,6,6],[3,0,8],[7,0,2]], queries = [[4,6],[0,4],[6,5],[7,4]]
Output: [1,2,2,3]
Explanation: In the first query, we change the weight of edge [1,3] to 6. After this operation, all the edges in the path from 4 to 6 have a weight of 6. Hence, the answer is 1.
In the second query, we change the weight of edges [0,3] and [3,1] to 6. After these operations, all the edges in the path from 0 to 4 have a weight of 6. Hence, the answer is 2.
In the third query, we change the weight of edges [1,3] and [5,2] to 6. After these operations, all the edges in the path from 6 to 5 have a weight of 6. Hence, the answer is 2.
In the fourth query, we change the weights of edges [0,7], [0,3] and [1,3] to 6. After these operations, all the edges in the path from 7 to 4 have a weight of 6. Hence, the answer is 3.
For each queries[i], it can be shown that answer[i] is the minimum number of operations needed to equalize all the edge weights in the path from ai to bi.
Constraints:
1 <= n <= 104edges.length == n - 1edges[i].length == 30 <= ui, vi < n1 <= wi <= 26- The input is generated such that
edgesrepresents a valid tree. 1 <= queries.length == m <= 2 * 104queries[i].length == 20 <= ai, bi < n
Approaches
2 approaches with complexity analysis and trade-offs.
This approach handles each query independently by performing a graph traversal to find the path between the two given nodes. For each query, it finds the path, collects the weights of all edges on that path, and then calculates the answer.
Algorithm
- For each query
(a, b):- Build an adjacency list representation of the tree if not already built. The list should store pairs of
(neighbor, weight). - Perform a Breadth-First Search (BFS) starting from node
ato find the unique path to nodeb. During the BFS, maintain aparentarray to reconstruct the path later.parent[i]will store the node from which we reached nodei. - Once the BFS reaches
b, the search can stop. The path is now implicitly stored in theparentarray. - Reconstruct the path by backtracking from
btoausing theparentarray. While backtracking from a nodecurrto its parentp = parent[curr], find the weight of the edge(curr, p). - Store the frequencies of all edge weights encountered on the path in a hash map or an array.
- Keep track of the total number of edges on the path (
pathLength). - After collecting all weights, find the maximum frequency (
maxFreq) among them. - The minimum number of operations for the query is
pathLength - maxFreq.
- Build an adjacency list representation of the tree if not already built. The list should store pairs of
Walkthrough
The core idea is to solve each query from scratch. Given a query with nodes a and b, we need to find the path connecting them. In a tree, this path is unique. We can use a graph traversal algorithm like Breadth-First Search (BFS) or Depth-First Search (DFS) to find this path. A straightforward way is to start a BFS from a, keeping track of the parent of each visited node. When we reach b, we can trace back the path to a using the parent pointers.
As we trace the path, we collect the weights of all its edges. We use a frequency map (or an array since weights are small) to count how many times each weight appears. The total number of edges on the path is also counted. To minimize the number of edge modifications, we should change all edge weights to match the most frequent weight on the path. If the most frequent weight appears maxFreq times and the total path length is pathLength, we need to change pathLength - maxFreq edges. This process is repeated for every single query.
import java.util.*; class Solution { public int[] minOperationsQueries(int n, int[][] edges, int[][] queries) { List<int[]>[] adj = new ArrayList[n]; for (int i = 0; i < n; i++) { adj[i] = new ArrayList<>(); } for (int[] edge : edges) { adj[edge[0]].add(new int[]{edge[1], edge[2]}); adj[edge[1]].add(new int[]{edge[0], edge[2]}); } int[] result = new int[queries.length]; for (int i = 0; i < queries.length; i++) { int u = queries[i][0]; int v = queries[i][1]; if (u == v) { result[i] = 0; continue; } // Find path from u to v using BFS int[] parent = new int[n]; int[] edgeWeightToParent = new int[n]; Arrays.fill(parent, -1); Queue<Integer> q = new LinkedList<>(); q.offer(u); parent[u] = u; // Mark u as visited and its own parent while (!q.isEmpty()) { int curr = q.poll(); if (curr == v) { break; } for (int[] neighbor : adj[curr]) { int nextNode = neighbor[0]; int weight = neighbor[1]; if (parent[nextNode] == -1) { parent[nextNode] = curr; edgeWeightToParent[nextNode] = weight; q.offer(nextNode); } } } // Backtrack from v to u to get path weights Map<Integer, Integer> freq = new HashMap<>(); int pathLength = 0; int curr = v; while (curr != u) { int w = edgeWeightToParent[curr]; freq.put(w, freq.getOrDefault(w, 0) + 1); pathLength++; curr = parent[curr]; } int maxFreq = 0; for (int count : freq.values()) { maxFreq = Math.max(maxFreq, count); } result[i] = pathLength - maxFreq; } return result; }}Complexity
Time
O(M * N), where M is the number of queries and N is the number of nodes. For each of the M queries, we perform a traversal (BFS/DFS) which takes O(N) time in a tree.
Space
O(N), where N is the number of nodes. This is for storing the adjacency list, the parent array, and the queue for BFS for each query.
Trade-offs
Pros
Simple to understand and implement.
Requires no complex data structures or algorithms beyond basic graph traversal.
Cons
Highly inefficient for a large number of queries.
The time complexity of O(M * N) is too slow for the given constraints and will likely result in a Time Limit Exceeded (TLE) error.
A much more efficient approach involves precomputation. The key observation is that the path between any two nodes a and b goes through their Lowest Common Ancestor (LCA). By precomputing information about paths from the root to every node, we can answer queries about paths between any two nodes quickly. Specifically, we can precompute the counts of each edge weight on the path from the root to every node. This turns the problem of finding frequencies on a path a-b into a constant number of lookups using the precomputed values for a, b, and lca(a, b).
Algorithm
-
Preprocessing: a. Build an adjacency list for the tree. b. Perform a single DFS traversal starting from the root (e.g., node 0). During the traversal, compute: i.
depth[u]: The depth of each nodeu. ii.parent[u][0]: The direct parent ofu(the2^0-th ancestor). iii.counts[u][w]: An array of prefix counts, wherecounts[u][w]is the number of edges with weightwon the path from the root tou. c. Build a binary lifting tableparent[u][j]which stores the2^j-th ancestor ofu. This can be done with dynamic programming:parent[u][j] = parent[parent[u][j-1]][j-1]. -
Query Processing: a. For each query
(a, b): b. Find the Lowest Common Ancestor,l = lca(a, b), using the precomputed binary lifting table. This takesO(log N)time. c. The path fromatobis composed of the path fromatoland the path frombtol. The total number of edges on this path ispathLength = depth[a] + depth[b] - 2 * depth[l]. d. The frequency of any weightwon the path fromatobcan be calculated using the precomputed prefix counts:freq(w) = counts[a][w] + counts[b][w] - 2 * counts[l][w]. e. Iterate through all possible weights (1 to 26) to find the maximum frequencymaxFreqon the path. f. The result for the query ispathLength - maxFreq.
Walkthrough
This approach is divided into two phases: preprocessing and query answering.
Preprocessing:
First, we traverse the tree using DFS from a root (say, node 0). This traversal allows us to compute several key pieces of information for each node u:
depth[u]: The distance from the root tou.parent[u][0]: The immediate parent ofu.counts[u]: An array wherecounts[u][w]stores the total count of edges with weightwon the path from the root tou. This is a form of prefix sum on the tree.
Next, we use the parent[u][0] information to build a binary lifting table, parent[u][j], which allows us to find the 2^j-th ancestor of any node u in O(1) time. Building this table takes O(N log N) time.
Query Answering:
For each query (a, b):
- We first find their Lowest Common Ancestor,
l = lca(a, b), inO(log N)time using the binary lifting table. - The total number of edges on the path
a -> bisdepth[a] + depth[b] - 2 * depth[l]. - The count of a specific weight
won the patha -> bis the sum of counts on pathsroot -> aandroot -> b, minus the counts on the overlapping pathroot -> lcounted twice. So,freq(w) = counts[a][w] + counts[b][w] - 2 * counts[l][w]. - We compute this for all possible weights (1 to 26) to find the maximum frequency
maxFreq. - The final answer is
pathLength - maxFreq.
This method processes each query very quickly after an initial setup cost.
import java.util.*; class Solution { private static final int MAX_WEIGHT = 27; private List<int[]>[] adj; private int[][] parent; private int[][] counts; private int[] depth; private int LOG_N; public int[] minOperationsQueries(int n, int[][] edges, int[][] queries) { adj = new ArrayList[n]; for (int i = 0; i < n; i++) { adj[i] = new ArrayList<>(); } for (int[] edge : edges) { adj[edge[0]].add(new int[]{edge[1], edge[2]}); adj[edge[1]].add(new int[]{edge[0], edge[2]}); } LOG_N = (int) (Math.log(n) / Math.log(2)) + 1; parent = new int[n][LOG_N]; for (int i = 0; i < n; i++) { Arrays.fill(parent[i], -1); } counts = new int[n][MAX_WEIGHT]; depth = new int[n]; dfs(0, -1, 0); for (int j = 1; j < LOG_N; j++) { for (int i = 0; i < n; i++) { if (parent[i][j - 1] != -1) { parent[i][j] = parent[parent[i][j - 1]][j - 1]; } } } int[] result = new int[queries.length]; for (int i = 0; i < queries.length; i++) { int u = queries[i][0]; int v = queries[i][1]; if (u == v) { result[i] = 0; continue; } int lcaNode = lca(u, v); int pathLength = depth[u] + depth[v] - 2 * depth[lcaNode]; int maxFreq = 0; for (int w = 1; w < MAX_WEIGHT; w++) { int freqOnPath = counts[u][w] + counts[v][w] - 2 * counts[lcaNode][w]; maxFreq = Math.max(maxFreq, freqOnPath); } result[i] = pathLength - maxFreq; } return result; } private void dfs(int u, int p, int d) { parent[u][0] = p; depth[u] = d; for (int[] neighbor : adj[u]) { int v = neighbor[0]; int w = neighbor[1]; if (v != p) { System.arraycopy(counts[u], 0, counts[v], 0, MAX_WEIGHT); counts[v][w]++; dfs(v, u, d + 1); } } } private int lca(int u, int v) { if (depth[u] < depth[v]) { int temp = u; u = v; v = temp; } for (int j = LOG_N - 1; j >= 0; j--) { if (depth[u] - (1 << j) >= depth[v]) { u = parent[u][j]; } } if (u == v) return u; for (int j = LOG_N - 1; j >= 0; j--) { if (parent[u][j] != -1 && parent[u][j] != parent[v][j]) { u = parent[u][j]; v = parent[v][j]; } } return parent[u][0]; }}Complexity
Time
O((N+M) * (log N + W_max)), where N is nodes, M is queries, and W_max is max weight. Preprocessing takes O(N * (log N + W_max)) for DFS and building tables. Each query takes O(log N + W_max) for LCA and frequency calculation.
Space
O(N * (log N + W_max)), where N is the number of nodes and W_max is the maximum weight value. This space is used for the adjacency list, depth array, binary lifting table `parent` (O(N log N)), and the prefix counts table `counts` (O(N * W_max)).
Trade-offs
Pros
Extremely efficient for a large number of queries.
The 'query' part of the time complexity is very low, making it suitable for online query processing.
Cons
More complex to implement, requiring knowledge of advanced tree algorithms like LCA and binary lifting.
Requires significant preprocessing time and space, which might be overkill for a very small number of queries.
Solutions
Solution
class Solution {public int[] minOperationsQueries(int n, int[][] edges, int[][] queries) { int m = 32 - Integer.numberOfLeadingZeros(n); List<int[]>[] g = new List[n]; Arrays.setAll(g, i->new ArrayList<>()); int[][] f = new int[n][m]; int[] p = new int[n]; int[][] cnt = new int[n][0]; int[] depth = new int[n]; for (var e : edges) { int u = e[0], v = e[1], w = e[2] - 1; g[u].add(new int[]{v, w}); g[v].add(new int[]{u, w}); } cnt[0] = new int[26]; Deque<Integer> q = new ArrayDeque<>(); q.offer(0); while (!q.isEmpty()) { int i = q.poll(); f[i][0] = p[i]; for (int j = 1; j < m; ++j) { f[i][j] = f[f[i][j - 1]][j - 1]; } for (var nxt : g[i]) { int j = nxt[0], w = nxt[1]; if (j != p[i]) { p[j] = i; cnt[j] = cnt[i].clone(); cnt[j][w]++; depth[j] = depth[i] + 1; q.offer(j); } } } int k = queries.length; int[] ans = new int[k]; for (int i = 0; i < k; ++i) { int u = queries[i][0], v = queries[i][1]; int x = u, y = v; if (depth[x] < depth[y]) { int t = x; x = y; y = t; } for (int j = m - 1; j >= 0; --j) { if (depth[x] - depth[y] >= (1 << j)) { x = f[x][j]; } } for (int j = m - 1; j >= 0; --j) { if (f[x][j] != f[y][j]) { x = f[x][j]; y = f[y][j]; } } if (x != y) { x = p[x]; } int mx = 0; for (int j = 0; j < 26; ++j) { mx = Math.max(mx, cnt[u][j] + cnt[v][j] - 2 * cnt[x][j]); } ans[i] = depth[u] + depth[v] - 2 * depth[x] - mx; } return ans; }}class Solution {public: vector<int> minOperationsQueries(int n, vector<vector<int>> &edges, vector<vector<int>> &queries) { int m = 32 - __builtin_clz(n); vector<pair<int, int>> g[n]; int f[n][m]; int p[n]; int cnt[n][26]; int depth[n]; memset(f, 0, sizeof(f)); memset(cnt, 0, sizeof(cnt)); memset(depth, 0, sizeof(depth)); memset(p, 0, sizeof(p)); for (auto &e : edges) { int u = e[0], v = e[1], w = e[2] - 1; g[u].emplace_back(v, w); g[v].emplace_back(u, w); } queue<int> q; q.push(0); while (!q.empty()) { int i = q.front(); q.pop(); f[i][0] = p[i]; for (int j = 1; j < m; ++j) { f[i][j] = f[f[i][j - 1]][j - 1]; } for (auto &[j, w] : g[i]) { if (j != p[i]) { p[j] = i; memcpy(cnt[j], cnt[i], sizeof(cnt[i])); cnt[j][w]++; depth[j] = depth[i] + 1; q.push(j); } } } vector<int> ans; for (auto &qq : queries) { int u = qq[0], v = qq[1]; int x = u, y = v; if (depth[x] < depth[y]) { swap(x, y); } for (int j = m - 1; ~j; --j) { if (depth[x] - depth[y] >= (1 << j)) { x = f[x][j]; } } for (int j = m - 1; ~j; --j) { if (f[x][j] != f[y][j]) { x = f[x][j]; y = f[y][j]; } } if (x != y) { x = p[x]; } int mx = 0; for (int j = 0; j < 26; ++j) { mx = max(mx, cnt[u][j] + cnt[v][j] - 2 * cnt[x][j]); } ans.push_back(depth[u] + depth[v] - 2 * depth[x] - mx); } return ans; }};class Solution: def minOperationsQueries(self, n: int, edges: List[List[int]], queries: List[List[int]]) -> List[int]: m = n . bit_length() g = [[] for _ in range(n)] f = [[0] * m for _ in range(n)] p = [0] * n cnt = [None] * n depth = [0] * n for u, v, w in edges: g[u]. append((v, w - 1)) g[v]. append((u, w - 1)) cnt[0] = [0] * 26 q = deque([0]) while q: i = q . popleft() f[i][0] = p[i] for j in range(1, m): f[i][j] = f[f[i][j - 1]][j - 1] for j, w in g[i]: if j != p[i]: p[j] = i cnt[j] = cnt[i][:] cnt[j][w] += 1 depth[j] = depth[i] + 1 q . append(j) ans = [] for u, v in queries: x, y = u, v if depth[x] < depth[y]: x, y = y, x for j in reversed(range(m)): if depth[x] - depth[y] >= (1 << j): x = f[x][j] for j in reversed(range(m)): if f[x][j] != f[y][j]: x, y = f[x][j], f[y][j] if x != y: x = p[x] mx = max(cnt[u][j] + cnt[v][j] - 2 * cnt[x][j] for j in range(26)) ans . append(depth[u] + depth[v] - 2 * depth[x] - mx) return ansVideo walkthrough
Newsletter
One sharp idea, every week
System design and interview prep — short enough to finish.
No spam. Unsubscribe anytime.
Practice
Same difficulty — related problems to reinforce the pattern.