Facebook Pixel

249. Group Shifted Strings 🔒

MediumArrayHash TableString
Leetcode Link

Problem Description

You are given an array of strings and need to group strings that belong to the same "shifting sequence".

A shifting sequence is formed by repeatedly shifting letters in a string:

  • Right shift: Replace each letter with the next letter in the alphabet (a→b, b→c, ..., z→a)
  • Left shift: Replace each letter with the previous letter in the alphabet (z←a, b←a, ..., a←z)

For example, the strings "abc", "bcd", "xyz", "yza", and "zab" all belong to the same shifting sequence because:

  • "abc" can be right-shifted to "bcd"
  • "bcd" can be right-shifted multiple times to eventually get "xyz"
  • "xyz" can be right-shifted to "yza"
  • "yza" can be right-shifted to "zab"
  • "zab" can be right-shifted to "abc" (completing the cycle)

The key insight is that two strings belong to the same shifting sequence if and only if they have the same pattern of differences between consecutive characters. For instance, "abc" and "xyz" both have the pattern where each character is exactly 1 position after the previous character in the alphabet.

The solution works by normalizing each string to start with the letter 'a' while preserving the relative differences between characters. All strings that normalize to the same pattern belong to the same group. The algorithm:

  1. For each string, calculate how many positions to shift so the first character becomes 'a'
  2. Apply this same shift to all characters in the string (wrapping around if needed)
  3. Use the resulting normalized string as a key to group the original strings

Return the groups in any order.

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

Intuition

The key observation is that strings in the same shifting sequence maintain the same relative distances between their characters. For example, in "abc", the distance from 'a' to 'b' is 1, and from 'b' to 'c' is also 1. Similarly, in "xyz", the distance from 'x' to 'y' is 1, and from 'y' to 'z' is also 1. This pattern of distances remains constant no matter how many times we shift.

To identify strings with the same pattern, we need a way to normalize them. Think of it like comparing melodies in different musical keys - the notes might be different, but the intervals between them create the same tune. Similarly, we can "transpose" each string to start with the same letter while preserving the intervals.

The natural choice is to normalize every string to start with 'a'. Here's why this works:

  • If we shift "abc" to start with 'a', it stays "abc"
  • If we shift "bcd" to start with 'a', we shift left by 1 to get "abc"
  • If we shift "xyz" to start with 'a', we shift left by 23 to get "abc"

All three strings normalize to "abc", confirming they're in the same group.

The normalization process involves:

  1. Finding the difference between the first character and 'a'
  2. Subtracting this difference from every character in the string
  3. Handling wraparound (when subtracting takes us below 'a', we add 26 to wrap back to the end of the alphabet)

This normalized form serves as a unique signature for each shifting sequence, allowing us to use it as a hash table key to group the original strings efficiently.

Solution Approach

The implementation uses a hash table to efficiently group strings by their normalized patterns.

Data Structure:

  • A defaultdict(list) named g stores the groups, where keys are normalized string patterns and values are lists of original strings that match each pattern.

Algorithm Steps:

  1. Iterate through each string in the input array:

    for s in strings:
  2. Calculate the shift difference needed to normalize the first character to 'a':

    diff = ord(s[0]) - ord("a")

    This tells us how many positions we need to shift left to make the first character 'a'.

  3. Build the normalized pattern by shifting each character:

    t = []
    for c in s:
        c = ord(c) - diff
        if c < ord("a"):
            c += 26
        t.append(chr(c))
    • Convert each character to its ASCII value
    • Subtract the shift difference
    • If the result falls below 'a' (ASCII 97), add 26 to wrap around to the end of the alphabet
    • Convert back to a character and add to our pattern list
  4. Use the normalized pattern as a key to group the original string:

    g["".join(t)].append(s)

    The normalized string "".join(t) serves as the unique identifier for this shifting sequence.

  5. Return all groups:

    return list(g.values())

