Facebook Pixel

2782. Number of Unique Categories 🔒

MediumUnion FindCountingInteractive
Leetcode Link

Problem Description

You have n elements numbered from 0 to n - 1, where each element belongs to a certain category. Your goal is to determine how many unique categories exist among these elements.

To help you solve this problem, you're provided with a CategoryHandler object that has one method:

  • haveSameCategory(a, b): This method takes two integers a and b and returns true if both elements belong to the same category, or false if they belong to different categories. The method also returns false if either a or b is invalid (less than 0 or greater than or equal to n).

The solution uses a Union-Find (Disjoint Set Union) data structure to group elements that belong to the same category. Here's how it works:

  1. Initialize parent array: Create an array p where each element initially points to itself as its parent (p[i] = i), meaning each element starts in its own category.

  2. Find function with path compression: The find(x) function returns the root representative of element x's category. It uses path compression (p[x] = find(p[x])) to optimize future lookups.

  3. Group elements by category: For every pair of elements (a, b) where a < b, check if they belong to the same category using haveSameCategory(a, b). If they do, union their sets by making the root of a point to the root of b.

  4. Count unique categories: After processing all pairs, count how many elements are their own root (i == p[i]). Each root represents a unique category.

The time complexity is O(n² × α(n)) where α(n) is the inverse Ackermann function (practically constant), since we check all pairs of elements. The space complexity is O(n) for the parent array.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

The key insight is recognizing that this is fundamentally a grouping problem. We need to identify which elements belong together in the same category, even though we don't know what the categories actually are - we can only check if two elements share the same category.

Think of it like this: imagine you have a collection of colored balls, but you're blindfolded. You can't see the colors, but you have a helper who can tell you whether any two balls you pick have the same color. How would you figure out how many different colors there are?

The natural approach is to compare elements pairwise and group them together when they match. If element a has the same category as element b, and element b has the same category as element c, then all three must be in the same category. This transitivity property suggests using a Union-Find data structure.

Why Union-Find? Because:

  1. We start with each element potentially being in its own category (worst case: n categories if all elements are different)
  2. As we discover that two elements share a category, we need to merge their groups
  3. We need to efficiently track which elements belong to the same group even after multiple merges
  4. At the end, we need to count the number of distinct groups

The Union-Find structure elegantly handles all these requirements. By checking all possible pairs (a, b) and unioning them when they share a category, we progressively build up the complete picture of which elements belong together. The final count of root elements (elements that are their own parent) gives us the number of unique categories, since each group has exactly one root representing that entire category.

Learn more about Union Find patterns.

Solution Approach

The solution implements a Union-Find (Disjoint Set Union) data structure with path compression to efficiently group elements by their categories.

Step 1: Initialize the parent array

p = list(range(n))

Create a parent array where p[i] = i for all elements from 0 to n-1. Initially, each element is its own parent, representing that it's in its own separate category.

Step 2: Define the find function with path compression

def find(x: int) -> int:
    if p[x] != x:
        p[x] = find(p[x])
    return p[x]

This recursive function finds the root representative of element x's category. The path compression optimization (p[x] = find(p[x])) directly connects x to its root, flattening the tree structure for faster future lookups.

Step 3: Check all pairs and union categories

for a in range(n):
    for b in range(a + 1, n):
        if categoryHandler.haveSameCategory(a, b):
            p[find(a)] = find(b)

Iterate through all unique pairs (a, b) where a < b. For each pair:

  • Call haveSameCategory(a, b) to check if they belong to the same category
  • If they do, perform a union operation: p[find(a)] = find(b)
  • This makes the root of a's group point to the root of b's group, effectively merging the two groups

Step 4: Count unique categories

return sum(i == x for i, x in enumerate(p))

After processing all pairs, count how many elements satisfy p[i] == i. These are the root elements, each representing a unique category. The total count gives us the number of distinct categories.

The algorithm ensures that all elements belonging to the same category will share the same root in the Union-Find structure, making the final count accurate and efficient.

Ready to land your dream job?

Unlock your dream job with a 5-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's walk through a small example with n = 5 elements, where the actual categories are:

  • Elements 0, 2, 4 belong to category A
  • Elements 1, 3 belong to category B

