Start learning free

LESSON 229 OF 442 · SORTING

Group Anagrams in Go: O(n log n * L log L) Sorting Approach

Practice O(n * log 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: the groups in any order, and the strings inside each group 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"]]
Explanation:  Any order of the groups, and of the strings inside a group, is accepted,
for example [["ate","eat","tea"],["bat"],["nat","tan"]].

Note:

For this lesson, your algorithm should run in O(n * log n * L * log L) time and use O(n * L) extra space, where n is the number of strings and L is the length of the longest string.
(There are faster solutions which we will discuss in future lessons)


STEP 1 OF 13
    Ctrl + Enter to check

    GO · SORTING

    Group Anagrams in Go, explained

    Problem overview

    The function groupAnagrams receives a slice of strings, strs, and returns them grouped so that each group holds words that are anagrams of one another, which means they use the same letters the same number of times. The groups can come back in any order.

    For ["eat", "tea", "tan", "ate", "nat", "bat"] the groups are eat, tea, ate, then tan, nat, then bat. The lesson's budget is O(n * log n * L * log L) time and O(n * L) extra space, where n is the number of strings and L the length of the longest one.

    Approach: sort the words by their sorted letters

    Two words are anagrams exactly when their letters, put in alphabetical order, give the same string. For "eat", "tea" and "ate" that string is "aet". This sorted form acts as a signature of the group.

    So sort the whole slice with the signature as the sort key. Anagrams share a signature, so they end up next to each other. A single pass over the sorted slice then cuts it into groups: a group ends where the next word's signature differs.

    The cost matches the lesson's note. Computing a signature sorts up to L letters, which is O(L log L). The sort of the slice makes O(n log n) comparisons, and each comparison computes two signatures. The final pass computes two more per word, which is cheaper. Faster ways exist, such as counting letters or using a map keyed by the signature, but this version uses only sorting.

    Step by step

    1. Assume a helper sorted(s) on Solution that returns a string's letters in alphabetical order. It is written at the end.
    2. Sort strs with sort.Slice, whose less function compares sol.sorted(strs[i]) with sol.sorted(strs[j]). The parameter is called strs because strings is the name of a standard package.
    3. Declare result, an empty [][]string, to collect the finished groups.
    4. Declare currentGroup, an empty []string, for the group being built.
    5. Loop over every index i of strs, so that each word can be compared with the next.
    6. Append strs[i] to currentGroup. In sorted order every word belongs to the group in progress.
    7. Detect the end of a group: either i is the last index, or the signature of strs[i] differs from that of strs[i+1]. The last-index test comes first, so with || the right side is skipped and strs[i+1] is never read past the end.
    8. Append currentGroup to result when the group is complete.
    9. Start a new group by assigning a new empty []string{} to currentGroup.
    10. Return result.
    11. Write the helper sorted with the same receiver, sol Solution, taking a string and returning a string.
    12. Copy the string into a byte slice b with []byte(s).
    13. Sort b with sort.Slice, comparing b[i] < b[j].
    14. Return string(b).

    The solution in Go

    func (sol Solution) sorted(s string) string {
    	b := []byte(s)
    	sort.Slice(b, func(i, j int) bool { return b[i] < b[j] })
    	return string(b)
    }
    
    func (sol Solution) groupAnagrams(strs []string) [][]string {
    	sort.Slice(strs, func(i, j int) bool {
    		return sol.sorted(strs[i]) < sol.sorted(strs[j])
    	})
    	result := [][]string{}
    	currentGroup := []string{}
    	for i := 0; i < len(strs); i++ {
    		currentGroup = append(currentGroup, strs[i])
    		if i == len(strs)-1 || sol.sorted(strs[i]) != sol.sorted(strs[i+1]) {
    			result = append(result, currentGroup)
    			currentGroup = []string{}
    		}
    	}
    	return result
    }
    

    Go details worth a closer look:

    • Strings are immutable. A Go string cannot be changed in place, so the helper copies it into a []byte, sorts that, and converts it back. The words here are lowercase English letters, one byte each, so sorting bytes sorts letters. Words with multi-byte characters would need []rune instead.
    • sort.Slice takes any slice and a less function, less(i, j), which says whether the element at index i goes before the one at j. It sorts the slice in place. The package sort must be imported.
    • The input slice is reordered. Because sort.Slice works in place, the caller's strs comes back sorted by signature. For the example input it becomes [bat eat tea ate tan nat].
    • The order is not stable. sort.Slice does not keep equal elements in their original order, so the words inside a group can come out in any order. The example can return [[bat] [eat tea ate] [tan nat]]. The groups themselves follow the alphabetical order of their signatures: abt, aet, ant.
    • A fresh slice per group. Writing currentGroup = []string{} gives the next group its own storage. Resetting with currentGroup = currentGroup[:0] would reuse the backing array, and later appends would overwrite what was already stored in result. In a small experiment that stores each group after resetting that way, two single-word groups cat and dog both print as [dog].
    • Value receiver. Solution has no fields, so the receiver sol Solution is only the way to call the helper as a method.

    Complexity

    Time: O(n * log n * L * log L). The sort does O(n log n) comparisons. Each comparison builds two signatures, each costing O(L log L). The grouping pass computes O(n) more signatures, which does not change the bound.

    Space: O(n * L). The result holds all n strings, which are up to L characters each. The helper allocates a copy of one string at a time, so the working memory beyond the result is O(L).

    Edge cases and common mistakes

    • An empty slice. The loop never runs and the function returns an empty, non-nil [][]string{}.
    • The empty string. [""] gives one group that holds the empty string: its signature is the empty string.
    • A single word. ["a"] gives [["a"]], since the last-index test closes the group.
    • Different lengths. ["ab", "abc", "ba"] gives the groups ab, ba and abc. Words of different lengths never share a signature.
    • Reading past the end. Checking strs[i+1] before checking i == len(strs)-1 would panic with an index out of range error on the last word.
    • Forgetting the last group. Closing a group only when the signature changes would drop the final group; the last-index test is what stores it.
    • Expecting a fixed order. Neither the order of the groups nor of the words inside them is promised, so compare results after sorting if you test by hand.

    Testing

    • ["eat", "tea", "tan", "ate", "nat", "bat"] gives three groups: bat; tan, nat; and eat, tea, ate, in some order.
    • [""] gives [[""]].
    • ["a"] gives [["a"]].
    • ["ab", "abc", "ba"] gives the groups ab, ba and abc.
    Keep going with GoAbout 340 step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free