Start learning free

LESSON 247 OF 348 · HASH TABLE

Group Anagrams in TypeScript: Sorted Keys in an Object

Practice O(n * L * log L) Read the full explanation

Given an array of strings, group the anagrams together. You can return the answer in any order.

An anagram is a word or phrase formed by rearranging the letters of a different word or phrase, using all the original letters exactly once.

Example:

Input:   strings = ["eat","tea","tan","ate","nat","bat"]

Output:  [["bat"],["nat","tan"],["ate","eat","tea"]]

Note:

Your algorithm should run in O(n * L * log L) time and use O(n * L) extra space.


STEP 1 OF 6
    Ctrl + Enter to check

    TYPESCRIPT · HASH TABLE

    Group Anagrams in TypeScript, explained

    Problem overview

    The method groupAnagrams receives an array of strings and returns an array of groups, where each group holds the words that are anagrams of one another. Two words are anagrams when they use exactly the same letters the same number of times. The groups may come back in any order.

    For ["eat", "tea", "tan", "ate", "nat", "bat"] there are three groups: eat, tea and ate together; tan and nat together; and bat alone. The solution below returns them as [["eat", "tea", "ate"], ["tan", "nat"], ["bat"]], which is the same grouping in a different order than the statement shows.

    Approach: give every anagram the same key

    The statement allows O(n * L * log L) time and O(n * L) extra space, where n is the number of words and L is the length of the longest one. Comparing every pair of words would multiply the work by another n. A better idea is a canonical form: a value that is the same for all anagrams of a word and different for words that are not anagrams.

    Sorting the letters gives exactly that. "eat", "tea" and "ate" all become "aet", while "tan" becomes "ant" and "bat" becomes "abt". Use the sorted string as the key of a dictionary whose values are lists of the original words. Each word is appended to the list under its key, and when the pass is over, the lists are the groups.

    Sorting one word of length L costs O(L log L), and it happens once per word, so the total is O(n * L * log L). The dictionary stores every word and every key, which is O(n * L).

    Step by step

    1. Create the dictionary. groupBySorted is a const empty object typed { [key: string]: string[] }. That index signature reads "any string key, with an array of strings as the value".
    2. Loop over the words. A for...of loop gives each word as s.
    3. Build the key. s.split("") turns the word into an array of characters, sort() orders them and join("") glues them back into a string. Strings have no sort method, which is why the detour through an array is needed.
    4. Create the group if it is new. If the key is not yet in the object, store an empty array under it. The condition is !(sortedString in groupBySorted); the parentheses matter because ! binds tighter than in.
    5. Add the word. Push s onto the array for its key.
    6. Return the groups. Object.values(groupBySorted) gives an array of the object's values, which are exactly the lists.

    The solution in TypeScript

    class Solution {
        groupAnagrams(strings: string[]): string[][] {
            const groupBySorted: { [key: string]: string[] } = {};
            for (const s of strings) {
                const sortedString = s.split("").sort().join("");
                if (!(sortedString in groupBySorted)) {
                    groupBySorted[sortedString] = [];
                }
                groupBySorted[sortedString].push(s);
            }
            return Object.values(groupBySorted);
        }
    }
    • Sorting without a comparator is fine here. The default sort() orders by string value, which is wrong for numbers ([10, 9, 1].sort() gives [1, 10, 9]) but exactly what we want for characters. All that matters is that equal letters always end up in the same order.
    • Uppercase and lowercase differ. Sorting puts "A" before "a", and they are different characters, so "a" and "A" go into separate groups.
    • The object is a dictionary. It is declared const because the variable is never reassigned. The contents still change when keys are added.
    • The order of Object.values. Keys that look like whole numbers come first, in ascending numeric order, and the other keys follow in insertion order. For ["21", "12", "3"] the result is [["3"], ["21", "12"]], because the keys are "12" and "3". The lesson accepts any order.
    • in also sees inherited names. "toString" in {} is true, because plain objects inherit properties. A Map<string, string[]>, with get, set and [...groups.values()], avoids the question and keeps insertion order.
    • Types are erased. The index signature is checked by the compiler only. At run time the object is an ordinary one.

    The same idea with a Map

    function groupMap(strings: string[]): string[][] {
        const groups = new Map<string, string[]>();
        for (const s of strings) {
            const key = s.split("").sort().join("");
            const group = groups.get(key);
            if (group === undefined) {
                groups.set(key, [s]);
            } else {
                group.push(s);
            }
        }
        return [...groups.values()];
    }

    It returns the same three groups for the example, in the order the keys first appeared. The logic is identical: one key per anagram class, one list per key. Use whichever form the problem or your team prefers.

    Complexity

    Time: O(n * L * log L). For each of the n words, splitting and joining take O(L) and sorting takes O(L log L). Dictionary lookups and pushes are O(1) on average, apart from hashing a key of length L, which is within O(L).

    Space: O(n * L). The dictionary holds every word once as a list element and one sorted key per group.

    Edge cases and common mistakes

    • Empty array. The result is [].
    • Empty string. Its key is "", so [""] gives [[""]].
    • No anagrams. Every word forms a group of its own.
    • Duplicate words. They are anagrams of themselves, so both copies appear in the same group.
    • Forgetting to initialise the list. Without the if, the first push would run on undefined and throw an error at run time.
    • Writing !sortedString in groupBySorted. That negates the string first, so the test is nonsense and the compiler reports it.
    • Using the original word as the key. Then no two words ever share a key, and every group has one word.

    Testing

    • ["eat", "tea", "tan", "ate", "nat", "bat"] gives [["eat", "tea", "ate"], ["tan", "nat"], ["bat"]].
    • [] gives [].
    • [""] gives [[""]].
    • ["a", "A"] gives [["a"], ["A"]].
    Keep going with TypeScriptAbout 340 step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free