Initial Setup:

Parent array p = [0, 1, 2, 3, 4]
Each element points to itself

Step 1: Check pairs and union same categories

Checking pair (0, 1): haveSameCategory(0, 1) returns false (different categories)

  • No union needed
  • p = [0, 1, 2, 3, 4]

Checking pair (0, 2): haveSameCategory(0, 2) returns true (both in category A)

  • Union: p[find(0)] = find(2) → p[0] = 2
  • p = [2, 1, 2, 3, 4]

Checking pair (0, 3): haveSameCategory(0, 3) returns false

  • No union needed
  • p = [2, 1, 2, 3, 4]

Checking pair (0, 4): haveSameCategory(0, 4) returns true (both in category A)

  • find(0) returns 2 (follows 0→2)
  • Union: p[find(0)] = find(4) → p[2] = 4
  • p = [2, 1, 4, 3, 4]

Checking pair (1, 2): haveSameCategory(1, 2) returns false

  • No union needed

Checking pair (1, 3): haveSameCategory(1, 3) returns true (both in category B)

  • Union: p[find(1)] = find(3) → p[1] = 3
  • p = [2, 3, 4, 3, 4]

Checking pair (1, 4): haveSameCategory(1, 4) returns false

  • No union needed

Checking pair (2, 3): haveSameCategory(2, 3) returns false

  • No union needed

Checking pair (2, 4): haveSameCategory(2, 4) returns true (both in category A)

  • find(2) returns 4 (follows 2→4)
  • find(4) returns 4
  • Union: p[4] = 4 (already connected)

Checking pair (3, 4): haveSameCategory(3, 4) returns false

  • No union needed

Step 2: Count unique categories

Final parent array with path compression applied:

  • find(0) = 4 (follows 0→2→4)
  • find(1) = 3 (follows 1→3)
  • find(2) = 4 (follows 2→4)
  • find(3) = 3 (itself)
  • find(4) = 4 (itself)

After path compression: p = [4, 3, 4, 3, 4]

Count elements where i == p[i]:

  • i=0: 0 ≠ 4 ✗
  • i=1: 1 ≠ 3 ✗
  • i=2: 2 ≠ 4 ✗
  • i=3: 3 = 3 ✓
  • i=4: 4 = 4 ✓

Result: 2 unique categories (elements 3 and 4 are roots representing categories B and A respectively)

Solution Implementation

