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 strings
- 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, thenwords[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.
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:
- The cost of using this word (stored in the Trie node)
- The next position we need to solve (which is
j+1
wherej
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:
-
Base Case: If
i >= len(target)
, we've successfully constructed the entire target string, so return 0. -
Trie Traversal: Starting from the root of the Trie and position
i
in the target:- For each character
target[j]
wherej
goes fromi
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
- For each character
-
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
- Calculate the total cost as:
-
Memoization: The
@cache
decorator ensures thatdfs(i)
is computed only once for each unique value ofi
, 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 EvaluatorExample 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)
- Option 1: Use "ab" (cost=2) and solve for
- 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)
- Option 2: Use "abcd" (cost=5) and solve for
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)
- Use "cd" (cost=1) and solve for
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)
whereL
is the sum of lengths of all words in thewords
array. Each character of every word is processed once during insertion. - DFS with memoization:
O(n^2)
wheren
is the length oftarget
. Thedfs
function can be called at mostn
times (once for each starting position intarget
). For each call todfs(i)
, the inner loop iterates from positioni
to the end oftarget
, which takesO(n)
time in the worst case. Since we use@cache
decorator for memoization, each statei
is computed only once. Therefore, the total time for all DFS calls isO(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 intarget
. - Recursion call stack:
O(n)
in the worst case when the recursion depth equals the length oftarget
.
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]
isNone
) - 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
Which of the following array represent a max heap?
Recommended Readings
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
Runtime Overview When learning about algorithms and data structures you'll frequently encounter the term time complexity This concept is fundamental in computer science and offers insights into how long an algorithm takes to complete given a certain input size What is Time Complexity Time complexity represents the amount of time
Want a Structured Path to Master System Design Too? Don’t Miss This!