Facebook Pixel

893. Groups of Special-Equivalent Strings

MediumArrayHash TableStringSorting
Leetcode Link

Problem Description

You have an array of strings where all strings have the same length. You can perform moves on these strings with a specific rule: in one move, you can swap any two characters at even positions (0, 2, 4, ...) with each other, OR swap any two characters at odd positions (1, 3, 5, ...) with each other.

Two strings are considered special-equivalent if you can transform one into the other by performing any number of these allowed moves. For example, "zzxy" and "xyzz" are special-equivalent because:

  • Start with "zzxy"
  • Swap characters at positions 0 and 2 (both even): "zzxy" โ†’ "xzzy"
  • Swap characters at positions 1 and 3 (both odd): "xzzy" โ†’ "xyzz"

The problem asks you to group all the strings into groups of special-equivalent strings. Each group must satisfy:

  1. Every pair of strings within the group are special-equivalent to each other
  2. The group is maximal (you cannot add any other string from the array that would be special-equivalent to all strings in the group)

Your task is to return the total number of such groups.

The key insight is that two strings are special-equivalent if and only if:

  • The characters at even positions in both strings can be rearranged to match each other
  • The characters at odd positions in both strings can be rearranged to match each other

This means strings are special-equivalent when they have the same set of characters at even positions (regardless of order) and the same set of characters at odd positions (regardless of order).

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

Intuition

Since we can swap any two characters at even positions with each other, and any two characters at odd positions with each other, we can rearrange the even-positioned characters in any order we want, and similarly for odd-positioned characters. However, we cannot move a character from an even position to an odd position or vice versa.

This means two strings are special-equivalent if and only if:

  • They have the exact same collection of characters at even positions (just possibly in different order)
  • They have the exact same collection of characters at odd positions (just possibly in different order)

For example, consider "abcd" and "cdab":

  • Even positions (0, 2): "abcd" has ['a', 'c'] and "cdab" has ['c', 'a'] - same characters!
  • Odd positions (1, 3): "abcd" has ['b', 'd'] and "cdab" has ['d', 'b'] - same characters!

So these strings are special-equivalent.

To identify which strings belong to the same group, we need a way to create a unique "signature" for each group. Since the order doesn't matter within even positions and odd positions separately, we can:

  1. Extract all characters at even positions and sort them
  2. Extract all characters at odd positions and sort them
  3. Concatenate these two sorted strings to create a canonical form

All strings that are special-equivalent will produce the same canonical form. For instance:

  • "abcd" โ†’ even: "ac" โ†’ sorted: "ac", odd: "bd" โ†’ sorted: "bd", canonical: "acbd"
  • "cdab" โ†’ even: "ca" โ†’ sorted: "ac", odd: "db" โ†’ sorted: "bd", canonical: "acbd"

By creating these canonical forms for all strings and counting the unique ones, we get the number of special-equivalent groups. We can use a set to automatically handle the uniqueness, making the solution elegant and efficient.

Learn more about Sorting patterns.

Solution Approach

The solution uses a set comprehension to elegantly solve this problem in a single line. Let's break down how it works:

  1. Iterate through each word: For each word in the words array, we need to create its canonical form.

  2. Extract and sort characters at even positions: Using Python's slice notation word[::2], we extract all characters at even indices (0, 2, 4, ...). Then we sort these characters using sorted(word[::2]).

  3. Extract and sort characters at odd positions: Similarly, word[1::2] extracts all characters at odd indices (1, 3, 5, ...), and we sort them with sorted(word[1::2]).

  4. Create the canonical form: We concatenate the two sorted lists using sorted(word[::2]) + sorted(word[1::2]), then join them into a single string with ''.join(). This creates a unique signature for each special-equivalent group.

  5. Use a set to count unique groups: By placing all canonical forms in a set s, duplicate canonical forms are automatically eliminated. Each unique canonical form represents one group of special-equivalent strings.

  6. Return the count: The number of unique canonical forms in the set equals the number of special-equivalent groups, so we return len(s).

