Facebook Pixel

3253. Construct String with Minimum Cost (Easy) 🔒

Problem Description

You are given a string target that you need to construct, along with an array of strings words and an array of integers costs (both arrays have the same length).

Starting with an empty string s, you can perform the following operation any number of times (including zero times):

  • Choose any index i from the range [0, words.length - 1]
  • Append words[i] to the end of string s
  • This operation costs costs[i]

Your goal is to find the minimum total cost to make string s equal to the target string. If it's impossible to construct the target string using the given words, return -1.

For example, if target = "abcd", words = ["ab", "cd", "abcd"], and costs = [3, 2, 5], you could:

  • Use words[0] ("ab") with cost 3, then words[1] ("cd") with cost 2, for a total cost of 5
  • Or use words[2] ("abcd") directly with cost 5
  • The minimum cost would be 5

The key insight is that you can use any word from the words array multiple times, and the words must be appended in sequence to form the exact target string.

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

Intuition

To construct the target string optimally, we need to make decisions at each position: which word from our available words should we use next? This naturally leads to a dynamic programming approach where we try different words starting from each position.

The key observation is that at any position i in the target string, we need to find all possible words that can match starting from that position. For instance, if we're at position i and we have words that match target[i:i+2], target[i:i+3], and target[i:i+5], we need to try all of them and pick the one that gives us the minimum total cost.

However, checking every word against every position would be inefficient. This is where a Trie (prefix tree) becomes invaluable. By storing all words in a Trie, we can efficiently find all words that match starting from any position in the target string. As we traverse the target string from position i, we simultaneously traverse the Trie. If at any point the Trie path doesn't exist, we know no word can match beyond that point.

The problem then becomes: for each position i in the target string, we explore all possible words that can start from position i by traversing the Trie. For each valid word found (reaching a node in Trie that marks the end of a word), we have two pieces of information:

  1. The cost of using this word (stored in the Trie node)
  2. The next position we need to solve (which is j+1 where j is the ending position of the current word)

This recursive structure naturally fits memoization: dfs(i) represents "what's the minimum cost to construct the target string starting from position i?" The answer would be the minimum among all valid words starting at position i, where each choice costs word_cost + dfs(next_position).

The Trie optimization is crucial here - instead of checking all words at each position (which could be expensive), we traverse the Trie character by character, pruning impossible paths early and finding all matching words in a single traversal.

Solution Approach

The solution uses a Trie data structure combined with memoized depth-first search to efficiently find the minimum cost.

Building the Trie

First, we create a Trie where each node contains:

  • An array children of length 26 (for lowercase English letters)
  • A cost variable storing the minimum cost to reach this node from the root

We insert each word from the words array into the Trie:

def insert(self, word: str, cost: int):
    node = self
    for c in word:
        idx = ord(c) - ord("a")
        if node.children[idx] is None:
            node.children[idx] = Trie()
        node = node.children[idx]
    node.cost = min(node.cost, cost)

The key detail is that if multiple words end at the same node, we keep only the minimum cost using min(node.cost, cost).

Memoized Search Function

The core algorithm is the recursive function dfs(i) which returns the minimum cost to construct target[i:] (the substring starting from index i):

@cache
def dfs(i: int) -> int:
    if i >= len(target):
        return 0  # Base case: entire string constructed
  
    ans = inf
    node = trie
    for j in range(i, len(target)):
        idx = ord(target[j]) - ord("a")
        if node.children[idx] is None:
            return ans  # No more matching words possible
        node = node.children[idx]
        ans = min(ans, node.cost + dfs(j + 1))
    return ans

The function works as follows:

  1. Base Case: If i >= len(target), we've successfully constructed the entire target string, so return 0.

  2. Trie Traversal: Starting from the root of the Trie and position i in the target:

    • For each character target[j] where j goes from i to the end of target
    • Check if the Trie has a child node for this character
    • If not, break early (no word can match beyond this point)
    • If yes, move to that child node
  3. Cost Calculation: At each valid node in the Trie that represents the end of a word (where node.cost < inf):

    • Calculate the total cost as: node.cost (cost of current word) + dfs(j + 1) (minimum cost for the remaining string)
    • Keep track of the minimum cost among all possibilities
  4. Memoization: The @cache decorator ensures that dfs(i) is computed only once for each unique value of i, avoiding redundant calculations.

Final Result

The answer is dfs(0) - the minimum cost to construct the entire target string starting from position 0. If this value is still inf, it means the target cannot be constructed, so we return -1.

