Facebook Pixel

2767. Partition String Into Minimum Beautiful Substrings

Problem Description

You are given a binary string s consisting of only '0's and '1's. Your task is to partition this string into one or more substrings where each substring must be beautiful.

A substring is considered beautiful if it satisfies two conditions:

  1. It doesn't contain leading zeros (i.e., it cannot start with '0' unless it's just "1")
  2. When interpreted as a binary number, it represents a power of 5 (like 1, 5, 25, 125, etc.)

For example:

  • "1" is beautiful because it's binary for 1, which is 5^0
  • "101" is beautiful because it's binary for 5, which is 5^1
  • "11001" is beautiful because it's binary for 25, which is 5^2
  • "01" is NOT beautiful because it has a leading zero
  • "10" is NOT beautiful because 2 is not a power of 5

You need to find the minimum number of beautiful substrings you can partition the string into. If it's impossible to partition the string into only beautiful substrings, return -1.

For instance, if s = "1011", you could partition it as "1" + "011", but "011" has a leading zero so it's not beautiful. You could also try "101" + "1", where "101" is binary for 5 (beautiful) and "1" is binary for 1 (beautiful), giving us 2 beautiful substrings total.

The solution uses dynamic programming with memoization. It preprocesses all powers of 5 up to a reasonable limit and stores them in a set. Then it uses a recursive function dfs(i) that tries all possible ways to partition the string starting from index i, checking if each substring represents a power of 5, and returns the minimum number of partitions needed.

Flowchart Walkthrough

First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:

Is it a graph?

  • No: The problem involves partitioning a string, not dealing with nodes and edges in a graph structure.

Need to solve for kth smallest/largest?

  • No: We're looking for the minimum number of partitions, not the kth smallest/largest element.

Involves Linked Lists?

  • No: The problem deals with string manipulation, not linked list operations.

Does the problem have small constraints?

  • Yes: Looking at the problem, the string length is typically small (often ≤ 15-20 characters in such problems), and we need to explore all possible ways to partition the string. The number of possible partitions grows exponentially, but with small constraints, this is manageable.

Brute force / Backtracking?

  • Yes: We need to try all possible ways to partition the string into beautiful substrings. At each position, we make a choice: where to end the current substring. We then recursively solve for the remaining string. If a choice leads to an invalid partition (like a substring with leading zeros or not representing a power of 5), we backtrack and try a different partition point.

Conclusion: The flowchart correctly identifies this as a backtracking problem. The solution uses backtracking with memoization (dynamic programming optimization) where:

  1. We try all possible ending positions for the current substring
  2. Check if the current substring is "beautiful" (no leading zeros and represents a power of 5)
  3. Recursively solve for the remaining string
  4. Backtrack if the current path doesn't lead to a valid solution
  5. Use memoization to avoid recalculating the same subproblems
Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

When we need to partition a string into valid substrings with certain properties, we face a decision at each position: where should the current substring end? This naturally leads to a recursive approach where we try all possibilities.

Think of it like cutting a rope - at each position, we can either make a cut or continue. For this problem, we start from the beginning of the string and ask: "If I take the first character, the first two characters, the first three characters... as a substring, which choice gives me the minimum total partitions?"

The key insight is that we need to explore all valid partition points. Starting from position i, we can form substrings s[i:i+1], s[i:i+2], ..., s[i:n]. For each substring, we check:

  1. Does it have leading zeros? If yes, skip it.
  2. Is it a power of 5 in binary? If no, skip it.
  3. If valid, we've found one beautiful substring, and now we need to solve the same problem for the remaining string.

Since powers of 5 are fixed values (1, 5, 25, 125, 625...), we can precompute them. In binary, these are: 1, 101, 11001, 1111101, etc. We store these in a set for O(1) lookup.

The recursive nature becomes clear: minimum partitions from position i = 1 + minimum partitions from position j+1, where j is where we decide to end the current substring. We try all valid j values and take the minimum.

We notice that we might solve the same subproblem multiple times (e.g., finding minimum partitions from position 5 could be needed in multiple recursive paths). This overlap of subproblems suggests using memoization to cache results, transforming our backtracking into dynamic programming.

If at any point we can't form valid partitions (like when we encounter a '0' that must start a substring), we return infinity to indicate impossibility. The final answer is -1 if the minimum is still infinity, otherwise it's the minimum number of partitions found.