Example walkthrough: If words = ["abcd", "cdab", "cbad", "xyzz", "zzxy", "zzyx"]:

  • "abcd" โ†’ even: "ac", odd: "bd" โ†’ canonical: "acbd"
  • "cdab" โ†’ even: "ca" โ†’ "ac", odd: "db" โ†’ "bd" โ†’ canonical: "acbd"
  • "cbad" โ†’ even: "ca" โ†’ "ac", odd: "bd" โ†’ canonical: "acbd"
  • "xyzz" โ†’ even: "xz", odd: "yz" โ†’ canonical: "xzyz"
  • "zzxy" โ†’ even: "zx" โ†’ "xz", odd: "zy" โ†’ "yz" โ†’ canonical: "xzyz"
  • "zzyx" โ†’ even: "zy" โ†’ "yz", odd: "zx" โ†’ "xz" โ†’ canonical: "yzxz"

The set would contain: {"acbd", "xzyz", "yzxz"}, giving us 3 groups.

Ready to land your dream job?

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

Start Evaluator

Example Walkthrough

Let's walk through a small example with words = ["abc", "bca", "bac", "cba"].

For each string, we'll extract characters at even and odd positions, sort them separately, then combine them to create a canonical form:

String: "abc"

  • Even positions (indices 0, 2): 'a' and 'c' โ†’ sorted: "ac"
  • Odd positions (index 1): 'b' โ†’ sorted: "b"
  • Canonical form: "ac" + "b" = "acb"

String: "bca"

  • Even positions (indices 0, 2): 'b' and 'a' โ†’ sorted: "ab"
  • Odd positions (index 1): 'c' โ†’ sorted: "c"
  • Canonical form: "ab" + "c" = "abc"

String: "bac"

  • Even positions (indices 0, 2): 'b' and 'c' โ†’ sorted: "bc"
  • Odd positions (index 1): 'a' โ†’ sorted: "a"
  • Canonical form: "bc" + "a" = "bca"

String: "cba"

  • Even positions (indices 0, 2): 'c' and 'a' โ†’ sorted: "ac"
  • Odd positions (index 1): 'b' โ†’ sorted: "b"
  • Canonical form: "ac" + "b" = "acb"

Now we collect all unique canonical forms:

  • "abc" โ†’ "acb"
  • "bca" โ†’ "abc"
  • "bac" โ†’ "bca"
  • "cba" โ†’ "acb" (duplicate of first)

Unique canonical forms: {"acb", "abc", "bca"}

Therefore, we have 3 groups of special-equivalent strings:

  • Group 1: ["abc", "cba"] (both have canonical form "acb")
  • Group 2: ["bca"] (canonical form "abc")
  • Group 3: ["bac"] (canonical form "bca")

The strings "abc" and "cba" are in the same group because we can transform "abc" to "cba" by swapping the characters at even positions 0 and 2.

Solution Implementation

