249. Group Shifted Strings 🔒
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:
- For each string, calculate how many positions to shift so the first character becomes
'a'
- Apply this same shift to all characters in the string (wrapping around if needed)
- Use the resulting normalized string as a key to group the original strings
Return the groups in any order.
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:
- Finding the difference between the first character and
'a'
- Subtracting this difference from every character in the string
- 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)
namedg
stores the groups, where keys are normalized string patterns and values are lists of original strings that match each pattern.
Algorithm Steps:
-
Iterate through each string in the input array:
for s in strings:
-
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'
. -
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
-
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. -
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 EvaluatorExample 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)
Which of the following array represent a max heap?
Recommended Readings
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Recursion Recursion is one of the most important concepts in computer science Simply speaking recursion is the process of a function calling itself Using a real life analogy imagine a scenario where you invite your friends to lunch https assets algo monster recursion jpg You first call Ben and ask
Runtime Overview When learning about algorithms and data structures you'll frequently encounter the term time complexity This concept is fundamental in computer science and offers insights into how long an algorithm takes to complete given a certain input size What is Time Complexity Time complexity represents the amount of time
Want a Structured Path to Master System Design Too? Don’t Miss This!