Facebook Pixel

3493. Properties Graph

Problem Description

You are given a 2D integer array properties with dimensions n x m, where each row represents a set of properties, and an integer k.

The problem asks you to build an undirected graph based on the following rules:

  • Each index i (from 0 to n-1) represents a node corresponding to properties[i]
  • Two nodes i and j are connected by an edge if and only if they share at least k distinct integers in common
  • The connection is undirected (if i connects to j, then j connects to i)

To determine if two nodes should be connected, you need to count the number of distinct integers that appear in both properties[i] and properties[j]. If this count is greater than or equal to k, an edge exists between nodes i and j.

Once the graph is constructed, you need to find and return the total number of connected components in the graph. A connected component is a group of nodes where you can reach any node from any other node within the same group by following edges.

For example, if properties = [[1,2,3], [1,2], [4,5], [1,2,4]] and k = 2:

  • Nodes 0 and 1 are connected (they share {1,2}, which is 2 elements)
  • Nodes 0 and 3 are connected (they share {1,2}, which is 2 elements)
  • Nodes 1 and 3 are connected (they share {1,2}, which is 2 elements)
  • Node 2 is not connected to any other node
  • This results in 2 connected components: {0,1,3} and {2}

Flowchart Walkthrough

First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:

Is it a graph?

  • Yes: The problem explicitly asks us to construct an undirected graph where nodes represent indices of the properties array, and edges exist between nodes based on the intersection condition.

Is it a tree?

  • No: The graph is not necessarily a tree. It can have cycles (for example, if nodes A, B, and C all share at least k common elements with each other, they form a cycle). Also, the graph might not be connected - it can have multiple disconnected components.

Is the problem related to directed acyclic graphs?

  • No: The problem states we need to build an "undirected" graph, not a directed one.

Is the problem related to shortest paths?

  • No: We're not looking for shortest paths between nodes. We need to find the number of connected components.

Does the problem involve connectivity?

  • Yes: The core task is to find the number of connected components in the graph, which is fundamentally a connectivity problem.

Disjoint Set Union?

  • While DSU could work here, the solution uses DFS instead. Both approaches are valid for finding connected components.

Does the problem have small constraints?

  • Not necessarily specified, but since we're dealing with connectivity in a general graph, we proceed with standard graph traversal.

DFS/backtracking?

  • Yes: DFS is the chosen approach to traverse each connected component and count them.

Conclusion: The flowchart leads us to use DFS for solving this connectivity problem. We build the graph based on the intersection criteria, then use DFS to visit all nodes in each connected component, counting how many times we initiate a new DFS traversal (which equals the number of connected components).

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 a graph connectivity problem in disguise. Each row in the properties array can be thought of as a node, and we need to determine which nodes should be connected based on their shared elements.

To find shared elements efficiently, we can convert each property array into a set. Sets allow us to quickly compute intersections using the & operator in Python. The size of the intersection tells us how many distinct integers two property arrays have in common.

Once we understand the graph structure, the problem becomes: "How many isolated groups of nodes exist?" This is the classic connected components problem. We need to traverse the graph and count how many separate "islands" or groups of connected nodes we have.

Why DFS? When we start from an unvisited node, DFS will explore all nodes reachable from it - essentially exploring the entire connected component that node belongs to. By marking nodes as visited during traversal, we ensure we don't count the same component multiple times. Each time we need to start a fresh DFS from an unvisited node, we've found a new connected component.

The algorithm flow becomes:

  1. Build the graph by checking all pairs of property arrays and connecting those with at least k common elements
  2. Traverse the graph using DFS, counting how many times we initiate a new traversal
  3. Each new DFS initiation represents discovering a new connected component

The beauty of this approach is that it transforms a seemingly complex problem about array intersections into a straightforward graph traversal problem that can be solved with a standard DFS template.

Learn more about Depth-First Search, Breadth-First Search, Union Find and Graph patterns.

Solution Approach

The implementation follows the DFS approach for finding connected components, with an optimization for computing intersections using sets.

Step 1: Convert Arrays to Sets