Example Walkthrough:

  • For "abc": diff = 0, normalized = "abc"
  • For "bcd": diff = 1, each character shifts left by 1, normalized = "abc"
  • For "xyz": diff = 23, each character shifts left by 23, normalized = "abc"

All three strings map to the same key "abc" in the hash table, so they end up in the same group.

Time Complexity: O(N × M) where N is the number of strings and M is the average length of strings. Space Complexity: O(N × M) for storing the hash table.

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 the algorithm with a small example: strings = ["abc", "bcd", "acef", "xyz", "az", "ba", "a", "z"]

Step 1: Process "abc"

  • First character is 'a', so diff = ord('a') - ord('a') = 0
  • Normalize each character: 'a' - 0 = 'a', 'b' - 0 = 'b', 'c' - 0 = 'c'
  • Normalized pattern: "abc"
  • Add "abc" to group with key "abc"

Step 2: Process "bcd"

  • First character is 'b', so diff = ord('b') - ord('a') = 1
  • Normalize each character:
    • 'b' - 1 = 'a'
    • 'c' - 1 = 'b'
    • 'd' - 1 = 'c'
  • Normalized pattern: "abc"
  • Add "bcd" to group with key "abc"

Step 3: Process "acef"

  • First character is 'a', so diff = 0
  • Normalize: 'a' - 0 = 'a', 'c' - 0 = 'c', 'e' - 0 = 'e', 'f' - 0 = 'f'
  • Normalized pattern: "acef"
  • Add "acef" to group with key "acef"

Step 4: Process "xyz"

  • First character is 'x', so diff = ord('x') - ord('a') = 23
  • Normalize each character:
    • 'x' - 23 = 'a'
    • 'y' - 23 = 'b'
    • 'z' - 23 = 'c'
  • Normalized pattern: "abc"
  • Add "xyz" to group with key "abc"

Step 5: Process "az"

  • First character is 'a', so diff = 0
  • Normalize: 'a' - 0 = 'a', 'z' - 0 = 'z'
  • Normalized pattern: "az"
  • Add "az" to group with key "az"

Step 6: Process "ba"

  • First character is 'b', so diff = 1
  • Normalize:
    • 'b' - 1 = 'a'
    • 'a' - 1 = -1 (below 'a'), so add 26: -1 + 26 = 25, which is 'z'
  • Normalized pattern: "az"
  • Add "ba" to group with key "az"

Step 7: Process "a"

  • First character is 'a', so diff = 0
  • Normalized pattern: "a"
  • Add "a" to group with key "a"

Step 8: Process "z"

  • First character is 'z', so diff = 25
  • Normalize: 'z' - 25 = 'a'
  • Normalized pattern: "a"
  • Add "z" to group with key "a"

Final Groups:

  • Key "abc": ["abc", "bcd", "xyz"]
  • Key "acef": ["acef"]
  • Key "az": ["az", "ba"]
  • Key "a": ["a", "z"]

The algorithm successfully groups strings that belong to the same shifting sequence by identifying their common normalized patterns.

Solution Implementation

