Facebook Pixel

2631. Group By

Problem Description

This problem asks you to extend JavaScript's built-in Array prototype with a custom groupBy method. The method should transform any array into a grouped object based on a provided function.

The groupBy method takes a callback function fn as its parameter. This callback function:

  • Accepts each item from the array as input
  • Returns a string that serves as the grouping key

The method should return an object where:

  • Each key is a unique string returned by calling fn on the array items
  • Each value is an array containing all items from the original array that produced that particular key when passed through fn

Key requirements:

  • Items in each grouped array must maintain their original order from the source array
  • The order of keys in the resulting object doesn't matter
  • You cannot use external libraries like lodash's _.groupBy

For example, if you have [1, 2, 3] and use String as the grouping function:

  • fn(1) returns "1", so 1 goes into the group with key "1"
  • fn(2) returns "2", so 2 goes into the group with key "2"
  • fn(3) returns "3", so 3 goes into the group with key "3"
  • Result: {"1": [1], "2": [2], "3": [3]}

The solution uses the reduce method to iterate through the array once, building up the grouped object by either creating new arrays for new keys or appending items to existing arrays for keys that have already been encountered.

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

Intuition

When we need to group array elements by some criteria, we're essentially building a mapping from keys to collections of values. Think about organizing items into buckets - each bucket has a label (the key), and we place items into the appropriate bucket based on what the function tells us.

The natural data structure for this is an object (or Record<string, T[]> in TypeScript), where each property represents a bucket. As we go through the array, we need to:

  1. Determine which bucket each item belongs to by calling fn(item)
  2. Place the item in that bucket

This is a classic accumulation pattern - we start with an empty object and gradually build it up by processing each element. JavaScript's reduce method is perfect for this because it's designed to transform an array into a single value (in this case, an object) by iterating through elements and accumulating results.

For each item, we face two scenarios:

  • The bucket (key) already exists: We simply add the item to the existing array
  • The bucket doesn't exist yet: We create a new bucket with this item as its first element

The beauty of reduce is that it handles the iteration and accumulation in one clean operation. We pass an empty object {} as the initial accumulator, and for each item, we:

  1. Calculate the key using fn(item)
  2. Check if acc[key] exists
  3. Either push to the existing array or create a new array with the current item
  4. Return the updated accumulator for the next iteration

This approach ensures we only traverse the array once (O(n) time complexity) and maintain the original order of elements within each group since we process items sequentially and use push to append them.

Solution Approach

The implementation extends the Array prototype by adding a new groupBy method. Let's walk through the key components:

TypeScript Declaration:

declare global {
    interface Array<T> {
        groupBy(fn: (item: T) => string): Record<string, T[]>;
    }
}

This declaration tells TypeScript that all arrays now have a groupBy method that accepts a function returning a string and produces a Record<string, T[]> (an object with string keys and array values).

Core Implementation using reduce:

Array.prototype.groupBy = function (fn) {
    return this.reduce((acc, item) => {
        const key = fn(item);
        if (acc[key]) {
            acc[key].push(item);
        } else {
            acc[key] = [item];
        }
        return acc;
    }, {});
};

The algorithm works as follows:

  1. Initialize with an empty object: The reduce method starts with {} as the initial accumulator value.

  2. Process each array element: For each item in the array:

    • Call fn(item) to get the grouping key
    • Store this key for use in the next steps
  3. Conditional bucket management:

    • If acc[key] exists: This means we've seen this key before, so we have an existing array. We simply push the current item to that array using acc[key].push(item).
    • If acc[key] doesn't exist: This is a new key, so we create a new array containing just the current item: acc[key] = [item].
  4. Return the accumulator: After handling the current item, we return acc to pass it to the next iteration.

  5. Final result: After all items are processed, reduce returns the fully populated object with all groups.

Example execution trace for [1, 2, 1].groupBy(String):

  • Start: acc = {}
  • Item 1: key = "1", acc["1"] doesn't exist → acc = {"1": [1]}
  • Item 2: key = "2", acc["2"] doesn't exist → acc = {"1": [1], "2": [2]}
  • Item 1: key = "1", acc["1"] exists → push to array → acc = {"1": [1, 1], "2": [2]}
  • Return: {"1": [1, 1], "2": [2]}

This pattern ensures O(n) time complexity with a single pass through the array, while maintaining the insertion order of elements within each group.

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 grouping an array of people by their age:

const people = [
  { name: "Alice", age: 25 },
  { name: "Bob", age: 30 },
  { name: "Charlie", age: 25 },
  { name: "David", age: 30 }
];

const result = people.groupBy(person => String(person.age));

Step-by-step execution:

  1. Initialize: Start with empty accumulator acc = {}

  2. Process Alice (age: 25):

    • Call fn({name: "Alice", age: 25}) → returns "25"
    • Check if acc["25"] exists → No
    • Create new array: acc["25"] = [{name: "Alice", age: 25}]
    • Accumulator becomes: {"25": [{name: "Alice", age: 25}]}
  3. Process Bob (age: 30):

    • Call fn({name: "Bob", age: 30}) → returns "30"
    • Check if acc["30"] exists → No
    • Create new array: acc["30"] = [{name: "Bob", age: 30}]
    • Accumulator becomes: {"25": [{name: "Alice", age: 25}], "30": [{name: "Bob", age: 30}]}
  4. Process Charlie (age: 25):

    • Call fn({name: "Charlie", age: 25}) → returns "25"
    • Check if acc["25"] exists → Yes!
    • Push to existing array: acc["25"].push({name: "Charlie", age: 25})
    • Accumulator becomes: {"25": [{name: "Alice", age: 25}, {name: "Charlie", age: 25}], "30": [{name: "Bob", age: 30}]}
  5. Process David (age: 30):

    • Call fn({name: "David", age: 30}) → returns "30"
    • Check if acc["30"] exists → Yes!
    • Push to existing array: acc["30"].push({name: "David", age: 30})
    • Final result: {"25": [{name: "Alice", age: 25}, {name: "Charlie", age: 25}], "30": [{name: "Bob", age: 30}, {name: "David", age: 30}]}

The key insight is that we build up our grouped object incrementally. When we encounter a new key, we create a new array bucket. When we see an existing key, we append to that bucket. The original order is preserved within each group because we process elements sequentially and use push to maintain insertion order.

Solution Implementation

