2746. Decremental String Concatenation
Problem Description
You have an array of n
strings called words
(0-indexed). You need to concatenate all these strings together using a special join operation.
The join operation join(x, y)
works as follows:
- Normally, it concatenates string
x
with stringy
to formxy
- However, if the last character of
x
equals the first character ofy
, one of these duplicate characters is deleted - For example:
join("ab", "ba") = "aba"
(the 'b' appears only once) andjoin("ab", "cde") = "abcde"
(no duplicate, so simple concatenation)
Starting with str₀ = words[0]
, you perform n-1
join operations. For each operation i
(from 1 to n-1), you have two choices:
- Join the current result with the next word:
strᵢ = join(strᵢ₋₁, words[i])
- Join the next word with the current result:
strᵢ = join(words[i], strᵢ₋₁)
Your goal is to find the strategy that results in the minimum possible length of the final concatenated string strₙ₋₁
.
The key insight is that the order of joining matters because:
- Different orders can lead to different numbers of character deletions
- The first and last characters of intermediate results affect future deletions
- You want to maximize the number of deletions (overlapping characters) to minimize the final length
Return the minimum possible length of the final concatenated string after performing all n-1
join operations optimally.
Intuition
When we think about this problem, we need to recognize what actually affects the final length. Each time we join two strings, we might save one character if there's an overlap. The key observation is that only the first and last characters of our current concatenated string matter for future operations.
Consider why: when we have a partially concatenated string like "abcdef"
, and we want to join it with the next word "xyz"
, the only characters that could potentially overlap are:
- The last character of
"abcdef"
(which is'f'
) if we dojoin("abcdef", "xyz")
- The first character of
"abcdef"
(which is'a'
) if we dojoin("xyz", "abcdef")
The middle characters "bcde"
don't affect any future joining decisions. This means we don't need to track the entire concatenated string - just its first and last characters!
This leads us to a dynamic programming approach where our state is:
- Which word we're currently processing (index
i
) - The first character of our concatenated string so far (character
a
) - The last character of our concatenated string so far (character
b
)
For each state (i, a, b)
, we have two choices:
- Append
words[i]
to the end: The new last character becomeswords[i][-1]
, first staysa
, and we save 1 character ifwords[i][0] == b
- Prepend
words[i]
to the beginning: The new first character becomeswords[i][0]
, last staysb
, and we save 1 character ifwords[i][-1] == a
We want to minimize the total length, so we recursively try both options and pick the better one. The base case is when we've processed all words.
The memoization (@cache
) is crucial because the same state (i, a, b)
can be reached through different joining sequences, and we don't want to recalculate the same subproblem multiple times.
Learn more about Dynamic Programming patterns.
Solution Approach
The solution uses memoization search (top-down dynamic programming) to find the minimum concatenated length.
Implementation Details:
-
Define the recursive function
dfs(i, a, b)
:i
: the index of the current word to processa
: the first character of the concatenated string so farb
: the last character of the concatenated string so far- Returns: the minimum additional length needed from position
i
onwards
-
Base case:
- When
i >= len(words)
, all words have been processed, return0
- When
-
Recursive case for word at index
i
:- Get the current word:
s = words[i]
- Option 1: Append
s
to the end- New state: first character remains
a
, last character becomess[-1]
- Length calculation:
x = dfs(i + 1, a, s[-1]) - int(s[0] == b)
- We subtract 1 if
s[0] == b
because one character overlaps and gets deleted
- New state: first character remains
- Option 2: Prepend
s
to the beginning- New state: first character becomes
s[0]
, last character remainsb
- Length calculation:
y = dfs(i + 1, s[0], b) - int(s[-1] == a)
- We subtract 1 if
s[-1] == a
because one character overlaps and gets deleted
- New state: first character becomes
- Return:
len(s) + min(x, y)
(add current word length plus the minimum from both options)
- Get the current word:
-
Memoization:
- The
@cache
decorator automatically memoizes the function results - This prevents recalculation of the same state
(i, a, b)
- The
-
Main function call:
- Start with the first word:
words[0]
- Initial state: first character is
words[0][0]
, last character iswords[0][-1]
- Return:
len(words[0]) + dfs(1, words[0][0], words[0][-1])
- Start with the first word:
Time Complexity: O(n × 26 × 26)
where n
is the number of words. We have at most n
positions and 26 × 26
possible character combinations for (a, b)
.
Space Complexity: O(n × 26 × 26)
for the memoization cache.
The algorithm efficiently explores all possible joining orders while avoiding redundant calculations through memoization, ensuring we find the globally optimal solution.
Ready to land your dream job?
Unlock your dream job with a 5-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a concrete example with words = ["ab", "bc", "cd"]
.
Initial Setup:
- Start with the first word:
"ab"
- Initial state: first char =
'a'
, last char ='b'
- We need to process words at indices 1 and 2
Step 1: Processing word at index 1 ("bc")
We call dfs(1, 'a', 'b')
to process "bc"
. We have two options:
Option 1: Append "bc" to the end (join("ab", "bc"))
- Check overlap: last char of current (
'b'
) == first char of "bc" ('b'
) ✓ - One character overlaps, so we save 1 character
- Result: "abc" (length 3 instead of 4)
- New state: first =
'a'
, last ='c'
- Recursive call:
dfs(2, 'a', 'c')
Option 2: Prepend "bc" to the beginning (join("bc", "ab"))
- Check overlap: last char of "bc" (
'c'
) == first char of current ('a'
) ✗ - No overlap, simple concatenation
- Result: "bcab" (length 4)
- New state: first =
'b'
, last ='b'
- Recursive call:
dfs(2, 'b', 'b')
Step 2: Processing word at index 2 ("cd") from Option 1
From state (2, 'a', 'c')
with current string "abc":
Option 1.1: Append "cd" (join("abc", "cd"))
- Check overlap:
'c'
=='c'
✓ - Save 1 character: "abcd" (length 4)
- Total from this path: 3 + 2 - 1 = 4
Option 1.2: Prepend "cd" (join("cd", "abc"))
- Check overlap:
'd'
=='a'
✗ - No overlap: "cdabc" (length 5)
- Total from this path: 3 + 2 = 5
Best from Option 1: 4
Step 2: Processing word at index 2 ("cd") from Option 2
From state (2, 'b', 'b')
with current string "bcab":
Option 2.1: Append "cd" (join("bcab", "cd"))
- Check overlap:
'b'
=='c'
✗ - No overlap: "bcabcd" (length 6)
- Total from this path: 4 + 2 = 6
Option 2.2: Prepend "cd" (join("cd", "bcab"))
- Check overlap:
'd'
=='b'
✗ - No overlap: "cdbcab" (length 6)
- Total from this path: 4 + 2 = 6
Best from Option 2: 6
Final Result:
- Option 1 path gives minimum length: 4
- The optimal joining sequence is: "ab" → "abc" → "abcd"
- This corresponds to: join("ab", "bc") then join("abc", "cd")
The algorithm returns 4 as the minimum possible length.
Solution Implementation
1class Solution:
2 def minimizeConcatenatedLength(self, words: List[str]) -> int:
3 from functools import cache
4
5 @cache
6 def dp(index: int, first_char: str, last_char: str) -> int:
7 """
8 Dynamic programming function to find minimum concatenated length.
9
10 Args:
11 index: Current index in words array
12 first_char: First character of the current concatenated string
13 last_char: Last character of the current concatenated string
14
15 Returns:
16 Minimum additional length needed from index onwards
17 """
18 # Base case: no more words to process
19 if index >= len(words):
20 return 0
21
22 current_word = words[index]
23
24 # Option 1: Append current word to the end of concatenated string
25 # If first char of current word equals last char of concatenated string,
26 # we can overlap by 1 character
27 append_to_end = dp(index + 1, first_char, current_word[-1]) - int(current_word[0] == last_char)
28
29 # Option 2: Prepend current word to the beginning of concatenated string
30 # If last char of current word equals first char of concatenated string,
31 # we can overlap by 1 character
32 prepend_to_start = dp(index + 1, current_word[0], last_char) - int(current_word[-1] == first_char)
33
34 # Return current word length plus minimum of both options
35 return len(current_word) + min(append_to_end, prepend_to_start)
36
37 # Start with first word and process remaining words
38 # Initial state: first and last characters are from words[0]
39 return len(words[0]) + dp(1, words[0][0], words[0][-1])
40
1class Solution {
2 // Memoization cache: [currentIndex][firstChar][lastChar]
3 private Integer[][][] memo;
4 private String[] words;
5 private int wordCount;
6
7 public int minimizeConcatenatedLength(String[] words) {
8 this.wordCount = words.length;
9 this.words = words;
10 // Initialize memoization array for all possible states
11 // memo[i][j][k] represents the minimum length starting from index i
12 // with j as the first character and k as the last character of the concatenated string
13 this.memo = new Integer[wordCount][26][26];
14
15 // Start with the first word and recursively process the rest
16 String firstWord = words[0];
17 int firstWordLength = firstWord.length();
18 int firstChar = firstWord.charAt(0) - 'a';
19 int lastChar = firstWord.charAt(firstWordLength - 1) - 'a';
20
21 return firstWordLength + dfs(1, firstChar, lastChar);
22 }
23
24 /**
25 * Recursive function with memoization to find minimum concatenated length
26 * @param currentIndex - current word index being processed
27 * @param firstCharIndex - index (0-25) of the first character of concatenated string so far
28 * @param lastCharIndex - index (0-25) of the last character of concatenated string so far
29 * @return minimum additional length needed from currentIndex onwards
30 */
31 private int dfs(int currentIndex, int firstCharIndex, int lastCharIndex) {
32 // Base case: processed all words
33 if (currentIndex >= wordCount) {
34 return 0;
35 }
36
37 // Check if already computed
38 if (memo[currentIndex][firstCharIndex][lastCharIndex] != null) {
39 return memo[currentIndex][firstCharIndex][lastCharIndex];
40 }
41
42 String currentWord = words[currentIndex];
43 int currentWordLength = currentWord.length();
44 int currentWordFirstChar = currentWord.charAt(0) - 'a';
45 int currentWordLastChar = currentWord.charAt(currentWordLength - 1) - 'a';
46
47 // Option 1: Append current word to the end
48 // If first char of current word matches last char of concatenated string, save 1 character
49 int appendToEnd = dfs(currentIndex + 1, firstCharIndex, currentWordLastChar);
50 if (currentWordFirstChar == lastCharIndex) {
51 appendToEnd -= 1; // Characters overlap, reduce length by 1
52 }
53
54 // Option 2: Prepend current word to the beginning
55 // If last char of current word matches first char of concatenated string, save 1 character
56 int prependToStart = dfs(currentIndex + 1, currentWordFirstChar, lastCharIndex);
57 if (currentWordLastChar == firstCharIndex) {
58 prependToStart -= 1; // Characters overlap, reduce length by 1
59 }
60
61 // Store and return minimum of both options plus current word length
62 int minimumLength = currentWordLength + Math.min(appendToEnd, prependToStart);
63 memo[currentIndex][firstCharIndex][lastCharIndex] = minimumLength;
64
65 return minimumLength;
66 }
67}
68
1class Solution {
2public:
3 int minimizeConcatenatedLength(vector<string>& words) {
4 int numWords = words.size();
5
6 // dp[wordIndex][firstChar][lastChar] stores the minimum length
7 // when processing from wordIndex to end, with firstChar as the first character
8 // of the concatenated result and lastChar as the last character
9 int dp[numWords][26][26];
10 memset(dp, 0, sizeof(dp));
11
12 // DFS with memoization to find minimum concatenated length
13 // Parameters:
14 // - wordIndex: current word being processed
15 // - firstCharIndex: index (0-25) of the first character of current concatenated string
16 // - lastCharIndex: index (0-25) of the last character of current concatenated string
17 function<int(int, int, int)> dfs = [&](int wordIndex, int firstCharIndex, int lastCharIndex) -> int {
18 // Base case: all words have been processed
19 if (wordIndex >= numWords) {
20 return 0;
21 }
22
23 // Return memoized result if already calculated
24 if (dp[wordIndex][firstCharIndex][lastCharIndex]) {
25 return dp[wordIndex][firstCharIndex][lastCharIndex];
26 }
27
28 string currentWord = words[wordIndex];
29 int currentWordLength = currentWord.size();
30 int currentWordFirstChar = currentWord[0] - 'a';
31 int currentWordLastChar = currentWord[currentWordLength - 1] - 'a';
32
33 // Option 1: Append current word to the end
34 // If first char of current word matches last char of concatenated string, save 1 char
35 int appendToEnd = dfs(wordIndex + 1, firstCharIndex, currentWordLastChar)
36 - (currentWordFirstChar == lastCharIndex ? 1 : 0);
37
38 // Option 2: Prepend current word to the beginning
39 // If last char of current word matches first char of concatenated string, save 1 char
40 int prependToBegin = dfs(wordIndex + 1, currentWordFirstChar, lastCharIndex)
41 - (currentWordLastChar == firstCharIndex ? 1 : 0);
42
43 // Store and return the minimum length: current word length + minimum of two options
44 return dp[wordIndex][firstCharIndex][lastCharIndex] = currentWordLength + min(appendToEnd, prependToBegin);
45 };
46
47 // Start with the first word and recursively process remaining words
48 string firstWord = words[0];
49 int firstWordFirstChar = firstWord.front() - 'a';
50 int firstWordLastChar = firstWord.back() - 'a';
51
52 return firstWord.size() + dfs(1, firstWordFirstChar, firstWordLastChar);
53 }
54};
55
1/**
2 * Minimizes the total length when concatenating all words in order
3 * by considering overlapping characters at word boundaries
4 * @param words - Array of strings to concatenate
5 * @returns The minimum possible concatenated length
6 */
7function minimizeConcatenatedLength(words: string[]): number {
8 const wordCount: number = words.length;
9
10 // Memoization table: dp[wordIndex][firstChar][lastChar]
11 // Stores minimum length when processing from wordIndex with given first and last chars
12 const memoTable: number[][][] = Array(wordCount)
13 .fill(0)
14 .map(() =>
15 Array(26)
16 .fill(0)
17 .map(() => Array(26).fill(0)),
18 );
19
20 /**
21 * Dynamic programming helper function to find minimum concatenated length
22 * @param currentIndex - Current word index being processed
23 * @param firstCharIndex - Character index (0-25) of the first character of concatenated string so far
24 * @param lastCharIndex - Character index (0-25) of the last character of concatenated string so far
25 * @returns Minimum length from current state to the end
26 */
27 const calculateMinLength = (currentIndex: number, firstCharIndex: number, lastCharIndex: number): number => {
28 // Base case: all words processed
29 if (currentIndex >= wordCount) {
30 return 0;
31 }
32
33 // Return memoized result if already computed
34 if (memoTable[currentIndex][firstCharIndex][lastCharIndex] > 0) {
35 return memoTable[currentIndex][firstCharIndex][lastCharIndex];
36 }
37
38 const currentWord: string = words[currentIndex];
39 const currentWordLength: number = currentWord.length;
40
41 // Option 1: Append current word to the end of concatenated string
42 // Check if last char of concatenated string matches first char of current word
43 const appendToEnd: number =
44 calculateMinLength(
45 currentIndex + 1,
46 firstCharIndex,
47 currentWord[currentWordLength - 1].charCodeAt(0) - 97
48 ) - (currentWord[0].charCodeAt(0) - 97 === lastCharIndex ? 1 : 0);
49
50 // Option 2: Prepend current word to the beginning of concatenated string
51 // Check if first char of concatenated string matches last char of current word
52 const prependToStart: number =
53 calculateMinLength(
54 currentIndex + 1,
55 currentWord[0].charCodeAt(0) - 97,
56 lastCharIndex
57 ) - (currentWord[currentWordLength - 1].charCodeAt(0) - 97 === firstCharIndex ? 1 : 0);
58
59 // Store and return the minimum of both options plus current word length
60 memoTable[currentIndex][firstCharIndex][lastCharIndex] =
61 Math.min(appendToEnd + currentWordLength, prependToStart + currentWordLength);
62
63 return memoTable[currentIndex][firstCharIndex][lastCharIndex];
64 };
65
66 // Start with the first word and calculate minimum for remaining words
67 const firstWord: string = words[0];
68 const firstWordLength: number = firstWord.length;
69
70 return firstWordLength +
71 calculateMinLength(
72 1,
73 firstWord[0].charCodeAt(0) - 97,
74 firstWord[firstWordLength - 1].charCodeAt(0) - 97
75 );
76}
77
Time and Space Complexity
The time complexity is O(n × 26²)
or O(n × C²)
where C = 26
(the number of possible characters), and the space complexity is O(n × 26²)
or O(n × C²)
.
Time Complexity Analysis:
- The function
dfs(i, a, b)
uses dynamic programming with memoization via@cache
- Parameter
i
ranges from1
ton-1
wheren = len(words)
- Parameters
a
andb
represent the first and last characters, each having at most 26 possible values (assuming lowercase English letters) - Total number of unique states:
n × 26 × 26 = n × 26²
- Each state computation takes
O(1)
time (comparing characters and accessing string endpoints) - Therefore, time complexity is
O(n × 26²)
=O(n × C²)
whereC = 26
Space Complexity Analysis:
- The
@cache
decorator stores all computed states in memory - Maximum number of cached states:
n × 26 × 26 = n × 26²
- Recursion depth is at most
n
- Therefore, space complexity is
O(n × 26²)
=O(n × C²)
for the cache plusO(n)
for recursion stack, which simplifies toO(n × C²)
Note: The reference answer mentions C
as the maximum length of the string, but in this context, C
actually represents the size of the character set (26 for lowercase English letters), not string length.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Incorrect Overlap Counting
A common mistake is miscalculating when characters overlap. Developers often confuse which characters to compare:
- When appending word
s
to the end: Compares[0]
with the currentlast_char
, nots[-1]
withfirst_char
- When prepending word
s
to the beginning: Compares[-1]
with the currentfirst_char
, nots[0]
withlast_char
Wrong Implementation:
# Incorrect comparisons
append_to_end = dp(index + 1, first_char, current_word[-1]) - int(current_word[-1] == last_char) # Wrong!
prepend_to_start = dp(index + 1, current_word[0], last_char) - int(current_word[0] == first_char) # Wrong!
Correct Implementation:
# Correct comparisons
append_to_end = dp(index + 1, first_char, current_word[-1]) - int(current_word[0] == last_char)
prepend_to_start = dp(index + 1, current_word[0], last_char) - int(current_word[-1] == first_char)
2. Forgetting to Track Both Ends of the String
Some solutions only track the total length without maintaining the first and last characters of the intermediate result. This makes it impossible to determine future overlaps.
Wrong Approach:
def dp(index, total_length): # Missing first and last character tracking
# Cannot determine overlaps without knowing boundary characters
pass
Correct Approach:
def dp(index, first_char, last_char): # Properly tracks both ends
# Can correctly calculate overlaps based on boundary characters
pass
3. Not Handling the Initial Word Properly
A common error is trying to include words[0]
in the recursive choices, leading to incorrect results or infinite recursion.
Wrong Implementation:
def minimizeConcatenatedLength(self, words):
@cache
def dp(index, first_char, last_char):
# ... recursive logic ...
# Wrong: Starting from index 0 with empty initial state
return dp(0, '', '') # This doesn't handle words[0] correctly
Correct Implementation:
def minimizeConcatenatedLength(self, words):
@cache
def dp(index, first_char, last_char):
# ... recursive logic ...
# Correct: Start with words[0] as the initial string
return len(words[0]) + dp(1, words[0][0], words[0][-1])
4. Subtracting Instead of Not Adding
The solution subtracts 1 when there's an overlap, but conceptually, we're "not adding" the overlapping character. Some implementations mistakenly try to subtract from the word length directly.
Wrong Logic:
# Trying to modify the current word's length
if current_word[0] == last_char:
append_length = len(current_word) - 1 # Wrong place to subtract
else:
append_length = len(current_word)
Correct Logic:
# Add full word length, then adjust the recursive result
append_to_end = dp(index + 1, first_char, current_word[-1]) - int(current_word[0] == last_char)
return len(current_word) + min(append_to_end, prepend_to_start)
5. Edge Case: Single Character Words
When words contain single characters, words[i][0]
and words[i][-1]
refer to the same character. The solution handles this correctly, but developers might overthink and add unnecessary special cases.
Unnecessary Complication:
if len(current_word) == 1:
# Special handling for single character (not needed!)
pass
else:
# Regular logic
pass
The original solution handles single-character words naturally without special cases, as the logic remains consistent regardless of word length.
Which algorithm is best for finding the shortest distance between two points in an unweighted graph?
Recommended Readings
What is Dynamic Programming Prerequisite DFS problems dfs_intro Backtracking problems backtracking Memoization problems memoization_intro Pruning problems backtracking_pruning Dynamic programming is an algorithmic optimization technique that breaks down a complicated problem into smaller overlapping sub problems in a recursive manner and uses solutions to the sub problems to construct a solution
Coding Interview Patterns Your Personal Dijkstra's Algorithm to Landing Your Dream Job The goal of AlgoMonster is to help you get a job in the shortest amount of time possible in a data driven way We compiled datasets of tech interview problems and broke them down by patterns This way
Recursion Recursion is one of the most important concepts in computer science Simply speaking recursion is the process of a function calling itself Using a real life analogy imagine a scenario where you invite your friends to lunch https assets algo monster recursion jpg You first call Ben and ask
Want a Structured Path to Master System Design Too? Don’t Miss This!