LeetCode 3799 - Word Squares II
We are given an array of distinct 4-letter words. Our goal is to construct every valid word square consisting of exactly four different words: - top forms the top row. - bottom forms the bottom row. - left forms the left column. - right forms the right column.
Difficulty: 🟡 Medium
Topics: Array, String, Backtracking, Sorting, Enumeration
Solution
LeetCode 3799 - Word Squares II
Problem Understanding
We are given an array of distinct 4-letter words. Our goal is to construct every valid word square consisting of exactly four different words:
topforms the top row.bottomforms the bottom row.leftforms the left column.rightforms the right column.
Since each word has length 4, only the four corner positions matter. The arrangement must satisfy these corner constraints:
top[0] == left[0]top[3] == right[0]bottom[0] == left[3]bottom[3] == right[3]
All four selected words must be distinct.
The input is a list of distinct lowercase strings, each of length 4. We must return every valid square as a 4-element array:
[top, left, right, bottom]
The final result must be sorted lexicographically according to this 4-tuple ordering.
The constraints are quite small:
- Number of words is at most 15.
- Every word has fixed length 4.
- All words are distinct.
These constraints immediately suggest that exhaustive search is possible, but we should still avoid checking every possible quadruple when a more structured search exists.
Some important observations:
- The words are distinct, so we never need to worry about duplicate entries in the input.
- Every valid square requires four different words.
- Only the four corner characters matter. The middle characters are irrelevant.
- Multiple valid squares can exist using the same set of words in different roles.
- The output must be lexicographically sorted, regardless of the order in which solutions are discovered.
Approaches
Brute Force
The most direct solution is to try every possible assignment of four distinct words:
(top, left, right, bottom)
For each quadruple, we check the four corner constraints:
top[0] == left[0]
top[3] == right[0]
bottom[0] == left[3]
bottom[3] == right[3]
If all constraints hold, we add the square to the answer.
This approach is correct because it explicitly examines every possible arrangement of four distinct words and accepts exactly those satisfying the required conditions.
However, its complexity is high. With up to 15 words, the number of ordered quadruples is:
15 × 14 × 13 × 12 = 32,760
While still manageable, we can do much better by exploiting the corner constraints during construction rather than after selecting all four words.
Key Insight
The corner constraints depend only on the first and last characters of each word.
Suppose we choose top and left.
Immediately, we already know:
top[0] == left[0]
must hold.
Once these two words are fixed:
rightmust start withtop[3].bottommust start withleft[3].
Finally, the last condition requires:
bottom[3] == right[3]
Instead of enumerating all quadruples, we can build candidate groups incrementally.
A useful optimization is to index words by their starting character. Then, after choosing top and left, we can directly retrieve valid candidates for right and bottom rather than scanning the entire array.
Because only 15 words exist, this pruning dramatically reduces unnecessary checks while keeping the implementation simple.
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(n⁴) | O(1) excluding output | Check every ordered quadruple |
| Optimal | O(n³) | O(n) | Use character indexing to prune candidates |
Algorithm Walkthrough
Step 1
Create a hash map that groups words by their first character.
For example:
{
'a': ["able", "area", ...],
'e': ["echo", "edge", ...]
}
This allows us to quickly find words that can serve as the right or bottom side of a square.
Step 2
Iterate through every ordered pair (top, left).
Since all four words must be distinct, skip pairs where:
top == left
Also enforce the first corner constraint:
top[0] == left[0]
If this condition fails, no valid square can be built from this pair.
Step 3
Determine the required starting letters for the remaining two words.
For the right word:
right[0] = top[3]
For the bottom word:
bottom[0] = left[3]
Use the hash map to retrieve only words beginning with these required letters.
Step 4
Iterate through every candidate right.
Ensure it is distinct from both top and left.
Step 5
Iterate through every candidate bottom.
Ensure it is distinct from all previously chosen words.
Step 6
Check the final remaining corner constraint:
bottom[3] == right[3]
If true, a valid square has been found.
Add:
[top, left, right, bottom]
to the answer.
Step 7
After all possibilities have been explored, sort the answer lexicographically.
Return the sorted list.
Why it works
The algorithm systematically constructs every ordered assignment of four distinct words that can satisfy the corner constraints. The first three constraints are enforced while selecting candidates, and the final constraint is checked before accepting a square. Because every valid square must satisfy these conditions and every feasible combination is considered exactly once, the algorithm finds all valid squares and no invalid ones.
Python Solution
from collections import defaultdict
from typing import List
class Solution:
def wordSquares(self, words: List[str]) -> List[List[str]]:
start_map = defaultdict(list)
for word in words:
start_map[word[0]].append(word)
result = []
for top in words:
for left in words:
if top == left:
continue
if top[0] != left[0]:
continue
right_candidates = start_map[top[3]]
bottom_candidates = start_map[left[3]]
for right in right_candidates:
if right == top or right == left:
continue
for bottom in bottom_candidates:
if (
bottom == top
or bottom == left
or bottom == right
):
continue
if bottom[3] == right[3]:
result.append(
[top, left, right, bottom]
)
result.sort()
return result
The implementation begins by building start_map, which groups words according to their first character. This allows efficient retrieval of words that satisfy the starting-character requirements for the right and bottom positions.
The nested loops first select top and left. Any pair violating the first corner constraint is discarded immediately.
Once top and left are fixed, the required first letters of right and bottom are known. Instead of scanning every word again, the algorithm retrieves only matching candidates from start_map.
For each candidate pair (right, bottom), the code verifies that all four words are distinct and checks the final corner condition. Every valid square is appended to the result.
Finally, the result list is sorted lexicographically before being returned.
Go Solution
func wordSquares(words []string) [][]string {
startMap := make(map[byte][]string)
for _, word := range words {
startMap[word[0]] = append(startMap[word[0]], word)
}
var result [][]string
for _, top := range words {
for _, left := range words {
if top == left {
continue
}
if top[0] != left[0] {
continue
}
rightCandidates := startMap[top[3]]
bottomCandidates := startMap[left[3]]
for _, right := range rightCandidates {
if right == top || right == left {
continue
}
for _, bottom := range bottomCandidates {
if bottom == top ||
bottom == left ||
bottom == right {
continue
}
if bottom[3] == right[3] {
result = append(result, []string{
top,
left,
right,
bottom,
})
}
}
}
}
}
for i := 0; i < len(result); i++ {
for j := i + 1; j < len(result); j++ {
a := result[i]
b := result[j]
swap := false
for k := 0; k < 4; k++ {
if a[k] < b[k] {
break
}
if a[k] > b[k] {
swap = true
break
}
}
if swap {
result[i], result[j] = result[j], result[i]
}
}
}
return result
}
The Go solution follows the same algorithm as the Python version. A map from starting character to candidate words provides efficient filtering. The primary difference is explicit slice management and manual lexicographic sorting of the result tuples. Since the maximum number of solutions is small, a simple comparison-based sort is sufficient.
Worked Examples
Example 1
words = ["able","area","echo","also"]
Character Index
| Start Character | Words |
|---|---|
| a | able, area, also |
| e | echo |
Iteration 1
Choose:
top = "able"
left = "area"
Check:
top[0] = 'a'
left[0] = 'a'
Valid.
Required candidates:
right starts with 'e'
bottom starts with 'a'
So:
right_candidates = ["echo"]
bottom_candidates = ["able","area","also"]
Candidate Evaluation
| right | bottom | Distinct? | bottom[3] | right[3] | Valid |
|---|---|---|---|---|---|
| echo | able | No | e | o | No |
| echo | area | No | a | o | No |
| echo | also | Yes | o | o | Yes |
Result:
["able","area","echo","also"]
Iteration 2
Choose:
top = "area"
left = "able"
Required:
right starts with 'a'
bottom starts with 'e'
Candidates:
right = "also"
bottom = "echo"
Check:
bottom[3] = 'o'
right[3] = 'o'
Valid.
Result:
["area","able","also","echo"]
After sorting:
[
["able","area","echo","also"],
["area","able","also","echo"]
]
Example 2
words = ["code","cafe","eden","edge"]
Index:
| Start Character | Words |
|---|---|
| c | code, cafe |
| e | eden, edge |
Every possible (top, left) pair eventually fails one of the corner constraints.
No valid square is generated.
Output:
[]
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(n³) | For each top-left pair, only matching right and bottom candidates are explored |
| Space | O(n) | Hash map storing words grouped by first character |
The dominant cost comes from iterating through candidate combinations after fixing top and left. Since the alphabet size is fixed and words are distributed among character buckets, the average number of candidates is reduced substantially. The auxiliary space is the character-index map containing all input words.
Test Cases
sol = Solution()
# Example 1
assert sol.wordSquares(
["able", "area", "echo", "also"]
) == [
["able", "area", "echo", "also"],
["area", "able", "also", "echo"]
]
# Example 2
assert sol.wordSquares(
["code", "cafe", "eden", "edge"]
) == []
# Minimum size input with no solution
assert sol.wordSquares(
["abcd", "efgh", "ijkl", "mnop"]
) == []
# Single valid square
assert sol.wordSquares(
["aaaa", "abca", "axya", "aaaa"]
) == []
# duplicate words are not allowed by constraints
# Four words that form one square
assert sol.wordSquares(
["able", "area", "echo", "also"]
)
# Verify lexicographic ordering
ans = sol.wordSquares(
["able", "area", "echo", "also"]
)
assert ans == sorted(ans)
# Larger input containing unrelated words
assert sol.wordSquares(
["able", "area", "echo", "also",
"xxxx", "yyyy", "zzzz"]
) == [
["able", "area", "echo", "also"],
["area", "able", "also", "echo"]
]
# Distinctness requirement stress test
assert sol.wordSquares(
["aaaa", "aaab", "aaba", "abaa"]
) == []
Test Summary
| Test | Why |
|---|---|
| Example 1 | Verifies multiple valid squares |
| Example 2 | Verifies empty result |
| Completely unrelated words | No corner constraints can be satisfied |
| Lexicographic ordering check | Ensures final sorting is correct |
| Additional irrelevant words | Confirms extra words do not affect correctness |
| Distinctness stress test | Ensures all four words must be different |
| Minimum-sized input | Validates behavior at lower constraint boundary |
Edge Cases
All Words Share the Same Starting Character
A large number of candidate combinations can arise when many words begin with the same letter. A buggy implementation might accidentally generate duplicate solutions or fail to enforce distinctness. This solution explicitly checks that all four selected words are different before accepting a square.
No Valid Top-Left Pair Exists
If no two words share the same first character, then the very first corner condition can never be satisfied. The algorithm immediately rejects such pairs and naturally returns an empty result.
Multiple Squares Using the Same Words
The same set of words may form different valid squares when assigned different roles. For example, a word that serves as top in one solution may serve as left in another. The algorithm treats every ordered assignment independently, ensuring all valid squares are discovered.
Candidate Buckets Are Empty
For some (top, left) pair, there may be no words beginning with the required character for right or bottom. In that case, the corresponding candidate list is empty and the nested loops simply perform no work. No special handling is required.
Output Ordering
Solutions may be discovered in an arbitrary traversal order. Since the problem requires lexicographic ordering by (top, left, right, bottom), the implementation performs a final sort before returning the answer, guaranteeing the required output format.