691. Stickers to Spell Word


Problem Description

In this problem, we are provided with n different types of stickers, with each sticker containing a single lowercase English word. Our goal is to construct a specific target string by cutting out individual letters from these stickers and rearranging them. Importantly, we can use any number of stickers, and each type of sticker is available in an infinite quantity.

The key objective is to determine the minimum number of stickers that we need to use to spell out the entire target string. If it's not possible to construct the target string from the stickers provided, the function should return -1.

It is important to note that:

  • Each sticker can be used multiple times.
  • The target string is generated by concatenating random words from the 1000 most common US English words.
  • The task involves both optimisation (minimising the number of stickers used) and combination (cutting and rearranging sticker letters to form the target).

Intuition

Finding the minimum number of stickers to form the target string can be broken down into exploring different combinations of sticker usage. The solution employs Breadth-First Search (BFS), a strategy used to traverse or search tree or graph data structures level by level.

Here's how the intuition develops:

  1. States Representation Using Bit Masking: Treat each letter in the target string as a bit in a bitmask. If a letter is included, its corresponding bit is set to 1, otherwise it's 0. This allows an efficient representation of which characters are currently used to form the target.

  2. Using a Queue for BFS: The queue stores states representing the letters from the target we have constructed at any step. Starting with no letters, the goal is to reach a state where all letters are obtained (all bits set to 1).

  3. Processing Each Sticker: The algorithm examines how adding each sticker can change the current state. This involves checking if adding a letter from the sticker can fill in a missing bit in the state.

  4. Avoiding Revisiting States: With potentially many stickers and target letters, the number of states can become large. Keeping track of visited states with a "visited" list ensures we don't process the same state multiple times.

  5. Incrementing the Number of Stickers: Each level in the BFS represents the use of an additional sticker. The depth of the level (how many stickers used) increases until the target is reached or all options are exhausted.

  6. Termination and Result: The search terminates when the full target is reached (all bits set to 1), or when no more states can be developed. The number of stickers (depth of BFS traversal) needed to accomplish the target is the answer, unless it is determined to be impossible, in which case -1 is returned.

In summary, the intuition behind the BFS approach is to explore all possible combinations of stickers incrementally and find the least number of stickers needed to create the target without revisiting the same intermediate states.

Learn more about Dynamic Programming, Backtracking and Bitmask patterns.

Not Sure What to Study? Take the 2-min Quiz to Find Your Missing Piece๏ผš

What's the output of running the following function using input 56?

1KEYBOARD = {
2    '2': 'abc',
3    '3': 'def',
4    '4': 'ghi',
5    '5': 'jkl',
6    '6': 'mno',
7    '7': 'pqrs',
8    '8': 'tuv',
9    '9': 'wxyz',
10}
11
12def letter_combinations_of_phone_number(digits):
13    def dfs(path, res):
14        if len(path) == len(digits):
15            res.append(''.join(path))
16            return
17
18        next_number = digits[len(path)]
19        for letter in KEYBOARD[next_number]:
20            path.append(letter)
21            dfs(path, res)
22            path.pop()
23
24    res = []
25    dfs([], res)
26    return res
27
1private static final Map<Character, char[]> KEYBOARD = Map.of(
2    '2', "abc".toCharArray(),
3    '3', "def".toCharArray(),
4    '4', "ghi".toCharArray(),
5    '5', "jkl".toCharArray(),
6    '6', "mno".toCharArray(),
7    '7', "pqrs".toCharArray(),
8    '8', "tuv".toCharArray(),
9    '9', "wxyz".toCharArray()
10);
11
12public static List<String> letterCombinationsOfPhoneNumber(String digits) {
13    List<String> res = new ArrayList<>();
14    dfs(new StringBuilder(), res, digits.toCharArray());
15    return res;
16}
17
18private static void dfs(StringBuilder path, List<String> res, char[] digits) {
19    if (path.length() == digits.length) {
20        res.add(path.toString());
21        return;
22    }
23    char next_digit = digits[path.length()];
24    for (char letter : KEYBOARD.get(next_digit)) {
25        path.append(letter);
26        dfs(path, res, digits);
27        path.deleteCharAt(path.length() - 1);
28    }
29}
30
1const KEYBOARD = {
2    '2': 'abc',
3    '3': 'def',
4    '4': 'ghi',
5    '5': 'jkl',
6    '6': 'mno',
7    '7': 'pqrs',
8    '8': 'tuv',
9    '9': 'wxyz',
10}
11
12function letter_combinations_of_phone_number(digits) {
13    let res = [];
14    dfs(digits, [], res);
15    return res;
16}
17
18function dfs(digits, path, res) {
19    if (path.length === digits.length) {
20        res.push(path.join(''));
21        return;
22    }
23    let next_number = digits.charAt(path.length);
24    for (let letter of KEYBOARD[next_number]) {
25        path.push(letter);
26        dfs(digits, path, res);
27        path.pop();
28    }
29}
30

