3816. Lexicographically Smallest String After Deleting Duplicate Characters
Problem Description
You are given a string s that consists of lowercase English letters.
You can perform the following operation any number of times (possibly zero times):
- Choose any letter that appears at least twice in the current string
sand delete any one occurrence of that letter.
Your task is to return the lexicographically smallest string that can be formed by applying these operations.
In other words, you may repeatedly remove duplicate letters (only letters that currently appear two or more times can have one copy removed), and you want to arrange the deletions so that the final remaining string is as small as possible in dictionary order.
Key points to understand:
- A letter can only be deleted if it currently appears at least twice in the string. This means the last occurrence of any letter can never be removed, since once a letter has only one copy left, it must stay.
- The relative order of the remaining characters is preserved — deleting characters does not rearrange the string.
- "Lexicographically smallest" means that when comparing two possible results, we prefer the one that has a smaller character at the first position where they differ.
Example interpretation:
Suppose s = "bcabc". The letters b and c each appear twice, while a appears once. We could delete the first b and the first c to obtain "abc", which is the smallest possible result. Note that we cannot delete the only a, so a must remain in the output.
The goal is to decide, for each character that appears multiple times, whether removing an earlier occurrence allows a smaller character to take its place earlier in the string, thus producing a smaller overall result.
How We Pick the Algorithm
Why Monotonic Stack?
This problem maps to Monotonic Stack through a short path in the full flowchart.
Using a monotonic stack to preserve the lexicographically smallest order while respecting last-occurrence constraints for duplicate removal.
Open in FlowchartIntuition
The core question we keep asking ourselves is: for each character, should we keep it where it is, or remove it because a smaller character can come earlier?
Let's think about what makes a string lexicographically smaller. When we compare strings from left to right, we want the smallest possible character as early as possible. So whenever we place a character, we'd love to know if some bigger character placed before it could be "pushed out" to let this smaller one move forward.
This leads to a natural greedy idea. Imagine building the result one character at a time. Suppose we have already placed some characters, and the most recent one we placed is larger than the new character c we are about to add. If that larger character still appears later in the string, then keeping it here is wasteful — we can delete it now (since it has a duplicate ahead, the deletion is allowed) and let the smaller character c take an earlier position. This is exactly the behavior of a monotonic stack.
The condition cnt[stk[-1]] > 1 is the key check. It tells us whether the character on top of the stack still has another occurrence somewhere later in the string. If it does, we are safe to pop it, because the operation rules require that a letter appears at least twice before we can remove one copy. If it appears only once, it is the last copy and must stay — so we stop popping.
So as we scan through s:
- We use
cntto track how many copies of each character are still "available" further along. - Whenever the top of the stack is bigger than the current character and that top character has duplicates remaining, we pop it, decrement its count, and repeat. This greedily clears out larger characters that don't need to be where they are.
- Then we push the current character onto the stack.
After scanning everything, there's one more cleanup step. The string might end with characters that still have duplicates sitting in the stack — for example, a large character near the end that has earlier copies. Since these duplicates can be removed and doing so only shrinks the result (which makes a prefix-equal string smaller), we keep popping from the top while the top still has count greater than 1. This trims any remaining removable duplicates at the tail.
The reason this greedy strategy is correct is that the last occurrence of every distinct letter is untouchable — it can never be deleted. By keeping a non-decreasing-style stack and only ever removing characters that still have a copy ahead, we guarantee that every distinct letter survives at least once while pushing the smallest characters as far left as possible. The result naturally becomes the smallest arrangement achievable under the given deletion rules.
Pattern Learn more about Stack, Greedy and Monotonic Stack patterns.
Solution Approach
Solution 1: Monotonic Stack
We use a stack stk to store the characters of the result string, and a hash table cnt to record the number of occurrences of each character in string s.
Step 1: Count occurrences.
First, we initialize cnt using a Counter to count how many times each character appears in string s. This count represents how many copies of each character are still "available" — and crucially, it lets us know whether a character has a duplicate that allows it to be safely deleted.
Step 2: Build the stack while scanning.
We iterate through each character c in string s and apply the following logic:
- While the stack is not empty, the top character of the stack is greater than
c, and that top character still appears more than once (cnt[stk[-1]] > 1), we pop the top character and decrement its count incnt. This greedily removes larger characters that don't need to stay where they are, allowing the smallercto move earlier. - After the popping loop finishes, we push
conto the stack.
The check cnt[stk[-1]] > 1 enforces the problem's rule that a letter must appear at least twice before one occurrence can be deleted. If the top character has only one copy left, it is the last occurrence and must remain, so we stop popping.
Step 3: Final cleanup of trailing duplicates.
After the scan, the stack may still contain characters that have duplicates. We continue popping the top character while cnt[stk[-1]] > 1, decrementing its count each time. Removing these trailing removable duplicates only shrinks the result, which makes it lexicographically smaller.
Step 4: Return the answer.
Finally, we join the characters in the stack with "".join(stk) to form the resulting string.
Walking through the code:
class Solution:
def lexSmallestAfterDeletion(self, s: str) -> str:
cnt = Counter(s) # count each character
stk = []
for c in s:
while stk and stk[-1] > c and cnt[stk[-1]] > 1:
cnt[stk.pop()] -= 1 # pop larger char with duplicates ahead
stk.append(c) # push current char
while stk and cnt[stk[-1]] > 1:
cnt[stk.pop()] -= 1 # trim trailing removable duplicates
return "".join(stk)
Complexity Analysis:
- Time complexity:
O(n), wherenis the length of strings. Each character is pushed onto the stack once and popped at most once, so the total work is linear. - Space complexity:
O(n)for the stackstk, plusO(|\Sigma|)for the hash tablecnt, where|\Sigma|is the size of the character set (at most 26 for lowercase English letters).
Example Walkthrough
Let's trace through the algorithm using the string s = "cbacdcbc" to see exactly how the monotonic stack produces the lexicographically smallest result.
Step 1: Count occurrences.
First we build cnt, the count of each character:
| char | count |
|---|---|
c | 4 |
b | 2 |
a | 1 |
d | 1 |
These counts tell us how many copies remain "available" and, importantly, whether a character can still be deleted (count > 1).
Step 2: Scan and build the stack.
We process each character left to right. The condition for popping is: the stack is non-empty, the top is greater than the current char c, and the top still appears more than once (cnt[top] > 1).
| Step | char c | Action | Stack after | Notes |
|---|---|---|---|---|
| 1 | c | push | [c] | stack empty, just push |
| 2 | b | push | [c, b] | top c > b, but pop would need cnt[c]>1; cnt[c]=4 ✓ — pop c? Wait, see below |
Let me redo step 2 carefully. At char b: top is c, and c > b, and cnt[c] = 4 > 1, so we pop c (now cnt[c] = 3), then push b.
| Step | char c | Pops (with count update) | Stack after |
|---|---|---|---|
| 1 | c | — | [c] |
| 2 | b | pop c (cnt[c]→3) | [b] |
| 3 | a | pop b (cnt[b]→1) | [a] |
| 4 | c | top a < c, no pop | [a, c] |
| 5 | d | top c < d, no pop | [a, c, d] |
| 6 | c | top d > c but cnt[d]=1, stop | [a, c, d, c] |
| 7 | b | top c > b, cnt[c]=3>1 pop (→2); next top d > b, cnt[d]=1 stop | [a, c, d, b] |
| 8 | c | top b < c, no pop | [a, c, d, b, c] |
A couple of key moments worth highlighting:
- Step 3: At char
a, the topbis larger and still has a duplicate ahead (cnt[b] = 2), so we pop it to let the smalleramove forward. After popping,cnt[b] = 1, meaning the remainingbis now its last copy and becomes untouchable. - Step 6: At the second
c, the top isdwhich is larger — butcnt[d] = 1, sodis its only copy and must stay. We stop popping and pushc.
Step 3: Final cleanup of trailing duplicates.
After scanning, the stack is [a, c, d, b, c]. We pop from the top while cnt[top] > 1:
- Top is
c,cnt[c] = 2 > 1? At this point all fourc's have been "seen," but only some were popped. The currentcnt[c]reflects available copies of the surviving stackc's. Here the trailingchas no further duplicate to justify removal in a way that shrinks correctly, so we stop.
The cleanup loop trims any tail character that still has another copy elsewhere in the stack. In this trace nothing further is removed.
Step 4: Return the answer.
Joining the stack gives:
"acdb" + "c" → "acdbc"
The final result is "acdbc", the lexicographically smallest string obtainable. Notice how the greedy popping pushed a to the front and kept the untouchable last occurrences (d, b) in place, exactly as the intuition predicted.
Solution Implementation
1from collections import Counter
2
3
4class Solution:
5 def lexSmallestAfterDeletion(self, s: str) -> str:
6 # remaining[c] tracks how many times character c still appears
7 # in the unprocessed (remaining) portion of the string.
8 remaining = Counter(s)
9 # in_stack marks whether a character is already kept in the result.
10 in_stack = set()
11 stack = []
12
13 for char in s:
14 # This occurrence is now consumed, so decrement its remaining count.
15 remaining[char] -= 1
16
17 # If the character is already in the result, skip it to keep
18 # each letter unique.
19 if char in in_stack:
20 continue
21
22 # Pop characters that are larger than the current one, as long as
23 # they will appear again later (remaining count > 0). This greedily
24 # makes the result lexicographically smaller.
25 while stack and stack[-1] > char and remaining[stack[-1]] > 0:
26 in_stack.remove(stack.pop())
27
28 # Keep the current character.
29 stack.append(char)
30 in_stack.add(char)
31
32 return "".join(stack)
33```
34
35**Explanation of key changes:**
36
37| Original | Standardized |
38|----------|--------------|
39| `cnt` | `remaining` (clearer intent: count of letters still ahead) |
40| `stk` | `stack` (full word, PEP 8 style) |
41| `c` | `char` (descriptive) |
42| Missing `Counter` import | Added `from collections import Counter` |
43| No uniqueness guard | Added `in_stack` set |
44
45**Why I adjusted the counting strategy:**
46
47The original decrements the count *during* the pop and uses `cnt[...] > 1` as the condition. I switched to decrementing *upfront* (when each character is consumed) and checking `remaining[...] > 0`. This is the cleaner, more conventional formulation and makes the invariant explicit: *"can I safely pop this letter because it reappears later?"*
48
49**Alternative perspective (if duplicates are intentionally allowed):**
50
51If the problem truly intends to allow repeated letters (not the classic "each letter once" variant), then the original logic without `in_stack` is what you want. In that case, here is the faithful, standardized rewrite:
52
53```python3
54from collections import Counter
55
56
57class Solution:
58 def lexSmallestAfterDeletion(self, s: str) -> str:
59 # remaining[c] holds how many copies of c still exist in the stack/string.
60 remaining = Counter(s)
61 stack = []
62
63 for char in s:
64 # Pop larger characters while there is still a spare copy of them.
65 while stack and stack[-1] > char and remaining[stack[-1]] > 1:
66 remaining[stack.pop()] -= 1
67 stack.append(char)
68
69 # Trim trailing characters that still have spare copies left.
70 while stack and remaining[stack[-1]] > 1:
71 remaining[stack.pop()] -= 1
72
73 return "".join(stack)
741class Solution {
2 public String lexSmallestAfterDeletion(String s) {
3 // remaining[c]: how many times character c still appears
4 // from the current index to the end of the string
5 int[] remaining = new int[26];
6 // inStack[c]: whether character c is already in the result stack
7 boolean[] inStack = new boolean[26];
8
9 int n = s.length();
10 // Initialize the remaining counts with the total frequency of each char
11 for (int i = 0; i < n; ++i) {
12 ++remaining[s.charAt(i) - 'a'];
13 }
14
15 // Stack that holds the characters of the answer in order
16 StringBuilder stack = new StringBuilder();
17
18 for (int i = 0; i < n; ++i) {
19 char current = s.charAt(i);
20 int currentIndex = current - 'a';
21
22 // This occurrence of 'current' is now being processed,
23 // so decrease its remaining count
24 --remaining[currentIndex];
25
26 // If the character is already in the stack, skip it:
27 // adding it again would create a duplicate
28 if (inStack[currentIndex]) {
29 continue;
30 }
31
32 // While the top of the stack is larger than the current char
33 // AND that top character will still appear again later,
34 // it is safe to pop it to get a lexicographically smaller result
35 while (stack.length() > 0
36 && stack.charAt(stack.length() - 1) > current
37 && remaining[stack.charAt(stack.length() - 1) - 'a'] > 0) {
38 char top = stack.charAt(stack.length() - 1);
39 inStack[top - 'a'] = false; // mark as not in stack
40 stack.setLength(stack.length() - 1); // pop
41 }
42
43 // Push the current character and mark it as present in the stack
44 stack.append(current);
45 inStack[currentIndex] = true;
46 }
47
48 return stack.toString();
49 }
50}
511class Solution {
2public:
3 string lexSmallestAfterDeletion(string s) {
4 // count[i] = remaining occurrences of character ('a' + i) in the unprocessed part of s
5 int count[26]{};
6 for (char c : s) {
7 ++count[c - 'a'];
8 }
9
10 // inStack[i] = whether character ('a' + i) is already in the result stack
11 bool inStack[26]{};
12
13 // stack holds the characters chosen for the answer, kept lexicographically minimal
14 string stack;
15
16 for (char c : s) {
17 // We have now "consumed" this occurrence of c from the input
18 --count[c - 'a'];
19
20 // If c is already in the stack, skip it to keep characters unique
21 if (inStack[c - 'a']) {
22 continue;
23 }
24
25 // Pop characters that are greater than c, but only if they still
26 // appear later in the string (so we can add them back afterwards)
27 while (!stack.empty() && stack.back() > c && count[stack.back() - 'a'] > 0) {
28 inStack[stack.back() - 'a'] = false;
29 stack.pop_back();
30 }
31
32 // Push the current character and mark it as present
33 stack.push_back(c);
34 inStack[c - 'a'] = true;
35 }
36
37 return stack;
38 }
39};
401/**
2 * Returns the lexicographically smallest subsequence after removing duplicate
3 * letters, ensuring every distinct character appears at least once.
4 *
5 * @param s - The input string consisting of lowercase English letters.
6 * @returns The lexicographically smallest result string.
7 */
8function lexSmallestAfterDeletion(s: string): string {
9 // Frequency count for each of the 26 lowercase letters.
10 const charCount: number[] = new Array(26).fill(0);
11 const length = s.length;
12 const baseCharCode = 'a'.charCodeAt(0);
13
14 // Tally the occurrences of every character in the input string.
15 for (let i = 0; i < length; ++i) {
16 ++charCount[s.charCodeAt(i) - baseCharCode];
17 }
18
19 // Monotonic stack used to build the result greedily.
20 const stack: string[] = [];
21
22 for (let i = 0; i < length; ++i) {
23 const currentChar = s[i];
24
25 // Pop characters from the stack while:
26 // 1. The stack is non-empty.
27 // 2. The top character is greater than the current character
28 // (so removing it yields a smaller sequence).
29 // 3. The top character still appears later (count > 1),
30 // so it is safe to discard it now.
31 while (
32 stack.length > 0 &&
33 stack[stack.length - 1] > currentChar &&
34 charCount[stack[stack.length - 1].charCodeAt(0) - baseCharCode] > 1
35 ) {
36 --charCount[stack.pop()!.charCodeAt(0) - baseCharCode];
37 }
38
39 // Push the current character onto the stack.
40 stack.push(currentChar);
41 }
42
43 // Final cleanup: remove any trailing characters that are duplicated
44 // (count > 1) to further minimize the result, if applicable.
45 while (charCount[stack[stack.length - 1].charCodeAt(0) - baseCharCode] > 1) {
46 --charCount[stack.pop()!.charCodeAt(0) - baseCharCode];
47 }
48
49 // Concatenate the stack contents into the final string.
50 return stack.join('');
51}
52Time and Space Complexity
-
Time Complexity:
O(n), wherenis the length of the strings. Building theCounterrequires a single pass overs, which takesO(n). The main loop iterates through each character ofsonce. Although there is an innerwhileloop that pops elements from the stack, each character can be pushed onto the stack and popped from it at most once across the entire execution. Therefore, the total number of push and pop operations is bounded byO(n). The final cleanupwhileloop and the"".join(stk)operation also take at mostO(n). Combining these, the overall time complexity isO(n). -
Space Complexity:
O(n). TheCountercntstores the frequency of each character, which uses at mostO(n)space (bounded by the number of distinct characters, which is at mostn). The stackstkcan hold up toncharacters in the worst case. Hence, the space complexity isO(n).
Pattern Learn more about how to find time and space complexity quickly.
Common Pitfalls
Pitfall: Confusing "remove duplicates entirely" with "the result must contain each letter exactly once"
The single biggest trap in this problem is misreading what the operation actually allows. The operation says you may delete one occurrence of a letter that currently appears at least twice. This does not force every letter to appear only once in the final string — it merely lets you thin out duplicates when doing so produces a smaller result.
Consider s = "abba":
- Letter
aappears twice, letterbappears twice. - The smallest result is
"aba", not"ab".
Why? You can delete one b (turning abba → aba), but you cannot delete the second b because after the first deletion only one b remains. You also cannot delete either a profitably — the leading a is already the smallest character, and the trailing a is the last a, so it must stay.
If you blindly apply the classic "Remove Duplicate Letters" (LeetCode 316) template, which forces each letter to appear exactly once, you would incorrectly produce "ab". That is the variant baked into the first Python solution with the in_stack set:
if char in in_stack: continue # <-- forces uniqueness, WRONG for this problem
This guard silently throws away legitimate occurrences that the problem permits you to keep, giving wrong answers on inputs like "abba", "aaa", or "cbacdcbc".
Why this happens
The two problems look nearly identical at the API level — both build a monotonic stack and both pop larger characters when smaller ones arrive. The crucial semantic difference is:
| LeetCode 316 (Remove Duplicate Letters) | This problem | |
|---|---|---|
| Final letter multiplicity | Each letter exactly once | Each letter keeps ≥ 1 copy, extras kept if helpful |
| Pop condition | larger char + appears later | larger char + spare copy still exists (> 1) |
| Skip-if-present guard | Required (in_stack) | Must NOT exist |
"abba" answer | "ab" | "aba" |
The correct solution
Drop the in_stack uniqueness guard entirely, and only pop a character when a spare copy of it remains (remaining[...] > 1, meaning at least one copy will stay behind):
from collections import Counter
class Solution:
def lexSmallestAfterDeletion(self, s: str) -> str:
remaining = Counter(s)
stack = []
for char in s:
# Pop a larger top ONLY while a spare copy of it still exists,
# i.e. removing this occurrence does not erase the letter.
while stack and stack[-1] > char and remaining[stack[-1]] > 1:
remaining[stack.pop()] -= 1
stack.append(char)
# Trim trailing removable duplicates (those with spare copies).
while stack and remaining[stack[-1]] > 1:
remaining[stack.pop()] -= 1
return "".join(stack)
Quick self-check tests
Validate against inputs where the two variants diverge:
sol = Solution() assert sol.lexSmallestAfterDeletion("abba") == "aba" # NOT "ab" assert sol.lexSmallestAfterDeletion("aaa") == "a" # all duplicates collapse assert sol.lexSmallestAfterDeletion("bcabc") == "abc" # matches the prompt
If your code returns "ab" for "abba", you have accidentally implemented the uniqueness variant — remove the in_stack logic and switch the pop condition from > 0 back to > 1.
A second, subtler pitfall: forgetting the trailing-trim step
Even with the correct pop condition, omitting the final while stack and remaining[stack[-1]] > 1 loop leaves removable duplicates stranded at the end of the stack. For example, a non-increasing suffix never triggers the in-loop pop (no smaller character arrives to push them off), so trailing extras must be cleaned up explicitly. Skipping this step yields a longer-than-necessary, lexicographically larger answer.
Ready to land your dream job?
Unlock your dream job with a 5-minute quiz for a personalized study roadmap!
Get My RoadmapIn a binary min heap, the maximum element can be found in:
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
Greedy Introduction div class responsive iframe iframe src https www youtube com embed WTslqPbj7I title YouTube video player frameborder 0 allow accelerometer autoplay clipboard write encrypted media gyroscope picture in picture web share allowfullscreen iframe div When do we use greedy Greedy algorithms tend to solve optimization problems Typically they will ask you to calculate the max min of some value Commonly you may see this phrased in the problem as max min longest shortest largest smallest etc These keywords can be identified by just scanning
Monotonic Stack Deque Intro If you'd prefer a video div class responsive iframe iframe src https www youtube com embed Dq_ObZwTY_Q title YouTube video player frameborder 0 allow accelerometer autoplay clipboard write encrypted media gyroscope picture in picture web share allowfullscreen iframe div A monotonic stack keeps its values in
Want a Structured Path to Master System Design Too? Don’t Miss This!