The time complexity is O(n * m + n^2) where n is the length of the target string and m is the total length of all words (for Trie construction). The space complexity is O(n + m) for the recursion stack, memoization cache, and Trie storage.

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 to illustrate the solution approach.

Given:

  • target = "abcd"
  • words = ["ab", "bc", "cd", "abcd"]
  • costs = [2, 3, 1, 5]

Step 1: Build the Trie

We insert each word with its cost into the Trie:

Root
├── a
│   └── b (cost=2)
│       └── c
│           └── d (cost=5)
├── b
│   └── c (cost=3)
└── c
    └── d (cost=1)

Step 2: Execute DFS with Memoization

Starting with dfs(0) (constructing from position 0):

At position 0, we traverse the Trie starting from root while matching with target[0:] = "abcd":

  • Match 'a': Move to node 'a' in Trie ✓
  • Match 'b': Move to node 'b' under 'a' ✓ (Found word "ab" with cost=2)
    • Option 1: Use "ab" (cost=2) and solve for dfs(2)
  • Match 'c': Move to node 'c' under 'b' ✓
  • Match 'd': Move to node 'd' under 'c' ✓ (Found word "abcd" with cost=5)
    • Option 2: Use "abcd" (cost=5) and solve for dfs(4)

For Option 1: Cost = 2 + dfs(2)

  • At dfs(2), we start matching from position 2 ("cd"):
    • Match 'c': Move to node 'c' in Trie ✓
    • Match 'd': Move to node 'd' under 'c' ✓ (Found word "cd" with cost=1)
      • Use "cd" (cost=1) and solve for dfs(4)
    • dfs(4) returns 0 (base case: reached end of target)
    • So dfs(2) = 1 + 0 = 1
  • Total for Option 1: 2 + 1 = 3

For Option 2: Cost = 5 + dfs(4)

  • dfs(4) returns 0 (base case)
  • Total for Option 2: 5 + 0 = 5

Therefore, dfs(0) = min(3, 5) = 3

Step 3: Return Result

The minimum cost to construct "abcd" is 3, achieved by using:

  • "ab" (cost=2) at position 0
  • "cd" (cost=1) at position 2

Note how the Trie helped us efficiently find all valid words starting at each position without checking every word in the array, and memoization ensured we only computed dfs(4) once even though it was needed in multiple paths.

Solution Implementation

