3703. Remove K-Balanced Substrings
Problem Description
You are given a string s containing only the characters '(' and ')', along with an integer k.
A string is called k-balanced if it consists of exactly k consecutive '(' characters immediately followed by k consecutive ')' characters — in other words, the string '(' * k + ')' * k. For example, when k = 3, the k-balanced string is "((()))".
Your task is to repeatedly remove all non-overlapping k-balanced substrings from s. After each round of removals, the remaining parts of the string are joined together, which may create new k-balanced substrings. This process continues until no k-balanced substring remains anywhere in the string.
Return the final string once no more removals can be performed.
Key points to understand:
- A substring is a contiguous sequence of characters, so the k-balanced pattern must appear as one unbroken block within
s. - Removals can cascade: deleting one k-balanced substring may bring other characters together to form a new k-balanced substring, which must then also be removed. For instance, with
s = "(())"andk = 1, removing the inner"()"leaves"()", which is removed in turn, producing an empty string. - The process only stops when the string contains no occurrence of the pattern
'(' * k + ')' * k.
Examples:
s = "(())",k = 1→ removing the inner"()"gives"()", then removing that gives"".s = "(()(",k = 1→ removing"()"gives"((", which contains no"()", so the answer is"((".s = "((()))()()()",k = 3→ removing"((()))"gives"()()()", which contains no block of three'('followed by three')', so the answer is"()()()".
Constraints:
2 <= s.length <= 10^5sconsists only of'('and')'1 <= k <= s.length / 2
How We Pick the Algorithm
Why Stack?
This problem maps to Stack through a short path in the full flowchart.
Matching balanced k-pairs of parentheses with cascading removals is a classic stack problem with run-length encoding.
Open in FlowchartIntuition
The first thought might be to simulate the process literally: scan the string for the pattern '(' * k + ')' * k, delete every occurrence, glue the pieces back together, and repeat. The trouble is that each full pass costs O(n) time, and in the worst case we may need many passes (think of deeply nested patterns like "((((...))))" with k = 1), pushing the total cost toward O(n^2) — too slow for n up to 10^5.
The key observation is that this problem behaves exactly like classic "remove adjacent matching pairs" problems (such as removing adjacent duplicates), which are naturally solved with a stack. Why? Because a removal only ever happens at the boundary between what we've already processed and the new character we're reading. When we delete a k-balanced substring, the characters on its left and right become adjacent — and a stack handles this merging automatically: after popping, the new top of the stack is precisely the character block that just became adjacent to the incoming characters.
So instead of doing repeated passes, we process the string once, left to right, and eagerly remove a k-balanced block the moment it forms:
- A k-balanced substring is "
kopens followed bykcloses." This means we should detect the moment we have accumulated exactlykconsecutive')'sitting right after at leastkconsecutive'('. - To check this efficiently, we don't push characters one by one. Instead, each stack entry stores a pair
(character, count)— a run-length encoding. The top of the stack tells us how many identical characters are currently at the end of the processed string. - When the top becomes
(')', k)and the element below it is('(', count >= k), we've found a k-balanced substring: pop the')'run and subtractkfrom the'('run. If that run drops to zero, pop it too.
The beautiful part is that cascading removals come for free. Suppose removing a block exposes a situation where the characters before and after it now form another k-balanced substring. Since the stack merges equal-character runs (incrementing the count rather than pushing a new entry), the very next characters we read will join the exposed run, and the same check will fire again. No second pass is ever needed.
One subtle point: why is it safe to check for exactly k closes rather than waiting to see more? Because a k-balanced substring requires the ')' run to be cut off at exactly k — and removing it as soon as the count hits k is always valid: if more ')' follow, they simply continue accumulating against the remaining '(' (whose count was at least k), allowing further removals just as the repeated-pass process would.
Each character is pushed and popped at most once (counts are only incremented or decremented), so the whole algorithm runs in O(n) time — a single linear scan replacing potentially many full-string passes.
Pattern Learn more about Stack patterns.
Solution Approach
Data Structure: Run-Length Encoded Stack
We use a stack stk where each element is a pair [character, count], representing a run of consecutive identical characters. For example, the string "((())" would be stored as [['(', 2], [')', 2], ['(', 1)] — wait, actually as [['(', 3], [')', 2]] after merging? No — "((())" is '(', '(', '(', ')', ')' minus removals; the stack always reflects the current surviving string in compressed form.
This run-length encoding is what lets us check "are there k consecutive ')' preceded by at least k consecutive '('?" in O(1) time.
Algorithm Walkthrough
We traverse s one character c at a time and perform two phases:
Phase 1 — Push (with run merging):
- If the stack is non-empty and the top element holds the same character as
c, increment its count:stk[-1][1] += 1
- Otherwise, push a new run:
stk.append([c, 1])
This guarantees adjacent runs in the stack always alternate between '(' and ')', and crucially, when a removal exposes an older run, subsequent equal characters merge into it automatically.
Phase 2 — Check and remove a k-balanced block:
A k-balanced substring can only complete on a ')', so we check immediately after processing a closing bracket. The removal condition is:
c == ')'— the current character is a close,len(stk) > 1— there is a run beneath the top (which must be a'('run, by alternation),stk[-1][1] == k— the')'run has reached exactlyk,stk[-2][1] >= k— the'('run beneath has at leastkopens to pair with.
When all four hold, we have found '(' * k + ')' * k at the end of the processed string, so we remove it:
stk.pop()— discard the entire')'run (its size is exactlyk),stk[-1][1] -= k— consumekopens from the run below,- if that run's count drops to
0, pop it as well, exposing whatever run lies beneath. That exposed run is now adjacent to the upcoming input characters, which is exactly how cascading removals are handled.
Final reconstruction:
After the scan, the stack holds the irreducible string in compressed form. We expand it:
"".join(c * v for c, v in stk)
Worked Example
Take s = "(())", k = 1:
| Step | Char | Stack after push | Removal check | Stack after removal |
|---|---|---|---|---|
| 1 | ( | [['(', 1]] | not ')' | [['(', 1]] |
| 2 | ( | [['(', 2]] | not ')' | [['(', 2]] |
| 3 | ) | [['(', 2], [')', 1]] | top count == 1, below >= 1 ✓ | [['(', 1]] |
| 4 | ) | [['(', 1], [')', 1]] | top count == 1, below >= 1 ✓ | [] (empty) |
The final answer is "" — the cascading removal happened naturally within a single pass, with no need to re-scan.
Implementation
class Solution:
def removeSubstring(self, s: str, k: int) -> str:
stk = []
for c in s:
# Phase 1: push with run merging
if stk and stk[-1][0] == c:
stk[-1][1] += 1
else:
stk.append([c, 1])
# Phase 2: remove a completed k-balanced block
if c == ")" and len(stk) > 1 and stk[-1][1] == k and stk[-2][1] >= k:
stk.pop()
stk[-1][1] -= k
if stk[-1][1] == 0:
stk.pop()
return "".join(c * v for c, v in stk)
Why Checking stk[-1][1] == k (Not >= k) Is Correct
The ')' run count can never exceed k while a valid '(' run of size >= k sits below it — the moment it hits exactly k, the block is removed on that same iteration. So if a ')' run ever grows beyond k, it means the run beneath had fewer than k opens, and no k-balanced substring ending there can ever exist. Checking equality is therefore both sufficient and safe.
Complexity Analysis
- Time:
O(n)— each character either increments a counter or creates a stack entry once; each removal only decrements counters or pops entries. Total stack operations are bounded by the number of characters, and the final join is linear. - Space:
O(n)— in the worst case (e.g., a string with no removable blocks), the stack stores runs covering the entire string.
Example Walkthrough
Let's trace the algorithm on s = "((())(()))" with k = 2. The k-balanced pattern to eliminate is "(())".
We process the string left to right, maintaining the run-length encoded stack and applying the two phases at each character:
| Step | Char | Stack after Phase 1 (push / merge) | Phase 2 check | Stack after Phase 2 |
|---|---|---|---|---|
| 1 | ( | [['(', 1]] | not ')' — skip | [['(', 1]] |
| 2 | ( | [['(', 2]] (merged into top run) | not ')' — skip | [['(', 2]] |
| 3 | ( | [['(', 3]] | not ')' — skip | [['(', 3]] |
| 4 | ) | [['(', 3], [')', 1]] (new run) | top count 1 != 2 — no removal | [['(', 3], [')', 1]] |
| 5 | ) | [['(', 3], [')', 2]] | top == 2 ✓, below 3 >= 2 ✓ → remove | pop ')' run, '(' count 3 - 2 = 1 → [['(', 1]] |
| 6 | ( | [['(', 2]] ← merges into the exposed '(' run | not ')' — skip | [['(', 2]] |
| 7 | ( | [['(', 3]] | not ')' — skip | [['(', 3]] |
| 8 | ) | [['(', 3], [')', 1]] | top count 1 != 2 — no removal | [['(', 3], [')', 1]] |
| 9 | ) | [['(', 3], [')', 2]] | top == 2 ✓, below 3 >= 2 ✓ → remove | pop ')' run, '(' count 3 - 2 = 1 → [['(', 1]] |
| 10 | ) | [['(', 1], [')', 1]] | top count 1 != 2 (and below only has 1 < 2 opens) — no removal | [['(', 1], [')', 1]] |
Final reconstruction: the stack [['(', 1], [')', 1]] expands to "()", which is the answer.
Two moments in this trace capture why the single-pass stack works:
- Step 5 (eager removal): the instant the
')'run reaches exactlyk = 2with at least2opens beneath it, the block"(())"is deleted on the spot — no separate scanning pass is needed to find it. - Step 6 (automatic stitching): after the removal, the surviving
'('run (count1) sits on top of the stack. The next incoming'('merges directly into it rather than starting a new run. This is exactly the "glue the pieces back together" effect from the problem statement, handled implicitly — characters on either side of a deleted block become one contiguous run, ready to participate in future removals.
Cross-checking against the literal process: in "((())(()))", the pattern "(())" occurs at indices 1–4 and 5–8. Removing both leaves the first and last characters, "()", which contains no "(())", so the process halts with "()" — matching the stack's output exactly, but computed in one linear pass instead of repeated full-string scans.
Solution Implementation
1class Solution:
2 def removeSubstring(self, s: str, k: int) -> str:
3 # Stack of [character, count] pairs, acting as a run-length
4 # encoded representation of the processed string so far.
5 stack: list[list] = []
6
7 for char in s:
8 # Merge the current character into the top run if it matches,
9 # otherwise start a new run on the stack.
10 if stack and stack[-1][0] == char:
11 stack[-1][1] += 1
12 else:
13 stack.append([char, 1])
14
15 # A removable pattern is "(" repeated k times followed by
16 # ")" repeated k times. Check for it once the top run is
17 # exactly k closing brackets and the run beneath it holds
18 # at least k opening brackets.
19 if (
20 char == ")"
21 and len(stack) > 1
22 and stack[-1][1] == k
23 and stack[-2][1] >= k
24 ):
25 # Remove the k closing brackets entirely.
26 stack.pop()
27 # Consume k opening brackets from the run below.
28 stack[-1][1] -= k
29 # Drop the run if no opening brackets remain in it.
30 if stack[-1][1] == 0:
31 stack.pop()
32
33 # Reconstruct the resulting string from the remaining runs.
34 return "".join(char * count for char, count in stack)
351class Solution {
2 public String removeSubstring(String s, int k) {
3 // Each stack entry is an int array of size 2:
4 // entry[0] = character (as int), entry[1] = count of consecutive occurrences
5 List<int[]> stack = new ArrayList<>();
6
7 for (char ch : s.toCharArray()) {
8 // Merge the current character into the top run if it matches,
9 // otherwise start a new run on the stack
10 if (!stack.isEmpty() && stack.get(stack.size() - 1)[0] == ch) {
11 stack.get(stack.size() - 1)[1] += 1;
12 } else {
13 stack.add(new int[] {ch, 1});
14 }
15
16 // Try to remove a "(^k )^k" pattern when the current character is ')'
17 if (ch == ')' && stack.size() > 1) {
18 int[] topEntry = stack.get(stack.size() - 1); // run of ')'
19 int[] prevEntry = stack.get(stack.size() - 2); // run of '(' below it
20
21 // Remove exactly k closing brackets matched with k opening brackets
22 if (topEntry[1] == k && prevEntry[1] >= k) {
23 // Pop the run of ')' entirely
24 stack.remove(stack.size() - 1);
25 // Consume k characters from the run of '('
26 prevEntry[1] -= k;
27 // Pop the '(' run as well if it is fully consumed
28 if (prevEntry[1] == 0) {
29 stack.remove(stack.size() - 1);
30 }
31 }
32 }
33 }
34
35 // Rebuild the resulting string from the remaining runs on the stack
36 StringBuilder result = new StringBuilder();
37 for (int[] entry : stack) {
38 for (int i = 0; i < entry[1]; i++) {
39 result.append((char) entry[0]);
40 }
41 }
42 return result.toString();
43 }
44}
451class Solution {
2public:
3 string removeSubstring(string s, int k) {
4 // Stack of (character, consecutive count) pairs to compress runs
5 vector<pair<char, int>> stack;
6
7 for (char ch : s) {
8 // Merge with the top group if the character matches,
9 // otherwise start a new group
10 if (!stack.empty() && stack.back().first == ch) {
11 stack.back().second += 1;
12 } else {
13 stack.emplace_back(ch, 1);
14 }
15
16 // When a ')' arrives, check whether the pattern "(((...)))"
17 // with exactly k of each bracket has just been completed
18 if (ch == ')' && stack.size() > 1) {
19 auto& top = stack.back(); // current ')' run
20 auto& prev = stack[stack.size() - 2]; // preceding '(' run
21
22 // The ')' run has reached length k and there are
23 // at least k matching '(' right before it
24 if (top.second == k && prev.second >= k) {
25 // Remove the k consecutive ')'
26 stack.pop_back();
27 // Consume k of the consecutive '('
28 prev.second -= k;
29 // Drop the '(' group entirely if it is exhausted
30 if (prev.second == 0) {
31 stack.pop_back();
32 }
33 }
34 }
35 }
36
37 // Rebuild the resulting string from the remaining groups
38 string result;
39 for (auto& group : stack) {
40 result.append(group.second, group.first);
41 }
42 return result;
43 }
44};
451/**
2 * Removes every occurrence of the substring consisting of exactly k
3 * consecutive '(' followed by exactly k consecutive ')', repeatedly,
4 * until no such pattern remains.
5 *
6 * Strategy:
7 * Use a stack that stores runs of identical characters as
8 * [character, consecutiveCount] pairs. Whenever a run of ')' reaches
9 * length k and is immediately preceded by a run of at least k '(',
10 * both the k ')' and k '(' are removed. This naturally handles
11 * cascading removals created by earlier deletions.
12 *
13 * @param s - the input string consisting of '(' and ')'
14 * @param k - the exact run length of brackets to remove
15 * @returns the string after all removals are performed
16 */
17function removeSubstring(s: string, k: number): string {
18 // Stack of [character, consecutive count] pairs to compress runs
19 const stack: [string, number][] = [];
20
21 for (const ch of s) {
22 // Merge with the top group if the character matches,
23 // otherwise start a new group
24 if (stack.length > 0 && stack[stack.length - 1][0] === ch) {
25 stack[stack.length - 1][1] += 1;
26 } else {
27 stack.push([ch, 1]);
28 }
29
30 // When a ')' arrives, check whether the pattern "(((...)))"
31 // with exactly k of each bracket has just been completed
32 if (ch === ')' && stack.length > 1) {
33 const top = stack[stack.length - 1]; // current ')' run
34 const prev = stack[stack.length - 2]; // preceding '(' run
35
36 // The ')' run has reached length k and there are
37 // at least k matching '(' right before it
38 if (top[1] === k && prev[0] === '(' && prev[1] >= k) {
39 // Remove the k consecutive ')'
40 stack.pop();
41 // Consume k of the consecutive '('
42 prev[1] -= k;
43 // Drop the '(' group entirely if it is exhausted
44 if (prev[1] === 0) {
45 stack.pop();
46 }
47 }
48 }
49 }
50
51 // Rebuild the resulting string from the remaining groups
52 let result = '';
53 for (const [character, count] of stack) {
54 result += character.repeat(count);
55 }
56 return result;
57}
58Time and Space Complexity
-
Time Complexity:
O(n), wherenis the length of strings.- The code iterates over each character of
sexactly once. - Within each iteration, all stack operations — checking the top element
stk[-1], incrementing a counter, appending, and popping — take constant timeO(1). - Although a pop merges or removes entries, each character contributes at most one push and one pop over the entire run, so the total work is bounded by
O(n)(amortized analysis). - The final step
"".join(c * v for c, v in stk)reconstructs the result string inO(n)time, since the total of all countsvcannot exceedn. - Overall:
O(n) + O(n) = O(n).
- The code iterates over each character of
-
Space Complexity:
O(n), wherenis the length of strings.- The stack
stkstores pairs of[character, count]. In the worst case (e.g., alternating characters with no removable substrings), the stack holds an entry for every character, requiringO(n)space. - The output string built by
"".join(...)also takes at mostO(n)space. - Overall:
O(n).
- The stack
Pattern Learn more about how to find time and space complexity quickly.
Common Pitfalls
Pitfall 1: Forgetting to Pop the '(' Run When Its Count Drops to Zero
This is the single most damaging mistake in the run-length stack approach. After a removal, the code does stack[-1][1] -= k. If you omit the follow-up cleanup:
if stack[-1][1] == 0: stack.pop()
a zombie entry like ['(', 0] stays on the stack. It contributes nothing to the output (so simple test cases still pass!), but it sits between the run beneath it and any future characters, silently breaking run merging — and with it, all cascading removals that depend on newly adjacent characters joining forces.
Failing example: s = "((()(()))", k = 2
- Correct process: remove the inner
"(())"→"((())", which again contains"(())"→ remove → final answer"(". - Buggy trace: after removing the inner block, the consumed
'('run becomes['(', 0]but is not popped. The final')'then cannot merge with the exposed[')', 1]run beneath the zombie — it starts a new run of size 1, the== kcheck never fires, and the output is the wrong string"((())".
Solution: Always pop zero-count runs immediately after decrementing. The stack invariant must be: runs strictly alternate between '(' and ')', and every count is positive. Any code path that decrements a count must restore this invariant before the next iteration.
Pitfall 2: Requiring the '(' Run to Be Exactly k (== Instead of >=)
It is tempting to mirror the closing-bracket check and write:
if char == ")" and stack[-1][1] == k and stack[-2][1] == k: # WRONG
But the k-balanced pattern is a substring — the k opening brackets just need to exist immediately before the k closing brackets; extra '(' characters to their left are perfectly fine and simply survive the removal.
Failing example: s = "((()))", k = 2
- Correct:
"(())"appears at indices 1–4 → removing it leaves"()". - Buggy: the
'('run has size 3,3 != 2, so nothing is ever removed and the entire input is returned unchanged.
Solution: The condition must be asymmetric: stack[-1][1] == k (the ')' run completes the block the instant it reaches k) but stack[-2][1] >= k (only k opens are consumed; the surplus remains on the stack).
Pitfall 3: Simulating the Process with Repeated str.replace
The "literal" translation of the problem statement:
pattern = "(" * k + ")" * k while pattern in s: s = s.replace(pattern, "") # O(n) per pass return s
is functionally correct but algorithmically a trap. Deeply nested input such as "(" * 50000 + ")" * 50000 with k = 1 requires ~n/2 passes, each scanning and rebuilding the string — O(n²) overall, which times out at n = 10^5.
Solution: Use the single-pass run-length stack. Cascades are handled for free: removing a block exposes the run beneath, and the merge step in Phase 1 stitches it together with upcoming characters, so no re-scan is ever needed. Total work is O(n).
Pitfall 4: Indexing stack[-2] Without Guarding the Stack Size
Writing the removal check as:
if char == ")" and stack[-1][1] == k and stack[-2][1] >= k: # WRONG
crashes (or worse, silently misbehaves via Python's negative indexing wrapping into stack[-2] == stack[0] when the stack has exactly one element... actually it raises IndexError only when the stack has one element and -2 is out of range — on a single-element stack stack[-2] throws). An input that begins with closing brackets, e.g. s = "))((" with k = 2, puts a lone [')', 2] run on the stack and triggers the fault.
Solution: Include len(stack) > 1 in the condition, and order the checks so it short-circuits before stack[-2] is touched.
Pitfall 5: Storing Runs as Tuples
Using stack.append((char, 1)) feels natural, but tuples are immutable, so both the merge step (stack[-1][1] += 1) and the consume step (stack[-1][1] -= k) raise TypeError. Rewriting every mutation as pop-and-re-push works but adds noise and constant-factor cost.
Solution: Store each run as a mutable two-element list ([char, count]), or keep two parallel structures / a small dataclass if you prefer explicit field names. The key requirement is in-place count mutation.
Ready to land your dream job?
Unlock your dream job with a 5-minute quiz for a personalized study roadmap!
Get My RoadmapHow does merge sort divide the problem into subproblems?
Recommended Readings
Stack Intro Following the Foundation Course Stay on that path and use the Foundation Course Stack module courses foundation stack_lifo_model instead This page is a quick Core Patterns refresher for students who already know the basics Imagine you have a pile of books on your desk If you want to add a
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 If you prefer videos here's a video that explains recursion in a fun and easy way 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 him
Want a Structured Path to Master System Design Too? Don’t Miss This!