We first convert each property array into a set using ss = list(map(set, properties)). This transformation is crucial because:

  • Set intersection operations are much faster than comparing arrays element by element
  • The & operator in Python directly gives us the intersection of two sets
  • We can get the size of the intersection with len(s1 & s2)

Step 2: Build the Graph

We create an adjacency list representation g = [[] for _ in range(n)] where g[i] stores all nodes connected to node i.

For each pair of nodes (i, j) where j < i, we:

  • Compute the intersection of their property sets: s1 & s2
  • Check if the intersection size is at least k: len(s1 & s2) >= k
  • If yes, add bidirectional edges: g[i].append(j) and g[j].append(i)

The nested loop structure ensures we only check each pair once (since we only consider j < i), avoiding duplicate edge additions.

Step 3: Count Connected Components with DFS

We maintain a visited array vis = [False] * n to track which nodes have been explored.

The DFS function is straightforward:

def dfs(i: int) -> None:
    vis[i] = True
    for j in g[i]:
        if not vis[j]:
            dfs(j)

It marks the current node as visited and recursively visits all unvisited neighbors, effectively exploring the entire connected component.

Step 4: Main Counting Logic

We iterate through all nodes:

  • If a node hasn't been visited, it's the start of a new connected component
  • We call dfs(i) to explore and mark all nodes in that component
  • We increment our answer counter ans += 1

Each call to dfs from the main loop represents discovering a new connected component, so the final value of ans gives us the total number of connected components in the graph.

The time complexity is O(nΒ²m) where n is the number of property arrays and m is the average size of each array (for computing intersections), plus O(n + E) for the DFS traversal where E is the number of edges.

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 concrete example with properties = [[1,2,3], [1,2], [4,5], [1,2,4]] and k = 2.

Step 1: Convert Arrays to Sets

  • properties[0] β†’ {1, 2, 3}
  • properties[1] β†’ {1, 2}
  • properties[2] β†’ {4, 5}
  • properties[3] β†’ {1, 2, 4}

Step 2: Build the Graph

We check all pairs where j < i:

For i=1:

  • Check nodes 1 and 0: {1,2} ∩ {1,2,3} = {1,2}, size = 2 β‰₯ k
  • Add edges: g[0].append(1) and g[1].append(0)

For i=2:

  • Check nodes 2 and 0: {4,5} ∩ {1,2,3} = {}, size = 0 < k (no edge)
  • Check nodes 2 and 1: {4,5} ∩ {1,2} = {}, size = 0 < k (no edge)

For i=3:

  • Check nodes 3 and 0: {1,2,4} ∩ {1,2,3} = {1,2}, size = 2 β‰₯ k
  • Add edges: g[0].append(3) and g[3].append(0)
  • Check nodes 3 and 1: {1,2,4} ∩ {1,2} = {1,2}, size = 2 β‰₯ k
  • Add edges: g[1].append(3) and g[3].append(1)
  • Check nodes 3 and 2: {1,2,4} ∩ {4,5} = {4}, size = 1 < k (no edge)

Final adjacency list:

  • g[0] = [1, 3]
  • g[1] = [0, 3]
  • g[2] = []
  • g[3] = [0, 1]

Step 3: Count Connected Components with DFS

Initialize: vis = [False, False, False, False], ans = 0

Iterate through nodes:

  • Node 0: Not visited, so ans += 1 (now ans = 1)

    • Call dfs(0): marks node 0 as visited
    • Explores neighbor 1: marks node 1 as visited
      • From node 1, explores neighbor 3: marks node 3 as visited
    • After DFS: vis = [True, True, False, True]
  • Node 1: Already visited, skip

  • Node 2: Not visited, so ans += 1 (now ans = 2)

    • Call dfs(2): marks node 2 as visited
    • No neighbors to explore
    • After DFS: vis = [True, True, True, True]
  • Node 3: Already visited, skip

Result: The algorithm returns ans = 2, representing two connected components:

  • Component 1: nodes {0, 1, 3} - all connected through shared elements {1, 2}
  • Component 2: node {2} - isolated with no connections

Solution Implementation