1from typing import List, Optional
2from math import inf
3from functools import cache
4
5
6class Trie:
7    """Trie data structure for efficient string prefix matching"""
8  
9    def __init__(self):
10        # Array to store 26 child nodes (one for each lowercase letter)
11        self.children: List[Optional[Trie]] = [None] * 26
12        # Minimum cost to form a word ending at this node
13        self.cost = inf
14
15    def insert(self, word: str, cost: int) -> None:
16        """Insert a word into the trie with its associated cost"""
17        node = self
18      
19        # Traverse through each character in the word
20        for char in word:
21            # Calculate index for the character (0-25 for 'a'-'z')
22            index = ord(char) - ord('a')
23          
24            # Create new node if path doesn't exist
25            if node.children[index] is None:
26                node.children[index] = Trie()
27          
28            # Move to the child node
29            node = node.children[index]
30      
31        # Update the minimum cost at the word's end node
32        node.cost = min(node.cost, cost)
33
34
35class Solution:
36    def minimumCost(self, target: str, words: List[str], costs: List[int]) -> int:
37        """
38        Find the minimum cost to construct the target string using given words.
39      
40        Args:
41            target: The string we want to construct
42            words: List of available words to use
43            costs: List of costs corresponding to each word
44          
45        Returns:
46            Minimum cost to construct target, or -1 if impossible
47        """
48      
49        @cache
50        def dfs(start_index: int) -> int:
51            """
52            Dynamic programming function to find minimum cost from a given position.
53          
54            Args:
55                start_index: Current position in the target string
56              
57            Returns:
58                Minimum cost to construct the remaining substring
59            """
60            # Base case: reached end of target string
61            if start_index >= len(target):
62                return 0
63          
64            # Initialize minimum cost as infinity
65            min_cost = inf
66            current_node = trie_root
67          
68            # Try to match words starting from current position
69            for end_index in range(start_index, len(target)):
70                # Get character index for trie traversal
71                char_index = ord(target[end_index]) - ord('a')
72              
73                # No matching word found, return current minimum
74                if current_node.children[char_index] is None:
75                    return min_cost
76              
77                # Move to next node in trie
78                current_node = current_node.children[char_index]
79              
80                # If current node represents end of a word, calculate cost
81                # Cost = word's cost + minimum cost for remaining substring
82                min_cost = min(min_cost, current_node.cost + dfs(end_index + 1))
83          
84            return min_cost
85      
86        # Build trie from words and their costs
87        trie_root = Trie()
88        for word, cost in zip(words, costs):
89            trie_root.insert(word, cost)
90      
91        # Find minimum cost starting from index 0
92        result = dfs(0)
93      
94        # Return -1 if target cannot be constructed, otherwise return the cost
95        return result if result < inf else -1
96
1/**
2 * Trie (Prefix Tree) data structure for efficient string matching
3 */
4class Trie {
5    // Large value representing infinity for cost comparisons
6    public final int INFINITY = 1 << 29;
7  
8    // Array to store child nodes (26 letters in English alphabet)
9    public Trie[] children = new Trie[26];
10  
11    // Minimum cost to form a word ending at this node
12    public int cost = INFINITY;
13
14    /**
15     * Inserts a word into the trie with its associated cost
16     * @param word The word to insert
17     * @param cost The cost associated with this word
18     */
19    public void insert(String word, int cost) {
20        Trie currentNode = this;
21      
22        // Traverse through each character in the word
23        for (char character : word.toCharArray()) {
24            int index = character - 'a';
25          
26            // Create new node if path doesn't exist
27            if (currentNode.children[index] == null) {
28                currentNode.children[index] = new Trie();
29            }
30          
31            // Move to the child node
32            currentNode = currentNode.children[index];
33        }
34      
35        // Update the cost at the end node (keep minimum if word already exists)
36        currentNode.cost = Math.min(currentNode.cost, cost);
37    }
38}
39
40/**
41 * Solution class for finding minimum cost to construct target string
42 */
43class Solution {
44    // Trie to store all available words with their costs
45    private Trie trie = new Trie();
46  
47    // Target string converted to character array for efficient access
48    private char[] targetArray;
49  
50    // Memoization array for dynamic programming (stores minimum cost from index i)
51    private Integer[] memo;
52
53    /**
54     * Finds the minimum cost to construct the target string using given words
55     * @param target The target string to construct
56     * @param words Array of available words to use
57     * @param costs Array of costs corresponding to each word
58     * @return Minimum cost to construct target, or -1 if impossible
59     */
60    public int minimumCost(String target, String[] words, int[] costs) {
61        // Build trie with all available words and their costs
62        for (int i = 0; i < words.length; ++i) {
63            trie.insert(words[i], costs[i]);
64        }
65      
66        // Initialize target array and memoization array
67        this.targetArray = target.toCharArray();
68        memo = new Integer[target.length()];
69      
70        // Calculate minimum cost using dynamic programming
71        int result = dfs(0);
72      
73        // Return result if valid, otherwise return -1
74        return result < trie.INFINITY ? result : -1;
75    }
76
77    /**
78     * Recursive function with memoization to find minimum cost from index i
79     * @param startIndex Current starting index in the target string
80     * @return Minimum cost to construct substring from startIndex to end
81     */
82    private int dfs(int startIndex) {
83        // Base case: reached end of target string
84        if (startIndex >= targetArray.length) {
85            return 0;
86        }
87      
88        // Return memoized result if already computed
89        if (memo[startIndex] != null) {
90            return memo[startIndex];
91        }
92      
93        // Initialize minimum cost for current position
94        memo[startIndex] = trie.INFINITY;
95        Trie currentNode = trie;
96      
97        // Try to match words starting from current index
98        for (int endIndex = startIndex; endIndex < targetArray.length; ++endIndex) {
99            int charIndex = targetArray[endIndex] - 'a';
100          
101            // If no matching path in trie, stop searching
102            if (currentNode.children[charIndex] == null) {
103                return memo[startIndex];
104            }
105          
106            // Move to next node in trie
107            currentNode = currentNode.children[charIndex];
108          
109            // If current node represents end of a word, consider using it
110            // Update minimum cost: current word cost + cost of remaining substring
111            memo[startIndex] = Math.min(memo[startIndex], 
112                                       currentNode.cost + dfs(endIndex + 1));
113        }
114      
115        return memo[startIndex];
116    }
117}
118
1class Trie {
2public:
3    // Array to store pointers to child nodes (26 letters a-z)
4    Trie* children[26]{};
5    // Minimum cost to form a word ending at this node
6    int minCost = INT_MAX;
7  
8    // Insert a word into the trie with its associated cost
9    void insert(string& word, int cost) {
10        Trie* currentNode = this;
11      
12        // Traverse through each character in the word
13        for (char ch : word) {
14            int index = ch - 'a';
15          
16            // Create new node if path doesn't exist
17            if (!currentNode->children[index]) {
18                currentNode->children[index] = new Trie();
19            }
20            currentNode = currentNode->children[index];
21        }
22      
23        // Update minimum cost at the end of the word
24        currentNode->minCost = min(currentNode->minCost, cost);
25    }
26};
27
28class Solution {
29public:
30    int minimumCost(string target, vector<string>& words, vector<int>& costs) {
31        // Build trie from all words with their costs
32        Trie* root = new Trie();
33        for (int i = 0; i < words.size(); ++i) {
34            root->insert(words[i], costs[i]);
35        }
36      
37        int targetLength = target.length();
38        // Memoization array: dp[i] = minimum cost to form target[i...n-1]
39        vector<int> dp(targetLength, 0);
40      
41        // Recursive function with memoization to find minimum cost
42        auto findMinCost = [&](this auto&& findMinCost, int startIndex) -> int {
43            // Base case: reached end of target string
44            if (startIndex >= targetLength) {
45                return 0;
46            }
47          
48            // Return memoized result if already computed
49            if (dp[startIndex] != 0) {
50                return dp[startIndex];
51            }
52          
53            // Initialize current position cost to maximum
54            dp[startIndex] = INT_MAX;
55            Trie* currentNode = root;
56          
57            // Try all possible words starting from current position
58            for (int endIndex = startIndex; endIndex < targetLength; ++endIndex) {
59                int charIndex = target[endIndex] - 'a';
60              
61                // No valid word can be formed from this position
62                if (!currentNode->children[charIndex]) {
63                    break;
64                }
65              
66                currentNode = currentNode->children[charIndex];
67              
68                // If a complete word is found, calculate cost
69                if (currentNode->minCost != INT_MAX) {
70                    int remainingCost = findMinCost(endIndex + 1);
71                    if (remainingCost != INT_MAX) {
72                        dp[startIndex] = min(dp[startIndex], 
73                                            currentNode->minCost + remainingCost);
74                    }
75                }
76            }
77          
78            return dp[startIndex];
79        };
80      
81        // Find minimum cost starting from index 0
82        int result = findMinCost(0);
83      
84        // Return -1 if target cannot be formed, otherwise return the cost
85        return result == INT_MAX ? -1 : result;
86    }
87};
88
1// Constants
2const INF = 1 << 29;
3
4// Trie node structure for efficient string matching
5interface TrieNode {
6    children: (TrieNode | null)[];
7    cost: number;
8}
9
10// Create a new Trie node
11function createTrieNode(): TrieNode {
12    return {
13        children: Array(26).fill(null),
14        cost: INF
15    };
16}
17
18// Insert a word into the Trie with its associated cost
19function insertWord(root: TrieNode, word: string, cost: number): void {
20    let currentNode: TrieNode = root;
21  
22    // Traverse through each character in the word
23    for (const char of word) {
24        const charIndex = char.charCodeAt(0) - 97; // Convert 'a'-'z' to 0-25
25      
26        // Create new node if path doesn't exist
27        if (!currentNode.children[charIndex]) {
28            currentNode.children[charIndex] = createTrieNode();
29        }
30      
31        currentNode = currentNode.children[charIndex]!;
32    }
33  
34    // Update the minimum cost at the end of the word
35    currentNode.cost = Math.min(currentNode.cost, cost);
36}
37
38// Main function to find minimum cost to construct target string
39function minimumCost(target: string, words: string[], costs: number[]): number {
40    // Build Trie from all available words
41    const trieRoot = createTrieNode();
42    for (let i = 0; i < words.length; i++) {
43        insertWord(trieRoot, words[i], costs[i]);
44    }
45  
46    const targetLength = target.length;
47    // Memoization array for dynamic programming
48    const dp: number[] = Array(targetLength).fill(0);
49  
50    // Recursive function with memoization to find minimum cost
51    function findMinCost(startIndex: number): number {
52        // Base case: reached end of target string
53        if (startIndex >= targetLength) {
54            return 0;
55        }
56      
57        // Return memoized result if already computed
58        if (dp[startIndex] !== 0) {
59            return dp[startIndex];
60        }
61      
62        // Initialize with infinity (impossible case)
63        dp[startIndex] = INF;
64        let currentNode: TrieNode | null = trieRoot;
65      
66        // Try all possible words starting from current position
67        for (let endIndex = startIndex; endIndex < targetLength; endIndex++) {
68            const charIndex = target.charCodeAt(endIndex) - 97;
69          
70            // No matching word in Trie, stop searching
71            if (!currentNode?.children[charIndex]) {
72                return dp[startIndex];
73            }
74          
75            currentNode = currentNode.children[charIndex];
76          
77            // If current node represents end of a word, try using it
78            if (currentNode!.cost < INF) {
79                const remainingCost = findMinCost(endIndex + 1);
80                dp[startIndex] = Math.min(dp[startIndex], currentNode!.cost + remainingCost);
81            }
82        }
83      
84        return dp[startIndex];
85    }
86  
87    // Start the recursive search from index 0
88    const result = findMinCost(0);
89  
90    // Return -1 if target cannot be constructed, otherwise return minimum cost
91    return result < INF ? result : -1;
92}
93

