LeetCode 1181 - Before and After Puzzle
The problem gives us an array of phrases, where each phrase is a string made of lowercase English letters and spaces. Every phrase is well-formed, meaning there are no leading or trailing spaces and no consecutive spaces.
Difficulty: 🟡 Medium
Topics: Array, Hash Table, String, Sorting
Solution
Problem Understanding
The problem gives us an array of phrases, where each phrase is a string made of lowercase English letters and spaces. Every phrase is well-formed, meaning there are no leading or trailing spaces and no consecutive spaces.
We must generate all possible "Before and After" puzzles by combining two different phrases. A valid merge happens when the last word of the first phrase is exactly the same as the first word of the second phrase.
Suppose we have:
"writing code"
"code rocks"
The last word of the first phrase is "code" and the first word of the second phrase is also "code". Since they match, we merge them into:
"writing code rocks"
The overlapping word appears only once in the final result.
The order matters. If phrase A can merge into phrase B, that does not automatically mean B can merge into A. We must consider every ordered pair (i, j) where i != j.
The final answer must satisfy two additional requirements:
- All generated puzzles must be distinct
- The output must be sorted lexicographically
The constraints are relatively small:
- At most 100 phrases
- Each phrase length is at most 100 characters
These limits tell us that an O(n^2) comparison approach is completely acceptable because 100^2 = 10,000, which is small.
Several edge cases are important:
- Multiple identical phrases may exist
- Different phrase pairs may generate the same merged result
- Single-word phrases are allowed
- A phrase can only merge with another phrase at a different index, even if the text is identical
- Some phrases may participate in many merges
- Some phrases may not participate in any merge at all
A naive implementation can easily produce duplicates or incorrectly duplicate the overlapping word during concatenation.
Approaches
Brute Force Approach
The brute-force solution examines every ordered pair of phrases (i, j) where i != j.
For each pair:
- Extract the last word of
phrases[i] - Extract the first word of
phrases[j] - Compare them
- If they match, construct the merged phrase
- Insert the merged phrase into a set to remove duplicates
After processing all pairs, convert the set into a sorted list.
This approach is correct because it explicitly checks every possible valid pairing. Since the problem requires considering all ordered pairs, exhaustive checking guarantees no valid merge is missed.
The downside is that we repeatedly split phrases and repeatedly compare many pairs that have no chance of matching.
Key Insight for an Improved Solution
The important observation is that phrases only matter if:
- Their last word matches another phrase's first word
Instead of blindly comparing every pair, we can group phrases by their first word.
We build a hash map:
first_word -> list of phrase indices
Then, for each phrase:
- Extract its last word
- Look up all phrases whose first word matches that last word
- Generate merges only for those candidates
This avoids unnecessary comparisons and makes the logic cleaner and more scalable.
Even though the asymptotic complexity remains close to O(n^2) in the worst case, the hash map significantly reduces wasted work in practice.
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(n² * L) | O(k) | Checks every pair directly |
| Optimal | O(n² * L) worst case | O(n + k) | Uses hash map to only examine relevant matches |
Here:
nis the number of phrasesLis the average phrase lengthkis the number of generated distinct puzzles
Algorithm Walkthrough
- Create a hash map that groups phrase indices by their first word.
For every phrase, split it into words and extract the first word. Store the phrase index inside:
first_word_map[first_word]
This allows fast lookup of all phrases that can potentially follow another phrase. 2. Create an empty set to store generated puzzles.
A set automatically removes duplicates, which is required because different phrase pairs may produce the same merged result. 3. Iterate through every phrase as the first phrase.
For each phrase:
- Split it into words
- Extract the last word
- Use the last word to look up candidate second phrases.
If the last word exists in the hash map, then all phrases associated with that word are potential matches. 5. For every candidate second phrase:
- Skip the case where indices are equal because
i != j - Merge the phrases carefully
Suppose:
phrase1 = "writing code"
phrase2 = "code rocks"
We do not want:
"writing code code rocks"
Instead, we remove the duplicated first word from the second phrase and append only the remaining suffix. 6. Insert the merged phrase into the result set. 7. After all phrases are processed:
- Convert the set into a list
- Sort lexicographically
- Return the sorted list
Why it works
The algorithm works because every valid puzzle must satisfy exactly one condition:
last_word(phrase1) == first_word(phrase2)
The hash map guarantees we efficiently find every phrase that satisfies this condition. Since every valid ordered pair is considered exactly once, no valid puzzle is missed. Using a set guarantees duplicates are removed. Finally, sorting produces the required lexicographical order.
Python Solution
from typing import List
from collections import defaultdict
class Solution:
def beforeAndAfterPuzzles(self, phrases: List[str]) -> List[str]:
first_word_map = defaultdict(list)
# Map first word -> indices of phrases
for index, phrase in enumerate(phrases):
words = phrase.split()
first_word = words[0]
first_word_map[first_word].append(index)
results = set()
# Try every phrase as the first phrase
for i, phrase1 in enumerate(phrases):
words1 = phrase1.split()
last_word = words1[-1]
# Find phrases whose first word matches
for j in first_word_map.get(last_word, []):
if i == j:
continue
phrase2 = phrases[j]
words2 = phrase2.split()
# Merge while avoiding duplicate overlap word
merged = phrase1 + phrase2[len(words2[0]):]
results.add(merged)
return sorted(results)
The implementation begins by constructing a hash map that groups phrase indices according to their first word. This preprocessing step enables efficient lookup later.
The main loop treats each phrase as the potential first phrase in a merge. For every phrase, we extract its last word and use the hash map to find candidate second phrases whose first word matches.
The merge operation is subtle. Instead of reconstructing the second phrase manually from split words, we reuse string slicing:
phrase2[len(words2[0]):]
This removes the overlapping first word from the second phrase while preserving the leading space before the remaining words.
For example:
phrase2 = "code rocks"
words2[0] = "code"
phrase2[4:] = " rocks"
Appending this suffix directly creates the correct merged result.
The set ensures uniqueness, and the final sorted conversion satisfies the lexicographical ordering requirement.
Go Solution
package main
import (
"sort"
"strings"
)
func beforeAndAfterPuzzles(phrases []string) []string {
firstWordMap := make(map[string][]int)
// Map first word -> indices
for i, phrase := range phrases {
words := strings.Split(phrase, " ")
firstWord := words[0]
firstWordMap[firstWord] = append(firstWordMap[firstWord], i)
}
results := make(map[string]bool)
// Try every phrase as the first phrase
for i, phrase1 := range phrases {
words1 := strings.Split(phrase1, " ")
lastWord := words1[len(words1)-1]
candidates, exists := firstWordMap[lastWord]
if !exists {
continue
}
for _, j := range candidates {
if i == j {
continue
}
phrase2 := phrases[j]
words2 := strings.Split(phrase2, " ")
merged := phrase1 + phrase2[len(words2[0]):]
results[merged] = true
}
}
answer := make([]string, 0, len(results))
for phrase := range results {
answer = append(answer, phrase)
}
sort.Strings(answer)
return answer
}
The Go solution follows the same algorithmic structure as the Python implementation.
A map[string][]int is used for the first-word lookup table. Since Go does not have a built-in set type, uniqueness is implemented using:
map[string]bool
String slicing works similarly to Python because Go strings are byte slices. Since the problem guarantees lowercase English letters and spaces only, byte indexing is safe and there are no Unicode concerns.
The final results are collected into a slice and sorted using sort.Strings.
Worked Examples
Example 1
Input:
["writing code", "code rocks"]
Step 1: Build first-word map
| Phrase | First Word |
|---|---|
| writing code | writing |
| code rocks | code |
Map becomes:
| Key | Value |
|---|---|
| writing | [0] |
| code | [1] |
Step 2: Process each phrase
Phrase 0: "writing code"
Last word:
code
Lookup:
firstWordMap["code"] = [1]
Candidate:
"code rocks"
Merge:
"writing code" + " rocks"
= "writing code rocks"
Result set:
{"writing code rocks"}
Phrase 1: "code rocks"
Last word:
rocks
No matching first word exists.
Final sorted output:
["writing code rocks"]
Example 2
Input:
[
"mission statement",
"a quick bite to eat",
"a chip off the old block",
"chocolate bar",
"mission impossible",
"a man on a mission",
"block party",
"eat my words",
"bar of soap"
]
First-word map
| First Word | Indices |
|---|---|
| mission | [0, 4] |
| a | [1, 2, 5] |
| chocolate | [3] |
| block | [6] |
| eat | [7] |
| bar | [8] |
Important merges
| First Phrase | Last Word | Matching Phrase | Result |
|---|---|---|---|
| a chip off the old block | block | block party | a chip off the old block party |
| a man on a mission | mission | mission impossible | a man on a mission impossible |
| a man on a mission | mission | mission statement | a man on a mission statement |
| a quick bite to eat | eat | eat my words | a quick bite to eat my words |
| chocolate bar | bar | bar of soap | chocolate bar of soap |
Sorted final result:
[
"a chip off the old block party",
"a man on a mission impossible",
"a man on a mission statement",
"a quick bite to eat my words",
"chocolate bar of soap"
]
Example 3
Input:
["a", "b", "a"]
Processing
Pair (0, 2):
"a" + ""
= "a"
Pair (2, 0):
"a" + ""
= "a"
The set removes duplicates.
Final output:
["a"]
Example 4
Input:
["ab ba", "ba ab", "ab ba"]
Valid merges
| Pair | Result |
|---|---|
| (0,1) | ab ba ab |
| (1,0) | ba ab ba |
| (1,2) | ba ab ba |
| (2,1) | ab ba ab |
Set removes duplicates.
Final output:
["ab ba ab", "ba ab ba"]
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(n² * L) worst case | In the worst case many phrases share the same first/last word |
| Space | O(n + k) | Hash map plus distinct generated results |
Here:
nis the number of phrasesLis the average phrase lengthkis the number of distinct generated puzzles
The preprocessing map requires linear space relative to the number of phrases. The dominant cost comes from generating and storing merged strings. Although the hash map reduces unnecessary comparisons in practice, the worst case still approaches quadratic behavior if every phrase can connect to every other phrase.
Test Cases
sol = Solution()
# Example 1
assert sol.beforeAndAfterPuzzles(
["writing code", "code rocks"]
) == ["writing code rocks"] # simple merge
# Example 2
assert sol.beforeAndAfterPuzzles(
[
"mission statement",
"a quick bite to eat",
"a chip off the old block",
"chocolate bar",
"mission impossible",
"a man on a mission",
"block party",
"eat my words",
"bar of soap"
]
) == [
"a chip off the old block party",
"a man on a mission impossible",
"a man on a mission statement",
"a quick bite to eat my words",
"chocolate bar of soap"
] # multiple valid merges
# Example 3
assert sol.beforeAndAfterPuzzles(
["a", "b", "a"]
) == ["a"] # duplicate merged result
# Example 4
assert sol.beforeAndAfterPuzzles(
["ab ba", "ba ab", "ab ba"]
) == ["ab ba ab", "ba ab ba"] # repeated phrases
# No valid merges
assert sol.beforeAndAfterPuzzles(
["hello world", "python code"]
) == [] # no matching boundaries
# Single phrase
assert sol.beforeAndAfterPuzzles(
["alone"]
) == [] # cannot merge with itself
# Chain-style overlaps
assert sol.beforeAndAfterPuzzles(
["a b", "b c", "c d"]
) == ["a b c", "b c d"] # sequential merges
# Multiple duplicates
assert sol.beforeAndAfterPuzzles(
["x y", "y z", "x y"]
) == ["x y z"] # same result from multiple pairs
# Single-word phrases
assert sol.beforeAndAfterPuzzles(
["a", "a"]
) == ["a"] # overlap consumes entire second phrase
# Multiple possible matches
assert sol.beforeAndAfterPuzzles(
["start end", "end middle", "end finish"]
) == [
"start end finish",
"start end middle"
] # one phrase connects to multiple others
Test Summary
| Test | Why |
|---|---|
["writing code", "code rocks"] |
Basic valid merge |
| Large mixed example | Multiple independent merges |
["a", "b", "a"] |
Duplicate output handling |
["ab ba", "ba ab", "ab ba"] |
Repeated phrases and duplicates |
| No valid merges | Ensures empty output works |
| Single phrase | Cannot merge with itself |
| Sequential overlaps | Multiple chain-like connections |
| Multiple duplicates | Same result from different pairs |
| Single-word phrases | Entire phrase overlap |
| One-to-many matches | One phrase matching multiple candidates |
Edge Cases
One important edge case occurs when multiple phrase pairs generate the same merged phrase. For example:
["a", "a"]
Both (0,1) and (1,0) generate "a". A naive implementation using a list would produce duplicates in the final answer. This implementation uses a set, which guarantees uniqueness automatically.
Another important edge case involves single-word phrases. Consider:
["a", "a"]
When merging, the overlapping word completely consumes the second phrase. If string concatenation is implemented incorrectly, extra spaces or duplicated words can appear. The slicing approach correctly appends an empty suffix and produces "a".
A third tricky case involves repeated phrases with different indices:
["ab ba", "ba ab", "ab ba"]
Even though the phrase text repeats, indices are still distinct. The problem only forbids i == j, not identical content. The implementation correctly allows merges between different indices while preventing self-merges.
A final edge case occurs when no valid matches exist at all. In such cases, the algorithm should return an empty list rather than None or an unsorted structure. Since the result set simply remains empty, converting and sorting it naturally returns an empty list.