1class Solution:
2    def numberOfComponents(self, properties: List[List[int]], k: int) -> int:
3        """
4        Find the number of connected components in a graph where nodes are connected
5        if they share at least k common properties.
6      
7        Args:
8            properties: List of property lists for each node
9            k: Minimum number of common properties required for connection
10      
11        Returns:
12            Number of connected components in the graph
13        """
14        def dfs(node: int) -> None:
15            """
16            Depth-first search to mark all nodes in the same component as visited.
17          
18            Args:
19                node: Current node to visit
20            """
21            visited[node] = True
22            # Visit all unvisited neighbors
23            for neighbor in adjacency_list[node]:
24                if not visited[neighbor]:
25                    dfs(neighbor)
26      
27        # Number of nodes (based on number of property lists)
28        n = len(properties)
29      
30        # Convert each property list to a set for efficient intersection operations
31        property_sets = [set(prop_list) for prop_list in properties]
32      
33        # Build adjacency list for the graph
34        adjacency_list = [[] for _ in range(n)]
35      
36        # Check all pairs of nodes to see if they should be connected
37        for i in range(n):
38            for j in range(i):
39                # Connect nodes i and j if they share at least k properties
40                common_properties = property_sets[i] & property_sets[j]
41                if len(common_properties) >= k:
42                    adjacency_list[i].append(j)
43                    adjacency_list[j].append(i)
44      
45        # Count connected components using DFS
46        component_count = 0
47        visited = [False] * n
48      
49        for i in range(n):
50            if not visited[i]:
51                # Found a new component, explore it completely
52                dfs(i)
53                component_count += 1
54      
55        return component_count
56
1class Solution {
2    // Adjacency list to represent the graph
3    private List<Integer>[] adjacencyList;
4    // Visited array to track visited nodes during DFS
5    private boolean[] visited;
6
7    public int numberOfComponents(int[][] properties, int k) {
8        int n = properties.length;
9      
10        // Initialize adjacency list and property sets for each node
11        adjacencyList = new List[n];
12        Set<Integer>[] propertySets = new Set[n];
13        Arrays.setAll(adjacencyList, i -> new ArrayList<>());
14        Arrays.setAll(propertySets, i -> new HashSet<>());
15      
16        // Convert each node's properties array to a HashSet for O(1) lookup
17        for (int i = 0; i < n; ++i) {
18            for (int property : properties[i]) {
19                propertySets[i].add(property);
20            }
21        }
22      
23        // Build graph: connect nodes if they share at least k common properties
24        for (int i = 0; i < n; ++i) {
25            for (int j = 0; j < i; ++j) {
26                // Count common properties between node i and node j
27                int commonCount = 0;
28                for (int property : propertySets[i]) {
29                    if (propertySets[j].contains(property)) {
30                        ++commonCount;
31                    }
32                }
33              
34                // If nodes share at least k properties, create an edge between them
35                if (commonCount >= k) {
36                    adjacencyList[i].add(j);
37                    adjacencyList[j].add(i);
38                }
39            }
40        }
41
42        // Count connected components using DFS
43        int componentCount = 0;
44        visited = new boolean[n];
45      
46        for (int i = 0; i < n; ++i) {
47            if (!visited[i]) {
48                // Start DFS from unvisited node to explore its component
49                dfs(i);
50                ++componentCount;
51            }
52        }
53      
54        return componentCount;
55    }
56
57    /**
58     * Depth-first search to mark all nodes in the same connected component
59     * @param node Current node being visited
60     */
61    private void dfs(int node) {
62        visited[node] = true;
63      
64        // Visit all unvisited neighbors
65        for (int neighbor : adjacencyList[node]) {
66            if (!visited[neighbor]) {
67                dfs(neighbor);
68            }
69        }
70    }
71}
72
1class Solution {
2public:
3    int numberOfComponents(vector<vector<int>>& properties, int k) {
4        int n = properties.size();
5      
6        // Create array of sets to store properties for each node
7        unordered_set<int> propertySets[n];
8      
9        // Create adjacency list for the graph
10        vector<int> adjacencyList[n];
11      
12        // Convert each node's properties to a set for efficient lookup
13        for (int i = 0; i < n; ++i) {
14            for (int property : properties[i]) {
15                propertySets[i].insert(property);
16            }
17        }
18      
19        // Build edges between nodes that share at least k common properties
20        for (int i = 0; i < n; ++i) {
21            auto& setI = propertySets[i];
22          
23            for (int j = 0; j < i; ++j) {
24                auto& setJ = propertySets[j];
25                int commonCount = 0;
26              
27                // Count common properties between node i and node j
28                for (int property : setI) {
29                    if (setJ.contains(property)) {
30                        ++commonCount;
31                    }
32                }
33              
34                // If they share at least k properties, create an edge
35                if (commonCount >= k) {
36                    adjacencyList[i].push_back(j);
37                    adjacencyList[j].push_back(i);
38                }
39            }
40        }
41      
42        // Count connected components using DFS
43        int componentCount = 0;
44        vector<bool> visited(n, false);
45      
46        // Define DFS lambda function to traverse connected components
47        auto dfs = [&](this auto&& dfs, int node) -> void {
48            visited[node] = true;
49          
50            // Visit all unvisited neighbors
51            for (int neighbor : adjacencyList[node]) {
52                if (!visited[neighbor]) {
53                    dfs(neighbor);
54                }
55            }
56        };
57      
58        // Find and count all connected components
59        for (int i = 0; i < n; ++i) {
60            if (!visited[i]) {
61                dfs(i);
62                ++componentCount;
63            }
64        }
65      
66        return componentCount;
67    }
68};
69
1/**
2 * Counts the number of connected components in a graph where nodes are connected
3 * if they share at least k common properties
4 * @param properties - Array where properties[i] contains the properties of node i
5 * @param k - Minimum number of shared properties required to form an edge
6 * @returns Number of connected components in the resulting graph
7 */
8function numberOfComponents(properties: number[][], k: number): number {
9    const nodeCount: number = properties.length;
10  
11    // Store properties of each node as a Set for efficient lookup
12    const propertySets: Set<number>[] = Array.from(
13        { length: nodeCount }, 
14        () => new Set<number>()
15    );
16  
17    // Adjacency list representation of the graph
18    const adjacencyList: number[][] = Array.from(
19        { length: nodeCount }, 
20        () => []
21    );
22
23    // Convert each node's properties array to a Set
24    for (let nodeIndex = 0; nodeIndex < nodeCount; nodeIndex++) {
25        for (const property of properties[nodeIndex]) {
26            propertySets[nodeIndex].add(property);
27        }
28    }
29
30    // Build the graph by checking shared properties between all pairs of nodes
31    for (let firstNode = 0; firstNode < nodeCount; firstNode++) {
32        for (let secondNode = 0; secondNode < firstNode; secondNode++) {
33            // Count common properties between the two nodes
34            let sharedPropertiesCount = 0;
35            for (const property of propertySets[firstNode]) {
36                if (propertySets[secondNode].has(property)) {
37                    sharedPropertiesCount++;
38                }
39            }
40          
41            // Create an edge if nodes share at least k properties
42            if (sharedPropertiesCount >= k) {
43                adjacencyList[firstNode].push(secondNode);
44                adjacencyList[secondNode].push(firstNode);
45            }
46        }
47    }
48
49    // Count connected components using DFS
50    let componentCount = 0;
51    const visited: boolean[] = Array(nodeCount).fill(false);
52
53    /**
54     * Depth-first search to mark all nodes in the same component as visited
55     * @param currentNode - The current node being explored
56     */
57    const depthFirstSearch = (currentNode: number): void => {
58        visited[currentNode] = true;
59      
60        // Visit all unvisited neighbors
61        for (const neighbor of adjacencyList[currentNode]) {
62            if (!visited[neighbor]) {
63                depthFirstSearch(neighbor);
64            }
65        }
66    };
67
68    // Find and count all connected components
69    for (let nodeIndex = 0; nodeIndex < nodeCount; nodeIndex++) {
70        if (!visited[nodeIndex]) {
71            depthFirstSearch(nodeIndex);
72            componentCount++;
73        }
74    }
75  
76    return componentCount;
77}
78

