691. Stickers to Spell Word - Explanation

Problem Link



Prerequisites

Before attempting this problem, you should be comfortable with:

  • Dynamic Programming (Memoization) - The core technique used to avoid recomputing subproblems when exploring different sticker combinations
  • Hash Maps / Frequency Counting - Used to track character counts in stickers and remaining target characters
  • Recursion with State - Building solutions by recursively trying different sticker choices
  • Bitmask DP (for bottom-up approach) - Representing which characters have been covered using binary representation

1. Dynamic Programming (Top-Down) - I

Intuition

We need to find the minimum number of stickers to spell the target string, where each sticker can be used multiple times. This is a classic memoization problem where we track which characters from the target still need to be covered. At each step, we pick a sticker that can help (contains the first remaining character) and recursively solve for the remaining characters.

Algorithm

  1. Preprocess each sticker into a character frequency map.
  2. Use memoization with the remaining target string as the key.
  3. For the current state, use the provided sticker's characters to cover as many target characters as possible, building a remaining target string.
  4. If characters remain, try each sticker that contains the first remaining character and recursively find the minimum.
  5. Cache and return the result (1 for using current sticker plus the minimum for remaining).
  6. Return -1 if no valid solution exists.
class Solution:
    def minStickers(self, stickers: List[str], target: str) -> int:
        stickCount = []
        for s in stickers:
            stickCount.append({})
            for c in s:
                stickCount[-1][c] = 1 + stickCount[-1].get(c, 0)

        dp = {}

        def dfs(t, stick):
            if t in dp:
                return dp[t]
            res = 1 if stick else 0
            remainT = ""
            for c in t:
                if c in stick and stick[c] > 0:
                    stick[c] -= 1
                else:
                    remainT += c
            if remainT:
                used = float("inf")
                for s in stickCount:
                    if remainT[0] not in s:
                        continue
                    used = min(used, dfs(remainT, s.copy()))
                dp[remainT] = used
                res += used
            return res

        res = dfs(target, {})
        return res if res != float("inf") else -1

Time & Space Complexity

  • Time complexity: O(mk2n)O(m * k *2 ^ n)
  • Space complexity: O(mk+2n)O(m * k + 2 ^ n)

Where nn is the length of the target string, mm is the number of stickers and kk is the average length of each sticker.


2. Dynamic Programming (Top-Down) - II

Intuition

This approach improves on the previous one by sorting the target string. When we sort the target, strings with the same character composition map to the same state, reducing the number of unique states. Instead of tracking the exact order of remaining characters, we only care about which characters and how many of each are needed.

Algorithm

  1. Sort the target string to normalize character order.
  2. Create frequency maps for each sticker.
  3. Use memoization with the sorted remaining string as the key.
  4. For each recursive call, build a frequency map of the current target.
  5. Try each sticker containing the first character. Subtract sticker characters from target frequency and build the remaining string.
  6. Sort the remaining string and recurse.
  7. Return 1 plus the minimum result from all valid sticker choices.
class Solution:
    def minStickers(self, stickers: List[str], target: str) -> int:
        tmp = [c for c in target]
        target = ''.join(sorted(tmp))
        stickCount = []
        for s in stickers:
            stickCount.append(Counter(s))

        dp = {}
        dp[""] = 0

        def dfs(t):
            if t in dp:
                return dp[t]

            tarMp = Counter(t)
            res = float("inf")
            for s in stickCount:
                if t[0] not in s:
                    continue
                remainT = []
                for c in tarMp:
                    if tarMp[c] > s[c]:
                        remainT.extend([c] * (tarMp[c] - s[c]))

                remainT = ''.join(sorted(remainT))
                res = min(res, 1 + dfs(remainT))

            dp[t] = res
            return res

        ans = dfs(target)
        return -1 if ans == float("inf") else ans

Time & Space Complexity

  • Time complexity: O(mk2n)O(m * k *2 ^ n)
  • Space complexity: O(mk+2n)O(m * k + 2 ^ n)

Where nn is the length of the target string, mm is the number of stickers and kk is the average length of each sticker.


3. Dynamic Programming (Bottom-Up)

Intuition

We can represent the state as a bitmask where each bit indicates whether a character in the target has been covered. Starting from state 0 (no characters covered), we iterate through all states and for each sticker, compute which new state we can reach. This bottom-up approach systematically explores all possible ways to build the target.

Algorithm

  1. Initialize a DP array of size 2^n (where n is target length), all set to -1 except dp[0] = 0.
  2. Iterate through all states from 0 to 2^n - 1.
  3. For each reachable state (dp[t] != -1), try applying each sticker.
  4. For each sticker, compute the next state by marking which target characters get covered.
  5. Update dp[nextState] to be the minimum of its current value and dp[t] + 1.
  6. Return dp[2^n - 1], which represents all characters covered.
class Solution:
    def minStickers(self, stickers: List[str], target: str) -> int:
        n = len(target)
        N = 1 << n
        dp = [-1] * N
        dp[0] = 0

        for t in range(N):
            if dp[t] == -1:
                continue
            for s in stickers:
                nextT = t
                for c in s:
                    for i in range(n):
                        if target[i] == c and not ((nextT >> i) & 1):
                            nextT |= 1 << i
                            break
                if dp[nextT] == -1 or dp[nextT] > dp[t] + 1:
                    dp[nextT] = dp[t] + 1
        return dp[N - 1]

Time & Space Complexity

  • Time complexity: O(nmk2n)O(n * m * k *2 ^ n)
  • Space complexity: O(2n)O(2 ^ n)

Where nn is the length of the target string, mm is the number of stickers and kk is the average length of each sticker.


Common Pitfalls

Forgetting That Stickers Can Be Reused

A common mistake is treating each sticker as a one-time use item. The problem explicitly states that stickers can be used infinitely many times. Your solution must allow revisiting the same sticker multiple times to cover repeated characters in the target.

Inefficient State Representation

Using the raw target string as the memoization key without normalization leads to redundant computation. Two different orderings of remaining characters (e.g., "abc" and "bac") represent the same problem state. Sorting the remaining string or using character frequency counts as the key significantly reduces the state space.

Not Pruning Useless Stickers

Trying every sticker at each step without filtering wastes time. If a sticker does not contain the first remaining character of the target, it cannot make progress toward completion. Always check that a sticker can contribute before recursing with it.

Incorrect Handling of the Impossible Case

Failing to detect when the target cannot be spelled leads to incorrect results or infinite loops. Before starting, verify that every character in the target appears in at least one sticker. During recursion, properly propagate infinity values when no valid solution exists for a subproblem.

Mutating Shared Data Structures

Modifying the sticker's character count map in place without creating a copy causes incorrect results across recursive branches. Always work with a fresh copy of the frequency map when simulating the use of a sticker to avoid corrupting state for sibling recursive calls.