Facebook Pixel

3816. Lexicographically Smallest String After Deleting Duplicate Characters

HardStackGreedyHash TableStringMonotonic Stack
LeetCode ↗

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 s and 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.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

How We Pick the Algorithm

Why Monotonic Stack?

This problem maps to Monotonic Stack through a short path in the full flowchart.

Computemax/min?yesMonotonicstack?yesMonotonic Stack

Using a monotonic stack to preserve the lexicographically smallest order while respecting last-occurrence constraints for duplicate removal.

Open in Flowchart

Intuition

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 cnt to 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 in cnt. This greedily removes larger characters that don't need to stay where they are, allowing the smaller c to move earlier.
  • After the popping loop finishes, we push c onto 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), where n is the length of string s. 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 stack stk, plus O(|\Sigma|) for the hash table cnt, 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:

charcount
c4
b2
a1
d1

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).

Stepchar cActionStack afterNotes
1cpush[c]stack empty, just push
2bpush[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.

Stepchar cPops (with count update)Stack after
1c[c]
2bpop c (cnt[c]→3)[b]
3apop b (cnt[b]→1)[a]
4ctop a < c, no pop[a, c]
5dtop c < d, no pop[a, c, d]
6ctop d > c but cnt[d]=1, stop[a, c, d, c]
7btop c > b, cnt[c]=3>1 pop (→2); next top d > b, cnt[d]=1 stop[a, c, d, b]
8ctop b < c, no pop[a, c, d, b, c]

A couple of key moments worth highlighting:

  • Step 3: At char a, the top b is larger and still has a duplicate ahead (cnt[b] = 2), so we pop it to let the smaller a move forward. After popping, cnt[b] = 1, meaning the remaining b is now its last copy and becomes untouchable.
  • Step 6: At the second c, the top is d which is larger — but cnt[d] = 1, so d is its only copy and must stay. We stop popping and push c.

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 four c's have been "seen," but only some were popped. The current cnt[c] reflects available copies of the surviving stack c's. Here the trailing c has 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)
74
1class 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}
51
1class 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};
40
1/**
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}
52

Time and Space Complexity

  • Time Complexity: O(n), where n is the length of the string s. Building the Counter requires a single pass over s, which takes O(n). The main loop iterates through each character of s once. Although there is an inner while loop 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 by O(n). The final cleanup while loop and the "".join(stk) operation also take at most O(n). Combining these, the overall time complexity is O(n).

  • Space Complexity: O(n). The Counter cnt stores the frequency of each character, which uses at most O(n) space (bounded by the number of distinct characters, which is at most n). The stack stk can hold up to n characters in the worst case. Hence, the space complexity is O(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 a appears twice, letter b appears twice.
  • The smallest result is "aba", not "ab".

Why? You can delete one b (turning abbaaba), 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 multiplicityEach letter exactly onceEach letter keeps ≥ 1 copy, extras kept if helpful
Pop conditionlarger char + appears laterlarger char + spare copy still exists (> 1)
Skip-if-present guardRequired (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 Roadmap
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Get a Personalized Study Roadmap:

In a binary min heap, the maximum element can be found in:


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More