Time and Space Complexity

Time Complexity: O(n^2 + L)

The time complexity consists of two main components:

  • Building the Trie: O(L) where L is the sum of lengths of all words in the words array. Each character of every word is processed once during insertion.
  • DFS with memoization: O(n^2) where n is the length of target. The dfs function can be called at most n times (once for each starting position in target). For each call to dfs(i), the inner loop iterates from position i to the end of target, which takes O(n) time in the worst case. Since we use @cache decorator for memoization, each state i is computed only once. Therefore, the total time for all DFS calls is O(n) * O(n) = O(n^2).

Space Complexity: O(n + L)

The space complexity consists of:

  • Trie structure: O(L) to store all characters of all words. In the worst case, if all words have completely different characters, we need space proportional to the total number of characters.
  • Memoization cache: O(n) to store the results for each starting position in target.
  • Recursion call stack: O(n) in the worst case when the recursion depth equals the length of target.

Since the recursion stack and memoization cache both contribute O(n), and the Trie contributes O(L), the total space complexity is O(n + L).

Common Pitfalls

1. Not Handling Word Reuse Properly

A common mistake is assuming each word can only be used once. The problem allows using the same word multiple times.

Incorrect Approach:

# Wrong: Marking words as "used"
used = [False] * len(words)
def dfs(i):
    for word_idx, word in enumerate(words):
        if not used[word_idx]:  # Wrong! Words can be reused
            used[word_idx] = True
            # ... try to use word
            used[word_idx] = False