1class Solution:
2    def numSpecialEquivGroups(self, words: List[str]) -> int:
3        # Use a set to store unique special-equivalent group signatures
4        unique_groups = {
5            # For each word, create a signature by:
6            # 1. Sorting characters at even indices (word[::2])
7            # 2. Sorting characters at odd indices (word[1::2])
8            # 3. Concatenating the two sorted strings
9            # This signature will be identical for special-equivalent strings
10            ''.join(sorted(word[::2]) + sorted(word[1::2])) 
11            for word in words
12        }
13      
14        # Return the count of unique special-equivalent groups
15        return len(unique_groups)
16
1class Solution {
2    /**
3     * Counts the number of groups of special-equivalent strings.
4     * Two strings are special-equivalent if we can swap any two characters at even indices,
5     * or swap any two characters at odd indices, to make them equal.
6     * 
7     * @param words Array of strings to group by special-equivalence
8     * @return Number of distinct special-equivalent groups
9     */
10    public int numSpecialEquivGroups(String[] words) {
11        // Use a HashSet to store unique normalized representations of strings
12        Set<String> uniqueGroups = new HashSet<>();
13      
14        // Process each word and add its normalized form to the set
15        for (String word : words) {
16            uniqueGroups.add(convert(word));
17        }
18      
19        // The size of the set represents the number of distinct groups
20        return uniqueGroups.size();
21    }
22
23    /**
24     * Converts a string to its normalized form for special-equivalence comparison.
25     * The normalized form is created by sorting characters at even positions and odd positions separately,
26     * then concatenating them. This ensures that special-equivalent strings have the same normalized form.
27     * 
28     * @param word The input string to normalize
29     * @return The normalized string representation
30     */
31    private String convert(String word) {
32        // List to store characters at even indices (0, 2, 4, ...)
33        List<Character> evenChars = new ArrayList<>();
34        // List to store characters at odd indices (1, 3, 5, ...)
35        List<Character> oddChars = new ArrayList<>();
36      
37        // Separate characters based on their position (even or odd index)
38        for (int i = 0; i < word.length(); i++) {
39            char currentChar = word.charAt(i);
40            if (i % 2 == 0) {
41                evenChars.add(currentChar);
42            } else {
43                oddChars.add(currentChar);
44            }
45        }
46      
47        // Sort both lists to create a canonical representation
48        Collections.sort(evenChars);
49        Collections.sort(oddChars);
50      
51        // Build the normalized string by concatenating sorted even and odd characters
52        StringBuilder normalizedString = new StringBuilder();
53      
54        // Append all sorted even-position characters
55        for (char c : evenChars) {
56            normalizedString.append(c);
57        }
58      
59        // Append all sorted odd-position characters
60        for (char c : oddChars) {
61            normalizedString.append(c);
62        }
63      
64        return normalizedString.toString();
65    }
66}
67
1class Solution {
2public:
3    int numSpecialEquivGroups(vector<string>& words) {
4        // Use a set to store unique representations of special-equivalent groups
5        unordered_set<string> uniqueGroups;
6      
7        // Process each word in the input array
8        for (auto& word : words) {
9            // Separate characters at odd and even indices
10            string oddChars = "";
11            string evenChars = "";
12          
13            // Iterate through each character in the word
14            for (int i = 0; i < word.size(); ++i) {
15                if (i & 1) {
16                    // Character at odd index (1, 3, 5, ...)
17                    oddChars += word[i];
18                } else {
19                    // Character at even index (0, 2, 4, ...)
20                    evenChars += word[i];
21                }
22            }
23          
24            // Sort characters at odd and even positions separately
25            // This creates a canonical form for special-equivalent strings
26            sort(oddChars.begin(), oddChars.end());
27            sort(evenChars.begin(), evenChars.end());
28          
29            // Concatenate sorted odd and even characters to form a unique key
30            // Words that are special-equivalent will have the same key
31            uniqueGroups.insert(evenChars + oddChars);
32        }
33      
34        // Return the number of unique special-equivalent groups
35        return uniqueGroups.size();
36    }
37};
38
1function numSpecialEquivGroups(words: string[]): number {
2    // Use a Set to store unique representations of special-equivalent groups
3    const uniqueGroups: Set<string> = new Set();
4  
5    // Process each word in the input array
6    for (const word of words) {
7        // Separate characters at odd and even indices
8        let oddChars: string = "";
9        let evenChars: string = "";
10      
11        // Iterate through each character in the word
12        for (let i = 0; i < word.length; i++) {
13            if (i & 1) {
14                // Character at odd index (1, 3, 5, ...)
15                oddChars += word[i];
16            } else {
17                // Character at even index (0, 2, 4, ...)
18                evenChars += word[i];
19            }
20        }
21      
22        // Sort characters at odd and even positions separately
23        // This creates a canonical form for special-equivalent strings
24        const sortedOddChars: string = oddChars.split('').sort().join('');
25        const sortedEvenChars: string = evenChars.split('').sort().join('');
26      
27        // Concatenate sorted even and odd characters to form a unique key
28        // Words that are special-equivalent will have the same key
29        uniqueGroups.add(sortedEvenChars + sortedOddChars);
30    }
31  
32    // Return the number of unique special-equivalent groups
33    return uniqueGroups.size;
34}
35

