3003. Maximize the Number of Partitions After Operations
Problem Description
You are given a string s
consisting of lowercase English letters and an integer k
.
The problem allows you to perform two operations:
-
Optional Change Operation: You can change at most one character in the string
s
to any other lowercase English letter. This step is optional - you can choose to change one character or leave the string unchanged. -
Partitioning Operation: After the optional change, you must partition the string using the following process:
- Start from the beginning of
s
- Find the longest prefix that contains at most
k
distinct characters - Remove this prefix from
s
and count it as one partition - Repeat this process with the remaining string until
s
becomes empty - The characters that remain after removing each prefix maintain their original order
- Start from the beginning of
Your goal is to find the maximum number of partitions you can create by optimally choosing whether to change a character (and which one to change if you do).
Example Walkthrough:
If s = "aabaab"
and k = 2
:
-
Without changing any character:
- First partition: "aabaa" (contains 2 distinct characters: 'a' and 'b')
- Second partition: "b" (contains 1 distinct character: 'b')
- Total: 2 partitions
-
If we change the character at index 2 from 'b' to 'a':
- New string: "aaaaab"
- First partition: "aaaaa" (contains 1 distinct character: 'a')
- Second partition: "b" (contains 1 distinct character: 'b')
- Total: 2 partitions
The task is to determine the optimal change (if any) that maximizes the number of resulting partitions.
Intuition
The key insight is that we need to explore two scenarios: partitioning with the original string and partitioning after changing one character. Since we want to maximize partitions, we need to consider what happens when we change any character at any position.
When we're processing the string character by character, at each position we face a decision point. If adding the current character to our current partition would exceed k
distinct characters, we must start a new partition. This greedy approach of taking the longest possible prefix ensures we're not creating unnecessary partitions.
The challenge is handling the "change at most one character" constraint efficiently. We can't simply try changing every character to every possible letter and recompute partitions from scratch - that would be too expensive. Instead, we need a way to track our state as we go through the string.
We can think of this as a dynamic programming problem where our state includes:
- Our current position in the string (
i
) - The set of distinct characters in the current partition (
cur
) - Whether we still have the option to change a character (
t
)
For tracking distinct characters efficiently, we can use bit manipulation. Each bit in an integer represents whether a specific character (a-z) is present in the current partition. This allows us to quickly check how many distinct characters we have using bit_count()
and combine character sets using bitwise OR.
At each position, we have several choices:
- If we haven't changed a character yet (
t = 1
), we can either:- Keep the current character and continue
- Change it to any of the 26 letters and continue
- If we've already used our change (
t = 0
), we just process the current character
When the current partition would exceed k
distinct characters after adding a character, we start a new partition. The recursion naturally handles trying all possibilities and returns the maximum number of partitions achievable.
The memoization (@cache
) is crucial here because the same state (position, current character set, change availability) can be reached through different paths, and we don't want to recompute the result each time.
Learn more about Dynamic Programming and Bitmask patterns.
Solution Approach
The solution uses dynamic programming with memoization to explore all possible partitioning strategies. Here's how the implementation works:
State Representation:
i
: Current position in the stringcur
: Bitmask representing the set of distinct characters in the current partitiont
: Boolean flag (1 or 0) indicating whether we can still change a character
Main Function Setup:
n = len(s)
return dfs(0, 0, 1)
We start at position 0, with an empty character set (bitmask = 0), and the ability to change one character (t = 1
).
Recursive DFS Function:
-
Base Case:
if i >= n: return 1
When we've processed all characters, we have one final partition.
-
Process Current Character:
v = 1 << (ord(s[i]) - ord("a")) nxt = cur | v
- Convert the current character to its corresponding bit position (0-25 for 'a'-'z')
- Create a bitmask
v
with only that bit set - Combine with current partition's character set using bitwise OR
-
Check if New Partition is Needed:
if nxt.bit_count() > k: ans = dfs(i + 1, v, t) + 1 else: ans = dfs(i + 1, nxt, t)
- If adding the current character exceeds
k
distinct characters, start a new partition - The new partition begins with just the current character (
v
) - Add 1 to the partition count when starting a new partition
- Otherwise, continue with the expanded character set
- If adding the current character exceeds
-
Consider Changing Current Character (if allowed):
if t: for j in range(26): nxt = cur | (1 << j) if nxt.bit_count() > k: ans = max(ans, dfs(i + 1, 1 << j, 0) + 1) else: ans = max(ans, dfs(i + 1, nxt, 0))
- If we haven't used our change yet (
t = 1
), try changing the current character to each of the 26 letters - For each possible letter
j
:- Calculate what the character set would be if we change to that letter
- If it exceeds
k
distinct characters, start a new partition - Otherwise, continue with the current partition
- Set
t = 0
in recursive calls since we've used our change - Keep track of the maximum partitions achievable
- If we haven't used our change yet (
Memoization:
The @cache
decorator automatically memoizes the function results. This prevents redundant calculations when the same state (i, cur, t)
is reached through different paths.
Time Complexity:
- Without memoization: O(26^n) in the worst case
- With memoization: O(n × 2^26 × 2) states, but in practice much fewer states are visited since we rarely have all 26 characters in a partition
Space Complexity: O(n × 2^26 × 2) for the memoization cache in the worst case, though actual usage is typically much smaller.
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 small example with s = "abb"
and k = 1
to illustrate how the solution explores different possibilities.
Initial State: We start with dfs(0, 0, 1)
- position 0, empty character set (bitmask = 0), can still change a character.
Step 1: Process index 0 (character 'a')
-
Current character 'a' has bitmask:
1 << 0 = 1
(bit 0 set) -
Adding 'a' to empty set:
0 | 1 = 1
(one distinct character) -
Since
bit_count(1) = 1 ≤ k
, we can add 'a' to current partition -
We explore two paths:
Path A: Keep 'a' unchanged
- Continue with
dfs(1, 1, 1)
- move to index 1, partition has {'a'}, still can change
Path B: Change 'a' to another letter
- Try all 26 letters. For example, changing 'a' to 'b':
- New bitmask:
1 << 1 = 2
(bit 1 set for 'b') - Continue with
dfs(1, 2, 0)
- move to index 1, partition has {'b'}, used our change
- New bitmask:
- Continue with
Step 2: Following Path A - at index 1 (character 'b')
- State:
dfs(1, 1, 1)
- partition currently has {'a'} - Current character 'b' has bitmask:
1 << 1 = 2
- Adding 'b' to {'a'}:
1 | 2 = 3
(bits 0 and 1 set) - Since
bit_count(3) = 2 > k
, we must start a new partition - Result:
dfs(2, 2, 1) + 1
- new partition starts with {'b'}, add 1 for completing previous partition
Step 3: Continuing Path A - at index 2 (character 'b')
- State:
dfs(2, 2, 1)
- partition currently has {'b'} - Current character 'b' has bitmask:
1 << 1 = 2
- Adding 'b' to {'b'}:
2 | 2 = 2
(still just 'b') - Since
bit_count(2) = 1 ≤ k
, we can add 'b' to current partition - Continue with
dfs(3, 2, 1)
Step 4: Base case reached
dfs(3, 2, 1)
returns 1 (one final partition)- Backtracking: Step 3 returns 1, Step 2 returns 1+1=2, Step 1 Path A returns 2
Alternative: Following Path B where we changed 'a' to 'b'
- At index 1 with state
dfs(1, 2, 0)
, partition has {'b'}, no changes left - Current character 'b' has bitmask:
2
- Adding 'b' to {'b'}:
2 | 2 = 2
(still one distinct character) - Continue to index 2, then base case
- This path gives us only 1 partition total: "bbb"
Final Result: The algorithm explores all possibilities and returns the maximum. Path A (keeping original string) gives 2 partitions: "a" and "bb". This is better than changing 'a' to 'b' which gives only 1 partition: "bbb".
The memoization ensures that if we reach the same state (i, cur, t)
through different paths, we only compute it once.
Solution Implementation
1class Solution:
2 def maxPartitionsAfterOperations(self, s: str, k: int) -> int:
3 from functools import cache
4
5 @cache
6 def dfs(index: int, current_chars: int, can_change: int) -> int:
7 """
8 Dynamic programming function to find maximum partitions.
9
10 Args:
11 index: Current position in string
12 current_chars: Bitmask representing characters in current partition
13 can_change: Flag indicating if we can still change one character (1 = can change, 0 = already used)
14
15 Returns:
16 Maximum number of partitions from current position
17 """
18 # Base case: reached end of string
19 if index >= n:
20 return 1
21
22 # Get bitmask for current character
23 char_bit = 1 << (ord(s[index]) - ord('a'))
24
25 # Add current character to the partition
26 next_chars = current_chars | char_bit
27
28 # Check if adding current character exceeds k distinct characters
29 if next_chars.bit_count() > k:
30 # Start new partition with current character
31 result = dfs(index + 1, char_bit, can_change) + 1
32 else:
33 # Continue with current partition
34 result = dfs(index + 1, next_chars, can_change)
35
36 # If we can still change one character
37 if can_change:
38 # Try changing current character to each possible letter
39 for letter_index in range(26):
40 # Create bitmask for the new character
41 next_chars = current_chars | (1 << letter_index)
42
43 if next_chars.bit_count() > k:
44 # Start new partition with the changed character
45 result = max(result, dfs(index + 1, 1 << letter_index, 0) + 1)
46 else:
47 # Continue with current partition including changed character
48 result = max(result, dfs(index + 1, next_chars, 0))
49
50 return result
51
52 # Store string length
53 n = len(s)
54
55 # Start DFS from index 0, empty partition, with ability to change one character
56 return dfs(0, 0, 1)
57
1class Solution {
2 // Memoization map: key is [position, bitmask of current partition, can change flag]
3 private Map<List<Integer>, Integer> memo = new HashMap<>();
4 private String inputString;
5 private int maxDistinctChars;
6
7 public int maxPartitionsAfterOperations(String s, int k) {
8 this.inputString = s;
9 this.maxDistinctChars = k;
10 // Start DFS from position 0, empty bitmask, with 1 change allowed
11 return dfs(0, 0, 1);
12 }
13
14 /**
15 * Dynamic programming with memoization to find maximum partitions
16 * @param index - current position in string
17 * @param currentMask - bitmask representing distinct characters in current partition
18 * @param canChange - flag indicating if we can still change a character (1 = yes, 0 = no)
19 * @return maximum number of partitions from current state
20 */
21 private int dfs(int index, int currentMask, int canChange) {
22 // Base case: reached end of string
23 if (index >= inputString.length()) {
24 return 1;
25 }
26
27 // Check memoization
28 var memoKey = List.of(index, currentMask, canChange);
29 if (memo.containsKey(memoKey)) {
30 return memo.get(memoKey);
31 }
32
33 // Get bitmask for current character
34 int charBit = 1 << (inputString.charAt(index) - 'a');
35
36 // Try not changing current character
37 int newMask = currentMask | charBit;
38 int maxPartitions;
39
40 if (Integer.bitCount(newMask) > maxDistinctChars) {
41 // Adding current char exceeds k distinct chars, start new partition
42 maxPartitions = dfs(index + 1, charBit, canChange) + 1;
43 } else {
44 // Continue with current partition
45 maxPartitions = dfs(index + 1, newMask, canChange);
46 }
47
48 // Try changing current character (if we haven't used our change yet)
49 if (canChange > 0) {
50 // Try all 26 possible characters
51 for (int charIndex = 0; charIndex < 26; ++charIndex) {
52 newMask = currentMask | (1 << charIndex);
53
54 if (Integer.bitCount(newMask) > maxDistinctChars) {
55 // Changing to this char would exceed k distinct chars, start new partition
56 maxPartitions = Math.max(maxPartitions,
57 dfs(index + 1, 1 << charIndex, 0) + 1);
58 } else {
59 // Continue with current partition after change
60 maxPartitions = Math.max(maxPartitions,
61 dfs(index + 1, newMask, 0));
62 }
63 }
64 }
65
66 // Store result in memoization map
67 memo.put(memoKey, maxPartitions);
68 return maxPartitions;
69 }
70}
71
1class Solution {
2public:
3 int maxPartitionsAfterOperations(string s, int k) {
4 int n = s.size();
5 // Memoization map: key = (index << 32) | (charMask << 1) | canChange
6 unordered_map<long long, int> memo;
7
8 // DFS function to find maximum partitions
9 // Parameters:
10 // - index: current position in string
11 // - charMask: bitmask representing distinct characters in current partition
12 // - canChange: flag indicating if we can still change one character (1 = yes, 0 = no)
13 function<int(int, int, int)> dfs = [&](int index, int charMask, int canChange) {
14 // Base case: reached end of string, count last partition
15 if (index >= n) {
16 return 1;
17 }
18
19 // Create unique key for memoization
20 long long stateKey = (long long)index << 32 | charMask << 1 | canChange;
21
22 // Return memoized result if exists
23 if (memo.count(stateKey)) {
24 return memo[stateKey];
25 }
26
27 // Get bitmask for current character
28 int currentCharBit = 1 << (s[index] - 'a');
29
30 // Calculate new character mask if we add current character
31 int newCharMask = charMask | currentCharBit;
32
33 // Calculate result without changing current character
34 int maxPartitions;
35 if (__builtin_popcount(newCharMask) > k) {
36 // Too many distinct characters, start new partition
37 maxPartitions = dfs(index + 1, currentCharBit, canChange) + 1;
38 } else {
39 // Continue with current partition
40 maxPartitions = dfs(index + 1, newCharMask, canChange);
41 }
42
43 // Try changing current character if we still have the option
44 if (canChange) {
45 // Try replacing current character with each letter 'a' to 'z'
46 for (int letter = 0; letter < 26; ++letter) {
47 int replacementCharBit = 1 << letter;
48 newCharMask = charMask | replacementCharBit;
49
50 if (__builtin_popcount(newCharMask) > k) {
51 // Too many distinct characters, start new partition
52 maxPartitions = max(maxPartitions,
53 dfs(index + 1, replacementCharBit, 0) + 1);
54 } else {
55 // Continue with current partition
56 maxPartitions = max(maxPartitions,
57 dfs(index + 1, newCharMask, 0));
58 }
59 }
60 }
61
62 // Memoize and return result
63 return memo[stateKey] = maxPartitions;
64 };
65
66 // Start DFS from index 0, empty character mask, with ability to change one character
67 return dfs(0, 0, 1);
68 }
69};
70
1/**
2 * Finds the maximum number of partitions after performing operations on string s
3 * @param s - Input string containing lowercase letters
4 * @param k - Maximum number of distinct characters allowed in each partition
5 * @returns Maximum number of partitions possible
6 */
7function maxPartitionsAfterOperations(s: string, k: number): number {
8 const stringLength = s.length;
9 // Memoization map to store computed results for state combinations
10 const memo: Map<bigint, number> = new Map();
11
12 /**
13 * Depth-first search to find maximum partitions
14 * @param index - Current position in string
15 * @param currentCharMask - Bitmask representing characters in current partition
16 * @param canChange - Whether we can still change a character (1 = yes, 0 = no)
17 * @returns Maximum number of partitions from current state
18 */
19 const dfs = (index: number, currentCharMask: number, canChange: number): number => {
20 // Base case: reached end of string
21 if (index >= stringLength) {
22 return 1;
23 }
24
25 // Create unique key for memoization: (index << 27) | (currentCharMask << 1) | canChange
26 const stateKey = (BigInt(index) << 27n) | (BigInt(currentCharMask) << 1n) | BigInt(canChange);
27
28 // Return memoized result if exists
29 if (memo.has(stateKey)) {
30 return memo.get(stateKey)!;
31 }
32
33 // Get bitmask for current character (a=0, b=1, ..., z=25)
34 const currentCharBit = 1 << (s.charCodeAt(index) - 97);
35
36 // Add current character to partition mask
37 let nextMask = currentCharMask | currentCharBit;
38 let maxPartitions = 0;
39
40 // Check if adding current character exceeds k distinct characters
41 if (bitCount(nextMask) > k) {
42 // Start new partition with current character
43 maxPartitions = dfs(index + 1, currentCharBit, canChange) + 1;
44 } else {
45 // Continue with current partition
46 maxPartitions = dfs(index + 1, nextMask, canChange);
47 }
48
49 // Try changing current character if we haven't used our change yet
50 if (canChange) {
51 // Try all 26 possible characters
52 for (let charIndex = 0; charIndex < 26; ++charIndex) {
53 nextMask = currentCharMask | (1 << charIndex);
54
55 if (bitCount(nextMask) > k) {
56 // Start new partition with changed character
57 maxPartitions = Math.max(
58 maxPartitions,
59 dfs(index + 1, 1 << charIndex, 0) + 1
60 );
61 } else {
62 // Continue with current partition after change
63 maxPartitions = Math.max(
64 maxPartitions,
65 dfs(index + 1, nextMask, 0)
66 );
67 }
68 }
69 }
70
71 // Store result in memo
72 memo.set(stateKey, maxPartitions);
73 return maxPartitions;
74 };
75
76 // Start DFS from beginning with empty partition and ability to change one character
77 return dfs(0, 0, 1);
78}
79
80/**
81 * Counts the number of set bits (1s) in a 32-bit integer
82 * Uses bit manipulation for efficient counting
83 * @param i - Integer to count bits in
84 * @returns Number of set bits
85 */
86function bitCount(i: number): number {
87 // Count bits in groups of 2
88 i = i - ((i >>> 1) & 0x55555555);
89 // Count bits in groups of 4
90 i = (i & 0x33333333) + ((i >>> 2) & 0x33333333);
91 // Count bits in groups of 8
92 i = (i + (i >>> 4)) & 0x0f0f0f0f;
93 // Count bits in groups of 16
94 i = i + (i >>> 8);
95 // Count bits in groups of 32
96 i = i + (i >>> 16);
97 // Return final count (max 32, so only need 6 bits)
98 return i & 0x3f;
99}
100
Time and Space Complexity
Time Complexity: O(n * 2^k * 26)
The time complexity is determined by the memoized recursive function dfs
:
- The function has three parameters:
i
(ranging from 0 to n),cur
(a bitmask with at most k bits set), andt
(binary: 0 or 1) - The number of possible states for
cur
is at most2^k
(all possible subsets of k distinct characters) - The total number of unique states is
O(n * 2^k * 2)
- For each state where
t = 1
, we try all 26 possible character replacements, adding a factor of 26 - Therefore, the overall time complexity is
O(n * 2^k * 26)
Space Complexity: O(n * 2^k)
The space complexity consists of:
- The memoization cache stores at most
O(n * 2^k * 2) = O(n * 2^k)
states - The recursion stack depth is at most
O(n)
in the worst case - Since
2^k
is typically larger thann
for reasonable values of k, the dominant factor is the cache - Therefore, the overall space complexity is
O(n * 2^k)
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Incorrect Partition Boundary Handling
The Pitfall:
A common mistake is misunderstanding when to create a new partition. Developers often incorrectly assume that when the character count exceeds k
, they should:
- Keep the current partition as-is and start fresh with an empty partition
- Or include the problematic character in the current partition before splitting
Incorrect Implementation:
if next_chars.bit_count() > k: # WRONG: Starting with empty partition result = dfs(index + 1, 0, can_change) + 1 # Or WRONG: Including the character that causes overflow result = dfs(index, 0, can_change) + 1
Why It's Wrong:
When we exceed k
distinct characters, we must:
- End the current partition BEFORE adding the problematic character
- Start a new partition WITH the current character (not empty)
- Continue processing from the NEXT position
Correct Implementation:
if next_chars.bit_count() > k: # Start new partition with ONLY the current character result = dfs(index + 1, char_bit, can_change) + 1
2. Inefficient State Representation Leading to TLE
The Pitfall: Using a set or string to track distinct characters instead of a bitmask causes Time Limit Exceeded (TLE) due to:
- Expensive hashing operations for memoization
- Slower set operations compared to bitwise operations
- Larger memory footprint
Incorrect Implementation:
@cache
def dfs(index: int, current_chars: frozenset, can_change: int) -> int:
# Using frozenset for distinct characters
next_chars = current_chars | {s[index]}
if len(next_chars) > k:
# ...
Why It's Wrong:
frozenset
operations are O(n) for union and require hashing for cache- The cache size becomes unnecessarily large
- Bit operations are O(1) and integers hash efficiently
Correct Implementation:
# Use bitmask for O(1) operations
char_bit = 1 << (ord(s[index]) - ord('a'))
next_chars = current_chars | char_bit
if next_chars.bit_count() > k:
# ...
3. Off-by-One Error in Partition Counting
The Pitfall: Incorrectly counting partitions, especially at the base case or when starting new partitions.
Incorrect Implementation:
if index >= n: return 0 # WRONG: Missing the last partition # Or when creating new partition: if next_chars.bit_count() > k: result = dfs(index + 1, char_bit, can_change) # WRONG: Forgot to add 1
Why It's Wrong:
- When we reach the end of the string, we still have one final partition to count
- When starting a new partition, we're completing the previous one, so we must add 1
Correct Implementation:
if index >= n: return 1 # Count the final partition if next_chars.bit_count() > k: result = dfs(index + 1, char_bit, can_change) + 1 # Add 1 for completed partition
4. Exploring Redundant Character Changes
The Pitfall: When trying character changes, not optimizing which characters to try, leading to unnecessary computation.
Suboptimal Approach:
if can_change:
for letter_index in range(26): # Always trying all 26 letters
# ...
Optimization:
if can_change:
# Skip trying to change to the same character
original_char = ord(s[index]) - ord('a')
for letter_index in range(26):
if letter_index == original_char:
continue # Skip redundant change
# ...
Better Optimization: Only try characters that would actually affect the partition boundary:
- Characters already in the current partition (won't increase distinct count)
- Characters that would trigger a new partition
- Skip characters that would result in the same distinct count without benefit
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
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
Bitmask and Dynamic Programming Bit manipulation is a crucial aspect of computer programming and one of the most powerful tools for bit manipulation is bitmasks Let's first understand what a bit is A bit is a binary digit It's the smallest piece of data in a computer and can be
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!