Facebook Pixel

3045. Count Prefix and Suffix Pairs II

HardTrieArrayStringString MatchingHash FunctionRolling Hash
Leetcode Link

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 of words[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:

  1. For each word, creates these character pairs
  2. Traverses the trie following these pairs
  3. At each node, adds the count of strings that have been inserted at that position (these represent valid prefix-suffix matches)
  4. 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.

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

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 of B
  • A must also match the end of B

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 of B AND
  • The last character of A must equal the last character of B
  • The 2nd character of A must equal the 2nd character of B AND
  • The 2nd-to-last character of A must equal the 2nd-to-last character of B
  • 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:

  1. Initialize an empty trie and a counter ans = 0

  2. 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] with s[n-1], s[1] with s[n-2], and so on
      • For example, "aba" becomes pairs: ('a','a'), ('b','b'), ('a','a')
  3. 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)
  4. Mark the end of the current string:

    • Increment node.cnt by 1 at the final position
    • This indicates that a complete string ends here

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 where i < 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 Evaluator

Example 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 its cnt=1 to answer β†’ ans = 1
    • Create new child ('b','b') with cnt=0
    • Create new child ('a','a') with cnt=0
  • 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'), add cnt=1 β†’ ans = 2
    • Move to ('b','b'), add cnt=0 β†’ ans = 2
    • Move to ('a','a'), add cnt=1 β†’ ans = 3
    • Create new child ('b','b') with cnt=0
    • Create new child ('a','a') with cnt=0
  • 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'), add cnt=1 β†’ ans = 4
    • Try to move to another ('a','a') child, but it doesn't exist from this node
    • Create new child ('a','a') with cnt=0
  • 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:

  1. Traverse and accumulate existing counts
  2. 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
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Which type of traversal does breadth first search do?


Recommended Readings

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

Load More