Facebook Pixel

1208. Get Equal Substrings Within Budget

Problem Description

You are given two strings s and t that have the same length, along with an integer maxCost.

Your goal is to transform string s into string t. The cost to change the i-th character of s to the i-th character of t is calculated as |s[i] - t[i]|, which represents the absolute difference between the ASCII values of the two characters.

You need to find the maximum length of a substring from s that can be changed to match the corresponding substring in t while keeping the total transformation cost less than or equal to maxCost.

For example, if you select a substring from position i to position j in string s, the total cost to transform this substring would be the sum of |s[k] - t[k]| for all k from i to j. This total cost must not exceed maxCost.

The function should return the maximum possible length of such a substring. If no substring can be transformed within the cost limit (even a single character transformation exceeds maxCost), return 0.

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

Intuition

The key insight is recognizing that if we can transform a substring of length x within the cost limit, then we can definitely transform any substring of length x-1 or smaller within that same limit (we can simply use a portion of the original substring). This property is called monotonicity - as the substring length decreases, it becomes easier to stay within the cost constraint.

This monotonic property immediately suggests using binary search on the answer. Instead of checking every possible substring length from 1 to n, we can binary search for the maximum valid length.

To efficiently calculate the cost of transforming any substring, we can use prefix sums. If we precompute an array f where f[i] stores the cumulative cost of transforming characters from index 0 to i-1, then the cost of transforming a substring from index i to j can be calculated in O(1) time as f[j+1] - f[i].

The binary search works by repeatedly checking if a certain length mid is achievable:

  • If we can find at least one substring of length mid with cost ≤ maxCost, then mid is valid and we should search for potentially larger lengths
  • If no substring of length mid satisfies the constraint, then we need to search for smaller lengths

For each candidate length during binary search, we slide a window of that length across the string and check if any position gives us a valid transformation cost using our prefix sum array. This allows us to efficiently determine whether a given length is achievable.

The combination of binary search (O(log n)) with the sliding window check (O(n) per check) gives us an overall time complexity of O(n log n), which is much better than the brute force approach of checking all possible substrings.

Learn more about Binary Search, Prefix Sum and Sliding Window patterns.

Solution Approach

The solution implements the binary search with prefix sum strategy discussed earlier. Let's walk through the implementation step by step:

1. Building the Prefix Sum Array:

f = list(accumulate((abs(ord(a) - ord(b)) for a, b in zip(s, t)), initial=0))

This creates an array f where f[i] represents the total cost of transforming the first i characters. The accumulate function with initial=0 ensures f[0] = 0, and each subsequent element is the cumulative sum of transformation costs. For any substring from index i to j, the transformation cost can be calculated as f[j+1] - f[i].

2. Binary Search Setup:

l, r = 0, n

We initialize the search range where l = 0 (minimum possible length) and r = n (maximum possible length, which is the entire string).

3. The Check Function:

def check(x):
    for i in range(n):
        j = i + mid - 1
        if j < n and f[j + 1] - f[i] <= maxCost:
            return True
    return False

This function determines if there exists any substring of length x that can be transformed within maxCost. It iterates through all possible starting positions i and checks if the substring from i to i + x - 1 has a transformation cost ≤ maxCost. The cost is calculated using the prefix sum: f[j+1] - f[i] where j = i + x - 1.

4. Binary Search Loop:

while l < r:
    mid = (l + r + 1) >> 1
    if check(mid):
        l = mid
    else:
        r = mid - 1