Learn more about Dynamic Programming and Backtracking patterns.

Solution Approach

The implementation uses memoization search (dynamic programming with recursion) to find the minimum number of beautiful substrings.

Step 1: Preprocess Powers of 5

First, we generate all powers of 5 that could possibly appear in the string. Since the string length is n, the maximum decimal value is 2^n - 1. We create a set ss containing powers of 5:

x = 1
ss = {x}  # Start with 5^0 = 1
for i in range(n):
    x *= 5
    ss.add(x)  # Add 5^1, 5^2, 5^3, ...

Step 2: Define the Recursive Function

We define dfs(i) which returns the minimum number of partitions needed from index i to the end of string s.

The function works as follows:

  • Base case: If i >= n, we've processed the entire string successfully, return 0.

  • Invalid case: If s[i] == "0", the current position starts with '0', making any substring starting here invalid (leading zero). Return inf to indicate impossibility.

  • Recursive case: Try all possible ending positions j from i to n-1:

    x = 0
    ans = inf
    for j in range(i, n):
        x = x << 1 | int(s[j])  # Build decimal value using bit operations
        if x in ss:  # Check if it's a power of 5
            ans = min(ans, 1 + dfs(j + 1))  # Take minimum

Step 3: Build Decimal Value Efficiently

As we extend the substring from position i to j, we build the decimal value incrementally:

  • x = x << 1 shifts existing bits left (multiply by 2)
  • | int(s[j]) adds the new bit

For example, if we're building "101":

  • Start: x = 0
  • Add '1': x = 0 << 1 | 1 = 1 (binary: 1)
  • Add '0': x = 1 << 1 | 0 = 2 (binary: 10)
  • Add '1': x = 2 << 1 | 1 = 5 (binary: 101)

Step 4: Memoization

The @cache decorator automatically memoizes the dfs function, storing results for each unique value of i. This prevents redundant calculations when the same subproblem appears in different recursive paths.

Step 5: Final Answer

We call dfs(0) to get the minimum partitions starting from index 0. If the result is inf, it means partitioning is impossible, so we return -1. Otherwise, we return the minimum number of partitions.

The time complexity is O(n^2) where we have n possible starting positions and for each position, we check up to n ending positions. The space complexity is O(n) for the recursion stack and memoization cache.

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 the solution with s = "11001":

Step 1: Preprocess Powers of 5

  • Generate powers of 5: ss = {1, 5, 25, 125, ...}
  • In binary: {1, 101, 11001, 1111101, ...}

Step 2: Start DFS from index 0

Call dfs(0):

  • s[0] = '1' (not '0', so we can proceed)
  • Try all possible substrings starting from index 0:

Option 1: Take substring s[0:1] = "1"

  • Build decimal: x = 1 (binary "1" = decimal 1)
  • Check: Is 1 in ss? Yes! (1 = 5^0)
  • Recursively solve: 1 + dfs(1)
    • Call dfs(1): s[1] = '1'
    • Try substring s[1:2] = "1": x = 1, in ss ✓, gives 1 + dfs(2)
    • Try substring s[1:3] = "10": x = 2, not in ss ✗
    • Try substring s[1:4] = "100": x = 4, not in ss ✗
    • Try substring s[1:5] = "1001": x = 9, not in ss ✗
    • dfs(1) returns inf (no valid partition found)
  • Option 1 total: 1 + inf = inf

Option 2: Take substring s[0:2] = "11"

  • Build decimal: x = 3 (binary "11" = decimal 3)
  • Check: Is 3 in ss? No!
  • Skip this option

Option 3: Take substring s[0:3] = "110"

  • Build decimal: x = 6 (binary "110" = decimal 6)
  • Check: Is 6 in ss? No!
  • Skip this option

Option 4: Take substring s[0:4] = "1100"

  • Build decimal: x = 12 (binary "1100" = decimal 12)
  • Check: Is 12 in ss? No!
  • Skip this option

Option 5: Take substring s[0:5] = "11001"

  • Build decimal: x = 25 (binary "11001" = decimal 25)
  • Check: Is 25 in ss? Yes! (25 = 5^2)
  • Recursively solve: 1 + dfs(5)
    • Call dfs(5): index 5 >= length 5
    • Base case: return 0
  • Option 5 total: 1 + 0 = 1