Time and Space Complexity

Time Complexity: O(n * m * log(m)) where n is the number of words and m is the average length of each word.

  • Iterating through all words: O(n)
  • For each word:
    • Slicing even indices word[::2]: O(m)
    • Slicing odd indices word[1::2]: O(m)
    • Sorting even characters: O(m/2 * log(m/2)) โ‰ˆ O(m * log(m))
    • Sorting odd characters: O(m/2 * log(m/2)) โ‰ˆ O(m * log(m))
    • Joining the sorted strings: O(m)
    • Adding to set: O(m) for string hashing
  • Overall: O(n * m * log(m))

Space Complexity: O(n * m) where n is the number of words and m is the average length of each word.

  • Set storage: In worst case, all words produce unique signatures, storing up to n strings, each of length m: O(n * m)
  • Temporary space for slicing and sorting: O(m) for each word operation
  • Overall: O(n * m)

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

Common Pitfalls

Pitfall 1: Incorrect Concatenation of Sorted Characters

The Problem: A common mistake is concatenating the sorted strings incorrectly. Some developers might write:

''.join(sorted(word[::2]) + sorted(word[1::2]))

This looks correct, but remember that sorted() returns a list of characters, not a string. When you use + to concatenate two lists, you get a single list containing all characters. Then ''.join() converts this combined list into a string.

However, developers might mistakenly try:

sorted(word[::2]) + sorted(word[1::2])  # Without join
# or
''.join(sorted(word[::2])) + ''.join(sorted(word[1::2]))  # Double join

The first attempt would store a list in the set (causing a TypeError since lists are unhashable), while the second would work but is unnecessarily verbose.

The Solution: Stick with the original approach: ''.join(sorted(word[::2]) + sorted(word[1::2])). This correctly:

  1. Creates two sorted lists of characters
  2. Concatenates the lists with +
  3. Joins all characters into a single string

Pitfall 2: Forgetting to Sort the Characters

The Problem: Some might try to create the signature without sorting:

''.join(word[::2] + word[1::2])

This would simply reconstruct a rearranged version of the original string but wouldn't create a canonical form. For example, "abcd" would become "acbd" and "cdab" would become "cadb" - these are different, even though the strings are special-equivalent.

The Solution: Always sort the characters at even and odd positions separately. The sorting ensures that all special-equivalent strings map to the same canonical form.

Pitfall 3: Mixing Up Even and Odd Indices

The Problem: Python's slicing syntax can be confusing. Some might accidentally use:

  • word[0::2] instead of word[::2] (though these are equivalent)
  • word[2::2] thinking it starts at position 2 (it actually starts at index 2, missing indices 0)
  • word[::1] and word[1::1] thinking these separate even/odd positions

The Solution: Remember the slice notation [start:stop:step]:

  • word[::2] means start at index 0, go to the end, step by 2 (gets indices 0, 2, 4, ...)
  • word[1::2] means start at index 1, go to the end, step by 2 (gets indices 1, 3, 5, ...)

Pitfall 4: Using a List Instead of a Set

The Problem: Using a list to collect canonical forms and then counting unique elements:

groups = [''.join(sorted(word[::2]) + sorted(word[1::2])) for word in words]
return len(set(groups))  # Extra conversion step

While this works, it's less efficient as it stores duplicates before converting to a set.

The Solution: Use a set comprehension directly with {} instead of a list comprehension with []. This automatically handles duplicates during creation, making the solution more efficient.

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

Which technique can we use to find the middle of a linked list?


Recommended Readings

Want a Structured Path to Master System Design Too? Donโ€™t Miss This!

Load More