DSA sheet · Stack · Greedy + Stack pattern

Remove Duplicate Letters

The second Stack + Greedy problem. The teacher solves it as a direct follow-up to Remove K Digits: there we deleted a bigger digit that came before a smaller one; here we do the same with letters, but with one extra safety check: we may only delete a letter if another copy of it comes later. She builds it in stages: a recursive brute force, a quick "keep the first copy" idea that fails, a stack that looks ahead by scanning (O(n²)), and finally the O(n) version with a last-index array and a visited array.

She also points out that the next sheet problem, Smallest Subsequence of Distinct Characters (LeetCode 1081), is worded differently but is the same problem, and leaves it for you to submit with this exact code.

Every part below follows the same order:
① the question in simple words → ② what the constraints tell us → ③ intuition → ④ building the logic from examples → ⑤ approach steps → ⑥ code → ⑦ code line by line → ⑧ dry run → ⑨ complexity & remember

Part 0 · Before starting

What is a stack?

A stack is a pile: you add on the top (push), look at the top (peek), and remove from the top (pop). The last thing in is the first thing out: LIFO (Last In, First Out).

a Python list as a stack
stack = []
stack.append('b')     # push
top = stack[-1]       # peek (don't remove)
x = stack.pop()       # pop (remove the top)
if stack: ...         # empty check: [] counts as False

We write a stack left = bottom, right = top. Always check that the stack is not empty before stack[-1] or stack.pop(), or Python raises IndexError.

Lexicographic order ("dictionary order")

"Lexicographically smallest" means "the one that comes first in a dictionary". Compare two strings letter by letter from the left; the first letter that differs decides. "abc" < "bca" because a < b at the very first position. Python's < on strings does exactly this, and so does > on single letters ('a' < 'b' < … < 'z').

Greedy + Stack, and what's new here

The pattern from Remove K Digits: walk left to right; before pushing a new item, pop the top while it is bigger than the new item. Popping a bigger item lets the smaller one move further left, and a smaller letter further left makes the whole string smaller. The stack stays mostly increasing (a monotonic stack), and it remembers the best answer we can build from the part we've read so far.

New in this problem: every distinct letter must appear exactly once in the answer. So we can't pop a letter just because it is bigger. We may pop it only if another copy of it appears later, so it can come back in a better spot. Otherwise popping it would lose that letter forever.

Greedy + Stack template, with the extra check
for i, x in enumerate(s):
    if x already in stack: skip it
    while stack and stack[-1] > x and stack[-1] appears after i:
        stack.pop()        # safe: we will meet it again later
    stack.append(x)

Part A · Brute force: recursion over deletions

LeetCode 316 (same as LeetCode 1081)

1The question in simple words

You get a string s of lowercase letters. Remove the extra copies so that each letter appears exactly once. You can't reorder anything; you only choose which copy of each letter to keep. Among all the ways to do that, return the one that is lexicographically smallest.

sbcabcremove the first b and the first c
answerabc"abc"

You could also remove the later b and c and get "bca". That also has each letter once, but "abc" comes first in the dictionary, so "abc" is the answer. Second example: "cbacdcbc" → "acdb".

2What the constraints tell us

3Intuition: why counting isn't enough

A first thought is to count each letter. A count above 1 tells you which letters have duplicates (b and c in "bcabc"). But it doesn't tell you which copy to delete: the first b or the second? That depends on what comes after each copy, and a count knows nothing about positions. So frequency alone doesn't solve it.

The brute-force idea is simple: try every way of deleting duplicates, and keep the smallest string that has all its letters unique.

4Building the logic

Write a recursive function solve(t):