Final Result:

  • dfs(0) returns min(inf, inf, inf, inf, 1) = 1
  • The string "11001" can be partitioned into 1 beautiful substring (itself represents 25 = 5^2)

The answer is 1.

Solution Implementation

1from functools import cache
2from math import inf
3from typing import Set
4
5class Solution:
6    def minimumBeautifulSubstrings(self, s: str) -> int:
7        @cache
8        def find_min_partitions(start_index: int) -> int:
9            """
10            Find minimum number of partitions from start_index to end of string.
11            Each partition must be a binary representation of a power of 5.
12          
13            Args:
14                start_index: Current position in the string
15              
16            Returns:
17                Minimum number of partitions needed, or inf if impossible
18            """
19            # Base case: reached end of string
20            if start_index >= string_length:
21                return 0
22          
23            # Cannot partition if current substring starts with '0'
24            # (no power of 5 has leading zeros in binary)
25            if s[start_index] == "0":
26                return inf
27          
28            current_number = 0
29            min_partitions = inf
30          
31            # Try all possible substrings starting from current position
32            for end_index in range(start_index, string_length):
33                # Build the number by shifting left and adding the current bit
34                current_number = (current_number << 1) | int(s[end_index])
35              
36                # If current number is a power of 5, try partitioning here
37                if current_number in powers_of_five:
38                    remaining_partitions = find_min_partitions(end_index + 1)
39                    min_partitions = min(min_partitions, 1 + remaining_partitions)
40          
41            return min_partitions
42      
43        # Initialize variables
44        string_length = len(s)
45      
46        # Generate all powers of 5 that could appear in the string
47        # Maximum possible value is 2^n - 1 where n is string length
48        powers_of_five: Set[int] = set()
49        power_of_five = 1
50        powers_of_five.add(power_of_five)
51      
52        # Generate powers of 5 up to maximum possible value
53        for _ in range(string_length):
54            power_of_five *= 5
55            powers_of_five.add(power_of_five)
56      
57        # Find minimum partitions starting from index 0
58        result = find_min_partitions(0)
59      
60        # Return -1 if no valid partition exists, otherwise return the result
61        return -1 if result == inf else result
62
1class Solution {
2    // Memoization array to store minimum partitions from index i
3    private Integer[] memo;
4    // Input binary string
5    private String binaryString;
6    // Set containing powers of 5 for quick lookup
7    private Set<Long> powersOfFive = new HashSet<>();
8    // Length of the input string
9    private int stringLength;
10
11    public int minimumBeautifulSubstrings(String s) {
12        // Initialize string length and input string
13        stringLength = s.length();
14        this.binaryString = s;
15      
16        // Initialize memoization array
17        memo = new Integer[stringLength];
18      
19        // Pre-compute all powers of 5 that could fit in the string
20        // Since we have at most n bits, we need powers of 5 up to 2^n
21        long powerOfFive = 1;
22        for (int i = 0; i <= stringLength; ++i) {
23            powersOfFive.add(powerOfFive);
24            powerOfFive *= 5;
25        }
26      
27        // Start DFS from index 0
28        int result = dfs(0);
29      
30        // If result exceeds string length, no valid partition exists
31        return result > stringLength ? -1 : result;
32    }
33
34    private int dfs(int startIndex) {
35        // Base case: reached end of string successfully
36        if (startIndex >= stringLength) {
37            return 0;
38        }
39      
40        // Cannot form valid number starting with '0'
41        if (binaryString.charAt(startIndex) == '0') {
42            return stringLength + 1; // Return impossible value
43        }
44      
45        // Return memoized result if already computed
46        if (memo[startIndex] != null) {
47            return memo[startIndex];
48        }
49      
50        // Try all possible substrings starting from current index
51        long currentNumber = 0;
52        int minPartitions = stringLength + 1; // Initialize with impossible value
53      
54        for (int endIndex = startIndex; endIndex < stringLength; ++endIndex) {
55            // Build number by shifting left and adding current bit
56            currentNumber = currentNumber << 1 | (binaryString.charAt(endIndex) - '0');
57          
58            // If current number is a power of 5, try partitioning here
59            if (powersOfFive.contains(currentNumber)) {
60                // Recursively find minimum partitions for remaining string
61                minPartitions = Math.min(minPartitions, 1 + dfs(endIndex + 1));
62            }
63        }
64      
65        // Memoize and return the result
66        return memo[startIndex] = minPartitions;
67    }
68}
69
1class Solution {
2public:
3    int minimumBeautifulSubstrings(string s) {
4        // Create a set to store powers of 5 that can be represented within the string length
5        unordered_set<long long> powersOfFive;
6        int n = s.size();
7        long long powerValue = 1;
8      
9        // Generate all powers of 5 up to the maximum possible value for string length n
10        for (int i = 0; i <= n; ++i) {
11            powersOfFive.insert(powerValue);
12            powerValue *= 5;
13        }
14      
15        // Memoization array to store minimum partitions for each starting index
16        int dp[n];
17        memset(dp, -1, sizeof(dp));
18      
19        // Recursive function with memoization to find minimum beautiful partitions
20        function<int(int)> findMinPartitions = [&](int startIdx) {
21            // Base case: reached end of string successfully
22            if (startIdx >= n) {
23                return 0;
24            }
25          
26            // Cannot form valid partition if current position starts with '0'
27            if (s[startIdx] == '0') {
28                return n + 1;  // Return impossible value
29            }
30          
31            // Return memoized result if already computed
32            if (dp[startIdx] != -1) {
33                return dp[startIdx];
34            }
35          
36            long long currentNumber = 0;
37            int minPartitions = n + 1;  // Initialize with impossible value
38          
39            // Try all possible substrings starting from current index
40            for (int endIdx = startIdx; endIdx < n; ++endIdx) {
41                // Build the binary number by shifting left and adding current bit
42                currentNumber = (currentNumber << 1) | (s[endIdx] - '0');
43              
44                // If current number is a power of 5, try partitioning here
45                if (powersOfFive.count(currentNumber)) {
46                    minPartitions = min(minPartitions, 1 + findMinPartitions(endIdx + 1));
47                }
48            }
49          
50            // Store and return the minimum partitions for this starting index
51            return dp[startIdx] = minPartitions;
52        };
53      
54        // Start the recursive search from index 0
55        int result = findMinPartitions(0);
56      
57        // Return -1 if no valid partition exists, otherwise return the result
58        return result > n ? -1 : result;
59    }
60};
61
1/**
2 * Finds the minimum number of beautiful substrings needed to partition the binary string.
3 * A beautiful substring is a binary representation of a power of 5 with no leading zeros.
4 * @param s - The binary string to partition
5 * @returns The minimum number of beautiful substrings, or -1 if impossible
6 */
7function minimumBeautifulSubstrings(s: string): number {
8    // Set to store all powers of 5 that can fit within the string length
9    const powersOfFive: Set<number> = new Set<number>();
10    const stringLength: number = s.length;
11  
12    // Memoization array for dynamic programming (-1 indicates uncomputed)
13    const memoization: number[] = new Array(stringLength).fill(-1);
14  
15    // Generate all powers of 5 up to the maximum possible value
16    for (let i = 0, powerOfFive = 1; i <= stringLength; ++i) {
17        powersOfFive.add(powerOfFive);
18        powerOfFive *= 5;
19    }
20  
21    /**
22     * Recursive function to find minimum partitions starting from index
23     * @param index - Current starting position in the string
24     * @returns Minimum number of beautiful substrings from this position
25     */
26    const findMinPartitions = (index: number): number => {
27        // Base case: reached end of string successfully
28        if (index === stringLength) {
29            return 0;
30        }
31      
32        // Invalid case: substring starts with '0' (leading zero)
33        if (s[index] === '0') {
34            return stringLength + 1; // Return impossible value
35        }
36      
37        // Return memoized result if already computed
38        if (memoization[index] !== -1) {
39            return memoization[index];
40        }
41      
42        // Initialize with impossible value
43        memoization[index] = stringLength + 1;
44      
45        // Try all possible substring endings from current position
46        for (let endIndex = index, currentNumber = 0; endIndex < stringLength; ++endIndex) {
47            // Build the binary number by shifting left and adding current bit
48            currentNumber = (currentNumber << 1) | (s[endIndex] === '1' ? 1 : 0);
49          
50            // If current number is a power of 5, try partitioning here
51            if (powersOfFive.has(currentNumber)) {
52                memoization[index] = Math.min(
53                    memoization[index], 
54                    1 + findMinPartitions(endIndex + 1)
55                );
56            }
57        }
58      
59        return memoization[index];
60    };
61  
62    // Start the recursive search from index 0
63    const result: number = findMinPartitions(0);
64  
65    // Return -1 if no valid partition exists, otherwise return the result
66    return result > stringLength ? -1 : result;
67}
68