Time and Space Complexity

Time Complexity: O(n^2 Γ— m)

The time complexity breaks down as follows:

  • Converting each property list to a set: O(n Γ— m) where n is the number of properties and m is the length of each property list
  • Building the graph requires comparing all pairs of properties:
    • There are O(n^2) pairs to compare (the nested loop structure with i and j)
    • For each pair, we compute the intersection s1 & s2 which takes O(m) time in the worst case
    • Therefore, building the graph takes O(n^2 Γ— m)
  • The DFS traversal visits each node and edge once, taking O(n + E) where E is the number of edges, which is at most O(n^2)
  • The dominant operation is building the graph: O(n^2 Γ— m)

Space Complexity: O(n Γ— m)

The space complexity consists of:

  • Storing the sets ss: O(n Γ— m) as we have n sets, each containing up to m elements
  • The adjacency list g: O(n + E) where E is at most O(n^2) in the worst case
  • The visited array vis: O(n)
  • The recursion stack for DFS: O(n) in the worst case
  • The dominant space usage is storing the sets: O(n Γ— m)

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

Common Pitfalls

1. Duplicate Edge Addition in Graph Construction

A common mistake is adding edges twice when building the graph, which can happen if you're not careful with the loop structure:

Incorrect approach:

# This adds each edge twice
for i in range(n):
    for j in range(n):
        if i != j and len(property_sets[i] & property_sets[j]) >= k:
            adjacency_list[i].append(j)
            adjacency_list[j].append(i)

