4 minute read

Problem StatementPermalink

problem

IntuitionPermalink

This algorithm aims to determine the number of complete connected components in an undirected graph. A complete component is a connected subgraph where every node is directly connected to every other node within the component.

ApproachPermalink

  1. Union-Find Data Structure
    • Initialize a Union-Find (Disjoint Set Union) data structure to efficiently manage connected components.
    • The find method implements path compression for efficient lookups.
    • The union method connects two nodes, merging their components while maintaining rank to optimize merging.
  2. Building Components

    • Traverse the given edge list and use the union function to group connected nodes.
    • After processing all edges, apply find on each node to ensure all connections are properly linked.
    • Use a dictionary to store nodes grouped by their root representatives.
  3. Checking Completeness
    • Iterate over each component and verify if all possible edges exist between its nodes.
    • If any expected edge is missing, the component is not complete.
    • Count only the complete components.

ComplexityPermalink

  • Time Complexity:

    • Union-Find operations: The find and union operations run in nearly constant time, i.e., O(α(n)), where α is the inverse Ackermann function.
    • Edge processing: O(E), where E is the number of edges.
    • Completeness check: In the worst case, O(V²) where V is the number of nodes in a component.
    • Overall, the worst-case complexity is O(V² + E).
  • Space Complexity:

    • O(V) for storing parent/root and rank arrays.
    • O(V) for the components dictionary.
    • Total space complexity is O(V + E).

CodePermalink

from collections import defaultdict
from typing import List

class UnionFind:
    def __init__(self, n):
        self.root = [i for i in range(n)]
        self.rank = [0] * n
        self.num_of_components = n

    def find(self, x):
        if self.root[x] == x:
            return x
        self.root[x] = self.find(self.root[x])  # Path compression
        return self.root[x]

    def union(self, x, y):
        rootX = self.find(x)
        rootY = self.find(y)
        if rootX == rootY:
            return

        if self.rank[rootX] < self.rank[rootY]:
            self.root[rootX] = rootY
        elif self.rank[rootX] > self.rank[rootY]:
            self.root[rootY] = rootX
        else:
            self.root[rootY] = rootX
            self.rank[rootX] += 1

        self.num_of_components -= 1

class Solution:
    def countCompleteComponents(self, n: int, edges: List[List[int]]) -> int:
        if not edges:
            return n

        uf = UnionFind(n)
        components = defaultdict(list)

        for x, y in edges:
            uf.union(x, y)

        for i in range(n):
            uf.find(i)  # Ensure proper root assignment

        for i, root in enumerate(uf.root):
            components[root].append(i)

        res = uf.num_of_components

        for root, nodes in components.items():
            n = len(nodes)
            is_completed = True
            for i in range(n):
                p = nodes[i]
                for j in range(i + 1, n):
                    q = nodes[j]
                    if [p, q] not in edges and [q, p] not in edges:
                        is_completed = False
                        break
            if not is_completed:
                res -= 1

        return max(res, 0)

EditorialPermalink

Approach 1: Adjacency ListPermalink

class Solution:
    def countCompleteComponents(self, n: int, edges: List[List[int]]) -> int:
        # Adjacency lists for each vertex
        graph = [[] for _ in range(n)]
        # Map to store frequency of each unique adjacency list
        component_freq = defaultdict(int)

        # Initialize adjacency lists with self-loops
        for vertex in range(n):
            graph[vertex] = [vertex]

        # Build adjacency lists from edges
        for v1, v2 in edges:
            graph[v1].append(v2)
            graph[v2].append(v1)

        # Count frequency of each unique adjacency pattern
        for vertex in range(n):
            neighbors = tuple(sorted(graph[vertex]))
            component_freq[neighbors] += 1

        # Count complete components where size equals frequency
        return sum(
            1
            for neighbors, freq in component_freq.items()
            if len(neighbors) == freq
        )

Approach 2: Depth-First Search (DFS)Permalink

class Solution:
    def countCompleteComponents(self, n: int, edges: List[List[int]]) -> int:
        # Adjacency lists for each vertex
        graph = defaultdict(list)

        # Build adjacency lists from edges
        for v1, v2 in edges:
            graph[v1].append(v2)
            graph[v2].append(v1)

        complete_count = 0
        visited = set()

        def _dfs(curr: int, info: list) -> None:
            visited.add(curr)
            info[0] += 1  # Increment vertex count
            info[1] += len(graph[curr])  # Add edges from current vertex

            # Explore unvisited neighbors
            for next_vertex in graph[curr]:
                if next_vertex not in visited:
                    _dfs(next_vertex, info)

        # Process each unvisited vertex
        for vertex in range(n):
            if vertex in visited:
                continue

            # info[0] = vertices count, info[1] = total edges count
            component_info = [0, 0]
            _dfs(vertex, component_info)

            # Check if component is complete - edges should be vertices * (vertices-1)
            if component_info[0] * (component_info[0] - 1) == component_info[1]:
                complete_count += 1

        return complete_count

Approach 3: Breadth-First Search (BFS)Permalink

class Solution:
    def countCompleteComponents(self, n: int, edges: list[list[int]]) -> int:
        # Create adjacency list representation of the graph
        graph = [[] for _ in range(n)]

        # Build graph from edges
        for u, v in edges:
            graph[u].append(v)
            graph[v].append(u)

        visited = [False] * n
        complete_components = 0

        # Process each unvisited vertex
        for vertex in range(n):
            if not visited[vertex]:
                # BFS to find all vertices in the current component
                component = []
                queue = [vertex]
                visited[vertex] = True

                while queue:
                    current = queue.pop(0)
                    component.append(current)

                    # Process neighbors
                    for neighbor in graph[current]:
                        if not visited[neighbor]:
                            queue.append(neighbor)
                            visited[neighbor] = True

                # Check if component is complete (all vertices have the right number of edges)
                is_complete = True
                for node in component:
                    if len(graph[node]) != len(component) - 1:
                        is_complete = False
                        break

                if is_complete:
                    complete_components += 1

        return complete_components

Approach 4: Disjoint Set Union (Union-Find)Permalink

class UnionFind:
    def __init__(self, n):
        self.parent = [-1] * n
        self.size = [1] * n

    def _find(self, node):
        # Find root of component with path compression
        if self.parent[node] == -1:
            return node
        self.parent[node] = self._find(self.parent[node])
        return self.parent[node]

    def _union(self, node_1, node_2):
        # Union by size
        root_1 = self._find(node_1)
        root_2 = self._find(node_2)

        if root_1 == root_2:
            return

        # Merge smaller component into larger one
        if self.size[root_1] > self.size[root_2]:
            self.parent[root_2] = root_1
            self.size[root_1] += self.size[root_2]
        else:
            self.parent[root_1] = root_2
            self.size[root_2] += self.size[root_1]


class Solution:
    def countCompleteComponents(self, n: int, edges: List[List[int]]) -> int:
        # Initialize Union Find and edge counter
        dsu = UnionFind(n)
        edge_count = {}

        # Connect components using edges
        for edge in edges:
            dsu._union(edge[0], edge[1])

        # Count edges in each component
        for edge in edges:
            root = dsu._find(edge[0])
            edge_count[root] = edge_count.get(root, 0) + 1

        # Check if each component is complete
        complete_count = 0
        for vertex in range(n):
            if dsu._find(vertex) == vertex:  # If vertex is root
                node_count = dsu.size[vertex]
                expected_edges = (node_count * (node_count - 1)) // 2
                if edge_count.get(vertex, 0) == expected_edges:
                    complete_count += 1

        return complete_count