1from typing import TypeVar, Callable, Dict, List
2
3# Generic type variable for array elements
4T = TypeVar('T')
5
6def group_by(array: List[T], fn: Callable[[T], str]) -> Dict[str, List[T]]:
7    """
8    Groups array elements into a dictionary based on a key function.
9  
10    This function takes an array and a function that generates keys,
11    then returns a dictionary where each key maps to a list of elements
12    that produced that key.
13  
14    Args:
15        array: The list of elements to group
16        fn: Function that returns a string key for each element
17      
18    Returns:
19        A dictionary where keys are strings and values are lists of elements
20      
21    Examples:
22        >>> group_by([1, 2, 3], str)
23        {'1': [1], '2': [2], '3': [3]}
24      
25        >>> users = [
26        ...     {'name': 'Alice', 'age': 25},
27        ...     {'name': 'Bob', 'age': 30},
28        ...     {'name': 'Charlie', 'age': 25}
29        ... ]
30        >>> group_by(users, lambda user: str(user['age']))
31        {'25': [{'name': 'Alice', 'age': 25}, {'name': 'Charlie', 'age': 25}], 
32         '30': [{'name': 'Bob', 'age': 30}]}
33    """
34    # Initialize an empty dictionary to store grouped results
35    accumulator = {}
36  
37    # Iterate through each item in the array
38    for current_item in array:
39        # Get the key for the current item by calling the function
40        key = fn(current_item)
41      
42        # If the key already exists in the dictionary, append the item to the existing list
43        if key in accumulator:
44            accumulator[key].append(current_item)
45        else:
46            # Otherwise, create a new list with the current item as the first element
47            accumulator[key] = [current_item]
48  
49    # Return the grouped dictionary
50    return accumulator
51
52
53# Alternative implementation using defaultdict for cleaner code
54from collections import defaultdict
55
56def group_by_alternative(array: List[T], fn: Callable[[T], str]) -> Dict[str, List[T]]:
57    """
58    Alternative implementation using defaultdict for automatic list initialization.
59  
60    This approach eliminates the need to check if a key exists before appending.
61  
62    Args:
63        array: The list of elements to group
64        fn: Function that returns a string key for each element
65      
66    Returns:
67        A dictionary where keys are strings and values are lists of elements
68    """
69    # Use defaultdict to automatically create empty lists for new keys
70    accumulator = defaultdict(list)
71  
72    # Iterate through each item and group by the key
73    for current_item in array:
74        # Get the key and append the item to the corresponding list
75        key = fn(current_item)
76        accumulator[key].append(current_item)
77  
78    # Convert defaultdict back to regular dict before returning
79    return dict(accumulator)
80
1import java.util.*;
2import java.util.function.Function;
3import java.util.stream.Collectors;
4
5/**
6 * Utility class that provides groupBy functionality for collections
7 * Since Java doesn't allow extending built-in classes like arrays or List,
8 * we create a utility class with static methods
9 */
10public class ArrayGroupByUtility {
11  
12    /**
13     * Groups array elements into a Map based on a key function
14     * 
15     * @param <T> The type of elements in the array
16     * @param array The input array to be grouped
17     * @param keyExtractor Function that returns a string key for each element
18     * @return A Map where keys are strings and values are Lists of elements
19     * 
20     * @example
21     * Integer[] numbers = {1, 2, 3};
22     * Map<String, List<Integer>> result = groupBy(numbers, String::valueOf);
23     * // {"1": [1], "2": [2], "3": [3]}
24     * 
25     * @example
26     * User[] users = {
27     *     new User("Alice", 25),
28     *     new User("Bob", 30),
29     *     new User("Charlie", 25)
30     * };
31     * Map<String, List<User>> result = groupBy(users, user -> String.valueOf(user.getAge()));
32     * // {"25": [User(Alice, 25), User(Charlie, 25)], "30": [User(Bob, 30)]}
33     */
34    public static <T> Map<String, List<T>> groupBy(T[] array, Function<T, String> keyExtractor) {
35        // Initialize result map to store grouped elements
36        Map<String, List<T>> groupedMap = new HashMap<>();
37      
38        // Iterate through each element in the array
39        for (T currentItem : array) {
40            // Get the key for the current item using the provided function
41            String key = keyExtractor.apply(currentItem);
42          
43            // If the key already exists, add the item to the existing list
44            if (groupedMap.containsKey(key)) {
45                groupedMap.get(key).add(currentItem);
46            } else {
47                // Otherwise, create a new list with the current item
48                List<T> newList = new ArrayList<>();
49                newList.add(currentItem);
50                groupedMap.put(key, newList);
51            }
52        }
53      
54        return groupedMap;
55    }
56  
57    /**
58     * Alternative implementation using Java 8 Streams
59     * Groups List elements into a Map based on a key function
60     * 
61     * @param <T> The type of elements in the list
62     * @param list The input list to be grouped
63     * @param keyExtractor Function that returns a string key for each element
64     * @return A Map where keys are strings and values are Lists of elements
65     */
66    public static <T> Map<String, List<T>> groupBy(List<T> list, Function<T, String> keyExtractor) {
67        // Use Java 8 Streams to group elements by the key returned from the provided function
68        return list.stream()
69                .collect(Collectors.groupingBy(
70                    keyExtractor,
71                    HashMap::new,
72                    Collectors.toList()
73                ));
74    }
75  
76    /**
77     * Overloaded method that accepts varargs for convenience
78     * 
79     * @param <T> The type of elements
80     * @param keyExtractor Function that returns a string key for each element
81     * @param items Variable number of items to be grouped
82     * @return A Map where keys are strings and values are Lists of elements
83     */
84    @SafeVarargs
85    public static <T> Map<String, List<T>> groupBy(Function<T, String> keyExtractor, T... items) {
86        return groupBy(items, keyExtractor);
87    }
88}
89
1#include <vector>
2#include <unordered_map>
3#include <string>
4#include <functional>
5
6/**
7 * Template class that provides groupBy functionality for vectors
8 * Groups vector elements into a map based on a key function
9 */
10template<typename T>
11class GroupByVector {
12public:
13    /**
14     * Groups vector elements by the key returned from the provided function
15     * 
16     * @param vec - The vector to group
17     * @param fn - Function that returns a string key for each element
18     * @return An unordered_map where keys are strings and values are vectors of elements
19     * 
20     * @example
21     * vector<int> nums = {1, 2, 3};
22     * auto result = GroupByVector<int>::groupBy(nums, [](int x) { return to_string(x); });
23     * // {"1": [1], "2": [2], "3": [3]}
24     * 
25     * @example
26     * struct User { string name; int age; };
27     * vector<User> users = {
28     *   {"Alice", 25},
29     *   {"Bob", 30},
30     *   {"Charlie", 25}
31     * };
32     * auto result = GroupByVector<User>::groupBy(users, [](const User& u) { return to_string(u.age); });
33     * // {"25": [{"Alice", 25}, {"Charlie", 25}], "30": [{"Bob", 30}]}
34     */
35    static std::unordered_map<std::string, std::vector<T>> groupBy(
36        const std::vector<T>& vec, 
37        std::function<std::string(const T&)> fn) {
38      
39        // Initialize empty map to store grouped results
40        std::unordered_map<std::string, std::vector<T>> accumulator;
41      
42        // Iterate through each item in the vector
43        for (const auto& currentItem : vec) {
44            // Get the key for the current item
45            std::string key = fn(currentItem);
46          
47            // Check if key already exists in the map
48            auto it = accumulator.find(key);
49          
50            if (it != accumulator.end()) {
51                // If the key exists, add the item to the existing vector
52                it->second.push_back(currentItem);
53            } else {
54                // Otherwise, create a new vector with the current item
55                accumulator[key] = std::vector<T>{currentItem};
56            }
57        }
58      
59        return accumulator;
60    }
61  
62    /**
63     * Alternative implementation using operator[] for cleaner syntax
64     * This version is more concise but may be slightly less efficient
65     * as operator[] creates an empty vector if key doesn't exist
66     */
67    static std::unordered_map<std::string, std::vector<T>> groupByAlternative(
68        const std::vector<T>& vec, 
69        std::function<std::string(const T&)> fn) {
70      
71        // Initialize empty map to store grouped results
72        std::unordered_map<std::string, std::vector<T>> accumulator;
73      
74        // Iterate through each item in the vector
75        for (const auto& currentItem : vec) {
76            // Get the key for the current item
77            std::string key = fn(currentItem);
78          
79            // Add item to the vector associated with the key
80            // operator[] creates empty vector if key doesn't exist
81            accumulator[key].push_back(currentItem);
82        }
83      
84        return accumulator;
85    }
86};
87
88/**
89 * Helper function to provide a more convenient interface
90 * Allows calling groupBy without specifying the template parameter explicitly
91 */
92template<typename T>
93std::unordered_map<std::string, std::vector<T>> groupBy(
94    const std::vector<T>& vec, 
95    std::function<std::string(const T&)> fn) {
96    return GroupByVector<T>::groupBy(vec, fn);
97}
98
1/**
2 * Extends the global Array interface to include the groupBy method
3 * This allows all arrays to have access to the groupBy functionality
4 */
5declare global {
6    interface Array<T> {
7        /**
8         * Groups array elements into an object based on a key function
9         * @param fn - Function that returns a string key for each element
10         * @returns An object where keys are strings and values are arrays of elements
11         */
12        groupBy(fn: (item: T) => string): Record<string, T[]>;
13    }
14}
15
16/**
17 * Implementation of the groupBy method for Array prototype
18 * Groups array elements by the key returned from the provided function
19 * 
20 * @example
21 * [1, 2, 3].groupBy(String) // {"1": [1], "2": [2], "3": [3]}
22 * 
23 * @example
24 * const users = [
25 *   { name: 'Alice', age: 25 },
26 *   { name: 'Bob', age: 30 },
27 *   { name: 'Charlie', age: 25 }
28 * ];
29 * users.groupBy(user => String(user.age)) 
30 * // {"25": [{name: 'Alice', age: 25}, {name: 'Charlie', age: 25}], "30": [{name: 'Bob', age: 30}]}
31 */
32Array.prototype.groupBy = function<T>(this: T[], fn: (item: T) => string): Record<string, T[]> {
33    // Use reduce to build the grouped object
34    return this.reduce<Record<string, T[]>>((accumulator, currentItem) => {
35        // Get the key for the current item
36        const key: string = fn(currentItem);
37      
38        // If the key already exists, add the item to the existing array
39        if (accumulator[key]) {
40            accumulator[key].push(currentItem);
41        } else {
42            // Otherwise, create a new array with the current item
43            accumulator[key] = [currentItem];
44        }
45      
46        return accumulator;
47    }, {}); // Initialize with empty object
48};
49
50// Export empty object to make this a module
51export {};
52