Correct Approach: The provided solution correctly allows word reuse by not tracking usage state.

2. Inefficient String Matching Without Trie

Many solutions attempt to check every word at every position, leading to redundant comparisons.

Inefficient Approach:

def dfs(i):
    if i >= len(target):
        return 0
  
    min_cost = inf
    for idx, word in enumerate(words):
        # This checks the entire word every time - O(word_length)
        if target[i:].startswith(word):
            min_cost = min(min_cost, costs[idx] + dfs(i + len(word)))
    return min_cost

This approach has worse time complexity because:

  • It checks every word at every position
  • String slicing and startswith() are expensive operations
  • No early termination when no words can match

Efficient Solution with Trie: The Trie approach allows:

  • Early termination when no words can match (when node.children[idx] is None)
  • Incremental matching character by character
  • Sharing common prefixes between words

3. Not Updating Minimum Cost for Duplicate Words

If multiple words are identical but have different costs, failing to keep only the minimum cost leads to suboptimal solutions.

Wrong Implementation:

def insert(self, word: str, cost: int):
    node = self
    for c in word:
        idx = ord(c) - ord("a")
        if node.children[idx] is None:
            node.children[idx] = Trie()
        node = node.children[idx]
    node.cost = cost  # Wrong! Should be min(node.cost, cost)

Correct Implementation:

node.cost = min(node.cost, cost)  # Keep only the minimum cost

4. Forgetting to Initialize Cost as Infinity

Not initializing the cost field properly in the Trie node can cause incorrect minimum calculations.

Wrong:

class Trie:
    def __init__(self):
        self.children = [None] * 26
        self.cost = 0  # Wrong! Should be inf

Correct:

self.cost = inf  # Properly initialized to infinity

This ensures that only explicitly set costs are considered valid, and unset nodes don't interfere with minimum calculations.

5. Missing Base Case or Incorrect Return Value

Forgetting the base case or returning the wrong value when target cannot be constructed.

Common Mistakes:

# Missing base case
def dfs(i):
    # Forgot: if i >= len(target): return 0
    min_cost = inf
    # ... rest of logic
  
# Wrong return value for impossible case
result = dfs(0)
return result  # Should check if result is inf and return -1

Correct Handling:

return result if result < inf else -1
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Which of the following array represent a max heap?


Recommended Readings

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

Load More