Prefix and Suffix Search
HardPrompt
Design a special dictionary that searches the words in it by a prefix and a suffix.
Implement the WordFilter class:
WordFilter(string[] words)Initializes the object with thewordsin the dictionary.f(string pref, string suff)Returns the index of the word in the dictionary, which has the prefixprefand the suffixsuff. If there is more than one valid index, return the largest of them. If there is no such word in the dictionary, return-1.
Example 1:
Input
["WordFilter", "f"]
[[["apple"]], ["a", "e"]]
Output
[null, 0]
Explanation
WordFilter wordFilter = new WordFilter(["apple"]);
wordFilter.f("a", "e"); // return 0, because the word at index 0 has prefix = "a" and suffix = "e".
Constraints:
1 <= words.length <= 1041 <= words[i].length <= 71 <= pref.length, suff.length <= 7words[i],prefandsuffconsist of lowercase English letters only.- At most
104calls will be made to the functionf.
Approaches
2 approaches with complexity analysis and trade-offs.
This is a straightforward brute-force approach. The constructor does minimal work, simply storing the list of words. For each query to the f function, we iterate through the entire list of words to find one that matches the given prefix and suffix.
Algorithm
- In the
WordFilterconstructor, store the inputwordsarray. - In the
f(pref, suff)method, iterate through thewordsarray from the last index (words.length - 1) down to0. - For each
wordat indexi:- Use string functions to check if
word.startsWith(pref)andword.endsWith(suff). - If both conditions are true, it means we've found a match. Since we are iterating backwards, this is the match with the largest index. Return
iimmediately.
- Use string functions to check if
- If the loop completes without finding any matches, return
-1.
Walkthrough
The WordFilter constructor initializes the object by storing the input array of words. The main logic resides in the f(pref, suff) method. This method iterates through the words array in reverse order, from the last element to the first. We iterate backwards because the problem requires us to return the largest index if multiple words match. For each word, we check two conditions: if it starts with the given pref and if it ends with the given suff. The built-in startsWith() and endsWith() string methods are used for this. The first word (while iterating backwards) that satisfies both conditions is the desired answer, and its index is returned immediately. If the loop finishes without finding any such word, it implies no word in the dictionary meets the criteria, and we return -1.
class WordFilter { String[] words; public WordFilter(String[] words) { this.words = words; } public int f(String pref, String suff) { for (int i = words.length - 1; i >= 0; i--) { if (words[i].startsWith(pref) && words[i].endsWith(suff)) { return i; } } return -1; }}Complexity
Time
Constructor: O(1). `f` method: O(N * L) for each call, where N is the number of words and L is the maximum length of a word. The string matching operations `startsWith` and `endsWith` can take up to O(L) time. For Q queries, the total time complexity is O(Q * N * L).
Space
O(N * L), where N is the number of words and L is the maximum length of a word. This space is used to store the dictionary of words itself.
Trade-offs
Pros
Simple to understand and implement.
Low memory overhead, as it only stores the original list of words.
Cons
Very slow for a large number of queries, as each query requires a full scan of the dictionary.
Likely to result in a 'Time Limit Exceeded' (TLE) error on competitive programming platforms for the given constraints.
This efficient approach involves significant pre-processing during initialization to make subsequent queries very fast. We build a single, specialized Trie data structure that cleverly encodes both suffix and prefix information. For each word, we generate multiple entries to insert into the Trie, each combining a suffix of the word with the word itself. This allows us to search for a prefix and a suffix simultaneously by querying the Trie with a single, specially constructed key.
Algorithm
- Define a
TrieNodeclass containing an array for child nodes (for 'a'-'z' and a separator character) and anindexfield. - In the
WordFilterconstructor, initialize a rootTrieNode. - Iterate through each
wordat indexifrom0ton-1. - For each
word, generate all its suffixes. - For each
suffix, create a special key:key = suffix + "#" + word(where '#' is a separator). - Insert this
keyinto the Trie. During insertion, for every node on the path, update itsindexfield toi. Since we process words in increasing order of their indices, this ensures theindexfield of a node always holds the largest index of a word whose key passes through it. - In the
f(pref, suff)method, create a search key:searchKey = suff + "#" + pref. - Traverse the Trie with this
searchKey. - If the path exists, the
indexstored at the final node is the answer. - If the path breaks at any point, no such word exists, so return
-1.
Walkthrough
The core idea is to transform the two-dimensional search (prefix and suffix) into a one-dimensional prefix search, which is what Tries excel at. We achieve this by creating composite keys.
For each word w at index i, we generate all its suffixes. For every suffix, we form a new string suffix + "#" + w, where # is a separator character that doesn't appear in the words. We insert all these generated strings into a single Trie. Each node in our Trie stores an index. When we insert a key corresponding to words[i], we update the index at every node along the insertion path to i. Because we iterate through the words from index 0 to n-1, any node's index will be overwritten with progressively larger values, automatically ensuring it holds the largest index of a word that has passed through it.
To query for a pref and suff, we construct a search key suff + "#" + pref. We then traverse the Trie with this key. If a path for this key exists, the node at the end of the path represents all words that have suff as a suffix and pref as a prefix. The index stored at this node will be the largest index of such a word, which is exactly what we need. If the path doesn't exist, no word matches, and we return -1.
class WordFilter { private static class TrieNode { TrieNode[] children; int index; TrieNode() { // Using '{' as separator, which is 'z' + 1 in ASCII children = new TrieNode[27]; index = -1; } } private final TrieNode root; public WordFilter(String[] words) { root = new TrieNode(); for (int i = 0; i < words.length; i++) { String word = words[i]; // For each suffix of the word... for (int j = 0; j <= word.length(); j++) { // Create a key like: "suffix{word}" String key = word.substring(word.length() - j) + "{" + word; insert(key, i); } } } private void insert(String key, int index) { TrieNode curr = root; for (char c : key.toCharArray()) { int charIndex = c - 'a'; if (curr.children[charIndex] == null) { curr.children[charIndex] = new TrieNode(); } curr = curr.children[charIndex]; curr.index = index; // Update index at every node on the path } } public int f(String pref, String suff) { // Search for a key like: "suff{pref}" String searchKey = suff + "{" + pref; TrieNode curr = root; for (char c : searchKey.toCharArray()) { int charIndex = c - 'a'; if (curr.children[charIndex] == null) { return -1; } curr = curr.children[charIndex]; } return curr.index; }}Complexity
Time
Constructor: O(N * L^2), where N is the number of words and L is the maximum word length. For each of the N words, we generate O(L) keys, and inserting each key of length O(L) takes O(L) time. `f` method: O(P + S), where P is the length of the prefix and S is the length of the suffix. This is bounded by O(L), making queries very fast.
Space
O(N * L^2), where N is the number of words and L is the maximum word length. The number of nodes in the Trie is bounded by the total number of characters in all the keys we insert. For each of the N words, we insert L+1 keys, and the total length of these keys for a single word is O(L^2).
Trade-offs
Pros
Extremely fast query time, making it ideal for scenarios with many queries.
The pre-computation in the constructor is a one-time cost.
It's a very elegant solution that fits the problem constraints perfectly.
Cons
The constructor can be slow and memory-intensive if the number of words or their lengths are very large.
More complex to implement compared to the brute-force approach.
Solutions
Solution
class Trie { Trie [] children = new Trie [ 26 ]; List < Integer > indexes = new ArrayList <>(); void insert ( String word , int i ) { Trie node = this ; for ( char c : word . toCharArray ()) { c -= 'a' ; if ( node . children [ c ] == null ) { node . children [ c ] = new Trie (); } node = node . children [ c ]; node . indexes . add ( i ); } } List < Integer > search ( String pref ) { Trie node = this ; for ( char c : pref . toCharArray ()) { c -= 'a' ; if ( node . children [ c ] == null ) { return Collections . emptyList (); } node = node . children [ c ]; } return node . indexes ; } } class WordFilter { private Trie p = new Trie (); private Trie s = new Trie (); public WordFilter ( String [] words ) { for ( int i = 0 ; i < words . length ; ++ i ) { String w = words [ i ]; p . insert ( w , i ); s . insert ( new StringBuilder ( w ). reverse (). toString (), i ); } } public int f ( String pref , String suff ) { suff = new StringBuilder ( suff ). reverse (). toString (); List < Integer > a = p . search ( pref ); List < Integer > b = s . search ( suff ); if ( a . isEmpty () || b . isEmpty ()) { return - 1 ; } int i = a . size () - 1 , j = b . size () - 1 ; while ( i >= 0 && j >= 0 ) { int c = a . get ( i ), d = b . get ( j ); if ( c == d ) { return c ; } if ( c > d ) { -- i ; } else { -- j ; } } return - 1 ; } } /** * Your WordFilter object will be instantiated and called as such: * WordFilter obj = new WordFilter(words); * int param_1 = obj.f(pref,suff); */class WordFilter { public: unordered_map < string , int > d ; WordFilter ( vector < string >& words ) { for ( int k = 0 ; k < words . size (); ++ k ) { string w = words [ k ]; int n = w . size (); for ( int i = 0 ; i <= n ; ++ i ) { string a = w . substr ( 0 , i ); for ( int j = 0 ; j <= n ; ++ j ) { string b = w . substr ( j , n - j ); d [ a + "." + b ] = k ; } } } } int f ( string pref , string suff ) { string key = pref + "." + suff ; if ( d . count ( key )) return d [ key ]; return - 1 ; } }; /** * Your WordFilter object will be instantiated and called as such: * WordFilter* obj = new WordFilter(words); * int param_1 = obj->f(pref,suff); */class Trie : def __init__ ( self ): self . children = [ None ] * 26 self . indexes = [] def insert ( self , word , i ): node = self for c in word : idx = ord ( c ) - ord ( "a" ) if node . children [ idx ] is None : node . children [ idx ] = Trie () node = node . children [ idx ] node . indexes . append ( i ) def search ( self , pref ): node = self for c in pref : idx = ord ( c ) - ord ( "a" ) if node . children [ idx ] is None : return [] node = node . children [ idx ] return node . indexes class WordFilter : def __init__ ( self , words : List [ str ]): self . p = Trie () self . s = Trie () for i , w in enumerate ( words ): self . p . insert ( w , i ) self . s . insert ( w [:: - 1 ], i ) def f ( self , pref : str , suff : str ) -> int : a = self . p . search ( pref ) b = self . s . search ( suff [:: - 1 ]) if not a or not b : return - 1 i , j = len ( a ) - 1 , len ( b ) - 1 while ~ i and ~ j : if a [ i ] == b [ j ]: return a [ i ] if a [ i ] > b [ j ]: i -= 1 else : j -= 1 return - 1 # Your WordFilter object will be instantiated and called as such: # obj = WordFilter(words) # param_1 = obj.f(pref,suff)Video walkthrough
Newsletter
One sharp idea, every week
System design and interview prep — short enough to finish.
No spam. Unsubscribe anytime.
Practice
Same difficulty — related problems to reinforce the pattern.