1# Definition for a category handler.
2# class CategoryHandler:
3#     def haveSameCategory(self, a: int, b: int) -> bool:
4#         pass
5
6from typing import Optional
7
8class Solution:
9    def numberOfCategories(
10        self, n: int, categoryHandler: Optional['CategoryHandler']
11    ) -> int:
12        """
13        Counts the number of distinct categories among n items.
14      
15        Args:
16            n: The total number of items (indexed from 0 to n-1)
17            categoryHandler: An object that can determine if two items belong to the same category
18          
19        Returns:
20            The number of distinct categories
21        """
22      
23        def find_root(node: int) -> int:
24            """
25            Finds the root of the set containing the given node.
26            Implements path compression for optimization.
27          
28            Args:
29                node: The node whose root we want to find
30              
31            Returns:
32                The root node of the set containing the input node
33            """
34            if parent[node] != node:
35                # Path compression: directly connect node to its root
36                parent[node] = find_root(parent[node])
37            return parent[node]
38      
39        # Initialize parent array where each node is its own parent (self-loop)
40        # This represents n disjoint sets initially
41        parent = list(range(n))
42      
43        # Check all pairs of items to determine if they belong to the same category
44        for item_a in range(n):
45            for item_b in range(item_a + 1, n):
46                # If two items have the same category, union their sets
47                if categoryHandler.haveSameCategory(item_a, item_b):
48                    # Union operation: connect the roots of both sets
49                    root_a = find_root(item_a)
50                    root_b = find_root(item_b)
51                    parent[root_a] = root_b
52      
53        # Count the number of distinct sets (categories)
54        # A node is a root if its parent is itself
55        num_categories = sum(1 for node, parent_node in enumerate(parent) if node == parent_node)
56      
57        return num_categories
58
1/**
2 * Definition for a category handler.
3 * class CategoryHandler {
4 *     public CategoryHandler(int[] categories);
5 *     public boolean haveSameCategory(int a, int b);
6 * };
7 */
8class Solution {
9    // Parent array for Union-Find data structure
10    private int[] parent;
11
12    /**
13     * Finds the number of distinct categories among n items
14     * using Union-Find algorithm to group items in the same category
15     * 
16     * @param n The total number of items
17     * @param categoryHandler Handler to check if two items belong to the same category
18     * @return The number of distinct categories
19     */
20    public int numberOfCategories(int n, CategoryHandler categoryHandler) {
21        // Initialize parent array where each item is its own parent initially
22        parent = new int[n];
23        for (int i = 0; i < n; ++i) {
24            parent[i] = i;
25        }
26      
27        // Check all pairs of items and union them if they belong to the same category
28        for (int itemA = 0; itemA < n; ++itemA) {
29            for (int itemB = itemA + 1; itemB < n; ++itemB) {
30                if (categoryHandler.haveSameCategory(itemA, itemB)) {
31                    // Union the two items by connecting their root parents
32                    parent[find(itemA)] = find(itemB);
33                }
34            }
35        }
36      
37        // Count the number of distinct categories (root parents)
38        int categoryCount = 0;
39        for (int i = 0; i < n; ++i) {
40            // An item is a root if it's its own parent
41            if (i == parent[i]) {
42                ++categoryCount;
43            }
44        }
45      
46        return categoryCount;
47    }
48
49    /**
50     * Finds the root parent of an item using path compression
51     * 
52     * @param x The item whose root parent needs to be found
53     * @return The root parent of the item
54     */
55    private int find(int x) {
56        // Path compression: directly connect x to its root parent
57        if (parent[x] != x) {
58            parent[x] = find(parent[x]);
59        }
60        return parent[x];
61    }
62}
63
1/**
2 * Definition for a category handler.
3 * class CategoryHandler {
4 * public:
5 *     CategoryHandler(vector<int> categories);
6 *     bool haveSameCategory(int a, int b);
7 * };
8 */
9class Solution {
10public:
11    int numberOfCategories(int n, CategoryHandler* categoryHandler) {
12        // Initialize parent array for Union-Find data structure
13        // Each element initially points to itself as parent
14        vector<int> parent(n);
15        iota(parent.begin(), parent.end(), 0);
16      
17        // Find function with path compression
18        // Returns the root parent of element x
19        function<int(int)> findRoot = [&](int x) {
20            if (parent[x] != x) {
21                // Path compression: make x point directly to root
22                parent[x] = findRoot(parent[x]);
23            }
24            return parent[x];
25        };
26      
27        // Check all pairs of elements and union them if they belong to the same category
28        for (int elementA = 0; elementA < n; ++elementA) {
29            for (int elementB = elementA + 1; elementB < n; ++elementB) {
30                // If two elements have the same category, union their sets
31                if (categoryHandler->haveSameCategory(elementA, elementB)) {
32                    // Union operation: make root of elementA point to root of elementB
33                    parent[findRoot(elementA)] = findRoot(elementB);
34                }
35            }
36        }
37      
38        // Count the number of distinct categories (connected components)
39        // An element is a root if it points to itself
40        int categoryCount = 0;
41        for (int i = 0; i < n; ++i) {
42            if (i == parent[i]) {
43                categoryCount++;
44            }
45        }
46      
47        return categoryCount;
48    }
49};
50
1/**
2 * Definition for a category handler.
3 * class CategoryHandler {
4 *     constructor(categories: number[]);
5 *     public haveSameCategory(a: number, b: number): boolean;
6 * }
7 */
8
9/**
10 * Counts the number of distinct categories using Union-Find algorithm
11 * @param n - Total number of items to categorize
12 * @param categoryHandler - Handler to check if two items belong to the same category
13 * @returns The number of distinct categories
14 */
15function numberOfCategories(n: number, categoryHandler: CategoryHandler): number {
16    // Initialize parent array where each element is its own parent (self-representative)
17    const parent: number[] = Array.from({ length: n }, (_, index) => index);
18  
19    /**
20     * Finds the root representative of a given element using path compression
21     * @param element - The element whose root we want to find
22     * @returns The root representative of the element
23     */
24    const findRoot = (element: number): number => {
25        // Path compression: directly connect element to its root
26        if (parent[element] !== element) {
27            parent[element] = findRoot(parent[element]);
28        }
29        return parent[element];
30    };
31  
32    // Check all pairs of items and union them if they belong to the same category
33    for (let itemA = 0; itemA < n; itemA++) {
34        for (let itemB = itemA + 1; itemB < n; itemB++) {
35            // If two items share the same category, merge their sets
36            if (categoryHandler.haveSameCategory(itemA, itemB)) {
37                const rootA = findRoot(itemA);
38                const rootB = findRoot(itemB);
39                // Union operation: make one root point to the other
40                parent[rootA] = rootB;
41            }
42        }
43    }
44  
45    // Count the number of distinct sets (categories)
46    let categoryCount = 0;
47    for (let i = 0; i < n; i++) {
48        // An element is a root representative if it points to itself
49        if (i === parent[i]) {
50            categoryCount++;
51        }
52    }
53  
54    return categoryCount;
55}
56

