2430. Maximum Deletions on a String
Problem Description
You are given a string s
consisting of only lowercase English letters. You need to delete the entire string using a series of operations and find the maximum number of operations possible.
In each operation, you can choose one of two actions:
- Delete the entire remaining string
s
(this counts as 1 operation) - Delete the first
i
letters ofs
if these firsti
letters are exactly equal to the nexti
letters that follow them, wherei
can be any value from1
tos.length / 2
For the second type of operation, you're essentially looking for a prefix that repeats immediately after itself. When you find such a repeating pattern, you can delete the first occurrence.
Example:
- If
s = "ababc"
, you can observe that the first 2 letters"ab"
are equal to the next 2 letters"ab"
. So you can delete the first"ab"
to get"abc"
(1 operation). - From
"abc"
, you cannot find any repeating prefix pattern, so you must delete the entire remaining string (1 operation). - Total: 2 operations
The goal is to find the maximum number of operations needed to delete all characters from the string. You want to maximize the number of steps by choosing the optimal deletion strategy at each point.
The solution uses dynamic programming with memoization, where dfs(i)
represents the maximum number of operations needed to delete all characters starting from index i
. For each position, it tries all possible prefix lengths j
where the prefix s[i:i+j]
equals s[i+j:i+j+j]
, and chooses the option that maximizes the total number of operations.
Intuition
The key insight is that we want to maximize the number of operations, which means we should try to make as many small deletions as possible rather than deleting large chunks or the entire string at once.
Think about it this way: if we can find a repeating pattern at the beginning of our string, we have a choice. We could either:
- Delete the entire string (1 operation)
- Delete just the first occurrence of the pattern and continue with the remaining string
The second option is often better because it gives us more opportunities to perform additional operations on the remaining string.
This naturally leads to a recursive approach. At each step, we look at the current string and ask: "What's the maximum number of operations I can perform starting from here?"
For any position i
in the string, we need to check all possible prefix lengths j
(from 1 to half of the remaining string length). If we find that s[i:i+j]
equals s[i+j:i+2j]
, we've found a valid repeating pattern. We can delete the first j
characters and then solve the same problem for the substring starting at position i+j
.
The recursive nature suggests dynamic programming. We can use memoization to avoid recalculating the same subproblems. The function dfs(i)
represents "what's the maximum number of operations needed to delete everything from position i
onwards?"
The base case is when we've reached the end of the string (i == n
), where no more operations are needed, so we return 0.
For each position, we always have the option to delete the entire remaining string (which gives us 1 operation). But we also check if we can do better by finding repeating prefixes. Among all valid options, we choose the one that gives us the maximum number of operations: max(1, 1 + dfs(i+j))
for all valid j
.
This greedy strategy of always choosing the option that maximizes operations, combined with exploring all possibilities through recursion and memoization, gives us the optimal solution.
Learn more about Dynamic Programming patterns.
Solution Approach
The solution uses memoization search (dynamic programming with recursion) to find the maximum number of operations.
Core Function: dfs(i)
We define a recursive function dfs(i)
that calculates the maximum number of operations needed to delete all characters from index i
to the end of the string.
Base Case:
- When
i == n
(reached the end of string), return0
since no characters remain to delete.
Recursive Case:
For each position i
, we have two strategies:
- Delete the entire remaining substring
s[i:]
- this gives us exactly 1 operation - Look for repeating prefixes and delete them
Finding Repeating Prefixes
For the second strategy, we enumerate all possible prefix lengths j
where:
1 ≤ j ≤ (n - i) // 2
(the prefix can be at most half of the remaining string)- Check if
s[i:i+j] == s[i+j:i+2j]
(the prefix equals the immediately following substring)
When we find such a repeating pattern, we can:
- Delete the first
j
characters (the first occurrence of the pattern) - Recursively solve for the remaining string starting at position
i+j
- This gives us
1 + dfs(i+j)
operations total
Maximizing Operations
Among all valid options (including deleting the entire remaining string), we choose the one that maximizes the number of operations:
ans = 1 # Option to delete entire remaining string
for j in range(1, (n - i) // 2 + 1):
if s[i:i+j] == s[i+j:i+2j]:
ans = max(ans, 1 + dfs(i+j))
Memoization with @cache
The @cache
decorator is crucial for efficiency. Without it, we would recalculate dfs(i)
for the same positions multiple times. The decorator automatically stores the results of function calls, so when dfs(i)
is called with the same i
value again, it returns the cached result instead of recalculating.
Time Complexity Analysis
- For each position
i
, we check up to(n-i)/2
possible prefix lengths - String comparison
s[i:i+j] == s[i+j:i+2j]
takesO(j)
time - With memoization, each position is calculated only once
- Overall complexity:
O(n³)
wheren
is the length of the string
The solution starts by calling dfs(0)
to process the entire string from the beginning, and returns the maximum number of operations possible.
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 = "aabbaabb"
.
Starting at position 0: dfs(0)
processes the entire string "aabbaabb"
- Option 1: Delete entire string → 1 operation
- Option 2: Check for repeating prefixes:
- j=1: "a" vs "a" ✓ (matches!) → Delete "a", get 1 + dfs(1)
- j=2: "aa" vs "bb" ✗ (no match)
- j=3: "aab" vs "baa" ✗ (no match)
- j=4: "aabb" vs "aabb" ✓ (matches!) → Delete "aabb", get 1 + dfs(4)
Let's explore the j=4 path: 1 + dfs(4)
At position 4: dfs(4)
processes "aabb"
- Option 1: Delete entire string → 1 operation
- Option 2: Check for repeating prefixes:
- j=1: "a" vs "a" ✓ (matches!) → Delete "a", get 1 + dfs(5)
- j=2: "aa" vs "bb" ✗ (no match)
Following j=1: 1 + dfs(5)
At position 5: dfs(5)
processes "abb"
- Option 1: Delete entire string → 1 operation
- Option 2: Check for repeating prefixes:
- j=1: "a" vs "b" ✗ (no match)
- Best option: Delete entire string → 1 operation
Backtracking:
dfs(5)
= 1dfs(4)
= max(1, 1 + dfs(5)) = max(1, 1 + 1) = 2dfs(0)
= max(1, ..., 1 + dfs(4)) = max(1, ..., 1 + 2) = 3
The maximum number of operations is 3:
- Delete "aabb" from "aabbaabb" → "aabb"
- Delete "a" from "aabb" → "abb"
- Delete "abb" entirely
The memoization ensures that if we encounter the same substring position again (like if multiple paths lead to dfs(4)
), we don't recalculate it.
Solution Implementation
1class Solution:
2 def deleteString(self, s: str) -> int:
3 from functools import cache
4
5 n = len(s)
6
7 @cache
8 def dfs(start_index: int) -> int:
9 """
10 Recursively find the maximum number of operations to delete the string.
11
12 Args:
13 start_index: Current starting position in the string
14
15 Returns:
16 Maximum number of delete operations from current position
17 """
18 # Base case: reached the end of string
19 if start_index == n:
20 return 0
21
22 # At minimum, we can delete the entire remaining string as one operation
23 max_operations = 1
24
25 # Try all possible lengths for the first part (up to half of remaining string)
26 # Since we need two equal consecutive parts, max length is half of remaining
27 max_length = (n - start_index) // 2
28
29 for length in range(1, max_length + 1):
30 # Extract first part and second part of equal length
31 first_part = s[start_index : start_index + length]
32 second_part = s[start_index + length : start_index + 2 * length]
33
34 # If the two consecutive parts are equal, we can delete the first part
35 if first_part == second_part:
36 # Recursively solve for the remaining string after deleting first part
37 operations = 1 + dfs(start_index + length)
38 max_operations = max(max_operations, operations)
39
40 return max_operations
41
42 # Start the recursion from index 0
43 return dfs(0)
44
1class Solution {
2 private int stringLength;
3 private Integer[] memo; // memoization array for dynamic programming
4 private int[][] lcp; // longest common prefix array
5
6 public int deleteString(String s) {
7 stringLength = s.length();
8 memo = new Integer[stringLength];
9 lcp = new int[stringLength + 1][stringLength + 1];
10
11 // Build the longest common prefix (LCP) array
12 // lcp[i][j] represents the length of common prefix starting at positions i and j
13 for (int i = stringLength - 1; i >= 0; i--) {
14 for (int j = i + 1; j < stringLength; j++) {
15 if (s.charAt(i) == s.charAt(j)) {
16 // If characters match, extend the common prefix from the next positions
17 lcp[i][j] = lcp[i + 1][j + 1] + 1;
18 }
19 }
20 }
21
22 // Start the recursive search from position 0
23 return dfs(0);
24 }
25
26 private int dfs(int startIndex) {
27 // Base case: reached the end of the string
28 if (startIndex == stringLength) {
29 return 0;
30 }
31
32 // Return memoized result if already computed
33 if (memo[startIndex] != null) {
34 return memo[startIndex];
35 }
36
37 // At minimum, we can delete the entire remaining string as one operation
38 memo[startIndex] = 1;
39
40 // Try all possible lengths for the first part to delete
41 // We can only delete if the first part equals the second part
42 for (int length = 1; length <= (stringLength - startIndex) / 2; length++) {
43 // Check if substring starting at startIndex with given length
44 // equals substring starting at (startIndex + length) with same length
45 if (lcp[startIndex][startIndex + length] >= length) {
46 // If equal substrings found, recursively solve for remaining string
47 memo[startIndex] = Math.max(memo[startIndex], 1 + dfs(startIndex + length));
48 }
49 }
50
51 return memo[startIndex];
52 }
53}
54
1class Solution {
2public:
3 int deleteString(string s) {
4 int n = s.size();
5
6 // lcp[i][j] stores the length of longest common prefix
7 // starting at index i and index j in string s
8 int lcp[n + 1][n + 1];
9 memset(lcp, 0, sizeof(lcp));
10
11 // Build LCP table using dynamic programming
12 // Process from right to left to build bottom-up
13 for (int i = n - 1; i >= 0; --i) {
14 for (int j = i + 1; j < n; ++j) {
15 if (s[i] == s[j]) {
16 // If characters match, extend the common prefix length
17 lcp[i][j] = lcp[i + 1][j + 1] + 1;
18 }
19 }
20 }
21
22 // dp[i] stores the maximum number of operations possible
23 // starting from index i to the end of string
24 int dp[n];
25 memset(dp, 0, sizeof(dp));
26
27 // Recursive function with memoization to find maximum operations
28 function<int(int)> dfs = [&](int startIdx) -> int {
29 // Base case: reached end of string
30 if (startIdx == n) {
31 return 0;
32 }
33
34 // Return memoized result if already computed
35 if (dp[startIdx]) {
36 return dp[startIdx];
37 }
38
39 // Minimum is 1 (delete entire remaining string)
40 dp[startIdx] = 1;
41
42 // Try all possible prefix lengths from 1 to half of remaining string
43 // (prefix must match the next substring of same length)
44 for (int prefixLen = 1; prefixLen <= (n - startIdx) / 2; ++prefixLen) {
45 // Check if prefix matches the next substring of same length
46 if (lcp[startIdx][startIdx + prefixLen] >= prefixLen) {
47 // If match found, we can delete this prefix and continue
48 dp[startIdx] = max(dp[startIdx], 1 + dfs(startIdx + prefixLen));
49 }
50 }
51
52 return dp[startIdx];
53 };
54
55 // Start from index 0 and find maximum operations
56 return dfs(0);
57 }
58};
59
1/**
2 * Finds the maximum number of operations to delete a string by repeatedly removing
3 * equal-length prefixes that are identical
4 * @param s - The input string to process
5 * @returns The maximum number of deletion operations possible
6 */
7function deleteString(s: string): number {
8 const stringLength: number = s.length;
9
10 // Memoization array to store results of subproblems
11 // memo[i] represents the maximum operations starting from index i
12 const memo: number[] = new Array(stringLength).fill(0);
13
14 /**
15 * Depth-first search with memoization to find maximum operations
16 * @param startIndex - The current starting position in the string
17 * @returns Maximum number of operations from the current position
18 */
19 const dfs = (startIndex: number): number => {
20 // Base case: reached the end of string
21 if (startIndex === stringLength) {
22 return 0;
23 }
24
25 // Return memoized result if already computed
26 if (memo[startIndex] > 0) {
27 return memo[startIndex];
28 }
29
30 // Initialize with minimum value (can always delete single character)
31 memo[startIndex] = 1;
32
33 // Try all possible prefix lengths from current position
34 // Maximum prefix length is half of remaining string length
35 const remainingLength: number = stringLength - startIndex;
36 const maxPrefixLength: number = remainingLength >> 1; // Bitwise right shift for division by 2
37
38 for (let prefixLength = 1; prefixLength <= maxPrefixLength; prefixLength++) {
39 // Check if the prefix matches the following substring of same length
40 const firstPart: string = s.slice(startIndex, startIndex + prefixLength);
41 const secondPart: string = s.slice(startIndex + prefixLength, startIndex + 2 * prefixLength);
42
43 if (firstPart === secondPart) {
44 // If match found, recursively solve for remaining string
45 // Add 1 for current operation
46 memo[startIndex] = Math.max(
47 memo[startIndex],
48 dfs(startIndex + prefixLength) + 1
49 );
50 }
51 }
52
53 return memo[startIndex];
54 };
55
56 // Start the recursive search from index 0
57 return dfs(0);
58}
59
Time and Space Complexity
The time complexity is O(n^3)
, and the space complexity is O(n^2)
.
Time Complexity Analysis:
- The
dfs
function can be called with at mostn
different values ofi
(from 0 to n-1). - For each call to
dfs(i)
, the loop iterates up to(n-i)//2
times, which isO(n)
in the worst case. - Inside each iteration, the string comparison
s[i:i+j] == s[i+j:i+j+j]
takesO(j)
time, wherej
can be up ton/2
. - Therefore, for each
dfs
call, the work done isO(n^2)
(summing up all the string comparisons). - Since we have
O(n)
unique calls due to memoization, the total time complexity isO(n) * O(n^2) = O(n^3)
.
Space Complexity Analysis:
- The
@cache
decorator stores the results of all unique calls todfs
, which can be at mostn
different values. - The recursion depth can be at most
O(n)
in the worst case. - Each string slicing operation
s[i:i+j]
creates a new string, but these are temporary and don't accumulate. - The dominant space usage comes from the cache storing
O(n)
results and the string slicing operations which can create strings of total lengthO(n^2)
across all comparisons. - Therefore, the space complexity is
O(n^2)
.
Note: The reference answer states O(n^2)
for time complexity, which would be achievable with more efficient string comparison methods like using rolling hash or Z-algorithm, but the given implementation has O(n^3)
time complexity due to the naive string comparison.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. String Slicing Performance Overhead
The most common pitfall in this solution is the repeated string slicing operations s[i:i+j] == s[i+j:i+2j]
inside the loop. Each string slice creates a new string object, which takes O(j) time and space. When this happens for every possible length at every position, it significantly impacts performance.
Problem Example:
# This comparison creates two new string objects every time if s[start_index : start_index + length] == s[start_index + length : start_index + 2 * length]:
Solution: Use character-by-character comparison or precompute a 2D table for substring equality:
class Solution:
def deleteString(self, s: str) -> int:
from functools import cache
n = len(s)
# Precompute substring equality in O(n²)
# equal[i][j] = True if s[i:i+j] == s[i+j:i+2j]
equal = [[False] * (n // 2 + 1) for _ in range(n)]
for i in range(n):
for length in range(1, (n - i) // 2 + 1):
is_equal = True
for k in range(length):
if s[i + k] != s[i + length + k]:
is_equal = False
break
equal[i][length] = is_equal
@cache
def dfs(start_index: int) -> int:
if start_index == n:
return 0
max_operations = 1
max_length = (n - start_index) // 2
for length in range(1, max_length + 1):
# Use precomputed result instead of string slicing
if equal[start_index][length]:
operations = 1 + dfs(start_index + length)
max_operations = max(max_operations, operations)
return max_operations
return dfs(0)
2. Forgetting to Clear Cache Between Test Cases
When using @cache
decorator in competitive programming platforms or when running multiple test cases, the cache persists between function calls. This can lead to incorrect results if the function is not properly isolated.
Problem Example:
@cache
def dfs(i): # Cache persists across different string inputs!
# ...
Solution: Either define the cached function inside the main function (as shown in the original solution) or manually clear the cache:
class Solution:
def deleteString(self, s: str) -> int:
# Define dfs inside deleteString so each call gets a fresh cache
@cache
def dfs(start_index: int) -> int:
# implementation
pass
result = dfs(0)
dfs.cache_clear() # Optional: explicitly clear cache
return result
3. Off-by-One Errors in Range Calculation
A subtle but common mistake is incorrectly calculating the maximum valid length for the prefix:
Problem Example:
# Wrong: might go out of bounds
for length in range(1, n - start_index): # Should be (n - start_index) // 2 + 1
Solution: Always ensure the range is range(1, (n - start_index) // 2 + 1)
to guarantee both parts fit within the remaining string.
4. Missing Base Case or Incorrect Return Value
Forgetting to handle the base case when the entire string has been processed:
Problem Example:
def dfs(start_index):
# Missing base case - will cause infinite recursion
max_operations = 1
# ...
Solution: Always check if start_index == n
and return 0 at the beginning of the function.
Which technique can we use to find the middle of a linked list?
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!