1525. Number of Good Ways to Split a String
Problem Description
You have a string s
that you need to split into two parts. The split is considered "good" when:
- You divide the string into two non-empty parts:
s_left
ands_right
- When concatenated, they form the original string:
s_left + s_right = s
- The number of distinct letters in
s_left
equals the number of distinct letters ins_right
Your task is to count how many different positions in the string allow for a "good" split.
For example, if s = "aacaba"
:
- Splitting at position 1:
"a"
and"acaba"
→ 1 distinct letter vs 3 distinct letters (not good) - Splitting at position 2:
"aa"
and"caba"
→ 1 distinct letter vs 3 distinct letters (not good) - Splitting at position 3:
"aac"
and"aba"
→ 2 distinct letters vs 2 distinct letters (good!) - Splitting at position 4:
"aaca"
and"ba"
→ 2 distinct letters vs 2 distinct letters (good!) - Splitting at position 5:
"aacab"
and"a"
→ 3 distinct letters vs 1 distinct letter (not good)
The solution uses a two-pointer approach with a counter. It maintains:
cnt
: A counter tracking distinct characters and their frequencies in the right part (initially the entire string)vis
: A set tracking distinct characters in the left part (initially empty)
As we iterate through the string, we move each character from the right part to the left part by:
- Adding the character to
vis
(left part's distinct characters) - Decreasing its count in
cnt
and removing it fromcnt
if count reaches 0 - Checking if the number of distinct characters is equal:
len(vis) == len(cnt)
The algorithm counts all positions where this equality holds, giving us the total number of good splits.
Intuition
The key insight is that we need to efficiently track the number of distinct characters on both sides of each potential split point. Instead of recalculating the distinct characters for each split from scratch (which would be inefficient), we can use a sliding approach.
Think of it like transferring items from one box to another. Initially, all characters are in the "right" box, and the "left" box is empty. As we move through the string character by character, we transfer each character from the right box to the left box. This way, we only need to update our counts incrementally rather than recalculating everything.
The clever part is how we track distinct characters:
- For the left side, we use a
set
because we only care about which characters are present, not their frequencies - For the right side, we use a
Counter
that tracks frequencies, because when a character's count drops to zero, it's no longer distinct in the right part
As we iterate through position i
in the string:
- Everything before position
i
forms the left part - Everything from position
i
onwards forms the right part - We move character at position
i
from right to left
This transforms an O(n²) problem (checking every split and counting distinct characters each time) into an O(n) solution. We're essentially maintaining a running state of distinct characters on both sides, updating it in O(1) time as we move our split point through the string.
The condition len(vis) == len(cnt)
elegantly checks if both sides have the same number of distinct characters. When this is true, we've found a good split and increment our answer counter.
Learn more about Dynamic Programming patterns.
Solution Approach
The solution implements a sliding window technique with two tracking mechanisms for distinct characters:
-
Initialize the right side counter: Create a
Counter
objectcnt
that contains all characters in the string with their frequencies. This represents all distinct characters initially in the right part. -
Initialize the left side tracker: Create an empty
set
calledvis
to track distinct characters in the left part. -
Iterate through each character: Process each character
c
in strings
one by one:a. Add to left side: Add character
c
to thevis
set. This marks it as present in the left part (duplicates are automatically handled by the set).b. Remove from right side: Decrease the count of
c
incnt
by 1. If the count reaches 0, remove the character fromcnt
entirely usingcnt.pop(c)
. This ensurescnt
only contains characters that still exist in the right part.c. Check for good split: Compare
len(vis)
withlen(cnt)
. If they're equal, we have found a good split. Add 1 toans
(using the boolean-to-integer conversion whereTrue
becomes 1 andFalse
becomes 0). -
Return the result: After processing all characters, return
ans
which contains the total count of good splits.
Time Complexity: O(n) where n is the length of the string. We iterate through the string once, and each operation inside the loop (set addition, counter update, length comparison) takes O(1) time.
Space Complexity: O(k) where k is the number of distinct characters in the string. Both cnt
and vis
store at most k distinct characters.
The elegance of this approach lies in maintaining a dynamic view of distinct characters on both sides without recalculating from scratch at each position.
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 = "ababa"
:
Initial Setup:
cnt = Counter({'a': 3, 'b': 2})
- all characters are in the right partvis = set()
- left part is emptyans = 0
Iteration 1: Process 'a' at index 0
- Add 'a' to
vis
:vis = {'a'}
- Decrease 'a' in
cnt
:cnt = {'a': 2, 'b': 2}
- Check:
len(vis) = 1
,len(cnt) = 2
→ Not equal, no increment - Split would be:
"a" | "baba"
(1 vs 2 distinct)
Iteration 2: Process 'b' at index 1
- Add 'b' to
vis
:vis = {'a', 'b'}
- Decrease 'b' in
cnt
:cnt = {'a': 2, 'b': 1}
- Check:
len(vis) = 2
,len(cnt) = 2
→ Equal!ans = 1
- Split would be:
"ab" | "aba"
(2 vs 2 distinct) ✓
Iteration 3: Process 'a' at index 2
- Add 'a' to
vis
:vis = {'a', 'b'}
(no change, 'a' already present) - Decrease 'a' in
cnt
:cnt = {'a': 1, 'b': 1}
- Check:
len(vis) = 2
,len(cnt) = 2
→ Equal!ans = 2
- Split would be:
"aba" | "ba"
(2 vs 2 distinct) ✓
Iteration 4: Process 'b' at index 3
- Add 'b' to
vis
:vis = {'a', 'b'}
(no change, 'b' already present) - Decrease 'b' in
cnt
:cnt = {'a': 1, 'b': 0}
→ Remove 'b' →cnt = {'a': 1}
- Check:
len(vis) = 2
,len(cnt) = 1
→ Not equal, no increment - Split would be:
"abab" | "a"
(2 vs 1 distinct)
Result: ans = 2
(there are 2 good splits possible)
The key insight: as we move through the string, we're efficiently transferring characters from right to left, updating our distinct character counts in constant time rather than recounting for each split.
Solution Implementation
1from collections import Counter
2
3class Solution:
4 def numSplits(self, s: str) -> int:
5 # Count frequency of each character in the entire string
6 right_counter = Counter(s)
7
8 # Track unique characters in the left partition
9 left_unique = set()
10
11 # Initialize count of good splits
12 good_splits = 0
13
14 # Iterate through each character to simulate splitting
15 for char in s:
16 # Add current character to left partition
17 left_unique.add(char)
18
19 # Remove one occurrence from right partition
20 right_counter[char] -= 1
21
22 # If character count reaches 0, remove it from right partition
23 if right_counter[char] == 0:
24 right_counter.pop(char)
25
26 # Check if both partitions have equal number of unique characters
27 if len(left_unique) == len(right_counter):
28 good_splits += 1
29
30 return good_splits
31
1class Solution {
2 public int numSplits(String s) {
3 // Map to store character frequency for the right part of the split
4 Map<Character, Integer> rightCharCount = new HashMap<>();
5
6 // Count frequency of each character in the entire string
7 for (char c : s.toCharArray()) {
8 rightCharCount.merge(c, 1, Integer::sum);
9 }
10
11 // Set to track unique characters in the left part of the split
12 Set<Character> leftUniqueChars = new HashSet<>();
13
14 // Counter for valid splits
15 int validSplitCount = 0;
16
17 // Iterate through string, moving characters from right to left part
18 for (char c : s.toCharArray()) {
19 // Add current character to left part
20 leftUniqueChars.add(c);
21
22 // Remove one occurrence of current character from right part
23 if (rightCharCount.merge(c, -1, Integer::sum) == 0) {
24 // If character count becomes 0, remove it from the map
25 rightCharCount.remove(c);
26 }
27
28 // Check if both parts have equal number of unique characters
29 if (leftUniqueChars.size() == rightCharCount.size()) {
30 validSplitCount++;
31 }
32 }
33
34 return validSplitCount;
35 }
36}
37
1class Solution {
2public:
3 int numSplits(string s) {
4 // Map to store frequency of each character in the right part
5 unordered_map<char, int> rightFreq;
6
7 // Initially, all characters are in the right part
8 for (char& c : s) {
9 ++rightFreq[c];
10 }
11
12 // Set to store unique characters in the left part
13 unordered_set<char> leftUnique;
14
15 // Counter for valid splits
16 int validSplits = 0;
17
18 // Iterate through the string, moving characters from right to left
19 for (char& c : s) {
20 // Add current character to the left part
21 leftUnique.insert(c);
22
23 // Remove one occurrence from the right part
24 if (--rightFreq[c] == 0) {
25 // If no more occurrences in right part, remove from map
26 rightFreq.erase(c);
27 }
28
29 // Check if both parts have equal number of unique characters
30 if (leftUnique.size() == rightFreq.size()) {
31 ++validSplits;
32 }
33 }
34
35 return validSplits;
36 }
37};
38
1function numSplits(s: string): number {
2 // Map to store frequency of each character in the right part
3 const rightFreq: Map<string, number> = new Map();
4
5 // Initially, all characters are in the right part
6 for (const char of s) {
7 rightFreq.set(char, (rightFreq.get(char) || 0) + 1);
8 }
9
10 // Set to store unique characters in the left part
11 const leftUnique: Set<string> = new Set();
12
13 // Counter for valid splits
14 let validSplits: number = 0;
15
16 // Iterate through the string, moving characters from right to left
17 for (const char of s) {
18 // Add current character to the left part
19 leftUnique.add(char);
20
21 // Remove one occurrence from the right part
22 const currentCount = rightFreq.get(char)! - 1;
23 if (currentCount === 0) {
24 // If no more occurrences in right part, remove from map
25 rightFreq.delete(char);
26 } else {
27 // Update the count in the map
28 rightFreq.set(char, currentCount);
29 }
30
31 // Check if both parts have equal number of unique characters
32 if (leftUnique.size === rightFreq.size) {
33 validSplits++;
34 }
35 }
36
37 return validSplits;
38}
39
Time and Space Complexity
Time Complexity: O(n)
The algorithm iterates through the string s
exactly once, where n
is the length of the string. Within each iteration:
- Adding an element to the set
vis
takesO(1)
time - Decrementing the counter value takes
O(1)
time - Popping from the counter when count reaches 0 takes
O(1)
time - Comparing the lengths of
vis
andcnt
takesO(1)
time
Since we perform n
iterations with O(1)
operations in each iteration, the overall time complexity is O(n)
.
Space Complexity: O(k)
where k
is the number of unique characters in the string
The space usage comes from:
cnt
: A Counter object that stores at mostk
unique characters and their frequencies, requiringO(k)
spacevis
: A set that stores at mostk
unique characters, requiringO(k)
space
Since k
is bounded by the size of the alphabet (at most 26 for lowercase English letters), we can also express this as O(1)
for a fixed alphabet size. However, in the general case where k
can vary with input, the space complexity is O(k)
.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
1. Off-by-One Error in Split Boundary
A common mistake is misunderstanding where the split occurs. When iterating through the string, developers might incorrectly think the last character should be included in the split count, but the problem requires both parts to be non-empty. The last iteration would leave the right part empty, making it an invalid split.
Incorrect approach:
for i in range(len(s)): # Wrong! This would include splitting after the last character
# Process split...
Correct approach:
for i in range(len(s) - 1): # Stop before the last character
# Or when using the character iteration approach:
for char in s[:-1]: # Exclude the last character
2. Not Properly Removing Characters from Counter
Forgetting to remove characters with zero count from the counter leads to incorrect distinct character counts. The counter would still contain keys with 0 values, inflating the distinct character count for the right part.
Incorrect approach:
right_counter[char] -= 1 # Forgot to remove when count reaches 0 # len(right_counter) would include characters with 0 count
Correct approach:
right_counter[char] -= 1 if right_counter[char] == 0: right_counter.pop(char) # Must remove to get accurate distinct count
3. Using List Instead of Set for Left Part
Using a list to track distinct characters in the left part would count duplicates, giving an incorrect count of distinct characters.
Incorrect approach:
left_chars = []
left_chars.append(char) # Would add duplicates
if len(left_chars) == len(right_counter): # Wrong count!
Correct approach:
left_unique = set()
left_unique.add(char) # Automatically handles duplicates
if len(left_unique) == len(right_counter): # Correct distinct count
4. Reinitializing Counter in Each Iteration
Some might try to create a new counter for each potential split position, leading to O(n²) time complexity instead of O(n).
Inefficient approach:
for i in range(len(s) - 1):
left_part = s[:i+1]
right_part = s[i+1:]
left_distinct = len(set(left_part)) # O(n) operation
right_distinct = len(set(right_part)) # O(n) operation
Efficient approach: Use the sliding window technique with incremental updates as shown in the solution, maintaining O(n) overall complexity.
Which of the following is a min heap?
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!