Time and Space Complexity

Time Complexity: O(n² × α(n))

The algorithm uses a Union-Find (Disjoint Set Union) data structure with path compression. Breaking down the time complexity:

  • The outer loop runs n times
  • The inner loop runs approximately n/2 times on average (from a+1 to n)
  • Total number of iterations: n × (n-1) / 2 = O(n²)
  • For each iteration, we call haveSameCategory() which takes O(1) time
  • When categories match, we perform two find() operations and one union operation
  • With path compression, find() operations have an amortized time complexity of O(α(n)), where α is the inverse Ackermann function (practically constant, but technically grows very slowly)
  • The final counting loop takes O(n) time

Overall: O(n²) iterations × O(α(n)) per iteration = O(n² × α(n))

For practical purposes, this can be considered O(n²) since α(n) is effectively constant for all reasonable values of n.

Space Complexity: O(n)

The space usage consists of:

  • The parent array p which stores n integers: O(n)
  • The recursive call stack for find() function: worst case O(n) if the tree becomes a chain before path compression occurs
  • No additional data structures are used

Total space complexity: O(n)

Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Not Finding Roots Before Union Operation

A critical mistake is directly connecting nodes without finding their roots first. This creates incorrect connections and fails to properly merge entire groups.

Incorrect Implementation:

# WRONG: Directly connecting nodes instead of roots
if categoryHandler.haveSameCategory(item_a, item_b):
    parent[item_a] = item_b  # This is incorrect!

Why it fails: If item_a is already part of a larger group with root r1, and item_b is part of another group with root r2, this code only connects item_a to item_b, leaving the rest of item_a's group disconnected from item_b's group.

Correct Implementation:

if categoryHandler.haveSameCategory(item_a, item_b):
    root_a = find_root(item_a)
    root_b = find_root(item_b)
    parent[root_a] = root_b  # Connect the roots, not the nodes

2. Incorrect Final Count Due to Not Updating Parent Array

After all union operations, the parent array might not have all nodes directly pointing to their ultimate root due to path compression only happening during find operations.

Problem: Counting parent[i] == i without ensuring all paths are compressed can give incorrect results.

Solution: Call find_root on all nodes before counting to ensure complete path compression:

# Ensure all paths are compressed
for i in range(n):
    find_root(i)

# Now count the roots
num_categories = sum(1 for i in range(n) if parent[i] == i)

3. Redundant Union Operations

Performing union operations when two elements are already in the same category wastes computation.

Optimization:

if categoryHandler.haveSameCategory(item_a, item_b):
    root_a = find_root(item_a)
    root_b = find_root(item_b)
    if root_a != root_b:  # Only union if they're in different sets
        parent[root_a] = root_b

This check prevents unnecessary writes to the parent array when elements are already grouped together, which can happen frequently as the algorithm progresses.

Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

How many ways can you arrange the three letters A, B and C?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More