Solution Approach

The solution to this LeetCode problem employs a Breadth-First Search (BFS) approach. For BFS, we typically use a queue data structure, and here's how the provided solution leverages BFS along with other techniques:

  1. Queue Initialization: We use Python's deque from the collections module to initialize a queue for our BFS which starts with an initial state of 0 signifying that no letters of the target have been used.

  2. State Representation: The state of our approach is represented as an integer where each bit corresponds to a character in the target string. If the i-th bit is set, it means the i-th character of target is already constructed using the stickers.

  3. Visited States Tracking: An array vis is initialized to keep track of which states have been visited to avoid redundant processing. Only unvisited states are pushed into the queue. Initially, only the state 0 is marked as visited (True).

  4. Breadth-First Search: The solution uses a while-loop that continues until either the queue is empty or the target state ((1 << n) - 1, where all n bits are set meaning all characters of target are obtained) is achieved.

    • Within the while-loop, a for-loop iterates over the current breadth of the queue, using queue.popleft() to examine states one by one.

    • For every current state, the algorithm goes through each sticker and calculates the nxt state after possibly using this sticker. It uses a Counter object from Python's collections module to count the availability of characters in the sticker which assists in determining the eligibility of placing a character in the target.

    • As it goes through the characters in the target, if it finds a character that is not yet used in the current state (checked using bitwise AND operation), and if the character is present in the sticker's Counter, it will update the nxt state by setting the corresponding bit (using bitwise OR operation) and decrementing the character's count in the counter.

  5. State Transitions and Level Traversal: Whenever we encounter a new state (one that hasn't been visited), we mark it as visited and add it to the queue for further exploration. After examining all possible sticker applications for a given level, the ans (answer) variable is incremented since it would take one more sticker to potentially reach the target state at the next deeper level of our search.

  6. Termination: If at some point the nxt state matches the target (all bits are set), we return the ans which represents the minimum number of stickers needed to achieve the target. If the queue is exhaustively processed without achieving the target, the function returns -1.

  7. Algorithm Complexity: The solution is quite comprehensive in that it explores all possible combinations but maintains efficiency by avoiding revisiting states and by processing levels depth by depth, typical to BFS. The time complexity is substantial due to the combinatory nature, but by no means exponential to the point of being unfeasible, as the early termination and visited state pruning substantially reduce the search space.

In conclusion, the provided solution is a clear-cut application of BFS with a bitwise representation of states and an intricate handling of transitions using stickers to efficiently solve the problem of finding the minimum number of stickers to create the target string.

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

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

Example Walkthrough

Let's consider a simple example to illustrate the solution approach. Suppose we have the following input:

Stickers: ["with", "example", "science"] Target: "wise"

  1. Queue Initialization: We start by initializing an empty queue and push the initial state 0 into it, representing that none of the target letters are used yet.

  2. State Representation: The target "wise" has four characters, each corresponding to a bit in our bitmask. Thus, "wise" corresponds to 1111 in binary when all characters are used.

  3. Visited States Tracking: We have an array vis that starts filled with False values, and we set vis[0] to True to mark our initial state as visited.

  4. Breadth-First Search:

    We begin our BFS. Initially, the queue contains only the initial state 0 (0000 in binary, since none of the target's letters have been covered).

    • We dequeue 0 and start examining our stickers. Let's start with the first sticker "with".

    • The target is "wise", and the sticker "with" can contribute w and i to the target. We then update our state; w is the first bit and i is the second bit. Thus our state, after using the sticker "with", becomes 0110.

    • We check our vis array and since this state is not visited, we mark it as visited (vis[0110] = True), then enqueue it.

    • Next, we take the next sticker on the list, "example". It can contribute e. However, since we're considering the BFS level that started with state 0, adding "example" does not help us progress towards our target as we need either s or e at this point. Hence, it doesn't change the state.

    • Finally, we consider the sticker "science" which can contribute s, i, and e, but we only care about s and e since i is already included from the "with" sticker. The state changes to 1111, which is our goal state.

  5. State Transitions and Level Traversal: We found our target state at the first BFS level with just two stickers used: with and science. Therefore, we would not process the queue further and our ans would be 2.

  6. Termination: Our traversal found the target state 1111, and we can return our answer 2. If we had not found our target, we would continue processing the queue, systematically using stickers at each level, and incrementing our sticker count until we either find the target or determine it is impossible.

In this example, our process was fairly straightforward as the target was reachable with a small number of sticker applications. In more complex cases, the breadth-first search would evaluate many more combinations by incrementing the state bit by bit until the target is fully constructed or found unattainable.

Solution Implementation

1from collections import deque, Counter
2
3class Solution:
4    def minStickers(self, stickers: List[str], target: str) -> int:
5        # Create a queue for breadth-first search and initialize it with an empty state.
6        queue = deque([0])
7        # Initialize the number of steps (minimum stickers) to 0.
8        steps = 0
9        # Calculate the length of the target word.
10        n = len(target)
11        # Create a visited states list to prevent repeated processing of the same state.
12        visited = [False] * (1 << n)
13        # Mark the empty state as visited.
14        visited[0] = True  
15      
16        # Start the breadth-first search.
17        while queue:
18            # Process all the states at the current level.
19            for _ in range(len(queue)):
20                current_state = queue.popleft()
21              
22                # If all characters are used, return the number of steps.
23                if current_state == (1 << n) - 1:
24                    return steps
25              
26                # Try all stickers for the current state.
27                for sticker in stickers:
28                    next_state = current_state
29                    sticker_count = Counter(sticker)
30                  
31                    # Attempt to match sticker characters with target characters.
32                    for i, char in enumerate(target):
33                        # If the character at position i is not yet added, and the sticker has the char.
34                        if not (next_state & (1 << i)) and sticker_count[char]:
35                            next_state |= 1 << i
36                            sticker_count[char] -= 1
37                          
38                    # If the next state has not been visited, mark it as visited and add to the queue.
39                    if not visited[next_state]:
40                        visited[next_state] = True
41                        queue.append(next_state)
42          
43            # Increment the step count after processing all states at the current level.
44            steps += 1
45      
46        # If target cannot be reached return -1 indicating not possible.
47        return -1
48
1class Solution {
2    public int minStickers(String[] stickers, String target) {
3        // Initialize a queue to store the states of characters from the target string
4        Deque<Integer> queue = new ArrayDeque<>();
5        queue.offer(0); // Start with an empty state (no characters covered)
6        int answer = 0; // Number of stickers used
7        int n = target.length(); // Length of the target string
8        boolean[] visited = new boolean[1 << n]; // Visited states to avoid repetition
9        visited[0] = true; // Mark the empty state as visited
10
11        // While there are states in the queue, continue processing
12        while (!queue.isEmpty()) {
13            // Process all states at the current level
14            for (int t = queue.size(); t > 0; --t) {
15                // Get and remove the current state from the queue
16                int currentState = queue.poll();
17                // If all characters are covered, return the count
18                if (currentState == (1 << n) - 1) {
19                    return answer;
20                }
21                // Try to cover more characters using each sticker
22                for (String sticker : stickers) {
23                    int nextState = currentState; // Start with the current state
24                    int[] charCount = new int[26]; // Frequency of each character in sticker
25                    for (char c : sticker.toCharArray()) {
26                        ++charCount[c - 'a'];
27                    }
28                    // Try to match sticker characters with uncovered characters in the target
29                    for (int i = 0; i < n; ++i) {
30                        int targetCharIndex = target.charAt(i) - 'a';
31                        // If the character at position i is not covered and
32                        // the sticker has the character, cover it and decrement the sticker's count
33                        if ((nextState & (1 << i)) == 0 && charCount[targetCharIndex] > 0) {
34                            nextState |= 1 << i;
35                            --charCount[targetCharIndex];
36                        }
37                    }
38                    // If the next state has not been visited, mark it as visited and add to queue
39                    if (!visited[nextState]) {
40                        visited[nextState] = true;
41                        queue.offer(nextState);
42                    }
43                }
44            }
45            // Increment the count after processing all states at the current level
46            ++answer;
47        }
48
49        // Return -1 if it's not possible to cover all the characters in the target string
50        return -1;
51    }
52}
53
1class Solution {
2public:
3    int minStickers(vector<string>& stickers, string target) {
4        // Initialize a queue that starts with the base state (no letters of the target are covered)
5        queue<int> statesQueue;
6        statesQueue.push(0);
7
8        // Variable to keep track of the number of stickers used
9        int numStickers = 0;
10
11        // Target string length
12        int targetLength = target.size();
13
14        // Boolean vector to keep track of visited states
15        vector<bool> visited(1 << targetLength, false);
16        visited[0] = true; // Starting state is visited
17
18        // BFS to find the minimum number of stickers needed
19        while (!statesQueue.empty()) {
20            // Process each state at the current level
21            for (int t = statesQueue.size(); t > 0; --t) {
22                int currentState = statesQueue.front();
23                statesQueue.pop();
24
25                // If all bits are set, we've covered all characters in the target
26                if (currentState == (1 << targetLength) - 1) return numStickers;
27
28                // Try to apply each sticker to this state
29                for (const auto& sticker : stickers) {
30                    int nextState = currentState;
31                    vector<int> letterCount(26, 0); // Count letters in the current sticker
32
33                    // Count the frequency of each letter in the sticker
34                    for (const char& c : sticker) {
35                        ++letterCount[c - 'a'];
36                    }
37
38                    // Try to use the sticker's letters to cover uncovered letters in the target
39                    for (int i = 0; i < targetLength; ++i) {
40                        int letterIndex = target[i] - 'a';
41                        if (!(nextState & (1 << i)) && letterCount[letterIndex] > 0) {
42                            // Set the corresponding bit if the letter can be covered
43                            nextState |= 1 << i;
44                            --letterCount[letterIndex];
45                        }
46                    }
47
48                    // If we've reached a new state, mark it as visited and add it to the queue
49                    if (!visited[nextState]) {
50                        visited[nextState] = true;
51                        statesQueue.push(nextState);
52                    }
53                }
54            }
55
56            // Increment the sticker count since a new level is processed
57            ++numStickers;
58        }
59
60        // If we've processed all states and didn't cover the whole target, return -1
61        return -1;
62    }
63};
64
1// Define a function to find the minimum number of stickers needed to form a target string.
2function minStickers(stickers: string[], target: string): number {
3    // Initialize a queue starting with the base state (no letters of the target are covered).
4    const statesQueue: number[] = [0];
5
6    // Variable to keep track of the number of stickers used.
7    let numStickers = 0;
8
9    // Target string length.
10    let targetLength = target.length;
11
12    // Boolean array to keep track of visited states.
13    let visited = new Array<boolean>(1 << targetLength).fill(false);
14    visited[0] = true; // Starting state is visited.
15
16    // Breadth-first search to find the minimum number of stickers needed.
17    while (statesQueue.length) {
18        // Process each state at the current level.
19        let levelSize = statesQueue.length;
20        for (let t = 0; t < levelSize; ++t) {
21            const currentState = statesQueue.shift() || 0;
22
23            // If all bits are set, we've covered all characters in the target.
24            if (currentState === (1 << targetLength) - 1) return numStickers;
25
26            // Try to apply each sticker to this state.
27            stickers.forEach(sticker => {
28                let nextState = currentState;
29                let letterCount = new Array<number>(26).fill(0); // Count letters in the current sticker.
30
31                // Count the frequency of each letter in the sticker.
32                for (const c of sticker) {
33                    letterCount[c.charCodeAt(0) - 'a'.charCodeAt(0)]++;
34                }
35
36                // Try to use the sticker's letters to cover uncovered letters in the target.
37                for (let i = 0; i < targetLength; ++i) {
38                    const letterIndex = target.charCodeAt(i) - 'a'.charCodeAt(0);
39                    if (!(nextState & (1 << i)) && letterCount[letterIndex] > 0) {
40                        // Set the corresponding bit if the letter can be covered.
41                        nextState |= 1 << i;
42                        letterCount[letterIndex]--;
43                    }
44                }
45
46                // If we've reached a new state, mark it as visited and add it to the queue.
47                if (!visited[nextState]) {
48                    visited[nextState] = true;
49                    statesQueue.push(nextState);
50                }
51            });
52        }
53
54        // Increment the sticker count since a new level is processed.
55        numStickers++;
56    }
57
58    // If we've processed all states and didn't cover the whole target, return -1.
59    return -1;
60}
61
Not Sure What to Study? Take the 2-min Quiz๏ผš

What's the output of running the following function using input [30, 20, 10, 100, 33, 12]?

1def fun(arr: List[int]) -> List[int]:
2    import heapq
3    heapq.heapify(arr)
4    res = []
5    for i in range(3):
6        res.append(heapq.heappop(arr))
7    return res
8
1public static int[] fun(int[] arr) {
2    int[] res = new int[3];
3    PriorityQueue<Integer> heap = new PriorityQueue<>();
4    for (int i = 0; i < arr.length; i++) {
5        heap.add(arr[i]);
6    }
7    for (int i = 0; i < 3; i++) {
8        res[i] = heap.poll();
9    }
10    return res;
11}
12
1class HeapItem {
2    constructor(item, priority = item) {
3        this.item = item;
4        this.priority = priority;
5    }
6}
7
8class MinHeap {
9    constructor() {
10        this.heap = [];
11    }
12
13    push(node) {
14        // insert the new node at the end of the heap array
15        this.heap.push(node);
16        // find the correct position for the new node
17        this.bubble_up();
18    }
19
20    bubble_up() {
21        let index = this.heap.length - 1;
22
23        while (index > 0) {
24            const element = this.heap[index];
25            const parentIndex = Math.floor((index - 1) / 2);
26            const parent = this.heap[parentIndex];
27
28            if (parent.priority <= element.priority) break;
29            // if the parent is bigger than the child then swap the parent and child
30            this.heap[index] = parent;
31            this.heap[parentIndex] = element;
32            index = parentIndex;
33        }
34    }
35
36    pop() {
37        const min = this.heap[0];
38        this.heap[0] = this.heap[this.size() - 1];
39        this.heap.pop();
40        this.bubble_down();
41        return min;
42    }
43
44    bubble_down() {
45        let index = 0;
46        let min = index;
47        const n = this.heap.length;
48
49        while (index < n) {
50            const left = 2 * index + 1;
51            const right = left + 1;
52
53            if (left < n && this.heap[left].priority < this.heap[min].priority) {
54                min = left;
55            }
56            if (right < n && this.heap[right].priority < this.heap[min].priority) {
57                min = right;
58            }
59            if (min === index) break;
60            [this.heap[min], this.heap[index]] = [this.heap[index], this.heap[min]];
61            index = min;
62        }
63    }
64
65    peek() {
66        return this.heap[0];
67    }
68
69    size() {
70        return this.heap.length;
71    }
72}
73
74function fun(arr) {
75    const heap = new MinHeap();
76    for (const x of arr) {
77        heap.push(new HeapItem(x));
78    }
79    const res = [];
80    for (let i = 0; i < 3; i++) {
81        res.push(heap.pop().item);
82    }
83    return res;
84}
85

Time and Space Complexity

The provided code performs a BFS (Breadth-First Search) over the states representing which characters of the target string have been covered by stickers. Each state is a bitmask of length n (where n is the length of the target string), with each bit representing whether the corresponding character in the target has been matched by a sticker.

Time Complexity

The time complexity of the algorithm can be estimated by considering the following:

  1. The breadth of the BFS is 2^n, where n is the length of the target string, because there are 2^n possible states in the worst case (each bit in the bitmask can be either 0 or 1).
  2. For each state, the algorithm iterates over all stickers, and then over each character in the target. Let m be the number of stickers, and k be the average length of a sticker.
  3. For each sticker, it attempts to match characters to the target (n characters at most). During this process, it counts occurrences using a Counter, which takes O(k) time.
  4. Upon matching characters, the algorithm updates the state and checks whether it has been visited before.

Therefore, the worst-case time complexity is O(2^n * m * n * k). This presumes that we could match every character in target with a character in a sticker every time, which represents the worst-case scenario for the number of operations needed.

Space Complexity

The space complexity of the algorithm is determined by:

  1. The vis list, which stores whether a state has been visited (vis has a size of 2^n).
  2. The q queue, which in the worst case could store all possible states (again, 2^n in size).
  3. The Counter instance for each sticker, repeated for every state in the BFS. However, since the Counter is temporary and only stores up to k elements (where k is the average length of a sticker), this does not exceed O(k) space at any instance, and thus does not dominate the space complexity.

So the dominant factor is the space required to store all states, which results in a space complexity of O(2^n).

Learn more about how to find time and space complexity quickly using problem constraints.

Fast Track Your Learning with Our Quick Skills Quiz:

What is an advantages of top-down dynamic programming vs bottom-up dynamic programming?


Recommended Readings


Got a question?ย Ask the Teaching Assistantย anything you don't understand.

Still not clear? Ask in the Forum, ย Discordย orย Submitย the part you don't understand to our editors.

โ†
โ†‘TA ๐Ÿ‘จโ€๐Ÿซ