The binary search continues while l < r:

  • Calculate mid = (l + r + 1) >> 1 (equivalent to (l + r + 1) // 2). The +1 ensures we round up to avoid infinite loops when l and r are adjacent.
  • If check(mid) returns True, a substring of length mid is valid, so we search for potentially larger lengths by setting l = mid.
  • If check(mid) returns False, length mid is too large, so we search for smaller lengths by setting r = mid - 1.

5. Return the Result: After the binary search completes, l contains the maximum valid substring length, which is returned as the answer.

The time complexity is O(n log n) where the binary search takes O(log n) iterations, and each check function call takes O(n) time. The space complexity is O(n) for storing the prefix sum array.

Ready to land your dream job?

Unlock your dream job with a 3-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's walk through a concrete example to illustrate the solution approach.

Given:

  • s = "abcd"
  • t = "bcdf"
  • maxCost = 3

Step 1: Calculate transformation costs and build prefix sum array

First, let's calculate the cost for each character:

  • Position 0: |'a' - 'b'| = |97 - 98| = 1
  • Position 1: |'b' - 'c'| = |98 - 99| = 1
  • Position 2: |'c' - 'd'| = |99 - 100| = 1
  • Position 3: |'d' - 'f'| = |100 - 102| = 2

Prefix sum array f = [0, 1, 2, 3, 5]:

  • f[0] = 0 (no characters)
  • f[1] = 0 + 1 = 1 (cost of first character)
  • f[2] = 1 + 1 = 2 (cost of first two characters)
  • f[3] = 2 + 1 = 3 (cost of first three characters)
  • f[4] = 3 + 2 = 5 (cost of all four characters)

Step 2: Binary Search

Initialize: l = 0, r = 4 (length of string)

Iteration 1:

  • mid = (0 + 4 + 1) >> 1 = 2
  • Check if length 2 is valid:
    • Substring [0,1]: cost = f[2] - f[0] = 2 - 0 = 2 ≤ 3
    • Found valid substring, so length 2 is achievable
  • Update: l = 2 (search for larger lengths)

Iteration 2:

  • mid = (2 + 4 + 1) >> 1 = 3
  • Check if length 3 is valid:
    • Substring [0,2]: cost = f[3] - f[0] = 3 - 0 = 3 ≤ 3
    • Found valid substring, so length 3 is achievable
  • Update: l = 3 (search for larger lengths)

Iteration 3:

  • mid = (3 + 4 + 1) >> 1 = 4
  • Check if length 4 is valid:
    • Substring [0,3]: cost = f[4] - f[0] = 5 - 0 = 5 > 3
    • No valid substring of length 4
  • Update: r = 3 (search for smaller lengths)

Termination:

  • l = 3, r = 3 (converged)
  • Return 3

Answer: 3

The maximum length substring that can be transformed within cost 3 is length 3. We can transform "abc" to "bcd" with total cost 3.

Solution Implementation

1class Solution:
2    def equalSubstring(self, s: str, t: str, maxCost: int) -> int:
3        """
4        Find the maximum length of a substring that can be changed from s to t
5        within the given maxCost budget.
6      
7        Args:
8            s: Source string
9            t: Target string
10            maxCost: Maximum cost allowed for character changes
11          
12        Returns:
13            Maximum length of substring that can be changed within budget
14        """
15      
16        def can_achieve_length(length):
17            """
18            Check if we can find a substring of given length that costs <= maxCost.
19          
20            Args:
21                length: The substring length to check
22              
23            Returns:
24                True if such substring exists, False otherwise
25            """
26            # Check all possible windows of size 'length'
27            for start_idx in range(string_length):
28                end_idx = start_idx + length - 1
29              
30                # Check if window is within bounds and cost is within budget
31                if end_idx < string_length:
32                    # Calculate cost for substring [start_idx, end_idx]
33                    window_cost = prefix_sum[end_idx + 1] - prefix_sum[start_idx]
34                    if window_cost <= maxCost:
35                        return True
36            return False
37      
38        # Initialize variables
39        string_length = len(s)
40      
41        # Build prefix sum array for costs
42        # prefix_sum[i] = sum of costs from index 0 to i-1
43        costs = (abs(ord(char_s) - ord(char_t)) for char_s, char_t in zip(s, t))
44        prefix_sum = list(accumulate(costs, initial=0))
45      
46        # Binary search for maximum valid substring length
47        left, right = 0, string_length
48      
49        while left < right:
50            # Try the larger half (ceiling division)
51            mid = (left + right + 1) >> 1
52          
53            if can_achieve_length(mid):
54                # If current length is achievable, try larger lengths
55                left = mid
56            else:
57                # If current length is not achievable, try smaller lengths
58                right = mid - 1
59      
60        return left
61
1class Solution {
2    private int maxCost;
3    private int[] prefixSum;  // Cumulative cost array
4    private int stringLength;
5  
6    public int equalSubstring(String s, String t, int maxCost) {
7        // Initialize instance variables
8        stringLength = s.length();
9        prefixSum = new int[stringLength + 1];
10        this.maxCost = maxCost;
11      
12        // Build prefix sum array where prefixSum[i+1] stores the cumulative cost
13        // of changing characters from index 0 to i
14        for (int i = 0; i < stringLength; i++) {
15            int cost = Math.abs(s.charAt(i) - t.charAt(i));
16            prefixSum[i + 1] = prefixSum[i] + cost;
17        }
18      
19        // Binary search for the maximum valid substring length
20        int left = 0;
21        int right = stringLength;
22      
23        while (left < right) {
24            // Calculate mid point, avoiding overflow
25            int mid = (left + right + 1) >>> 1;
26          
27            // If a substring of length mid exists within budget, search for longer
28            if (canFindValidSubstring(mid)) {
29                left = mid;
30            } else {
31                // Otherwise, search for shorter substring
32                right = mid - 1;
33            }
34        }
35      
36        return left;
37    }
38  
39    /**
40     * Checks if there exists a substring of given length that can be changed
41     * within the maxCost budget
42     * 
43     * @param substringLength the length of substring to check
44     * @return true if such substring exists, false otherwise
45     */
46    private boolean canFindValidSubstring(int substringLength) {
47        // Try all possible starting positions for a substring of given length
48        for (int startIndex = 0; startIndex + substringLength - 1 < stringLength; startIndex++) {
49            int endIndex = startIndex + substringLength - 1;
50          
51            // Calculate cost using prefix sum: cost = prefixSum[end+1] - prefixSum[start]
52            int totalCost = prefixSum[endIndex + 1] - prefixSum[startIndex];
53          
54            // If cost is within budget, we found a valid substring
55            if (totalCost <= maxCost) {
56                return true;
57            }
58        }
59      
60        return false;
61    }
62}
63
1class Solution {
2public:
3    int equalSubstring(string s, string t, int maxCost) {
4        int n = s.size();
5      
6        // Build prefix sum array to store cumulative costs
7        // prefixSum[i] = total cost to change s[0...i-1] to t[0...i-1]
8        int prefixSum[n + 1];
9        prefixSum[0] = 0;
10        for (int i = 0; i < n; ++i) {
11            prefixSum[i + 1] = prefixSum[i] + abs(s[i] - t[i]);
12        }
13      
14        // Check if a substring of given length can be made equal within maxCost
15        auto canMakeEqual = [&](int length) -> bool {
16            // Try all possible substrings of the given length
17            for (int start = 0; start + length - 1 < n; ++start) {
18                int end = start + length - 1;
19                // Calculate cost for substring [start, end] using prefix sum
20                int cost = prefixSum[end + 1] - prefixSum[start];
21                if (cost <= maxCost) {
22                    return true;
23                }
24            }
25            return false;
26        };
27      
28        // Binary search for the maximum valid substring length
29        int left = 0, right = n;
30        while (left < right) {
31            // Use upper mid to avoid infinite loop when left = right - 1
32            int mid = (left + right + 1) >> 1;
33            if (canMakeEqual(mid)) {
34                // If current length is valid, try longer substrings
35                left = mid;
36            } else {
37                // If current length is invalid, try shorter substrings
38                right = mid - 1;
39            }
40        }
41      
42        return left;
43    }
44};
45
1/**
2 * Find the maximum length of a substring that can be changed with a given cost budget
3 * @param s - Source string
4 * @param t - Target string
5 * @param maxCost - Maximum cost allowed for character changes
6 * @returns Maximum length of substring that can be changed within budget
7 */
8function equalSubstring(s: string, t: string, maxCost: number): number {
9    const length: number = s.length;
10  
11    // Build prefix sum array to store cumulative costs
12    // prefixSum[i] represents total cost to change characters from index 0 to i-1
13    const prefixSum: number[] = Array(length + 1).fill(0);
14  
15    // Calculate prefix sums of character change costs
16    for (let i = 0; i < length; i++) {
17        const changeCost: number = Math.abs(s.charCodeAt(i) - t.charCodeAt(i));
18        prefixSum[i + 1] = prefixSum[i] + changeCost;
19    }
20  
21    /**
22     * Check if a substring of given length can be changed within budget
23     * @param substringLength - Length of substring to check
24     * @returns True if any substring of this length fits within budget
25     */
26    const canChangeSubstring = (substringLength: number): boolean => {
27        // Try all possible starting positions for substring of given length
28        for (let startIndex = 0; startIndex + substringLength - 1 < length; startIndex++) {
29            const endIndex: number = startIndex + substringLength;
30            const totalCost: number = prefixSum[endIndex] - prefixSum[startIndex];
31          
32            if (totalCost <= maxCost) {
33                return true;
34            }
35        }
36        return false;
37    };
38  
39    // Binary search for maximum valid substring length
40    let left: number = 0;
41    let right: number = length;
42  
43    while (left < right) {
44        // Calculate mid point (ceil division to favor larger values)
45        const mid: number = (left + right + 1) >> 1;
46      
47        if (canChangeSubstring(mid)) {
48            // If mid length is valid, search for potentially longer substrings
49            left = mid;
50        } else {
51            // If mid length exceeds budget, search for shorter substrings
52            right = mid - 1;
53        }
54    }
55  
56    return left;
57}
58

Time and Space Complexity

Time Complexity: O(n × log n)

The algorithm uses binary search on the answer space (the length of the substring). The binary search runs for O(log n) iterations since we're searching in the range [0, n] where n is the length of the string.

For each binary search iteration, the check(mid) function is called. Inside check(), there's a loop that iterates through all possible starting positions i from 0 to n-1, performing O(1) operations in each iteration (checking if j < n and if f[j + 1] - f[i] <= maxCost). Therefore, check() has a time complexity of O(n).

Additionally, before the binary search, we compute the prefix sum array f using accumulate(), which takes O(n) time.

Overall time complexity: O(n) for preprocessing + O(log n) binary search iterations × O(n) per check = O(n × log n)

Space Complexity: O(n)

The algorithm uses additional space for:

  • The prefix sum array f which stores n + 1 elements (including the initial 0), requiring O(n) space
  • A few variables (l, r, mid, n) which use O(1) space

Overall space complexity: O(n)

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

Common Pitfalls

1. Off-by-One Error in Binary Search Midpoint Calculation

The Pitfall: A common mistake is using mid = (left + right) // 2 instead of mid = (left + right + 1) // 2 when the update logic sets left = mid. This can cause an infinite loop when left and right differ by 1.

Example of the Problem:

# Incorrect version that causes infinite loop
while left < right:
    mid = (left + right) // 2  # Wrong!
    if can_achieve_length(mid):
        left = mid  # When left=3, right=4, mid will always be 3
    else:
        right = mid - 1

When left = 3 and right = 4, mid will always equal 3, and if the condition is true, left stays at 3 forever.

The Solution: Always use mid = (left + right + 1) // 2 (or (left + right + 1) >> 1) when your binary search updates with left = mid. This ensures the midpoint rounds up, preventing infinite loops.

2. Incorrect Prefix Sum Index Mapping

The Pitfall: Confusing the indexing when calculating substring costs using prefix sums. Remember that prefix_sum[i] represents the sum of elements from index 0 to i-1 (exclusive of i).

Example of the Problem:

# Incorrect calculation
window_cost = prefix_sum[end_idx] - prefix_sum[start_idx + 1]  # Wrong!

# Or forgetting the bounds check
window_cost = prefix_sum[end_idx + 1] - prefix_sum[start_idx]  # No bounds check!

The Solution: For a substring from index start to end (inclusive), the cost should be:

window_cost = prefix_sum[end + 1] - prefix_sum[start]

And always verify that end + 1 doesn't exceed the prefix sum array length.

3. Using Sliding Window Instead of Binary Search

The Pitfall: While sliding window seems intuitive for this problem, directly applying it can miss the optimal solution because the window might need to "skip" certain high-cost positions.

Example of the Problem:

# Incorrect sliding window approach
left = 0
max_length = 0
current_cost = 0

for right in range(len(s)):
    current_cost += abs(ord(s[right]) - ord(t[right]))
  
    while current_cost > maxCost:
        current_cost -= abs(ord(s[left]) - ord(t[left]))
        left += 1
  
    max_length = max(max_length, right - left + 1)

This actually works correctly! The confusion arises because both sliding window and binary search are valid approaches for this problem. However, the binary search approach is more generalizable to similar problems where a simple sliding window might not work.

The Solution: Both approaches are valid for this specific problem. Choose based on:

  • Sliding window: O(n) time, simpler to implement
  • Binary search with prefix sum: O(n log n) time, more generalizable pattern

4. Forgetting Edge Cases

The Pitfall: Not handling cases where even a single character transformation exceeds maxCost, or when maxCost is 0.

Example of the Problem:

# Missing edge case handling
if maxCost == 0:
    # Should check if any position has s[i] == t[i]
    pass

The Solution: The binary search approach naturally handles these edge cases because:

  • If no valid substring exists, the binary search will converge to left = 0
  • The prefix sum approach correctly handles maxCost = 0 by checking if any substring has zero transformation cost
Discover Your Strengths and Weaknesses: Take Our 3-Minute Quiz to Tailor Your Study Plan:

What's the output of running the following function using the following tree as input?

1def serialize(root):
2    res = []
3    def dfs(root):
4        if not root:
5            res.append('x')
6            return
7        res.append(root.val)
8        dfs(root.left)
9        dfs(root.right)
10    dfs(root)
11    return ' '.join(res)
12
1import java.util.StringJoiner;
2
3public static String serialize(Node root) {
4    StringJoiner res = new StringJoiner(" ");
5    serializeDFS(root, res);
6    return res.toString();
7}
8
9private static void serializeDFS(Node root, StringJoiner result) {
10    if (root == null) {
11        result.add("x");
12        return;
13    }
14    result.add(Integer.toString(root.val));
15    serializeDFS(root.left, result);
16    serializeDFS(root.right, result);
17}
18
1function serialize(root) {
2    let res = [];
3    serialize_dfs(root, res);
4    return res.join(" ");
5}
6
7function serialize_dfs(root, res) {
8    if (!root) {
9        res.push("x");
10        return;
11    }
12    res.push(root.val);
13    serialize_dfs(root.left, res);
14    serialize_dfs(root.right, res);
15}
16

Recommended Readings

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

Load More