Time and Space Complexity

Time Complexity: O(n²)

The algorithm uses dynamic programming with memoization. The dfs function is called at most n times (once for each starting position from 0 to n-1), where each call is cached. For each call to dfs(i), the function iterates through all positions from i to n-1 in the for loop, performing O(n-i) operations. In the worst case, this gives us:

  • Position 0: iterates through n positions
  • Position 1: iterates through n-1 positions
  • Position 2: iterates through n-2 positions
  • ...and so on

The total work done is n + (n-1) + (n-2) + ... + 1 = n(n+1)/2 = O(n²).

Space Complexity: O(n)

The space complexity consists of:

  1. The recursion call stack depth, which is at most O(n) when the string is partitioned into n single-character substrings
  2. The memoization cache stores at most n different states (one for each starting position)
  3. The set ss stores powers of 5, and since we only generate up to n powers of 5, it uses O(n) space
  4. Other variables use O(1) space

Therefore, the overall space complexity is O(n).

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

Common Pitfalls

1. Insufficient Powers of 5 Generation

One common mistake is not generating enough powers of 5 to cover all possible valid substrings in the input. If you only generate a fixed small number of powers (like up to 5^6), you might miss valid partitions for longer strings.

Incorrect approach:

# Only generates first 6 powers of 5
powers_of_five = {1, 5, 25, 125, 625, 3125}