1from collections import defaultdict
2from typing import List
3
4class Solution:
5    def groupStrings(self, strings: List[str]) -> List[List[str]]:
6        # Dictionary to group strings by their normalized pattern
7        groups = defaultdict(list)
8      
9        for string in strings:
10            # Calculate the shift needed to normalize the first character to 'a'
11            shift_amount = ord(string[0]) - ord('a')
12          
13            # Build the normalized pattern for this string
14            normalized_pattern = []
15            for char in string:
16                # Shift the character backwards by shift_amount
17                shifted_char_code = ord(char) - shift_amount
18              
19                # Handle wrap-around: if we go below 'a', add 26 to wrap to the end of alphabet
20                if shifted_char_code < ord('a'):
21                    shifted_char_code += 26
22              
23                # Convert back to character and add to pattern
24                normalized_pattern.append(chr(shifted_char_code))
25          
26            # Use the normalized pattern as key to group similar strings
27            pattern_key = ''.join(normalized_pattern)
28            groups[pattern_key].append(string)
29      
30        # Return all groups as a list of lists
31        return list(groups.values())
32
1class Solution {
2    public List<List<String>> groupStrings(String[] strings) {
3        // Map to group strings by their normalized pattern
4        // Key: normalized string pattern (shifted to start with 'a')
5        // Value: list of original strings that share the same pattern
6        Map<String, List<String>> patternGroups = new HashMap<>();
7      
8        // Process each string in the input array
9        for (String originalString : strings) {
10            // Convert string to character array for manipulation
11            char[] characters = originalString.toCharArray();
12          
13            // Calculate the shift amount needed to normalize the pattern
14            // This is the distance from the first character to 'a'
15            int shiftAmount = characters[0] - 'a';
16          
17            // Apply the shift to all characters to create a normalized pattern
18            for (int i = 0; i < characters.length; i++) {
19                // Shift each character by the calculated amount
20                characters[i] = (char) (characters[i] - shiftAmount);
21              
22                // Handle wrap-around for characters that go below 'a'
23                if (characters[i] < 'a') {
24                    characters[i] += 26;
25                }
26            }
27          
28            // Convert the normalized character array back to string
29            String normalizedPattern = new String(characters);
30          
31            // Add the original string to the group with this normalized pattern
32            // If the pattern doesn't exist yet, create a new list for it
33            patternGroups.computeIfAbsent(normalizedPattern, key -> new ArrayList<>())
34                         .add(originalString);
35        }
36      
37        // Return all groups as a list of lists
38        return new ArrayList<>(patternGroups.values());
39    }
40}
41
1class Solution {
2public:
3    vector<vector<string>> groupStrings(vector<string>& strings) {
4        // HashMap to store normalized pattern as key and group of strings as value
5        unordered_map<string, vector<string>> patternToStrings;
6      
7        // Process each string in the input
8        for (auto& str : strings) {
9            // Build normalized pattern by shifting all characters relative to first character
10            string normalizedPattern;
11            int shiftAmount = str[0] - 'a';  // Calculate shift needed to make first char 'a'
12          
13            for (int i = 0; i < str.size(); ++i) {
14                // Shift each character by the same amount
15                char shiftedChar = str[i] - shiftAmount;
16              
17                // Handle wrap-around for characters that go below 'a'
18                if (shiftedChar < 'a') {
19                    shiftedChar += 26;
20                }
21              
22                normalizedPattern.push_back(shiftedChar);
23            }
24          
25            // Add the original string to its pattern group
26            patternToStrings[normalizedPattern].emplace_back(str);
27        }
28      
29        // Convert the map values to result vector
30        vector<vector<string>> result;
31        for (auto& pair : patternToStrings) {
32            result.emplace_back(move(pair.second));
33        }
34      
35        return result;
36    }
37};
38
1function groupStrings(strings: string[]): string[][] {
2    // HashMap to store normalized pattern as key and group of strings as value
3    const patternToStrings: Map<string, string[]> = new Map();
4  
5    // Process each string in the input
6    for (const str of strings) {
7        // Build normalized pattern by shifting all characters relative to first character
8        let normalizedPattern = '';
9        // Calculate shift needed to make first char 'a'
10        const shiftAmount = str.charCodeAt(0) - 'a'.charCodeAt(0);
11      
12        for (let i = 0; i < str.length; i++) {
13            // Shift each character by the same amount
14            let shiftedCharCode = str.charCodeAt(i) - shiftAmount;
15          
16            // Handle wrap-around for characters that go below 'a'
17            if (shiftedCharCode < 'a'.charCodeAt(0)) {
18                shiftedCharCode += 26;
19            }
20          
21            // Convert char code back to character and append to pattern
22            normalizedPattern += String.fromCharCode(shiftedCharCode);
23        }
24      
25        // Add the original string to its pattern group
26        if (!patternToStrings.has(normalizedPattern)) {
27            patternToStrings.set(normalizedPattern, []);
28        }
29        patternToStrings.get(normalizedPattern)!.push(str);
30    }
31  
32    // Convert the map values to result array
33    const result: string[][] = [];
34    for (const [pattern, stringGroup] of patternToStrings) {
35        result.push(stringGroup);
36    }
37  
38    return result;
39}
40

