Facebook Pixel

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 string y to form xy
  • However, if the last character of x equals the first character of y, one of these duplicate characters is deleted
  • For example: join("ab", "ba") = "aba" (the 'b' appears only once) and join("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:

  1. Join the current result with the next word: strᵢ = join(strᵢ₋₁, words[i])
  2. 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.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

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 do join("abcdef", "xyz")
  • The first character of "abcdef" (which is 'a') if we do join("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:

  1. Append words[i] to the end: The new last character becomes words[i][-1], first stays a, and we save 1 character if words[i][0] == b
  2. Prepend words[i] to the beginning: The new first character becomes words[i][0], last stays b, and we save 1 character if words[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:

  1. Define the recursive function dfs(i, a, b):

    • i: the index of the current word to process
    • a: the first character of the concatenated string so far
    • b: the last character of the concatenated string so far
    • Returns: the minimum additional length needed from position i onwards
  2. Base case:

    • When i >= len(words), all words have been processed, return 0
  3. 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 becomes s[-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
    • Option 2: Prepend s to the beginning
      • New state: first character becomes s[0], last character remains b
      • 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
    • Return: len(s) + min(x, y) (add current word length plus the minimum from both options)
  4. Memoization:

    • The @cache decorator automatically memoizes the function results
    • This prevents recalculation of the same state (i, a, b)
  5. Main function call:

    • Start with the first word: words[0]
    • Initial state: first character is words[0][0], last character is words[0][-1]
    • Return: len(words[0]) + dfs(1, words[0][0], words[0][-1])

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 Evaluator

Example 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 from 1 to n-1 where n = len(words)
  • Parameters a and b 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²) where C = 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 plus O(n) for recursion stack, which simplifies to O(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: Compare s[0] with the current last_char, not s[-1] with first_char
  • When prepending word s to the beginning: Compare s[-1] with the current first_char, not s[0] with last_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.

Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Which algorithm is best for finding the shortest distance between two points in an unweighted graph?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More