I've been working on Leetcode questions trying to improve my coding skills and I became curious about this when doing this problem https://leetcode.com/problems/implement-trie-prefix-tree/
Most answers I found online implement the trie by creating a node struct and then using a build method to instantiate the node and fill it with default values. But I'm wondering why this is necessary and they don't just define the Node with those default values? I tried it and it seems to work exactly the same without the build method so I've got to assume it's some sort of best practices reason. I can understand why a build method would be better for a more complicated node structure. But, what I'm really interested in is what can go wrong with what I did because I haven't been able to find any conclusive answers to that.
Typical Solution:
class Trie {
private:
struct Node {
bool isWord = false;
Node* children[26];
};
Node* buildNode(){// helper method to build the default node
Node* root = new Node();
root-> isWord = false;
for (int i = 0; i<26; i++){
root->children[i] = NULL;
}
return root;
}
Node* root;
public:
Trie() {
root = buildNode();
}
void insert(string word) {
Node* curr = root;
for (char ch : word){
int index = ch - 'a'; // convert char to int 0-25
if (!curr->children[index]){//if NULL
curr->children[index] = buildNode();
}
curr = curr->children[index];
}
curr->isWord = true;
}
My Solution:
class Trie {
private:
struct Node {
bool isWord = false;
Node* children[26] = {};
};
Node* root;
public:
Trie() {
root = new Node();
}
void insert(string word) {
Node* curr = root;
for (char ch : word){
int index = ch - 'a'; // convert char to int 0-25
if (!curr->children[index]){//if NULL
curr->children[index] = new Node();
}
curr = curr->children[index];
}
curr->isWord = true;
}
both implementations seem to work and perform the same way. So why is the first one preferred over the 2nd?
LeetCode has issues
As was noted in the comments, your code gets the job done. I expect it passes all LeetCode tests.
But it sure does not pass mine! Although I like your solution better than the one with a separate "build" routine, you would probably get into trouble if you submitted your code to a job interviewer. On the up-side, you have gotten the insertion algorithm exactly right. That's better than about half of the small sample of solutions I checked on the LeetCode website. Unfortunately, however, your use of raw pointers and operator
new, with no matching calls to operatordelete, has created a gigantic memory leak. When you use operatornew, you have a responsibility to write a proper destructor to clean up, when your objects are deallocated.The fact that LeetCode allows you to skip this is the sort of thing that makes folks say:
Even if you had supplied a destructor, I think your solution should still be rejected by LeetCode, because it does not use one of the smart pointers from the Standard Library. In a production setting, class
Triewould almost certainly be coded usingstd::unique_ptr.It is revealing that LeetCode does not ask programmers to code a function to erase keys from a
Trie. That would open the issue of deleting nodes. With a smart pointer, it is not too difficult (see below), but with raw pointers you will find yourself coding some sort of "tear-down" routine that is analogous to the "build" function cited in the OP.So, it is not just the destructor that is missing in the raw-pointer implementation. It is also the "tear-down" function that deallocates a
Node. The good news is that once you have a tear-down routine, you can call it as a helper from the destructor function, as well as from functionerase!Is anything else missing? Unfortunately, yes. For a class like this, which manages dynamically allocated memory, you need to follow the "Rule of Three" or the "Rule of Five." At a minimum, that means coding a copy constructor and a copy-assignment operator, to go along with the destructor. See CppReference.
Use the tools in the Standard Library
When I solved this, I side-stepped the whole issue of cleanup, by using
std::unique_ptrandstd::array. This allowed me to follow the "Rule of Zero." Not only did I avoid the task of writing a destructor, I also obviated the need to write any sort of "build" or "tear-down" code at all. That's a whole bunch of error-prone tedium that I ducked!My code follows the example on Wikipedia, so it uses the name
is_terminalrather thanisWord. Other than that, ourinsertroutines are basically identical.Mine adds a check for valid characters, but that may be a defect, because the spec promises that all the characters in a
wordare lower-case letters. My double-checks just slow things down. If I could prove to my satisfaction that all the clients of classTrierespected the precondition, I would remove the extra checks.As an exercise, I tossed in an
erasefunction, and a stream insertion operator. Functioneraseis substantially different than the one on Wikipedia. It is more aggressive about deallocating nodes, when they are no longer in use.operator<<outputs a list of the keys stored in aTrie. It was helpful during testing of classTrie.Here is the output.