745. Prefix and Suffix Search
Problem Description
You need to design a data structure that can efficiently search for words based on both a prefix (beginning) and a suffix (ending).
The WordFilter
class should support two operations:
-
Constructor
WordFilter(string[] words)
: Initialize the data structure with an array of words. Each word has an index based on its position in the array (starting from 0). -
Method
f(string pref, string suff)
: Find a word in the dictionary that starts with the given prefixpref
and ends with the given suffixsuff
. If multiple words match both conditions, return the index of the word with the largest index (the one that appears latest in the original array). If no word matches both conditions, return-1
.
For example, if you have words ["apple", "apply", "ample"]
:
f("ap", "le")
would return2
because both "apple" (index 0) and "ample" (index 2) match, and 2 is the larger indexf("app", "ly")
would return1
because only "apply" matchesf("ap", "x")
would return-1
because no word starts with "ap" and ends with "x"
The solution pre-computes all possible prefix-suffix combinations for each word and stores them in a dictionary with their indices. When a query comes in, it simply looks up the (prefix, suffix)
pair in the dictionary. Since words are processed in order and later indices overwrite earlier ones, the dictionary automatically keeps the largest index for each combination.
Intuition
The key insight is that we need to quickly find words that match both a prefix and suffix constraint. The naive approach would be to check every word during each query, but this would be too slow for multiple queries.
Instead of searching at query time, we can pre-process all the information we might need. Think about it this way: for any word, there are a finite number of possible prefixes and suffixes. For example, the word "apple" has these prefixes: ""
, "a"
, "ap"
, "app"
, "appl"
, "apple"
and these suffixes: ""
, "e"
, "le"
, "ple"
, "pple"
, "apple"
.
Any query f(pref, suff)
will ask for one of these prefix-suffix combinations. So why not compute all possible combinations beforehand and store them?
For each word, we can generate all possible (prefix, suffix)
pairs. If a word has length n
, there are (n+1) Γ (n+1)
such pairs (including empty strings). We store each pair as a key in a dictionary, with the word's index as the value.
The clever part is handling multiple words that share the same (prefix, suffix)
combination. Since we want the largest index, we simply process the words in order - when we encounter a duplicate (prefix, suffix)
pair, the later word's index naturally overwrites the earlier one in the dictionary.
This transforms our search problem into a simple dictionary lookup problem. We trade space (storing all combinations) for time (constant-time lookups). The preprocessing might seem expensive with O(nΒ² Γ m)
where n
is average word length and m
is number of words, but it makes each subsequent query O(1)
, which is ideal when we expect many queries.
Learn more about Trie patterns.
Solution Approach
The solution uses a preprocessing strategy with a hash map to achieve constant-time lookups.
Data Structure:
- A dictionary
self.d
that maps(prefix, suffix)
tuples to word indices.
Initialization (__init__
method):
-
Create an empty dictionary
self.d
to store all prefix-suffix combinations. -
Iterate through each word with its index using
enumerate(words)
:- For word at index
k
with lengthn
- Generate all possible prefixes:
w[:i]
wherei
ranges from0
ton+1
- When
i=0
: prefix is""
(empty string) - When
i=1
: prefix is first character - When
i=n
: prefix is entire word
- When
- For word at index
-
For each prefix, generate all possible suffixes:
w[j:]
wherej
ranges from0
ton+1
- When
j=0
: suffix is entire word - When
j=n-1
: suffix is last character - When
j=n
: suffix is""
(empty string)
- When
-
Store the combination:
self.d[(prefix, suffix)] = k
- The key is a tuple
(prefix, suffix)
- The value is the current word's index
k
- If the same
(prefix, suffix)
pair appears in multiple words, the later index overwrites the earlier one, automatically giving us the largest index.
- The key is a tuple
Query (f
method):
Simply look up the (pref, suff)
tuple in the dictionary:
- Use
self.d.get((pref, suff), -1)
- Returns the stored index if the combination exists
- Returns
-1
if the combination doesn't exist (default value)
Example walkthrough:
For words = ["apple"]
:
- The word "apple" (index 0) generates these combinations:
("", "apple")
,("", "pple")
,("", "ple")
,("", "le")
,("", "e")
,("", "")
("a", "apple")
,("a", "pple")
, ... ,("a", "")
("ap", "apple")
,("ap", "pple")
, ... ,("ap", "")
- And so on for all prefix lengths
All these combinations map to index 0 in the dictionary.
Time Complexity:
- Initialization:
O(N Γ LΒ²)
whereN
is number of words andL
is average word length - Query:
O(1)
for dictionary lookup
Space Complexity: O(N Γ LΒ²)
to store all prefix-suffix combinations
Ready to land your dream job?
Unlock your dream job with a 3-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a concrete example with words = ["a", "banana", "band", "bana"]
.
Initialization Phase:
For each word, we generate all prefix-suffix combinations and store them with the word's index:
Word "a" (index 0):
- Prefixes:
""
,"a"
- Suffixes:
"a"
,""
- Combinations stored:
("", "a") β 0
("", "") β 0
("a", "a") β 0
("a", "") β 0
Word "banana" (index 1):
- Generates 49 combinations (7 prefixes Γ 7 suffixes)
- Key combinations include:
("b", "a") β 1
(starts with "b", ends with "a")("ban", "ana") β 1
(starts with "ban", ends with "ana")("banana", "banana") β 1
(entire word)- And 46 more...
Word "band" (index 2):
- Generates 25 combinations (5 prefixes Γ 5 suffixes)
- Notable combinations:
("b", "d") β 2
("ban", "d") β 2
("band", "and") β 2
Word "bana" (index 3):
- Generates 25 combinations
- Important:
("ban", "a") β 3
(overwrites any previous mapping) - Also:
("b", "a") β 3
(overwrites the previous("b", "a") β 1
)
Query Phase:
Now let's execute some queries:
-
f("b", "a")
:- Look up
("b", "a")
in dictionary - Returns
3
(was overwritten by "bana" which has the largest index)
- Look up
-
f("ban", "d")
:- Look up
("ban", "d")
in dictionary - Returns
2
(only "band" matches this combination)
- Look up
-
f("ban", "ana")
:- Look up
("ban", "ana")
in dictionary - Returns
1
(only "banana" has this prefix-suffix pair)
- Look up
-
f("x", "y")
:- Look up
("x", "y")
in dictionary - Key doesn't exist, returns
-1
- Look up
The key insight: when "bana" (index 3) was processed, it overwrote the ("b", "a")
entry that was previously set by "banana" (index 1). This automatic overwriting ensures we always get the largest index for any prefix-suffix combination.
Solution Implementation
1from typing import List
2
3class WordFilter:
4 def __init__(self, words: List[str]):
5 # Dictionary to store all possible (prefix, suffix) combinations
6 # Key: (prefix, suffix) tuple, Value: index of the word
7 self.prefix_suffix_map = {}
8
9 # Process each word with its index
10 for index, word in enumerate(words):
11 word_length = len(word)
12
13 # Generate all possible prefixes (including empty string)
14 for prefix_end in range(word_length + 1):
15 prefix = word[:prefix_end]
16
17 # For each prefix, generate all possible suffixes (including empty string)
18 for suffix_start in range(word_length + 1):
19 suffix = word[suffix_start:]
20
21 # Store the combination with the current index
22 # Later occurrences will overwrite earlier ones, keeping the largest index
23 self.prefix_suffix_map[(prefix, suffix)] = index
24
25 def f(self, pref: str, suff: str) -> int:
26 # Look up the (prefix, suffix) combination in the map
27 # Return the stored index if found, otherwise return -1
28 return self.prefix_suffix_map.get((pref, suff), -1)
29
30
31# Your WordFilter object will be instantiated and called as such:
32# obj = WordFilter(words)
33# param_1 = obj.f(pref, suff)
34
1class WordFilter {
2 // HashMap to store all prefix-suffix combinations with their highest index
3 private Map<String, Integer> prefixSuffixMap = new HashMap<>();
4
5 /**
6 * Constructor that preprocesses the words array to build all possible
7 * prefix-suffix combinations for efficient lookup
8 * @param words Array of words to be filtered
9 */
10 public WordFilter(String[] words) {
11 // Iterate through each word with its index
12 for (int wordIndex = 0; wordIndex < words.length; ++wordIndex) {
13 String currentWord = words[wordIndex];
14 int wordLength = currentWord.length();
15
16 // Generate all possible prefixes (including empty string)
17 for (int prefixEnd = 0; prefixEnd <= wordLength; ++prefixEnd) {
18 String prefix = currentWord.substring(0, prefixEnd);
19
20 // Generate all possible suffixes (including entire word)
21 for (int suffixStart = 0; suffixStart <= wordLength; ++suffixStart) {
22 String suffix = currentWord.substring(suffixStart);
23
24 // Store the combination with a delimiter
25 // Later occurrences will overwrite earlier ones, keeping the highest index
26 String key = prefix + "." + suffix;
27 prefixSuffixMap.put(key, wordIndex);
28 }
29 }
30 }
31 }
32
33 /**
34 * Finds the largest index of a word that has the given prefix and suffix
35 * @param pref The prefix to search for
36 * @param suff The suffix to search for
37 * @return The largest index of matching word, or -1 if no match found
38 */
39 public int f(String pref, String suff) {
40 // Construct the key and retrieve the stored index
41 String key = pref + "." + suff;
42 return prefixSuffixMap.getOrDefault(key, -1);
43 }
44}
45
46/**
47 * Your WordFilter object will be instantiated and called as such:
48 * WordFilter obj = new WordFilter(words);
49 * int param_1 = obj.f(pref,suff);
50 */
51
1class WordFilter {
2public:
3 // Hash map to store all prefix-suffix combinations with their corresponding word index
4 unordered_map<string, int> prefixSuffixMap;
5
6 WordFilter(vector<string>& words) {
7 // Process each word in the input array
8 for (int wordIndex = 0; wordIndex < words.size(); ++wordIndex) {
9 string currentWord = words[wordIndex];
10 int wordLength = currentWord.size();
11
12 // Generate all possible prefixes (including empty prefix)
13 for (int prefixLen = 0; prefixLen <= wordLength; ++prefixLen) {
14 string prefix = currentWord.substr(0, prefixLen);
15
16 // Generate all possible suffixes (including empty suffix)
17 for (int suffixStart = 0; suffixStart <= wordLength; ++suffixStart) {
18 string suffix = currentWord.substr(suffixStart, wordLength - suffixStart);
19
20 // Create a unique key by combining prefix and suffix with a delimiter
21 string key = prefix + "." + suffix;
22
23 // Store the word index (later indices overwrite earlier ones for duplicates)
24 prefixSuffixMap[key] = wordIndex;
25 }
26 }
27 }
28 }
29
30 int f(string pref, string suff) {
31 // Construct the key using the same format as in constructor
32 string searchKey = pref + "." + suff;
33
34 // Check if the key exists in the map
35 if (prefixSuffixMap.count(searchKey)) {
36 return prefixSuffixMap[searchKey];
37 }
38
39 // Return -1 if no word matches the given prefix and suffix
40 return -1;
41 }
42};
43
44/**
45 * Your WordFilter object will be instantiated and called as such:
46 * WordFilter* obj = new WordFilter(words);
47 * int param_1 = obj->f(pref,suff);
48 */
49
1// Hash map to store all prefix-suffix combinations with their corresponding word index
2const prefixSuffixMap: Map<string, number> = new Map();
3
4// Initialize the word filter with an array of words
5function WordFilter(words: string[]): void {
6 // Clear the map for fresh initialization
7 prefixSuffixMap.clear();
8
9 // Process each word in the input array
10 for (let wordIndex = 0; wordIndex < words.length; wordIndex++) {
11 const currentWord = words[wordIndex];
12 const wordLength = currentWord.length;
13
14 // Generate all possible prefixes (including empty prefix)
15 for (let prefixLen = 0; prefixLen <= wordLength; prefixLen++) {
16 const prefix = currentWord.substring(0, prefixLen);
17
18 // Generate all possible suffixes (including empty suffix)
19 for (let suffixStart = 0; suffixStart <= wordLength; suffixStart++) {
20 const suffix = currentWord.substring(suffixStart);
21
22 // Create a unique key by combining prefix and suffix with a delimiter
23 const key = `${prefix}.${suffix}`;
24
25 // Store the word index (later indices overwrite earlier ones for duplicates)
26 prefixSuffixMap.set(key, wordIndex);
27 }
28 }
29 }
30}
31
32// Find the index of the word that has the given prefix and suffix
33function f(pref: string, suff: string): number {
34 // Construct the key using the same format as in initialization
35 const searchKey = `${pref}.${suff}`;
36
37 // Check if the key exists in the map and return its value
38 if (prefixSuffixMap.has(searchKey)) {
39 return prefixSuffixMap.get(searchKey)!;
40 }
41
42 // Return -1 if no word matches the given prefix and suffix
43 return -1;
44}
45
Time and Space Complexity
Time Complexity:
-
Initialization (
__init__
):O(N * L^2)
whereN
is the number of words andL
is the maximum length of a word. For each word, we generate all possible prefix-suffix combinations. There are(L+1)
possible prefixes (including empty string) and(L+1)
possible suffixes, resulting in(L+1)^2
combinations per word. Thus, the total time complexity isO(N * L^2)
. -
Query (
f
):O(1)
for dictionary lookup operation.
Space Complexity:
- Space:
O(N * L^2)
for storing the dictionary. In the worst case, each word of lengthL
generates(L+1)^2
prefix-suffix pairs. WithN
words, the total space needed isO(N * L^2)
. Note that while multiple prefix-suffix combinations might map to the same word index, we still need to store all unique(prefix, suffix)
tuples as keys in the dictionary.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Memory Limit Exceeded for Large Inputs
The main pitfall of this solution is its massive memory consumption. For each word, we generate all possible prefix-suffix combinations, which is O(LΒ²) combinations per word. With constraints like 10,000 words each up to 10 characters long, this creates up to 1 million entries in the dictionary.
Why this happens:
- A word of length L generates (L+1) Γ (L+1) combinations
- For "apple" (length 5): 6 Γ 6 = 36 combinations
- For 10,000 such words: potentially 360,000+ dictionary entries
Solution: Use a Trie-based approach with a special encoding:
class WordFilter:
def __init__(self, words: List[str]):
self.trie = {}
for index, word in enumerate(words):
# Create a special format: suffix#word
# This allows searching for both prefix and suffix in one trie
word_length = len(word)
for i in range(word_length + 1):
# Combine suffix with word using delimiter
encoded = word[i:] + '#' + word
# Insert into trie
node = self.trie
for char in encoded:
if char not in node:
node[char] = {}
node = node[char]
# Store the index at each node
node['index'] = index
def f(self, pref: str, suff: str) -> int:
# Search for suff#pref in the trie
search_key = suff + '#' + pref
node = self.trie
for char in search_key:
if char not in node:
return -1
node = node[char]
return node.get('index', -1)
2. Handling Edge Cases with Empty Strings
The current solution correctly handles empty prefixes and suffixes, but developers often forget these cases when implementing from scratch.
Common mistake:
# Incorrect: doesn't include empty string cases
for prefix_end in range(1, word_length + 1): # Misses empty prefix
for suffix_start in range(word_length): # Misses empty suffix
Correct approach (as in the solution):
# Include 0 for empty prefix and word_length+1 for empty suffix
for prefix_end in range(word_length + 1):
for suffix_start in range(word_length + 1):
3. Optimization Opportunity Missed
While the current solution works, you could optimize by avoiding redundant storage when prefix and suffix overlap.
Optimization: Only store valid combinations where prefix and suffix don't exceed word length when combined:
def __init__(self, words: List[str]):
self.prefix_suffix_map = {}
for index, word in enumerate(words):
word_length = len(word)
for prefix_end in range(word_length + 1):
prefix = word[:prefix_end]
for suffix_start in range(word_length + 1):
# Skip invalid combinations where prefix and suffix overlap incorrectly
if prefix_end > suffix_start:
continue
suffix = word[suffix_start:]
self.prefix_suffix_map[(prefix, suffix)] = index
This reduces memory usage when words have many overlapping prefix-suffix combinations.
What's the output of running the following function using input [30, 20, 10, 100, 33, 12]
?
1def fun(arr: List[int]) -> List[int]:
2 import heapq
3 heapq.heapify(arr)
4 res = []
5 for i in range(3):
6 res.append(heapq.heappop(arr))
7 return res
8
1public static int[] fun(int[] arr) {
2 int[] res = new int[3];
3 PriorityQueue<Integer> heap = new PriorityQueue<>();
4 for (int i = 0; i < arr.length; i++) {
5 heap.add(arr[i]);
6 }
7 for (int i = 0; i < 3; i++) {
8 res[i] = heap.poll();
9 }
10 return res;
11}
12
1class HeapItem {
2 constructor(item, priority = item) {
3 this.item = item;
4 this.priority = priority;
5 }
6}
7
8class MinHeap {
9 constructor() {
10 this.heap = [];
11 }
12
13 push(node) {
14 // insert the new node at the end of the heap array
15 this.heap.push(node);
16 // find the correct position for the new node
17 this.bubble_up();
18 }
19
20 bubble_up() {
21 let index = this.heap.length - 1;
22
23 while (index > 0) {
24 const element = this.heap[index];
25 const parentIndex = Math.floor((index - 1) / 2);
26 const parent = this.heap[parentIndex];
27
28 if (parent.priority <= element.priority) break;
29 // if the parent is bigger than the child then swap the parent and child
30 this.heap[index] = parent;
31 this.heap[parentIndex] = element;
32 index = parentIndex;
33 }
34 }
35
36 pop() {
37 const min = this.heap[0];
38 this.heap[0] = this.heap[this.size() - 1];
39 this.heap.pop();
40 this.bubble_down();
41 return min;
42 }
43
44 bubble_down() {
45 let index = 0;
46 let min = index;
47 const n = this.heap.length;
48
49 while (index < n) {
50 const left = 2 * index + 1;
51 const right = left + 1;
52
53 if (left < n && this.heap[left].priority < this.heap[min].priority) {
54 min = left;
55 }
56 if (right < n && this.heap[right].priority < this.heap[min].priority) {
57 min = right;
58 }
59 if (min === index) break;
60 [this.heap[min], this.heap[index]] = [this.heap[index], this.heap[min]];
61 index = min;
62 }
63 }
64
65 peek() {
66 return this.heap[0];
67 }
68
69 size() {
70 return this.heap.length;
71 }
72}
73
74function fun(arr) {
75 const heap = new MinHeap();
76 for (const x of arr) {
77 heap.push(new HeapItem(x));
78 }
79 const res = [];
80 for (let i = 0; i < 3; i++) {
81 res.push(heap.pop().item);
82 }
83 return res;
84}
85
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!