3045. Count Prefix and Suffix Pairs II
Problem Description
You are given a 0-indexed array of strings called words
.
The problem asks you to find pairs of strings where one string is both a prefix AND a suffix of another string.
Specifically, you need to count how many pairs of indices (i, j)
exist where:
i < j
(the first index must be smaller than the second)words[i]
is both a prefix and a suffix ofwords[j]
For example:
"aba"
is both a prefix and suffix of"ababa"
(starts with "aba" and ends with "aba") β this would count as a valid pair"abc"
is only a prefix of"abcd"
but not a suffix β this would NOT count
The solution uses a Trie data structure with a clever approach: instead of storing single characters, it stores character pairs. For each string s
, it creates pairs by combining the character at position i
from the start with the character at position i
from the end: (s[i], s[m-i-1])
.
When a string is both a prefix and suffix of another string, their character pairs will match in this special pairing scheme. The algorithm:
- For each word, creates these character pairs
- Traverses the trie following these pairs
- At each node, adds the count of strings that have been inserted at that position (these represent valid prefix-suffix matches)
- After traversing, increments the count at the final node
The time complexity is O(n * m)
where n
is the number of words and m
is the average length of words.
Intuition
The key insight is recognizing that when a string is both a prefix and suffix of another string, there's a special symmetry we can exploit.
Let's think about what it means for string A
to be both a prefix and suffix of string B
:
A
must match the beginning ofB
A
must also match the end ofB
For example, if A = "aba"
and B = "ababa"
:
- The first 3 characters of
B
are"aba"
(prefix match) - The last 3 characters of
B
are"aba"
(suffix match)
Now here's the clever observation: if we look at the characters of A
from both ends simultaneously, they must match the corresponding positions in B
from both ends. Specifically:
- The 1st character of
A
must equal the 1st character ofB
AND - The last character of
A
must equal the last character ofB
- The 2nd character of
A
must equal the 2nd character ofB
AND - The 2nd-to-last character of
A
must equal the 2nd-to-last character ofB
- And so on...
This gives us the idea to represent each string as a sequence of character pairs: (first_char, last_char)
, (second_char, second_to_last_char)
, etc.
By storing these pairs in a trie structure, we can efficiently check if one string's pair sequence is a prefix of another string's pair sequence. When string A
's pair sequence is a prefix of string B
's pair sequence, it means A
is both a prefix and suffix of B
.
The beauty of this approach is that we only need to traverse each string once, creating these pairs and checking against previously inserted strings in the trie. Each time we find a match during traversal, we know we've found a valid prefix-suffix pair.
Learn more about Trie patterns.
Solution Approach
The solution uses a Trie data structure to efficiently store and search for character pair patterns.
Data Structure Setup:
- We define a
Node
class with two attributes:children
: a dictionary to store child nodes, where keys are character pairs(char_from_start, char_from_end)
cnt
: a counter to track how many strings end at this node
Algorithm Steps:
-
Initialize an empty trie and a counter
ans = 0
-
Process each word in the array:
- Start from the root of the trie
- Create character pairs using
zip(s, reversed(s))
:- This pairs up
s[0]
withs[n-1]
,s[1]
withs[n-2]
, and so on - For example,
"aba"
becomes pairs:('a','a')
,('b','b')
,('a','a')
- This pairs up
-
Traverse and build the trie for each character pair:
- If the pair doesn't exist as a child, create a new node
- Move to the child node corresponding to this pair
- Add the current node's
cnt
to our answer (this represents all previously inserted strings that match up to this point)
-
Mark the end of the current string:
- Increment
node.cnt
by 1 at the final position - This indicates that a complete string ends here
- Increment
Why this works:
- When we traverse with a new string and encounter nodes with
cnt > 0
, it means there are shorter strings that are both prefixes and suffixes of the current string - The pairing mechanism
(s[i], s[m-i-1])
ensures that we're checking both prefix and suffix conditions simultaneously - By accumulating the counts during traversal, we automatically count all valid
(i, j)
pairs wherei < j
Time Complexity: O(n * m)
where n
is the number of words and m
is the average length of each word.
Space Complexity: O(n * m)
for storing the trie nodes.
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 solution with words = ["a", "aba", "ababa", "aa"]
.
Step 1: Process "a"
- Create pairs:
zip("a", "a")
β[('a', 'a')]
- Start at root, create child for
('a', 'a')
- No existing nodes, so
ans = 0
- Mark end: set
cnt = 1
at this node
Trie state:
root β ('a','a')[cnt=1]
Step 2: Process "aba"
- Create pairs:
zip("aba", "aba")
β[('a','a'), ('b','b'), ('a','a')]
- Traverse from root:
- Move to
('a','a')
child (exists), add itscnt=1
to answer βans = 1
- Create new child
('b','b')
withcnt=0
- Create new child
('a','a')
withcnt=0
- Move to
- Mark end: set
cnt = 1
at final node
Trie state:
root β ('a','a')[cnt=1] β ('b','b')[cnt=0] β ('a','a')[cnt=1]
Found: "a" is both prefix and suffix of "aba" (pair (0,1))
Step 3: Process "ababa"
- Create pairs:
zip("ababa", "ababa")
β[('a','a'), ('b','b'), ('a','a'), ('b','b'), ('a','a')]
- Traverse from root:
- Move to
('a','a')
, addcnt=1
βans = 2
- Move to
('b','b')
, addcnt=0
βans = 2
- Move to
('a','a')
, addcnt=1
βans = 3
- Create new child
('b','b')
withcnt=0
- Create new child
('a','a')
withcnt=0
- Move to
- Mark end: set
cnt = 1
at final node
Found: "a" and "aba" are both prefix and suffix of "ababa" (pairs (0,2) and (1,2))
Step 4: Process "aa"
- Create pairs:
zip("aa", "aa")
β[('a','a'), ('a','a')]
- Traverse from root:
- Move to
('a','a')
, addcnt=1
βans = 4
- Try to move to another
('a','a')
child, but it doesn't exist from this node - Create new child
('a','a')
withcnt=0
- Move to
- Mark end: set
cnt = 1
at final node
Found: "a" is both prefix and suffix of "aa" (pair (0,3))
Final answer: 4 (pairs: (0,1), (0,2), (1,2), (0,3))
Solution Implementation
1class TrieNode:
2 """
3 A node in the Trie data structure.
4 Uses __slots__ for memory optimization.
5 """
6 __slots__ = ["children", "count"]
7
8 def __init__(self):
9 # Dictionary to store child nodes, keyed by (prefix_char, suffix_char) tuples
10 self.children = {}
11 # Count of words that end at this node
12 self.count = 0
13
14
15class Solution:
16 def countPrefixSuffixPairs(self, words: List[str]) -> int:
17 """
18 Count the number of pairs (i, j) where i < j and words[i] is both
19 a prefix and suffix of words[j].
20
21 The algorithm uses a Trie where each node represents a character pair
22 (prefix_char, suffix_char). By traversing with paired characters from
23 the start and end of each word simultaneously, we can efficiently check
24 if previous words are both prefixes and suffixes.
25
26 Args:
27 words: List of strings to analyze
28
29 Returns:
30 Number of valid prefix-suffix pairs
31 """
32 result = 0
33 root = TrieNode()
34
35 # Process each word in order
36 for word in words:
37 current_node = root
38
39 # Create character pairs: (first_char, last_char), (second_char, second_last_char), etc.
40 # This allows checking prefix and suffix conditions simultaneously
41 for char_pair in zip(word, reversed(word)):
42 # Create new node if this character pair hasn't been seen at this position
43 if char_pair not in current_node.children:
44 current_node.children[char_pair] = TrieNode()
45
46 # Move to the child node corresponding to this character pair
47 current_node = current_node.children[char_pair]
48
49 # Add count of all previous words that match up to this position
50 # These words are both prefixes and suffixes of the current word
51 result += current_node.count
52
53 # Mark that this word ends at the current node
54 current_node.count += 1
55
56 return result
57
1class Node {
2 // Map to store child nodes, key is the hash of prefix and suffix characters
3 Map<Integer, Node> children = new HashMap<>();
4 // Count of strings that end at this node
5 int count;
6}
7
8class Solution {
9 public long countPrefixSuffixPairs(String[] words) {
10 long answer = 0;
11 Node trieRoot = new Node();
12
13 // Process each word in the array
14 for (String word : words) {
15 Node currentNode = trieRoot;
16 int wordLength = word.length();
17
18 // Build trie path by pairing characters from start and end
19 for (int i = 0; i < wordLength; i++) {
20 // Create a unique key by combining:
21 // - Character at position i from the start (prefix)
22 // - Character at position i from the end (suffix)
23 // Multiply first char by 32 to avoid collisions
24 int pairKey = word.charAt(i) * 32 + word.charAt(wordLength - i - 1);
25
26 // Create new node if path doesn't exist
27 currentNode.children.putIfAbsent(pairKey, new Node());
28
29 // Move to the child node
30 currentNode = currentNode.children.get(pairKey);
31
32 // Add count of previously seen strings with same prefix-suffix pattern
33 answer += currentNode.count;
34 }
35
36 // Increment count at the end node for this word
37 currentNode.count++;
38 }
39
40 return answer;
41 }
42}
43
1class TrieNode {
2public:
3 // Map to store child nodes, key is the combined hash of prefix and suffix characters
4 unordered_map<int, TrieNode*> children;
5 // Count of strings that end at this node
6 int count;
7
8 TrieNode() : count(0) {}
9};
10
11class Solution {
12public:
13 long long countPrefixSuffixPairs(vector<string>& words) {
14 long long result = 0;
15 TrieNode* root = new TrieNode();
16
17 // Process each word in the array
18 for (const string& word : words) {
19 TrieNode* currentNode = root;
20 int wordLength = word.length();
21
22 // Build trie path for current word
23 // Simultaneously check prefix and suffix by combining characters
24 for (int i = 0; i < wordLength; ++i) {
25 // Create a unique key by combining:
26 // - prefix character at position i (word[i])
27 // - suffix character at corresponding position from end (word[wordLength - i - 1])
28 // Multiply by 32 to ensure unique hash values for different character pairs
29 int combinedKey = word[i] * 32 + word[wordLength - i - 1];
30
31 // Create new node if path doesn't exist
32 if (currentNode->children.find(combinedKey) == currentNode->children.end()) {
33 currentNode->children[combinedKey] = new TrieNode();
34 }
35
36 // Move to the child node
37 currentNode = currentNode->children[combinedKey];
38
39 // Add count of all words that share this prefix-suffix pattern
40 result += currentNode->count;
41 }
42
43 // Increment count at the end node for this word
44 ++currentNode->count;
45 }
46
47 return result;
48 }
49};
50
1// Global trie node structure using Map to store children
2interface TrieNode {
3 children: Map<number, TrieNode>;
4 cnt: number;
5}
6
7// Creates a new trie node
8function createNode(): TrieNode {
9 return {
10 children: new Map<number, TrieNode>(),
11 cnt: 0
12 };
13}
14
15/**
16 * Counts the number of prefix-suffix pairs in the given array of words.
17 * Uses a trie structure where each node represents a combination of prefix and suffix characters.
18 *
19 * @param words - Array of strings to process
20 * @returns The total count of prefix-suffix pairs
21 */
22function countPrefixSuffixPairs(words: string[]): number {
23 let totalCount: number = 0;
24 const rootNode: TrieNode = createNode();
25
26 // Process each word in the array
27 for (const word of words) {
28 let currentNode: TrieNode = rootNode;
29 const wordLength: number = word.length;
30
31 // Build the trie path for current word
32 for (let index: number = 0; index < wordLength; index++) {
33 // Create a unique key combining the character at position i from start
34 // and the character at position i from end
35 const prefixChar: number = word.charCodeAt(index);
36 const suffixChar: number = word.charCodeAt(wordLength - index - 1);
37 const combinedKey: number = prefixChar * 32 + suffixChar;
38
39 // Create new node if path doesn't exist
40 if (!currentNode.children.has(combinedKey)) {
41 currentNode.children.set(combinedKey, createNode());
42 }
43
44 // Move to the next node in the trie
45 currentNode = currentNode.children.get(combinedKey)!;
46
47 // Add count of words that have reached this node before
48 totalCount += currentNode.cnt;
49 }
50
51 // Increment count for the current word's complete path
52 currentNode.cnt++;
53 }
54
55 return totalCount;
56}
57
Time and Space Complexity
The time complexity is O(n Γ m)
, where n
is the number of words in the array and m
is the maximum length among all strings.
For each word in words
(n iterations), the algorithm iterates through each character of that word to create pairs of (s[i], s[-(i+1)])
using zip(s, reversed(s))
. This pairing operation takes O(m)
time for a word of length m
. Within each character iteration, the trie operations (checking existence, creating nodes, and incrementing counters) are O(1)
. Therefore, the overall time complexity is O(n Γ m)
.
The space complexity is O(n Γ m)
. In the worst case, when all words have completely different prefix-suffix pairs, the trie needs to store a unique path for each word. Each word can contribute up to m
nodes (one for each character position), and with n
words, the maximum number of nodes in the trie is O(n Γ m)
. Each node stores a dictionary of children and a counter, but the space for these is already accounted for in the node count. The __slots__
optimization helps reduce memory overhead per node instance, but doesn't change the asymptotic complexity.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Misunderstanding the Character Pairing Logic
Pitfall: Developers often misunderstand why we pair characters from the start and end of the string. They might think we need to check prefix and suffix separately, leading to incorrect implementations like:
# INCORRECT: Checking prefix and suffix separately
def countPairs_wrong(words):
result = 0
for j in range(len(words)):
for i in range(j):
if words[j].startswith(words[i]) and words[j].endswith(words[i]):
result += 1
return result
Why it's wrong: While this brute force approach works, it defeats the purpose of using a Trie and has O(nΒ²m) complexity.
Solution: Understand that the character pairing (s[i], s[len(s)-1-i])
simultaneously validates both conditions. When a string is both a prefix and suffix, these pairs will match perfectly.
2. Incorrect Order of Operations
Pitfall: Adding the count AFTER incrementing it, or incrementing before traversal:
# INCORRECT: Wrong order
for char_pair in zip(word, reversed(word)):
if char_pair not in current_node.children:
current_node.children[char_pair] = TrieNode()
current_node = current_node.children[char_pair]
# Incrementing before accumulating - WRONG!
current_node.count += 1
result += current_node.count # This includes the current word itself
Solution: Always accumulate the count BEFORE incrementing. The correct order is:
- Traverse and accumulate existing counts
- Only increment count after full traversal
3. Handling Edge Cases Incorrectly
Pitfall: Not considering that a word cannot be a prefix-suffix pair with itself:
# INCORRECT: Might count self-pairs if not careful if word in previous_words: # Wrong approach result += 1
Solution: The algorithm naturally handles this by processing words in order and only counting previously inserted words (those with smaller indices).
4. Memory Inefficiency with Character Pairs
Pitfall: Storing character pairs inefficiently or creating unnecessary objects:
# INEFFICIENT: Creating new strings for pairs char_pair = word[i] + word[-(i+1)] # Creates new string objects
Solution: Use tuples for character pairs as they are immutable and hashable:
char_pair = (word[i], word[-(i+1)]) # Efficient tuple
# Or better yet, use zip with reversed:
for char_pair in zip(word, reversed(word)):
5. Forgetting to Initialize the Trie Root
Pitfall: Reusing the same root for multiple test cases or forgetting to initialize:
# INCORRECT: Using class variable (shared across instances)
class Solution:
root = TrieNode() # This is shared!
def countPrefixSuffixPairs(self, words):
# Will have stale data from previous calls
Solution: Always create a fresh Trie root for each function call:
def countPrefixSuffixPairs(self, words):
root = TrieNode() # Fresh root for each call
Which type of traversal does breadth first search do?
Recommended Readings
Trie Introduction Imagine you have a magic dictionary where as soon as you start spelling a word it immediately tells you how many words that start with those letters How cool would that be This is exactly the kind of magic a data structure called trie performs What's a Trie
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
Want a Structured Path to Master System Design Too? Donβt Miss This!