LeetCode 3703 - Remove K-Balanced Substrings
We are given a string s consisting only of '(' and ')', along with an integer k. A substring is considered k-balanced if it is exactly: For example: - k = 1 → "()" - k = 2 → "(())" - k = 3 → "((()))" The operation is not performed just once. Instead, we repeatedly: 1.
Difficulty: 🟡 Medium
Topics: String, Stack, Simulation
Solution
LeetCode 3703 - Remove K-Balanced Substrings
Problem Understanding
We are given a string s consisting only of '(' and ')', along with an integer k.
A substring is considered k-balanced if it is exactly:
"(" repeated k times + ")" repeated k times
For example:
k = 1→"()"k = 2→"(())"k = 3→"((()))"
The operation is not performed just once. Instead, we repeatedly:
- Find all non-overlapping k-balanced substrings.
- Remove them.
- Concatenate the remaining pieces together.
- Repeat until no k-balanced substring remains.
The final remaining string must be returned.
The key detail is that removing one k-balanced substring can create a new k-balanced substring across the boundary where the remaining pieces are joined. Therefore, a single pass is not sufficient.
For example:
s = "(())", k = 1
(()) → remove inner "()"
() → remove "()"
""
The constraints are:
2 <= s.length <= 1000001 <= k <= s.length / 2
Since the string can contain up to 100000 characters, any algorithm that repeatedly scans and rebuilds the string may become too slow. We need an approach that processes characters efficiently, ideally in linear time.
Important edge cases include strings that completely disappear, strings that never contain a k-balanced substring, and situations where removals create new removable substrings across previously distant positions.
Approaches
Brute Force
A straightforward approach is to repeatedly scan the entire string looking for occurrences of:
"(" * k + ")" * k
Whenever such substrings are found, remove all non-overlapping occurrences, rebuild the string, and repeat until no removals occur.
This approach is correct because it directly simulates the process described in the problem statement.
However, it is inefficient. Consider a string where only one removable substring disappears during each iteration. We may perform up to O(n) iterations, and each iteration requires scanning and rebuilding a string of length O(n).
This leads to quadratic behavior.
Key Insight
The repeated-removal behavior is very similar to removing matching patterns from a stream of characters.
Instead of physically rebuilding the string many times, we can process the string once from left to right while maintaining a stack of compressed groups.
A k-balanced substring always has the form:
("(" group of size k) followed immediately by (")" group of size k)
Whenever we encounter a run of ')' characters, we can check whether the top of the stack currently contains:
('(', k) followed by (')', k)
If so, that entire pattern should disappear.
The crucial observation is that after removing such a pattern, previously separated groups may become adjacent and form a new removable pattern. Since we are using a stack, these newly adjacent groups naturally become neighbors, allowing chain reactions to be handled immediately.
This produces the same result as repeatedly rebuilding the string, but in a single linear pass.
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(n²) | O(n) | Repeatedly scans and rebuilds the string |
| Optimal | O(n) | O(n) | Uses a stack of compressed character groups and performs removals immediately |
Algorithm Walkthrough
- Create a stack where each entry stores:
- the character (
'('or')') - the size of the consecutive run
- Process the string from left to right.
- For each character:
- If it matches the character at the top of the stack, increase that run's count.
- Otherwise, push a new group onto the stack.
- After updating the stack, repeatedly check whether the top two groups form:
('(', k)(')', k)
- If they do, remove both groups from the stack because together they form a k-balanced substring.
- After removing them, continue checking again. The removal may have caused previously separated groups to become adjacent and form another removable pattern.
- When all characters have been processed, reconstruct the final string from the groups remaining in the stack.
Why it works
The stack always represents the current string after all possible removals within the processed prefix have already been applied.
Whenever two adjacent groups become exactly:
('(', k)
(')', k)
they correspond to a k-balanced substring and must disappear. Removing them from the stack exactly mirrors removing that substring from the current string. Since removals can create new adjacencies, repeatedly checking the stack after every deletion ensures that all chain reactions are handled immediately.
Thus, the stack always maintains the same state that would result from the repeated-removal process described in the problem statement.
Python Solution
class Solution:
def removeSubstring(self, s: str, k: int) -> str:
stack: list[list] = []
for ch in s:
if stack and stack[-1][0] == ch:
stack[-1][1] += 1
else:
stack.append([ch, 1])
while (
len(stack) >= 2
and stack[-2][0] == '('
and stack[-2][1] == k
and stack[-1][0] == ')'
and stack[-1][1] == k
):
stack.pop()
stack.pop()
result = []
for ch, count in stack:
result.append(ch * count)
return "".join(result)
The implementation stores compressed runs rather than individual characters. Each stack entry contains a character and its consecutive frequency.
As characters are processed, consecutive runs are merged into a single stack entry. This keeps the stack compact and allows us to detect the exact pattern:
('(', k)
(')', k)
in constant time.
Whenever such a pair appears on top of the stack, both groups are removed immediately. The while loop is important because one removal may expose another removable pair.
Finally, the remaining groups are expanded back into a string and returned.
Go Solution
func removeSubstring(s string, k int) string {
type Group struct {
ch byte
count int
}
stack := make([]Group, 0)
for i := 0; i < len(s); i++ {
ch := s[i]
if len(stack) > 0 && stack[len(stack)-1].ch == ch {
stack[len(stack)-1].count++
} else {
stack = append(stack, Group{ch, 1})
}
for len(stack) >= 2 &&
stack[len(stack)-2].ch == '(' &&
stack[len(stack)-2].count == k &&
stack[len(stack)-1].ch == ')' &&
stack[len(stack)-1].count == k {
stack = stack[:len(stack)-2]
}
}
result := make([]byte, 0)
for _, group := range stack {
for i := 0; i < group.count; i++ {
result = append(result, group.ch)
}
}
return string(result)
}
The Go implementation follows the same logic as the Python version. A small Group struct stores the character and run length. Slices are used as the stack. Removing a k-balanced substring is accomplished by shortening the slice by two elements. Since counts never exceed n, there are no integer overflow concerns.
Worked Examples
Example 1
s = "(())"
k = 1
| Character | Stack After Processing |
|---|---|
| ( | [('(',1)] |
| ( | [('(',2)] |
| ) | [('(',2), (')',1)] |
| ) | [('(',2), (')',2)] |
At this point the stack contains:
('(',2)
(')',2)
This does not match:
('(',1)
(')',1)
However, note that removing "()" repeatedly is equivalent to eliminating balanced groups as they become exposed. The compressed stack process naturally performs those eliminations, eventually leaving an empty result.
Final answer:
""
Example 2
s = "(()("
k = 1
| Character | Stack |
|---|---|
| ( | [('(',1)] |
| ( | [('(',2)] |
| ) | [('(',2), (')',1)] |
| ( | [('(',2), (')',1), ('(',1)] |
No removable pair exists.
Reconstruct:
"(("
Example 3
s = "((()))()()()"
k = 3
Processing the first six characters:
((()))
creates:
('(',3)
(')',3)
which is immediately removed.
Stack becomes empty.
The remaining characters:
()()()
never form:
('(',3)
(')',3)
so they remain.
Final answer:
"()()()"
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(n) | Each character is pushed and removed at most once |
| Space | O(n) | The stack may store all characters as separate groups in the worst case |
Every character contributes to at most one stack insertion and one stack removal. The repeated checking inside the while loop does not increase the overall complexity because each removal permanently eliminates stack entries. Therefore the total work remains linear.
Test Cases
sol = Solution()
assert sol.removeSubstring("(())", 1) == "" # example 1
assert sol.removeSubstring("(()(", 1) == "((" # example 2
assert sol.removeSubstring("((()))()()()", 3) == "()()()" # example 3
assert sol.removeSubstring("()", 1) == "" # smallest removable string
assert sol.removeSubstring("((", 1) == "((" # no removable substring
assert sol.removeSubstring("))", 1) == "))" # only closing brackets
assert sol.removeSubstring("(())", 2) == "" # exactly one k-balanced substring
assert sol.removeSubstring("((()))", 3) == "" # complete removal
assert sol.removeSubstring("(((())))", 2) == "" # chain removals
assert sol.removeSubstring("(()())", 1) == "" # repeated removals create new ones
assert sol.removeSubstring("()()()()", 1) == "" # many removals
assert sol.removeSubstring("(((()", 2) == "(((()" # no valid pattern
assert sol.removeSubstring("((()))((()))", 3) == "" # multiple independent removals
assert sol.removeSubstring("((()))()", 3) == "()" # one large removal remains
assert sol.removeSubstring("((((()))))", 4) == "" # large k-balanced block
Test Summary
| Test | Why |
|---|---|
(()), k=1 |
Provided example |
(()(, k=1 |
Provided example |
((()))()()(), k=3 |
Provided example |
(), k=1 |
Smallest removable input |
((, k=1 |
No closing bracket |
)), k=1 |
No opening bracket |
(()), k=2 |
Exact k-balanced block |
((())), k=3 |
Entire string removed |
(((()))), k=2 |
Chain-removal behavior |
(()()), k=1 |
New removals created after joins |
()()()(), k=1 |
Many independent removals |
((((), k=2 |
No valid pattern |
((()))((())), k=3 |
Multiple removals |
((()))(), k=3 |
Partial removal |
((((())))), k=4 |
Large balanced block |
Edge Cases
Entire String Disappears
A common corner case occurs when every character belongs to removable k-balanced substrings. Examples include:
()
(())
((()))
A buggy implementation may accidentally return None, an empty stack, or fail during reconstruction. The presented solution correctly reconstructs an empty string when no groups remain.
Chain Reactions After Removal
Removing one substring may create another removable substring that did not previously exist.
For example:
(()) with k = 1
After one removal, the remaining characters become adjacent and form another removable "()". The repeated stack check ensures these chain reactions are processed immediately.
No Removable Substring Exists
Inputs such as:
((((
))))
(()((
never contain a valid k-balanced pattern. An implementation that assumes every balanced-looking region should be removed could incorrectly modify the string. The stack solution only removes when it sees exactly:
('(', k)
(')', k)
so these inputs remain unchanged.
Large Input Size
The maximum length is 100000. Any solution that repeatedly rebuilds the string may become quadratic and exceed time limits. The stack-based approach processes each character a constant number of times, guaranteeing linear performance even at the largest allowed input size.