Why it fails: For a string of length 15, the maximum binary value could be "111111111111111" (15 ones), which equals 32767 in decimal. This is larger than 5^6 (15625), so we'd need 5^7 (78125) in our set.

Solution: Generate powers of 5 based on the string length. Since the maximum value representable by an n-bit binary string is 2^n - 1, we should generate powers of 5 up to at least this value:

powers_of_five = set()
power_of_five = 1
powers_of_five.add(power_of_five)

# Generate enough powers to cover all possible values
while power_of_five <= (1 << string_length):  # 2^n
    power_of_five *= 5
    powers_of_five.add(power_of_five)

2. Integer Overflow in Building Binary Numbers

When building the decimal value from binary string character by character, the bit manipulation might cause issues if not handled properly, especially with the order of operations.

Incorrect approach:

# Might cause unexpected behavior with operator precedence
current_number = current_number << 1 + int(s[end_index])  # Wrong!

Why it fails: Without parentheses, the addition happens before the OR operation due to operator precedence, leading to incorrect values.

Solution: Use proper parentheses or the bitwise OR operator:

# Correct approaches:
current_number = (current_number << 1) | int(s[end_index])
# OR
current_number = (current_number << 1) + int(s[end_index])

3. Not Handling Edge Case of Single '0'

A subtle pitfall is not properly handling strings that consist of only zeros or start with mandatory zeros that cannot be avoided.

Incorrect assumption: Checking only if s[start_index] == "0" is sufficient.

Why it fails: Consider s = "0". While we correctly identify that we cannot start a substring with '0', we need to ensure we return -1 for the entire string, not just skip this position.

Solution: The current implementation handles this correctly by returning inf when encountering a leading zero, which propagates up and eventually returns -1. However, it's important to understand this flow:

# For s = "0":
# dfs(0) sees s[0] = "0", returns inf
# main function converts inf to -1

4. Forgetting to Clear Cache Between Test Cases

When using @cache decorator in a class method, the cache persists across multiple test case calls, which can lead to incorrect results if the input changes.

Problem scenario:

# If this solution is called multiple times with different inputs
solution = Solution()
result1 = solution.minimumBeautifulSubstrings("1011")  # Cache populated
result2 = solution.minimumBeautifulSubstrings("111")   # Uses wrong cached values!

Solution: Either:

  1. Move the cached function outside the class method
  2. Clear cache explicitly
  3. Use the function parameter approach (current solution is correct as it defines the function inside the method, creating a new cache each time)

The current implementation avoids this by defining find_min_partitions inside the method, ensuring a fresh cache for each call.

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

What is the best way of checking if an element exists in a sorted array once in terms of time complexity? Select the best that applies.


Recommended Readings

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

Load More