Facebook Pixel

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
  • 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.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

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 length one (by adding ones)
  • From length zero, we can go to length zero + zero or zero + 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:

  1. Main Function Structure:

    • Define a recursive function dfs(i) that calculates the number of good strings that can be constructed starting from length i
    • 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
  2. 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
  3. Recursive Transitions:

    ans += dfs(i + zero) + dfs(i + one)
    • From current length i, we can either:
      • Add zero zeros to reach length i + zero
      • Add one ones to reach length i + one
    • The total count is the sum of good strings from both branches
  4. Modulo Operation:

    return ans % mod
    • Apply modulo 10^9 + 7 to keep the numbers manageable and meet the problem requirements
  5. 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 Evaluator

Example 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:

  1. Length 3: "111" (path: 0→3)
  2. Length 4: "0011" (path: 0→2→4)
  3. Length 5: "00111" (path: 0→2→5)
  4. 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:

  1. The memoization cache stores results for each unique value of i that the function visits, which can be at most high + 1 values (from 0 to high).
  2. The recursion stack depth in the worst case can also be O(high), when we have consecutive calls like dfs(0) → dfs(1) → dfs(2) → ... → dfs(high) if either zero or one equals 1.

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
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

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

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

Load More