LeetCode 3887 - Incremental Even-Weighted Cycle Queries

This problem describes a dynamic process of building an undirected graph with weighted edges where the weights are binary, either 0 or 1. You start with n isolated nodes labeled from 0 to n-1.

LeetCode Problem 3887

Difficulty: 🔴 Hard
Topics: Union-Find, Graph Theory

Solution

Problem Understanding

This problem describes a dynamic process of building an undirected graph with weighted edges where the weights are binary, either 0 or 1. You start with n isolated nodes labeled from 0 to n-1. You are given a list of potential edges with weights, and you must add each edge in the given order only if doing so preserves the property that all cycles in the graph have an even total weight.

In simpler terms, each time you consider adding an edge, you are effectively checking if adding it would create a cycle whose sum of edge weights is odd. If it does, you skip the edge; if it does not, you add it. The goal is to count how many edges are successfully added.

The input consists of an integer n and a list of edges, where each edge is [u, v, w]. The output is a single integer indicating the number of edges added.

Constraints tell us that both n and the number of edges can be up to 5 * 10^4, so any solution that requires explicitly enumerating all cycles or doing repeated graph traversals per edge will be too slow. The edge weights are binary, which hints that XOR operations or parity tracking may be useful.

Edge cases include attempting to add an edge between nodes already connected in a cycle and edges with weight 0, which never increase the parity of the cycle sum.

Approaches

Brute Force Approach

A brute-force method would attempt to add each edge and then enumerate all cycles in the graph to check if every cycle has even weight. One way to do this is using DFS or BFS from every node to detect cycles and compute their weights. While correct in principle, this approach is far too slow for n up to 50,000 because cycle detection in general graphs can be O(n^3) in the worst case. Storing all cycles would also require exponential space in dense graphs.

Optimal Approach

The key insight is that the problem is equivalent to maintaining a graph with binary-weighted edges where the XOR of edge weights along any cycle must be 0 modulo 2. This can be efficiently tracked using a Union-Find data structure with parity tracking.

The idea is to maintain a disjoint-set for connected components and track, for each node, the XOR of weights along the path to the root of its set. When adding an edge [u, v, w], we can determine if u and v are already connected and whether adding the edge preserves the even-cycle condition:

  1. If u and v are in different components, adding the edge cannot create a cycle, so we can safely add it and union the components.
  2. If u and v are in the same component, adding the edge would form a cycle. The XOR of weights along the path from u to v plus the new edge weight must be even (0 modulo 2) to allow the edge. If it is odd, we skip the edge.

This approach allows processing each edge in nearly constant amortized time using union-find with path compression.

Approach Time Complexity Space Complexity Notes
Brute Force O(n^3) O(n^2) Detect all cycles explicitly, too slow
Optimal O(alpha(n) * m) O(n) Union-Find with parity tracking, efficient

Here alpha(n) is the inverse Ackermann function, practically constant, and m is the number of edges.

Algorithm Walkthrough

  1. Initialize a Union-Find (Disjoint Set Union) structure for n nodes, with an additional array parity[i] that stores the XOR sum of weights from node i to its component root.
  2. Iterate over each edge [u, v, w] in the input.
  3. For each edge, find the root of u and v. Compute the XOR sum of weights along the path from u to its root and from v to its root.
  4. If u and v are in different components, union them and update the parity array to maintain the path XOR consistency.
  5. If u and v are in the same component, check whether the XOR sum along the path plus the new edge weight w is even. If yes, the edge can be added; otherwise, skip it.
  6. Keep a counter of successfully added edges and return it after processing all edges.

Why it works: This approach maintains the invariant that for every connected component, the XOR along any path between two nodes accurately reflects the parity of any cycle. Adding an edge either merges components without forming a cycle or validates the cycle parity using the XOR property.

Python Solution

from typing import List

class Solution:
    def numberOfEdgesAdded(self, n: int, edges: List[List[int]]) -> int:
        parent = list(range(n))
        parity = [0] * n  # parity from node to root

        def find(u: int) -> int:
            if parent[u] != u:
                orig = parent[u]
                parent[u] = find(parent[u])
                parity[u] ^= parity[orig]
            return parent[u]

        def union(u: int, v: int, w: int) -> bool:
            ru, rv = find(u), find(v)
            if ru != rv:
                parent[ru] = rv
                parity[ru] = parity[u] ^ parity[v] ^ w
                return True
            # u and v already connected, check cycle parity
            if (parity[u] ^ parity[v] ^ w) == 0:
                return True
            return False

        count = 0
        for u, v, w in edges:
            if union(u, v, w):
                count += 1
        return count

Implementation Explanation:

The find function implements path compression and updates the parity of nodes to reflect the XOR of weights along the path to the root. The union function either merges two disjoint components while preserving parity, or, if the nodes are already connected, checks if adding the edge would maintain even cycles. The counter increments only for successfully added edges.

Go Solution

func numberOfEdgesAdded(n int, edges [][]int) int {
    parent := make([]int, n)
    parity := make([]int, n)
    for i := 0; i < n; i++ {
        parent[i] = i
    }

    var find func(int) int
    find = func(u int) int {
        if parent[u] != u {
            orig := parent[u]
            parent[u] = find(parent[u])
            parity[u] ^= parity[orig]
        }
        return parent[u]
    }

    union := func(u, v, w int) bool {
        ru, rv := find(u), find(v)
        if ru != rv {
            parent[ru] = rv
            parity[ru] = parity[u] ^ parity[v] ^ w
            return true
        }
        if (parity[u] ^ parity[v] ^ w) == 0 {
            return true
        }
        return false
    }

    count := 0
    for _, e := range edges {
        if union(e[0], e[1], e[2]) {
            count++
        }
    }
    return count
}

Go Notes: The Go solution mirrors Python closely. Slices are initialized for parent and parity arrays. The XOR operation and union-find logic are identical. Function closures are used for find and union to simplify the code.

Worked Examples

Example 1

Input: n = 3, edges = [[0,1,1],[1,2,1],[0,2,1]]

Step Edge Action Parent Parity Count
1 [0,1,1] Different roots, union [1,1,2] [1,0,0] 1
2 [1,2,1] Different roots, union [1,2,2] [1,1,0] 2
3 [0,2,1] Same component, parity=1^0^1=0? No, 1 unchanged unchanged 2

Output: 2

Example 2

Input: n = 3, edges = [[0,1,1],[1,2,1],[0,2,0]]

Step Edge Action Parent Parity Count
1 [0,1,1] Union [1,1,2] [1,0,0] 1
2 [1,2,1] Union [1,2,2] [1,1,0] 2
3 [0,2,0] Same component, parity=1^0^0=1? Even? Yes, add unchanged unchanged 3

Output: 3

Complexity Analysis

Measure Complexity Explanation
Time O(alpha(n) * m) Each union/find operation is nearly constant; m is the number of edges
Space O(n) Parent and parity arrays for