2466. Count Ways To Build Good Strings
Problem Description
You need to construct strings using a specific process and count how many "good" strings can be formed.
String Construction Rules:
- Start with an empty string
- At each step, you can do one of two operations:
- Append
zero
number of '0' characters to the string - Append
one
number of '1' characters to the string
- Append
- You can repeat these operations any number of times
What is a Good String:
A good string is any string constructed using the above process that has a length between low
and high
(inclusive).
Task:
Count the total number of different good strings that can be constructed. Since this number can be very large, return the result modulo 10^9 + 7
.
Example Understanding:
If zero = 2
, one = 3
, low = 3
, high = 5
:
- You can append 2 zeros at a time or 3 ones at a time
- Valid good strings would have lengths 3, 4, or 5
- For length 3: You can create "111" (append 3 ones once)
- For length 4: You can create "0011" or "1100" (append 2 zeros and 2 ones in different orders)
- For length 5: You can create "00111", "11100", "01110", etc.
- Count all such distinct possibilities
The solution uses dynamic programming with memoization, where dfs(i)
represents the number of good strings that can be constructed starting from length i
. Starting from length 0 (empty string), we explore all possible ways to add zero
zeros or one
ones, counting valid strings when their length falls within the [low, high]
range.
Intuition
Think of this problem as building strings step by step, where at each point you're deciding whether to add zero
zeros or one
ones to your current string length.
The key insight is that we don't need to track the actual strings themselves - we only care about their lengths. Why? Because the problem asks for the count of different strings, and two strings are different if they have different sequences of operations, even if they end up with the same length.
Starting from an empty string (length 0), we can visualize this as a decision tree:
- From length 0, we can go to length
zero
(by adding zeros) or lengthone
(by adding ones) - From length
zero
, we can go to lengthzero + zero
orzero + one
- And so on...
This naturally leads to a recursive approach: from any current length i
, we can transition to length i + zero
or i + one
. Whenever we reach a length within [low, high]
, we've found a good string, so we count it.
However, multiple paths can lead to the same length. For example, if zero = 2
and one = 3
, we can reach length 5 by:
- Adding 2 zeros then 3 ones (0→2→5)
- Adding 3 ones then 2 zeros (0→3→5)
These are different strings even though they have the same length! But here's the crucial observation: once we've calculated how many good strings can be formed starting from a particular length i
, that count remains the same regardless of how we reached length i
. This is why memoization works perfectly here.
The function dfs(i)
represents: "Starting from length i
, how many good strings can I create?" We stop exploring when i > high
because any string longer than high
cannot contribute to our answer. For lengths within [low, high]
, we add 1 to our count (representing the current valid string) and continue exploring further possibilities.
Learn more about Dynamic Programming patterns.
Solution Approach
The solution uses memoization search (top-down dynamic programming) to efficiently count the number of good strings.
Implementation Details:
-
Main Function Structure:
- Define a recursive function
dfs(i)
that calculates the number of good strings that can be constructed starting from lengthi
- The base case: if
i > high
, return 0 since we've exceeded the maximum allowed length - Use the
@cache
decorator to memoize results and avoid redundant calculations
- Define a recursive function
-
Counting Logic:
if low <= i <= high: ans += 1
- When the current length
i
falls within the valid range[low, high]
, we've found a good string, so increment the count by 1
- When the current length
-
Recursive Transitions:
ans += dfs(i + zero) + dfs(i + one)
- From current length
i
, we can either:- Add
zero
zeros to reach lengthi + zero
- Add
one
ones to reach lengthi + one
- Add
- The total count is the sum of good strings from both branches
- From current length
-
Modulo Operation:
return ans % mod
- Apply modulo
10^9 + 7
to keep the numbers manageable and meet the problem requirements
- Apply modulo
-
Starting Point:
return dfs(0)
- Start from length 0 (empty string) and explore all possibilities
Why Memoization Works:
The key insight is that many different sequences of operations can lead to the same length. For instance, if we've already calculated dfs(5)
, we don't need to recalculate it whether we reached length 5 by adding 2+3 or 3+2. The @cache
decorator automatically stores and retrieves these computed values.
Time Complexity: O(high)
- We compute at most high + 1
different states (lengths from 0 to high)
Space Complexity: O(high)
- For the memoization cache and recursion stack
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 with zero = 2
, one = 3
, low = 3
, high = 5
.
Starting from an empty string (length 0), we'll trace through the recursive calls:
Initial Call: dfs(0)
- Current length: 0
- Is 0 in [3, 5]? No, so don't count this
- Explore two branches:
dfs(0 + 2)
=dfs(2)
(add 2 zeros)dfs(0 + 3)
=dfs(3)
(add 3 ones)
Branch 1: dfs(2)
- Current length: 2
- Is 2 in [3, 5]? No, so don't count this
- Explore two branches:
dfs(2 + 2)
=dfs(4)
(add 2 more zeros)dfs(2 + 3)
=dfs(5)
(add 3 ones)
Branch 1.1: dfs(4)
- Current length: 4
- Is 4 in [3, 5]? Yes! Count = 1
- Explore two branches:
dfs(4 + 2)
=dfs(6)
(exceeds high=5, returns 0)dfs(4 + 3)
=dfs(7)
(exceeds high=5, returns 0)
- Return: 1 + 0 + 0 = 1
Branch 1.2: dfs(5)
- Current length: 5
- Is 5 in [3, 5]? Yes! Count = 1
- Explore two branches:
dfs(5 + 2)
=dfs(7)
(exceeds high=5, returns 0)dfs(5 + 3)
=dfs(8)
(exceeds high=5, returns 0)
- Return: 1 + 0 + 0 = 1
Back to dfs(2)
: Returns 1 + 1 = 2
Branch 2: dfs(3)
- Current length: 3
- Is 3 in [3, 5]? Yes! Count = 1
- Explore two branches:
dfs(3 + 2)
=dfs(5)
(already computed via memoization, returns 1)dfs(3 + 3)
=dfs(6)
(exceeds high=5, returns 0)
- Return: 1 + 1 + 0 = 2
Final Result:
dfs(0)
= dfs(2)
+ dfs(3)
= 2 + 2 = 4
The 4 good strings correspond to:
- Length 3: "111" (path: 0→3)
- Length 4: "0011" (path: 0→2→4)
- Length 5: "00111" (path: 0→2→5)
- Length 5: "11100" (path: 0→3→5)
Note how dfs(5)
was reached through two different paths (0→2→5 and 0→3→5), but memoization ensured we only computed it once.
Solution Implementation
1class Solution:
2 def countGoodStrings(self, low: int, high: int, zero: int, one: int) -> int:
3 """
4 Count the number of good strings that can be constructed.
5 A good string has length between low and high (inclusive),
6 and is constructed by appending either 'zero' zeros or 'one' ones at each step.
7
8 Args:
9 low: Minimum length of a good string
10 high: Maximum length of a good string
11 zero: Number of zeros to append in one operation
12 one: Number of ones to append in one operation
13
14 Returns:
15 Count of good strings modulo 10^9 + 7
16 """
17 from functools import cache
18
19 MOD = 10**9 + 7
20
21 @cache
22 def dfs(current_length: int) -> int:
23 """
24 Recursive function to count good strings starting from current_length.
25
26 Args:
27 current_length: Current length of the string being constructed
28
29 Returns:
30 Count of good strings that can be formed
31 """
32 # Base case: if current length exceeds high, no valid strings can be formed
33 if current_length > high:
34 return 0
35
36 # Initialize answer counter
37 answer = 0
38
39 # If current length is within the valid range, count this as a good string
40 if low <= current_length <= high:
41 answer += 1
42
43 # Recursively explore two possibilities:
44 # 1. Append 'zero' zeros to current string
45 # 2. Append 'one' ones to current string
46 answer += dfs(current_length + zero) + dfs(current_length + one)
47
48 # Return answer modulo MOD to prevent overflow
49 return answer % MOD
50
51 # Start the recursion from length 0
52 return dfs(0)
53
1class Solution {
2 // Constants
3 private static final int MOD = (int) 1e9 + 7;
4
5 // Memoization array to store computed results
6 private int[] memo;
7
8 // Boundary values for valid string lengths
9 private int minLength;
10 private int maxLength;
11
12 // Step sizes for constructing strings
13 private int zeroStep;
14 private int oneStep;
15
16 /**
17 * Counts the number of good strings that can be formed.
18 * A good string has length between low and high (inclusive),
19 * and is constructed by appending either 'zero' or 'one' characters at each step.
20 *
21 * @param low minimum length of a good string
22 * @param high maximum length of a good string
23 * @param zero number of characters added when choosing zero option
24 * @param one number of characters added when choosing one option
25 * @return count of good strings modulo 10^9 + 7
26 */
27 public int countGoodStrings(int low, int high, int zero, int one) {
28 // Initialize memoization array with -1 (uncomputed state)
29 memo = new int[high + 1];
30 Arrays.fill(memo, -1);
31
32 // Store parameters as instance variables for DFS access
33 minLength = low;
34 maxLength = high;
35 this.zeroStep = zero;
36 this.oneStep = one;
37
38 // Start DFS from length 0
39 return dfs(0);
40 }
41
42 /**
43 * Performs depth-first search to count valid strings starting from current length.
44 * Uses dynamic programming with memoization to avoid redundant calculations.
45 *
46 * @param currentLength current string length being considered
47 * @return number of valid strings that can be formed from this state
48 */
49 private int dfs(int currentLength) {
50 // Base case: if current length exceeds maximum, no valid strings possible
51 if (currentLength > maxLength) {
52 return 0;
53 }
54
55 // Return memoized result if already computed
56 if (memo[currentLength] != -1) {
57 return memo[currentLength];
58 }
59
60 // Initialize count for current state
61 long count = 0;
62
63 // If current length is within valid range, count it as one good string
64 if (currentLength >= minLength && currentLength <= maxLength) {
65 count++;
66 }
67
68 // Recursively explore both options: adding 'zero' or 'one' characters
69 count += dfs(currentLength + zeroStep) + dfs(currentLength + oneStep);
70
71 // Apply modulo to prevent overflow
72 count %= MOD;
73
74 // Store result in memoization array and return
75 memo[currentLength] = (int) count;
76 return memo[currentLength];
77 }
78}
79
1class Solution {
2public:
3 const int MOD = 1e9 + 7;
4
5 int countGoodStrings(int low, int high, int zero, int one) {
6 // dp[i] stores the number of good strings that can be formed starting from length i
7 vector<int> dp(high + 1, -1);
8
9 // Define recursive function using lambda
10 function<int(int)> dfs = [&](int currentLength) -> int {
11 // Base case: if current length exceeds high, no valid strings can be formed
12 if (currentLength > high) {
13 return 0;
14 }
15
16 // Return memoized result if already computed
17 if (dp[currentLength] != -1) {
18 return dp[currentLength];
19 }
20
21 // Initialize count: if current length is within [low, high], count this as 1 good string
22 long count = (currentLength >= low && currentLength <= high) ? 1 : 0;
23
24 // Recursively count strings by appending 'zero' zeros or 'one' ones
25 count += dfs(currentLength + zero); // Append 'zero' zeros
26 count += dfs(currentLength + one); // Append 'one' ones
27
28 // Apply modulo to prevent overflow
29 count %= MOD;
30
31 // Memoize the result
32 dp[currentLength] = count;
33
34 return count;
35 };
36
37 // Start DFS from length 0
38 return dfs(0);
39 }
40};
41
1const MOD = 1e9 + 7;
2
3function countGoodStrings(low: number, high: number, zero: number, one: number): number {
4 // dp[i] stores the number of good strings that can be formed starting from length i
5 const dp: number[] = new Array(high + 1).fill(-1);
6
7 // Define recursive function to count good strings
8 const dfs = (currentLength: number): number => {
9 // Base case: if current length exceeds high, no valid strings can be formed
10 if (currentLength > high) {
11 return 0;
12 }
13
14 // Return memoized result if already computed
15 if (dp[currentLength] !== -1) {
16 return dp[currentLength];
17 }
18
19 // Initialize count: if current length is within [low, high], count this as 1 good string
20 let count = (currentLength >= low && currentLength <= high) ? 1 : 0;
21
22 // Recursively count strings by appending 'zero' zeros or 'one' ones
23 count += dfs(currentLength + zero); // Append 'zero' zeros
24 count += dfs(currentLength + one); // Append 'one' ones
25
26 // Apply modulo to prevent overflow
27 count %= MOD;
28
29 // Memoize the result
30 dp[currentLength] = count;
31
32 return count;
33 };
34
35 // Start DFS from length 0
36 return dfs(0);
37}
38
Time and Space Complexity
The time complexity is O(high)
. The recursive function dfs(i)
explores all possible values from 0
to high
. Due to memoization with @cache
, each unique value of i
from 0
to high
is computed exactly once. Since we can reach at most high + 1
different states (values from 0
to high
), and each state performs constant time operations (checking conditions and making at most two recursive calls that are either cached or will be cached), the total time complexity is O(high)
.
The space complexity is O(high)
. This comes from two sources:
- The memoization cache stores results for each unique value of
i
that the function visits, which can be at mosthigh + 1
values (from0
tohigh
). - The recursion stack depth in the worst case can also be
O(high)
, when we have consecutive calls likedfs(0) → dfs(1) → dfs(2) → ... → dfs(high)
if eitherzero
orone
equals1
.
Therefore, the overall space complexity is O(high)
.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Forgetting to Apply Modulo at Each Step
Pitfall: A common mistake is only applying modulo at the final return, not during intermediate calculations:
# WRONG - Can cause integer overflow
@cache
def dfs(current_length: int) -> int:
if current_length > high:
return 0
answer = 0
if low <= current_length <= high:
answer += 1
# This addition can overflow before modulo is applied
answer += dfs(current_length + zero) + dfs(current_length + one)
return answer # Missing modulo here!
# Then applying modulo only at the end
return dfs(0) % MOD
Solution: Apply modulo at each recursive step to prevent overflow:
# CORRECT
@cache
def dfs(current_length: int) -> int:
if current_length > high:
return 0
answer = 0
if low <= current_length <= high:
answer += 1
answer += dfs(current_length + zero) + dfs(current_length + one)
return answer % MOD # Apply modulo here
2. Using Bottom-Up DP with Incorrect Range
Pitfall: When converting to bottom-up DP, iterating only from low
to high
:
# WRONG - Misses valid combinations
dp = [0] * (high + 1)
for i in range(low, high + 1): # Starting from low is incorrect
if i - zero >= 0:
dp[i] += dp[i - zero]
if i - one >= 0:
dp[i] += dp[i - one]
Solution: Start from 0 and build up all possible lengths:
# CORRECT
dp = [0] * (high + 1)
dp[0] = 1 # Empty string as base case
for i in range(1, high + 1):
if i - zero >= 0:
dp[i] = (dp[i] + dp[i - zero]) % MOD
if i - one >= 0:
dp[i] = (dp[i] + dp[i - one]) % MOD
result = sum(dp[low:high + 1]) % MOD
3. Incorrect Base Case Handling
Pitfall: Not properly handling the case when low = 0
:
# WRONG - Doesn't count empty string when low = 0
@cache
def dfs(current_length: int) -> int:
if current_length > high:
return 0
answer = 0
if low < current_length <= high: # Wrong condition
answer += 1
Solution: Use inclusive comparison for both bounds:
# CORRECT if low <= current_length <= high: # Inclusive on both ends answer += 1
4. Cache Clearing Between Test Cases
Pitfall: In competitive programming platforms with multiple test cases, forgetting to clear the cache:
# WRONG - Cache persists across test cases
@cache
def dfs(current_length: int) -> int:
# ... implementation
# Multiple test cases will use stale cached values
Solution: Clear cache or use local function definition:
# CORRECT - Define function inside the main method
def countGoodStrings(self, low: int, high: int, zero: int, one: int) -> int:
@cache
def dfs(current_length: int) -> int:
# ... implementation
return dfs(0)
# Cache is local to this function call
How would you design a stack which has a function min
that returns the minimum element in the stack, in addition to push
and pop
? All push
, pop
, min
should have running time O(1)
.
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
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
Want a Structured Path to Master System Design Too? Don’t Miss This!