On "bcabc": deleting the first b gives "cabc" (still has two c's) → deleting a c gives "abc" ✓, the first answer stored. Other branches later give "bca", "cab" and so on; each is compared with "abc" and loses.

Doubt 1: what if we delete a letter that has only one copy, like the 'a' in "bcabc"?
→ The teacher notices that such branches end up shorter than an answer we already have ("bc" is length 2, but "abc" is length 3). A shorter string has lost a letter, so it can never be valid. Her pruning is: once the string is as short as the best answer, don't recurse further. In the code below we say it more directly: only delete a letter that still appears more than once. Then every leaf keeps every letter exactly once.
Doubt 2: how does this help with "how do I check a candidate is valid"?
→ len(set(t)) == len(t) means no letter repeats. That's the "return True if no duplicates" helper she mentions.

5Approach steps

  1. Start with best = None and call solve(s).
  2. In solve(t): if all letters are unique → update best if t is smaller; return.
  3. Else for each i where t[i] still appears more than once → solve(t without index i).
  4. Return best.

6Code (Python)

Brute force: try every way to delete duplicates
class Solution:
    def removeDuplicateLetters(self, s):
        self.best = None
        self.solve(s)
        return self.best

    def solve(self, t):
        if len(set(t)) == len(t):                  # all letters unique
            if self.best is None or t < self.best:
                self.best = t
            return
        for i in range(len(t)):
            if t.count(t[i]) > 1:                  # only delete a repeated letter
                self.solve(t[:i] + t[i + 1:])

7Code line by line

linewhat it means
if len(set(t)) == len(t):A set keeps one copy of each letter. Same size as t → nothing repeats → valid.
if self.best is None or t < self.best:Keep the dictionary-smallest valid string seen so far.
if t.count(t[i]) > 1:Only delete a letter that has another copy, so no letter is ever lost.
self.solve(t[:i] + t[i + 1:])Recurse on the string without position i.

8Dry run (top of the recursion tree for "bcabc")

solve("bcabc")                 b and c repeat
├─ delete b@0 → "cabc"
│   ├─ delete c@0 → "abc"   unique → best = "abc"
│   └─ delete c@3 → "cab"   unique, "cab" > "abc" → keep "abc"
├─ delete c@1 → "babc"
│   ├─ delete b@0 → "abc"   same as best
│   └─ delete b@2 → "bac"   "bac" > "abc"
├─ delete b@3 → "bcac"
│   ├─ delete c@1 → "bac"
│   └─ delete c@3 → "bca"
└─ delete c@4 → "bcab"
    ├─ delete b@0 → "cab"
    └─ delete b@3 → "bca"
answer: "abc"

9Complexity & remember

RememberBrute force = try every set of deletions, keep the smallest all-unique string. Exponential.

Part B · A tempting idea that fails: keep the first copy

1The idea

Walk left to right. If a letter hasn't been seen yet, add it to the answer; if it has, skip it. This removes all duplicates in one pass.

2Run it on "bcabc"

iletterseen before?answer so far
0bno → addb
1cno → addbc
2ano → addbca
3byes → skipbca
4cyes → skipbca

We get "bca", but the answer is "abc". The duplicates are gone, but nothing makes the result smallest. Once b and c were placed, the small 'a' had to sit behind them.

3The fix comes from Remove K Digits

The teacher recalls the previous problem. There, while reading 1, 4 we couldn't decide anything yet, because the digits were going up. When 3 arrived after 4, we knew deleting the 4 makes the number smaller, so we went back and deleted it. Give letters numbers to see the same picture here: a = 1, b = 2, c = 3, d = 4. "bca" is 2, 3, 1. As long as the letters rise (2, 3) we keep them. When 'a' (1) arrives, everything bigger before it (b, c) is worth deleting.

But there's a catch: in Remove K Digits we could delete any digit while k > 0. Here we must keep one copy of every letter. We may only go back and delete b and c if we are sure another b and another c come later. In "bcabc", they do (indices 3 and 4), so deleting them is safe. Parts C and D are two ways of answering "does it come later?".


Part C · Stack + look ahead by scanning (O(n²))

1The question (same)

Same problem. Now we use the Greedy + Stack idea with a direct check: "does this letter appear later?"

2What the constraints tell us

n ≤ 10⁴, so an O(n²) method does about 10⁸ steps. The teacher calls it possible but slow. It's a stepping stone to Part D.

3Intuition

Stand at position i with the new letter ch. Before popping the top, walk ahead from i + 1 and look for another copy of the top letter. If you find one, the pop is safe.

4Building the logic

5Approach steps

  1. For each index i and letter ch: if ch is in the stack, skip.
  2. While the top > ch and the top appears after i (found by scanning) → pop it and remove it from the set.
  3. Push ch and add it to the set.
  4. Join the stack.

6Code (Python)

Stack + look ahead by scanning (correct, but O(n²))
class Solution:
    def removeDuplicateLetters(self, s):
        stack = []
        in_stack = set()
        for i, ch in enumerate(s):
            if ch in in_stack:
                continue
            # s.find(x, i + 1) scans to the right: -1 means "not found"
            while stack and stack[-1] > ch and s.find(stack[-1], i + 1) != -1:
                in_stack.remove(stack.pop())
            stack.append(ch)
            in_stack.add(ch)
        return ''.join(stack)

7Code line by line

linewhat it means
if ch in in_stack: continueThis letter is already placed in the answer, so skip the copy.
s.find(stack[-1], i + 1) != -1Search s from index i + 1 to the end for the top letter. This walk costs up to O(n) each time.
in_stack.remove(stack.pop())Pop the top and forget that it was in the stack.

8Dry run

The steps are identical to Part D's dry run below. The only difference is how "appears later" is answered: here by scanning to the right, there by one array lookup.

9Complexity & remember

RememberScanning ahead works, but it's slow. We want "does x appear after i?" in O(1).

Part D · Optimal: stack + last index + visited (O(n))

1The question (same)

Remove duplicates, keep each letter once, and get the lexicographically smallest result, in one pass.

2What the constraints tell us

3Intuition: write down where each letter appears for the last time

"Does letter x appear after position i?" is the same as "is the last position of x bigger than i?". So, before the main loop, walk the string once and store the last index of every letter. For "bcabc":

index01234
sbcabclast['a'] = 2, last['b'] = 3, last['c'] = 4

The teacher builds it step by step: b at 0, then c at 1, a at 2, then b again at 3 (overwrite 0 with 3), then c at 4 (overwrite 1 with 4). Now "does c appear after index 2?" is one lookup: last['c'] = 4 > 2 → yes.

4Building the logic

Why the greedy pop is safe here

Say the top is letter t, the new letter is c, t > c, and t's last index is after i. If we keep t where it is, the answer has t at that position. If we pop it, c takes that position. All letters before it are the same, and c < t at the first position that differs, so the string is smaller. And we haven't lost t, because we'll meet it again later and push it then. Bigger letter on top + it appears again later → pop it.

If t does not appear later, popping it would remove the last chance to include t, so the answer would be missing a letter (invalid). Then we stop popping, even if t > c. That's why we can't simply copy the Remove K Digits loop.

The visited array: skip letters already in the stack

The teacher raises this while walking forward: if a letter is already sitting in the stack and another copy of it arrives, pushing it again would put the letter in the answer twice. For example, in "cbacdcbc" the stack is [a, c, d] when the c at index 5 arrives, so that c must be skipped. To know this in O(1), keep visited[letter] = True while that letter is in the stack. You could also use a set; she says any "is it already there?" structure works.

Doubt 1: if a letter is already in the stack, could moving it to this later spot ever be better?
→ No. Look at the letter right above it in the stack (call it y). When y was pushed, our letter x was on top and was not popped. The only reasons not to pop are "x is not bigger than y" or "x has no later copy". But we're now looking at a later copy of x, so it must be that x < y. Moving x to the end would put y (bigger) in x's place, making the string larger. So the copy already in the stack is in the best spot, and skipping the new one is right. (If x is on top, moving it changes nothing.)

Un-mark when you pop (her key point)

When 'a' arrives in "bcabc", b and c are already marked visited. We pop them. If we forget to set visited back to False, then when the later b and c arrive, we'd think "already in the stack" and skip them, and the answer would be just "a". So every pop must also do visited[top] = False.

Why arrays and not a HashMap

The letters are only 'a' to 'z', a fixed size of 26. An array indexed by ord(ch) - ord('a') (Java: ch - 'a') gives 0 for 'a', 1 for 'b', … 25 for 'z'. Arrays avoid the hashing work a map does on every lookup, so the teacher prefers an int array for last index and a boolean array for visited.

As in Remove K Digits, her Java code uses a StringBuilder as the stack because Java's Stack class is slower. In Python we use a list and ''.join.

5Approach steps

  1. First pass: last[letter] = the last index where it appears.
  2. Make visited = 26 × False and an empty stack.
  3. For each index i and letter ch: if visited[ch] → skip.
  4. While the stack isn't empty, the top > ch, and last[top] > i → pop the top and set visited[top] = False.
  5. Push ch and set visited[ch] = True.
  6. Return the stack joined into a string.

6Code (Python)

Optimal: stack + last index + visited
class Solution:
    def removeDuplicateLetters(self, s):
        last = [0] * 26                       # last index of each letter
        for i, ch in enumerate(s):
            last[ord(ch) - ord('a')] = i

        visited = [False] * 26                # is this letter in the stack now?
        stack = []
        for i, ch in enumerate(s):
            idx = ord(ch) - ord('a')
            if visited[idx]:
                continue                      # already placed: skip this copy
            # bigger letter on top AND it appears again later: drop it
            while stack and stack[-1] > ch and last[ord(stack[-1]) - ord('a')] > i:
                top = stack.pop()
                visited[ord(top) - ord('a')] = False   # it can come back later
            stack.append(ch)
            visited[idx] = True
        return ''.join(stack)

7Code line by line

linewhat it means
last[ord(ch) - ord('a')] = iLater positions overwrite earlier ones, so after the loop each cell holds the last index.
if visited[idx]: continueThis letter is already in the stack, in its best spot (Doubt 1).
while stack and stack[-1] > ch and last[...] > i:Empty check first. Then: the top is bigger (popping helps) and the top comes again later (popping is safe).
top = stack.pop() visited[...] = FalseDrop it and un-mark it so its later copy can be pushed.
stack.append(ch) visited[idx] = TruePlace the new letter and mark it as in the stack.
return ''.join(stack)Bottom to top is the answer, left to right.

8Dry run

Example 1: s = "bcabc" (last: a = 2, b = 3, c = 4)

ichwhat we pop (and why)pushstack after (bottom → top)visited
0bnothing (empty)bbb
1cnothing (b < c)cb cb, c
2apop c (c > a, last c = 4 > 2); pop b (b > a, last b = 3 > 2)aaa
3bnothing (a < b); b was un-marked, so not skippedba ba, b
4cnothing (b < c)ca b ca, b, c
after i = 1
bc
after i = 2 (b, c popped)
a
end
abc

Answer "abc" ✓. If we had forgotten to un-mark b and c at i = 2, both would be skipped at i = 3 and 4, and we'd return "a".

Example 2: s = "cbacdcbc" (last: a = 2, b = 6, c = 7, d = 4)

index01234567
scbacdcbc
ichwhat we pop (and why)pushstack after
0cnothingcc
1bpop c (c > b, last c = 7 > 1)bb
2apop b (b > a, last b = 6 > 2)aa
3cnothing (a < c)ca c
4dnothing (c < d)da c d
5cskip: c is visited—a c d
6bd > b, but last d = 4 < 6 → d never comes again → can't popba c d b
7cskip: c is visited—a c d b
after i = 4
acd
after i = 6 (d had to stay)
acdb

Answer "acdb" ✓. Row 6 is the important one: b is smaller than d, but d has no copy left, so popping it would lose d. The "appears later" check is what makes this problem different from Remove K Digits.

9Complexity & remember

Rememberlast[] first. Skip if visited. While top > ch and last[top] > i → pop and un-mark. Push and mark. Join.

Part E · Revision page

approachideatimespaceverdict
frequency countknows which letters repeatO(n)O(26)can't tell which copy to delete
recursiontry every deletion, keep the smallest unique stringexponential (~2ⁿ)O(n) depthTLE
keep the first copyskip letters already seenO(n)O(26)"bca" instead of "abc"
stack + scanning aheadpop a bigger top if s.find finds it laterO(n²)O(n)correct, slow
stack + last + visitedpop a bigger top if last[top] > iO(n)O(1) + answerbest
Remove K DigitsRemove Duplicate Letters
pop whentop > ch and k > 0top > ch and top appears later
budgetexactly k deletionskeep exactly one of each letter
skip rulenoneskip a letter already in the stack
clean-upcut leftover k, strip leading zerosnone
If you remember only 5 lines 1. A smaller letter earlier = a smaller string, so pop bigger letters before a smaller one.
2. But only pop a letter if it appears again later (last[top] > i).
3. Skip a letter that's already in the stack (visited).
4. When you pop, un-mark visited so the letter can return.
5. 26-size arrays instead of maps; O(n) time. LeetCode 1081 is the same problem.
Mistakes to avoid ✗ popping without checking that the letter comes later (loses letters: "cbacdcbc" would break)
✗ using >= for last (it must be strictly after i)
✗ forgetting visited[top] = False on pop
✗ not skipping letters already in the stack (duplicates in the answer)
✗ if instead of while (only one pop per letter)
✗ peeking at an empty stack
test it yourself (paste under any solution)
s = Solution()
print(s.removeDuplicateLetters("bcabc"))      # abc
print(s.removeDuplicateLetters("cbacdcbc"))   # acdb
print(s.removeDuplicateLetters("a"))          # a
print(s.removeDuplicateLetters("aaaa"))       # a
print(s.removeDuplicateLetters("abcd"))       # abcd
print(s.removeDuplicateLetters("dcba"))       # dcba
print(s.removeDuplicateLetters("ecbacba"))    # eacb

Based on this video: Remove Duplicate Letters | Stack + Greedy