Time and Space Complexity

Time Complexity: O(L) where L is the sum of the lengths of all strings.

The algorithm iterates through each string in the input list once. For each string, it performs the following operations:

  • Calculate the difference between the first character and 'a': O(1)
  • Iterate through each character in the string: O(length of string)
  • For each character, perform constant time operations (arithmetic and character conversion): O(1)
  • Join the transformed characters: O(length of string)
  • Append to the dictionary: O(1) amortized

Since we process each character of each string exactly once, and the total number of characters across all strings is L, the overall time complexity is O(L).

Space Complexity: O(L) where L is the sum of the lengths of all strings.

The space usage includes:

  • The dictionary g which stores all the strings: O(L) in total
  • For each string, we create a temporary list t to store the transformed characters: O(length of string)
  • The joined string key for the dictionary: O(length of string)
  • The final result list containing all strings: O(L)

The dominant space usage comes from storing all the input strings in the dictionary and the result list. Since the total length of all strings is L, the space complexity is O(L).

Common Pitfalls

1. Incorrect Handling of Wrap-Around for Negative Shifts

The Pitfall: A common mistake is using the modulo operator incorrectly when handling wrap-around. Developers often write:

shifted_char_code = (ord(char) - shift_amount) % 26 + ord('a')

This fails because (ord(char) - shift_amount) gives the absolute ASCII value, not the position within the alphabet. For example, if char = 'b' (ASCII 98) and shift_amount = 3, you get (98 - 3) % 26 = 95 % 26 = 17, which maps to 'r' instead of the expected 'y'.

The Correct Approach: First convert to alphabet position (0-25), then apply modulo:

alphabet_position = ord(char) - ord('a')
shifted_position = (alphabet_position - shift_amount) % 26
shifted_char_code = shifted_position + ord('a')

2. Not Handling Single Character Strings

The Pitfall: The algorithm works correctly for single-character strings, but developers sometimes add unnecessary special case handling that can introduce bugs:

if len(string) == 1:
    groups[string].append(string)  # Wrong! Breaks grouping
    continue

This would incorrectly group single characters by themselves rather than by their shifting pattern (all single characters belong to the same group).

The Correct Approach: Let the standard algorithm handle single characters naturally. All single-character strings will normalize to 'a' and group together correctly.

3. Using String Concatenation in a Loop

The Pitfall: Building the normalized pattern using string concatenation:

normalized_pattern = ""
for char in string:
    # ... calculate shifted_char
    normalized_pattern += shifted_char  # Inefficient!

This creates a new string object for each concatenation, resulting in O(M²) time complexity for a string of length M.

The Correct Approach: Use a list and join at the end (as shown in the solution):

normalized_pattern = []
for char in string:
    # ... calculate shifted_char
    normalized_pattern.append(shifted_char)
pattern_key = ''.join(normalized_pattern)

4. Forgetting to Handle Empty Strings

The Pitfall: The code might crash or behave unexpectedly with empty strings:

shift_amount = ord(string[0]) - ord('a')  # IndexError if string is empty!

The Correct Approach: Add a check for empty strings:

for string in strings:
    if not string:  # Handle empty string
        groups[""].append(string)
        continue
    # ... rest of the normalization logic

5. Using Tuple Keys Instead of String Keys

The Pitfall: Some developers might keep the normalized pattern as a tuple:

pattern_key = tuple(normalized_pattern)  # Works but less intuitive
groups[pattern_key].append(string)

While this works functionally, it's less readable and harder to debug. String keys are more natural for representing character patterns.

The Correct Approach: Convert to string for cleaner, more readable code:

pattern_key = ''.join(normalized_pattern)
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Which of the following array represent a max heap?


Recommended Readings

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

Load More