This results in duplicate edges because when i=0, j=1, we add the edge, and when i=1, j=0, we add it again. While this doesn't affect correctness for connected components, it wastes memory and time.

Correct approach:

# Only check each pair once
for i in range(n):
    for j in range(i):  # j < i ensures we check each pair only once
        if len(property_sets[i] & property_sets[j]) >= k:
            adjacency_list[i].append(j)
            adjacency_list[j].append(i)

2. Inefficient Intersection Calculation

Using lists instead of sets for intersection operations is a major performance pitfall:

Inefficient approach:

# O(mΒ²) time for each intersection
def count_common(list1, list2):
    count = 0
    for item in list1:
        if item in list2:  # O(m) lookup in list
            count += 1
    return count

# Building graph without converting to sets
for i in range(n):
    for j in range(i):
        if count_common(properties[i], properties[j]) >= k:
            # add edge

Efficient approach:

# Convert to sets first - O(m) intersection
property_sets = [set(prop_list) for prop_list in properties]
# Then use set intersection - much faster
common = property_sets[i] & property_sets[j]

3. Counting Duplicate Properties Incorrectly

The problem asks for distinct integers in common. A pitfall is counting duplicates:

Incorrect interpretation:

# If properties[i] = [1, 1, 2] and properties[j] = [1, 2, 2]
# This might incorrectly count 1 twice or 2 twice

Correct approach: The solution correctly uses sets which automatically handle distinctness - each element appears only once in a set, so the intersection size gives the count of distinct common elements.

4. Stack Overflow with Deep Recursion

For very large graphs with long chains, the recursive DFS might cause stack overflow:

Potential issue:

def dfs(node):
    visited[node] = True
    for neighbor in adjacency_list[node]:
        if not visited[neighbor]:
            dfs(neighbor)  # Can cause stack overflow for deep graphs

Solution using iterative DFS:

def dfs_iterative(start):
    stack = [start]
    while stack:
        node = stack.pop()
        if visited[node]:
            continue
        visited[node] = True
        for neighbor in adjacency_list[node]:
            if not visited[neighbor]:
                stack.append(neighbor)

5. Edge Case: Empty Properties or k = 0

Not handling edge cases properly:

Potential issues:

  • When k = 0, all nodes should be connected (any two nodes share at least 0 properties)
  • Empty property lists properties[i] = [] can only share 0 properties with others

Robust handling:

# Ensure k is handled properly
if k == 0:
    return 1 if n > 0 else 0  # All nodes in one component

# The set intersection naturally handles empty lists
# set([]) & set([1,2]) = set() with len = 0
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Which of these pictures shows the visit order of a depth-first search?


Recommended Readings

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

Load More