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:
- It doesn't contain leading zeros (i.e., it cannot start with '0' unless it's just "1")
- 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:
- We try all possible ending positions for the current substring
- Check if the current substring is "beautiful" (no leading zeros and represents a power of 5)
- Recursively solve for the remaining string
- Backtrack if the current path doesn't lead to a valid solution
- Use memoization to avoid recalculating the same subproblems
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:
- Does it have leading zeros? If yes, skip it.
- Is it a power of 5 in binary? If no, skip it.
- 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, return0
. -
Invalid case: If
s[i] == "0"
, the current position starts with '0', making any substring starting here invalid (leading zero). Returninf
to indicate impossibility. -
Recursive case: Try all possible ending positions
j
fromi
ton-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 EvaluatorExample 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 ✓, gives1 + 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)
returnsinf
(no valid partition found)
- Call
- 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
- Call
- Option 5 total:
1 + 0 = 1
Final Result:
dfs(0)
returnsmin(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:
- The recursion call stack depth, which is at most
O(n)
when the string is partitioned into n single-character substrings - The memoization cache stores at most
n
different states (one for each starting position) - The set
ss
stores powers of 5, and since we only generate up ton
powers of 5, it usesO(n)
space - 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:
- Move the cached function outside the class method
- Clear cache explicitly
- 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.
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
What is Dynamic Programming Prerequisite DFS problems dfs_intro Backtracking problems backtracking Memoization problems memoization_intro Pruning problems backtracking_pruning Dynamic programming is an algorithmic optimization technique that breaks down a complicated problem into smaller overlapping sub problems in a recursive manner and uses solutions to the sub problems to construct a solution
Backtracking Template Prereq DFS with States problems dfs_with_states Combinatorial search problems Combinatorial search problems involve finding groupings and assignments of objects that satisfy certain conditions Finding all permutations combinations subsets and solving Sudoku are classic combinatorial problems The time complexity of combinatorial problems often grows rapidly with the size of
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
Want a Structured Path to Master System Design Too? Don’t Miss This!