Time and Space Complexity

Time Complexity: O(n)

The reduce method iterates through each element in the array exactly once. For each element:

  • Calling the provided function fn(item) takes O(1) assuming the function itself is O(1)
  • Checking if acc[key] exists is O(1) for object property access
  • Adding an element to an array with push() is amortized O(1)
  • Creating a new array with a single element is O(1)

Since we perform constant-time operations for each of the n elements, the overall time complexity is O(n).

Space Complexity: O(n)

The space complexity consists of:

  • The accumulator object acc which will store all n elements distributed across different keys: O(n)
  • Each element is stored exactly once in the resulting object structure
  • The number of keys in the result object is at most n (when each element maps to a unique key): O(n) in the worst case
  • Temporary variables like key use O(1) space

The total space complexity is O(n) where n is the number of elements in the input array.

Common Pitfalls

1. Non-String Keys from Callback Function

The most critical pitfall is when the callback function returns non-string values. The specification explicitly states that fn should return a string, but JavaScript's dynamic typing allows any value to be used as an object key, which gets automatically coerced to a string.

Problem Example:

[1, 2, 3].groupBy(x => x); // Returns number, not string
// This "works" but violates the contract
// Result: {"1": [1], "2": [2], "3": [3]}

[{id: 1}, {id: 2}].groupBy(item => item); // Returns object
// Objects get stringified to "[object Object]"
// Result: {"[object Object]": [{id: 1}, {id: 2}]} - All items grouped under same key!

