2744. Find Maximum Number of String Pairs
Problem Description
You are given a 0-indexed array words
containing distinct strings. Your task is to find the maximum number of pairs that can be formed from this array.
Two strings can form a pair if they meet these conditions:
- One string is exactly the reverse of the other string
- They are at different positions in the array where the first position index is less than the second position index (
0 <= i < j < words.length
) - Each string can be used in at most one pair
For example, if you have words = ["cd", "ac", "dc", "ca", "zz"]
, you can form 2 pairs:
- "cd" (index 0) pairs with "dc" (index 2) because "cd" reversed is "dc"
- "ac" (index 1) pairs with "ca" (index 3) because "ac" reversed is "ca"
- "zz" cannot form a pair because there's no other "zz" in the array (its reverse is itself)
The solution uses a hash table to count occurrences. As we iterate through each word w
:
- We check if the reversed version of
w
(written asw[::-1]
) already exists in our counter - If it does, we can form a pair, so we add that count to our answer
- We then add the current word
w
to our counter for future potential pairs
This approach ensures we only count valid pairs where i < j
since we're checking for reversed strings that appeared before the current position.
Intuition
The key insight is that we need to find pairs where one string is the reverse of another, and we want to maximize the number of such pairs. Since each string can only be used once, we need an efficient way to match strings with their reverses.
Think about what happens when we traverse the array from left to right. For each string we encounter, we want to know: "Has the reverse of this string appeared before?" If it has, we can form a pair immediately.
This naturally leads us to use a hash table approach. As we go through the array:
- For the current string, check if its reverse has been seen before
- If yes, we can form pairs with all previous occurrences of the reversed string
- Then, record the current string for future potential matches
Why does this work? Consider the constraint i < j
. When we're at position j
, we only need to look backwards for potential pairs at positions i
where i < j
. By maintaining a counter of all strings we've seen so far, we automatically satisfy this ordering constraint.
The beauty of this approach is that we handle the pairing greedily - whenever we find a match, we count it immediately. Since the problem asks for the maximum number of pairs and each string can only be used once, this greedy approach is optimal. We don't need to worry about "saving" a string for a better match later because any valid pairing contributes equally to our count.
The time complexity is O(n)
where n
is the number of words, making this solution very efficient compared to checking all possible pairs which would be O(n²)
.
Solution Approach
The solution uses a hash table to efficiently track and match strings with their reverses. Here's how the implementation works:
Data Structure: We use a Counter
object (hash table) called cnt
to store the frequency of each string we've encountered so far.
Algorithm Steps:
-
Initialize variables:
cnt = Counter()
- An empty hash table to track string occurrencesans = 0
- Counter for the number of pairs formed
-
Iterate through each word in the
words
array:for w in words:
-
Check for reverse match:
ans += cnt[w[::-1]]
w[::-1]
generates the reversed string ofw
cnt[w[::-1]]
returns the count of how many times the reversed string has appeared before- We add this count to
ans
because each previous occurrence of the reversed string can form a pair with the current string
-
Update the counter:
cnt[w] += 1
- Record the current string in our hash table for future potential matches
- This happens after checking for pairs to ensure we don't pair a string with itself
Why this works:
- The order of operations is crucial: we check for matches before adding the current string
- This ensures we only count pairs where
i < j
(the reversed string appeared at an earlier index) - Each string is counted exactly once in a pair because we only look backwards
Example walkthrough with words = ["cd", "ac", "dc", "ca"]
:
- Process "cd":
cnt["dc"] = 0
, no pair formed, updatecnt["cd"] = 1
- Process "ac":
cnt["ca"] = 0
, no pair formed, updatecnt["ac"] = 1
- Process "dc":
cnt["cd"] = 1
, one pair formed!ans = 1
, updatecnt["dc"] = 1
- Process "ca":
cnt["ac"] = 1
, one pair formed!ans = 2
, updatecnt["ca"] = 1
- Final answer: 2 pairs
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 a small example to illustrate the solution approach.
Example: words = ["ab", "ba", "cc", "ba", "ab", "ba"]
We'll trace through the algorithm step by step:
Initial State:
cnt = {}
(empty counter)ans = 0
(no pairs yet)
Step 1 - Process "ab" (index 0):
- Check if reverse "ba" exists in
cnt
: No (count = 0) - Add 0 to
ans
:ans = 0
- Update counter:
cnt = {"ab": 1}
Step 2 - Process "ba" (index 1):
- Check if reverse "ab" exists in
cnt
: Yes! (count = 1) - Add 1 to
ans
:ans = 1
(formed pair: "ab"[0] with "ba"[1]) - Update counter:
cnt = {"ab": 1, "ba": 1}
Step 3 - Process "cc" (index 2):
- Check if reverse "cc" exists in
cnt
: No (count = 0) - Note: "cc" reversed is still "cc", but there's no previous "cc"
- Add 0 to
ans
:ans = 1
- Update counter:
cnt = {"ab": 1, "ba": 1, "cc": 1}
Step 4 - Process "ba" (index 3):
- Check if reverse "ab" exists in
cnt
: Yes! (count = 1) - Add 1 to
ans
:ans = 2
(formed pair: "ab"[0] with "ba"[3]) - Note: The "ab" at index 0 hasn't been used yet, so it can pair
- Update counter:
cnt = {"ab": 1, "ba": 2}
Step 5 - Process "ab" (index 4):
- Check if reverse "ba" exists in
cnt
: Yes! (count = 2) - Add 2 to
ans
:ans = 4
(can form pairs with both "ba"[1] and "ba"[3]) - Update counter:
cnt = {"ab": 2, "ba": 2}
Step 6 - Process "ba" (index 5):
- Check if reverse "ab" exists in
cnt
: Yes! (count = 2) - Add 2 to
ans
:ans = 6
(can form pairs with "ab"[0] and "ab"[4]) - Update counter:
cnt = {"ab": 2, "ba": 3}
Final Answer: 6 pairs
The key insight is that when we encounter a string, we can form as many pairs as there are previous occurrences of its reverse. This greedy approach maximizes the number of pairs while respecting the constraint that each string position can only be used once.
Solution Implementation
1class Solution:
2 def maximumNumberOfStringPairs(self, words: List[str]) -> int:
3 # Dictionary to count occurrences of each word
4 word_count = Counter()
5 # Variable to store the number of valid pairs
6 pair_count = 0
7
8 # Iterate through each word in the list
9 for word in words:
10 # Check if the reverse of current word exists in our counter
11 # If it exists, it means we can form a pair
12 pair_count += word_count[word[::-1]]
13
14 # Add the current word to our counter for future pairing
15 word_count[word] += 1
16
17 return pair_count
18
1class Solution {
2 public int maximumNumberOfStringPairs(String[] words) {
3 // Map to store encoded string representations and their counts
4 // Key: encoded value of string (first char << 5 | second char)
5 // Value: count of occurrences
6 Map<Integer, Integer> encodedStringCount = new HashMap<>();
7 int pairCount = 0;
8
9 // Iterate through each word in the array
10 for (String word : words) {
11 // Extract the first and second characters as 0-based indices (a=0, b=1, etc.)
12 int firstChar = word.charAt(0) - 'a';
13 int secondChar = word.charAt(1) - 'a';
14
15 // Check if the reverse of current word exists in the map
16 // Reverse is encoded as: secondChar << 5 | firstChar
17 // If found, increment pair count by the number of reverses found
18 int reverseEncoding = (secondChar << 5) | firstChar;
19 pairCount += encodedStringCount.getOrDefault(reverseEncoding, 0);
20
21 // Add current word to the map with its encoding: firstChar << 5 | secondChar
22 // The << 5 operation ensures unique encoding for each two-character combination
23 // since we have at most 26 letters (needs 5 bits: 2^5 = 32 > 26)
24 int currentEncoding = (firstChar << 5) | secondChar;
25 encodedStringCount.merge(currentEncoding, 1, Integer::sum);
26 }
27
28 return pairCount;
29 }
30}
31
1class Solution {
2public:
3 int maximumNumberOfStringPairs(vector<string>& words) {
4 // Hash map to store the count of each string pattern
5 // Key: encoded value of the string (first_char << 5 | second_char)
6 unordered_map<int, int> stringPatternCount;
7
8 int pairCount = 0;
9
10 // Iterate through each word in the array
11 for (auto& word : words) {
12 // Extract the two characters and convert to 0-based index
13 int firstChar = word[0] - 'a';
14 int secondChar = word[1] - 'a';
15
16 // Check if the reverse pattern exists in the map
17 // Reverse pattern: secondChar in high bits, firstChar in low bits
18 int reversePattern = (secondChar << 5) | firstChar;
19 pairCount += stringPatternCount[reversePattern];
20
21 // Store the current pattern in the map
22 // Current pattern: firstChar in high bits, secondChar in low bits
23 int currentPattern = (firstChar << 5) | secondChar;
24 stringPatternCount[currentPattern]++;
25 }
26
27 return pairCount;
28 }
29};
30
1/**
2 * Counts the maximum number of string pairs where one string is the reverse of another
3 * @param words - Array of strings to check for reverse pairs
4 * @returns The number of valid string pairs found
5 */
6function maximumNumberOfStringPairs(words: string[]): number {
7 // Map to store encoded character pairs as keys and their counts as values
8 // Using number index signature for better TypeScript syntax
9 const pairCount: Record<number, number> = {};
10
11 // Counter for the total number of valid pairs
12 let totalPairs = 0;
13
14 // Iterate through each word in the array
15 for (const word of words) {
16 // Extract first and last characters and convert to 0-based indices (a=0, b=1, ..., z=25)
17 const firstCharIndex = word.charCodeAt(0) - 97;
18 const lastCharIndex = word.charCodeAt(word.length - 1) - 97;
19
20 // Check if the reverse pair exists in the map
21 // Encode as: (lastChar << 5) | firstChar to create unique key for reverse
22 const reversePairKey = (lastCharIndex << 5) | firstCharIndex;
23 totalPairs += pairCount[reversePairKey] || 0;
24
25 // Store current pair in the map for future matching
26 // Encode as: (firstChar << 5) | lastChar to create unique key
27 const currentPairKey = (firstCharIndex << 5) | lastCharIndex;
28 pairCount[currentPairKey] = (pairCount[currentPairKey] || 0) + 1;
29 }
30
31 return totalPairs;
32}
33
Time and Space Complexity
The time complexity is O(n × m)
, where n
is the length of the array words
and m
is the average length of each word string. This is because:
- The algorithm iterates through all
n
words once - For each word
w
, it performsw[::-1]
which takesO(m)
time to reverse the string - The hash table operations (lookup and insertion) take
O(m)
time since strings are used as keys and comparing/hashing strings requires examining their characters
The space complexity is O(n × m)
. This is because:
- The
Counter
dictionarycnt
can store up ton
unique words as keys - Each key (word) has length
m
on average, so storing all keys requiresO(n × m)
space - The reversed string
w[::-1]
creates a temporary string of lengthm
, but this doesn't affect the overall space complexity
Note: The reference answer simplifies this to O(n)
by likely assuming constant-length strings or treating string operations as O(1)
, which is a common simplification in some contexts where string length is bounded by a small constant.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Attempting to Pair a Palindrome with Itself
The Pitfall: When encountering palindromes (strings that read the same forwards and backwards, like "aa", "aba"), developers might incorrectly count them as pairs since the string equals its own reverse.
Why it happens: The condition cnt[w[::-1]]
will find the palindrome itself if we're not careful about the order of operations.
Example of incorrect approach:
# WRONG: Adding to counter before checking for word in words: word_count[word] += 1 # Adding first (WRONG!) pair_count += word_count[word[::-1]]
With words = ["aa", "bb"]
, this would incorrectly count "aa" as forming a pair with itself.
The Solution: The correct implementation checks for the reverse BEFORE adding the current word to the counter. This ensures a palindrome won't match with itself:
# CORRECT: Check first, then add for word in words: pair_count += word_count[word[::-1]] # Check first word_count[word] += 1 # Then add
2. Double-Counting Pairs
The Pitfall: Trying to count all possible matches at once without considering the constraint that each string can only be used once.
Example of incorrect approach:
# WRONG: This might count the same pair multiple times
for i in range(len(words)):
for j in range(i+1, len(words)):
if words[i] == words[j][::-1]:
pair_count += 1
The Solution: The hash table approach naturally prevents double-counting because once we've found a match for a string with its reverse, we only increment the counter by the exact number of available reverses seen so far.
3. Handling Duplicate Strings Incorrectly
The Pitfall: Although the problem states the array contains "distinct strings", if there were duplicates, a naive approach might pair a string with another instance of the same string at a different index.
Example scenario: If words = ["ab", "ba", "ab"]
, we should form 2 pairs, not 1.
The Solution: The counter-based approach handles this correctly by counting occurrences. Each "ab" can pair with each previously seen "ba", giving us the correct count automatically through pair_count += word_count[word[::-1]]
.
Given a sorted array of integers and an integer called target, find the element that
equals to the target and return its index. Select the correct code that fills the
___
in the given code snippet.
1def binary_search(arr, target):
2 left, right = 0, len(arr) - 1
3 while left ___ right:
4 mid = (left + right) // 2
5 if arr[mid] == target:
6 return mid
7 if arr[mid] < target:
8 ___ = mid + 1
9 else:
10 ___ = mid - 1
11 return -1
12
1public static int binarySearch(int[] arr, int target) {
2 int left = 0;
3 int right = arr.length - 1;
4
5 while (left ___ right) {
6 int mid = left + (right - left) / 2;
7 if (arr[mid] == target) return mid;
8 if (arr[mid] < target) {
9 ___ = mid + 1;
10 } else {
11 ___ = mid - 1;
12 }
13 }
14 return -1;
15}
16
1function binarySearch(arr, target) {
2 let left = 0;
3 let right = arr.length - 1;
4
5 while (left ___ right) {
6 let mid = left + Math.trunc((right - left) / 2);
7 if (arr[mid] == target) return mid;
8 if (arr[mid] < target) {
9 ___ = mid + 1;
10 } else {
11 ___ = mid - 1;
12 }
13 }
14 return -1;
15}
16
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!