Use full names
First of all, don't use arbitary abbreviations: HTTP is fine, it's well known and "domain knowledge", res
isn't. It takes very little effort to use result
instead, just type it in full.
Naming is difficult, but good naming makes code incredibly clearer. It's a skill worth polishing, and time well spent.
Create meaningful types
What is a std::vector<std::vector<char>>
? A list of words? Something else?
The main issue with issue an all-purpose type directly is that it can represent anything and it can do anything, even operations that would be wild.
A type is all of:
- A name, the who.
- A set of invariants on the values it can take, the what.
- A collection of operations, the how.
It is important to fully think out the invariants and the operations:
- What does it mean for a board to be "unbalanced"? That is, having 2 rows of different length? Should that be allowed?
- What does it mean, then, to erase a character in a row? Should that be allowed?
- Should reordering the rows be allowed?
Ideally, you want to ensure that:
- A 100% valid/representable ratio for the set of values.
- A 100% meaningful/possible ratio for the set of operations.
I advise naming first, and setting them some representation of the state to get started. If well encapsulated, you can change it easily enough after all:
class Board {
public:
// I generally recommend public first; it's the API after all.
private:
std::vector<std::vector<char>> colums;
};
struct Dimensions {
int rows = 0;
int columns = 0;
};
struct Position {
int x = 0;
int y = 0;
};
This sets you up to write better code.
Note: I do not particularly endorse the std::vector<std::vector<char>>
representation but... it doesn't matter. Now that it's private it can be changed at leisure without any impact on existing clients!
And with regard to API, let's start with a streaming operator to be able to print... everything.
bool operator<<(std::ostream& out, Position const& position);
Then let's review what we need of the board:
class Board {
public:
// As supplied by LeetCode.
explicit Board(std::vector<std::vector<char>> const& board);
Dimensions get_dimensions() const noexcept;
// Throws on positions out of bounds.
char operator[](Position position) const;
private:
// ...
};
This means we can talk about Position
:
bool operator==(Position const& left, Position const& right) noexcept;
bool operator!=(Position const& left, Position const& right) noexcept;
Adding equality should generally be seen as a trigger to consider hashing, so let's:
template <>
struct std::hash<Position> {
std::size_t operator()(Position const& position) const;
};
And it'd be nice to have a way to check whether a Position is within the bounds of the board:
bool is_within(Position const& position, Dimensions const& dimensions);
That sets up nicely.
Encapsulate, encapsulate, encapsulate.
Some types, like Position and Dimensions, do not really have much in the sense of invariants (though negative dimensions should raise an eyebrow), so having them as struct
is fine.
On the other hand, types like a Trie
do maintain complex invariants; for example your Trie
is pruning the nodes it no longer needs. Those should be well encapsulated:
- Members should be
private
.
- And mutable references to them should not be exposed.
Encapsulation is also nice to reduce dependencies, such as the inner representation of Board
which can be changed at will.
So... let's rethink Trie
:
class Trie {
public:
class Node {
public:
// We'll come back later
private:
bool endWord = false;
std::unordered_map<char, Node> children;
};
// We'll come back later
private:
Node root;
};
Now, the operations:
// Of course!
std::ostream& operator<<(std::ostream& out, Trie const& trie);
We'll need to iterate over the Trie, as well as insert and remove words from it:
class Trie {
public:
Node const* root() const;
void insert(std::string_view word);
void remove(std::string_view word);
private:
// ...
};
And the same for Node, really.
class Node {
public:
Node const* get_child(char c) const;
void insert(std::string_view suffix);
void remove(std::string_view suffix);
private:
// ...
};
Do note how I "raised the level" of the operations, from talking about nitty gritty details (getChild/addChild) to talking about the problem domain. In general, it's a win, as it encapsulates implementation details.
Lifetime of Nodes
As you noted, there's a lifetime issue: what if a node is removed that is still referenced?
You solved it by std::shared_ptr
which works, though it's expensive and verbose.
In this particular problem, however, you didn't need to. That is, you can separate the logical concept of removing a word from the Trie and the implementation concern of removing a node from the Trie. You're starting with a fixed list of words, after all, so there won't be unbounded growth.
The lifetime problem can therefore be fixed, cheaply, by simply moving the node to a deleted list.
class Node {
public:
// As above.
private:
using Children = std::unordered_map<char, Node>;
bool endWord = false;
Children children;
std::vector<Children::node_type> deleted_children;
};
node_type
comes from C++17's extract
method; very useful to avoid allocating separately:
- The node still exists, though outside the container so that
find
, or iteration do not see it.
- All iterators to it are invalidated, but pointers and references are not.
You can also merge it back into this container (or another) but that won't be of use here.
Don't Repeat Yourself.
This is painful to write, and to read:
search(board, {r + 1, c}, stack, child.get());
search(board, {r - 1, c}, stack, child.get());
search(board, {r, c + 1}, stack, child.get());
search(board, {r, c - 1}, stack, child.get());
Hopefully the only difference are the coordinates...
Instead imagine:
for (Direction direction : { Down, Up, Right, Left }) {
search(board, position.next(direction), stack, child);
}
Isn't it much clearer?
Or you could go with:
std::array<Position; 4> get_neighbours(Position p) {
return {{ { p.x + 1, p.y }, { p.x - 1, p.y }
{ p.x, p.y + 1 }, { p.x, p.y - 1 } }};
}
for (auto neighbour : get_neighbours(loc)) {
search(board, neighbour, stack, child);
}
It has the advantage of avoiding to forget a neighbour in the list, and since the directions are not otherwise used, that may be good enough without introducing a long-ish next
method.
Anything but copy/pasting mostly identical lines of code is better, really.
With regard to the algorithmic details:
I'm somewhat concerned about the algorithmic complexity of that std::find(stack.begin(), stack.end(), loc)
call. Your stack can get as long as the longer word (or close to), so we're talking O(L) for each individual call, and it's called within a recursion called within the inner loop. Sounds like a recipe for quadratic complexity in the inner loop; best avoided. It may be worth keeping a std::hash_set<Position>
instead to get O(1).1
Similarly, it may be worth eliminating impossible words from the get go. Since you can't reuse any board cell within a given word, doing a quick count of the number of occurrences of each character on the board and comparing it to a quick count for each word would eliminate the words that cannot possibly be on the board very cheaply. I'd suggest doing it even before creating the Trie
. Bonus: it automatically eliminates words longer than the number of cells of the board; a nice way to introduce terrible complexity in your current algorithm....
1 I'd create a Path
class containing the set of Positions and a string equal to the sequence of characters seen so far with add(Position, Board const&)
and remove(Position, Board const&)
methods. As a bonus, you can remove stackToWord
.
With regard to the nitpicky details:
- Write tests.
- Yes, I know that you can't submit them. Write them anyway, in a separate file, and compile and run locally to check your edge cases.
- And also check performance edge cases; like... a 8x8 board with a square of 7x7 As (upper left), then an A in bottom right, and the rest all Bs, with a word being 50 As: is it much slower than usual 8x8 boards?
- Don't hardcode
cout
, that's what operator<<
is for:
- It allows bundling additional detail on the line.
- It instantly becomes trivial to write a test for that code too; it's nice to ensure debug code works, if you can't rely on it, how are you going to debug the rest? Don't want to go chasing gooses!
- Always use
{}
around blocks, even when the grammar allows omitting it such as after if
.
- Search for GOTO FAIL to learn how blocks can save the day...
static
has many meanings in C and C++. Just read some reference, like the one I linked. \$\endgroup\$