Solution: Always ensure the callback explicitly returns a string:

// Good practices:
[1, 2, 3].groupBy(x => String(x));
[{id: 1}, {id: 2}].groupBy(item => String(item.id));
[{id: 1}, {id: 2}].groupBy(item => JSON.stringify(item));

2. Mutating the Original Array

While the current implementation doesn't mutate the original array, a common mistake when modifying prototypes is accidentally changing the original data.

Problem Example:

// Bad implementation that sorts original array
Array.prototype.groupBy = function(fn) {
    this.sort(); // Mutates original array!
    // ... rest of implementation
};

Solution: Never modify this directly. If you need a sorted version, create a copy:

Array.prototype.groupBy = function(fn) {
    const sorted = [...this].sort(); // Work with a copy
    // ... rest of implementation
};

3. Callback Function Throws Errors

If the callback function throws an error for certain items, the entire groupBy operation fails.

Problem Example:

const data = [1, null, 3];
data.groupBy(x => x.toString()); // Error: Cannot read property 'toString' of null

Solution: Add error handling or validate inputs:

Array.prototype.groupBy = function(fn) {
    return this.reduce((acc, item) => {
        try {
            const key = fn(item);
            if (typeof key !== 'string') {
                throw new TypeError(`Callback must return a string, got ${typeof key}`);
            }
            acc[key] = acc[key] || [];
            acc[key].push(item);
        } catch (error) {
            // Either skip the item or handle the error appropriately
            console.warn(`Error processing item ${item}:`, error);
        }
        return acc;
    }, {});
};

4. Name Collision with Future JavaScript Features

groupBy is actually being considered for future JavaScript specifications. Your implementation might conflict with native implementations.

Problem Example:

// Your implementation
Array.prototype.groupBy = function(fn) { /* ... */ };

// Future native implementation might have different behavior
// This could break your code when browsers implement it

Solution: Use a namespaced method name or check for existing implementation:

if (!Array.prototype.groupBy) {
    Array.prototype.groupBy = function(fn) { /* ... */ };
}

// Or use a unique name
Array.prototype.customGroupBy = function(fn) { /* ... */ };

5. Python-Specific: Unhashable Keys

In the Python implementation, there's an additional pitfall where the function might attempt to return unhashable types.

Problem Example:

# This will fail if fn returns a list or dict
group_by([1, 2, 3], lambda x: [x])  # Lists can't be dictionary keys

Solution: Ensure the function returns only hashable types (strings, numbers, tuples):

def group_by(array: List[T], fn: Callable[[T], str]) -> Dict[str, List[T]]:
    accumulator = {}
    for current_item in array:
        key = fn(current_item)
        if not isinstance(key, str):
            raise TypeError(f"Callback must return string, got {type(key).__name__}")
        # ... rest of implementation
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Depth first search is equivalent to which of the tree traversal order?


Recommended Readings

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

Load More