🎓 A1Learner
🔍
0 of 93 lessons completed

DSA Remove Duplicate Letters

Remove Duplicate Letters keeps exactly one copy of each letter in a string. The kept letters form the smallest result in lexicographic order, the dictionary order of strings.

The problem

Remove Duplicate Letters keeps exactly one copy of each letter in a string. The kept letters form the smallest result in lexicographic order. Lexicographic order is the dictionary order of strings, compared letter by letter from the left.

The answer in this article is that smallest string. Every distinct letter of the input appears once in it. The result order is a new order, not the order the letters were first seen.

The cases used in this article

for s in ['bcabc', 'cbacdcbc', 'aaaa', 'zyx']:
    print('input:', s, 'distinct letters:', len(set(s)))

The first case answers abc. The second answers acdb, which is not the sorted order of its letters. That gap between sorted and achievable is the heart of the problem.

Intuition: sort the distinct letters

The first idea collects the distinct letters and sorts them. Sorting gives the smallest order in the abstract. The input may simply never allow that order.

The trap is the string cbacdcbc. Its sorted distinct letters spell abcd. No subsequence of the input spells abcd, because d sits before the last b and c, so a result starting a, then b, then c, then d cannot be traced in order.

The sorted order that cannot be traced

s = 'cbacdcbc'
print('sorted distinct letters:', ''.join(sorted(set(s))))
pos = -1
ok = True
for ch in 'abcd':
    nxt = s.find(ch, pos + 1)
    if nxt == -1:
        ok = False
        print('letter', ch, 'not found after index', pos)
        break
    pos = nxt
print('abcd is a subsequence:', ok)

A result must be a subsequence of the input, meaning its letters appear in the input in the same order, not necessarily side by side. The smallest answer is the smallest subsequence that uses each distinct letter once. That constraint shapes the whole solution.

Brute force: test every subsequence that keeps one copy each

The brute force enumerates every subsequence of the input. It keeps those holding each distinct letter exactly once. The smallest kept subsequence is the answer.

Enumerating subsequences

import itertools
def brute(s):
    distinct = set(s)
    tested = [0]
    best = None
    indices = range(len(s))
    for r in range(len(s) + 1):
        for combo in itertools.combinations(indices, r):
            tested[0] += 1
            sub = ''.join(s[i] for i in combo)
            if set(sub) == distinct and len(sub) == len(distinct):
                if best is None or sub < best:
                    best = sub
    return best, tested[0]
result, tested = brute('bcabc')
print('result:', result)
print('subsequences tested:', tested)

A five-letter string already has 32 subsequences. Each added letter doubles that count. The optimal stack builds the same answer in one walk, with each letter pushed and popped at most once.

The optimal solution: a rising stack that pops only safe letters

A monotonic stack is a stack whose letters stay in rising order, and a larger letter on top is popped only when it appears again later in the string. That one sentence is the whole method.

The last occurrence table records the final index of each letter. The in-stack set records which letters are already placed, so a repeat of a placed letter is skipped. Each distinct letter enters the final stack exactly once.

The stack walk on every case

def smallest(s):
    last = {}
    for index, ch in enumerate(s):
        last[ch] = index
    stack = []
    in_stack = set()
    pushes = [0]
    pops = [0]
    skips = [0]
    for index, ch in enumerate(s):
        if ch in in_stack:
            skips[0] += 1
            continue
        while stack and stack[-1] > ch and last[stack[-1]] > index:
            in_stack.remove(stack.pop())
            pops[0] += 1
        stack.append(ch)
        in_stack.add(ch)
        pushes[0] += 1
    return ''.join(stack), pushes[0], pops[0], skips[0]
for s in ['bcabc', 'cbacdcbc', 'aaaa', 'zyx']:
    result, pushes, pops, skips = smallest(s)
    print('input:', s, 'result:', result, 'pushes:', pushes, 'pops:', pops, 'skips:', skips)

Every case from the start of the article is answered. The string zyx keeps its order, because no letter in it ever returns later, so no pop is ever safe. The string aaaa collapses to a single a.

Walkthrough: reducing bcabc step by step

The walk shows each push, pop, and skip for the first case. The state is the stack after each letter. A pop prints its reason, that the popped letter returns later.

Pushes and pops for bcabc

s = 'bcabc'
last = {}
for index, ch in enumerate(s):
    last[ch] = index
stack = []
in_stack = set()
for index, ch in enumerate(s):
    if ch in in_stack:
        print('skip', ch, 'already placed')
        continue
    while stack and stack[-1] > ch and last[stack[-1]] > index:
        gone = stack.pop()
        in_stack.remove(gone)
        print('pop', gone, 'it returns later')
    stack.append(ch)
    in_stack.add(ch)
    print('push', ch, 'stack:', ''.join(stack))
print('result:', ''.join(stack))

Both opening letters pop when a arrives, because both return later. The stack then rebuilds as abc from the later copies. The result is read straight off the final stack.

Complexity

Each letter is pushed at most once and popped at most once, so the walk costs O(n) time for a string of length n. Its space is at most one stack entry and one table entry per distinct letter.

Counting the work instead of timing it

for n in [5, 8, 16]:
    print('length', n, '- stack pushes at most:', n, ' brute subsequences:', 2 ** n)

At length 16 the walk pushes at most 16 letters. The brute force would test 65536 subsequences. The push and pop counts printed in the optimal section stay well under the length of each input.

Edge cases and pitfalls

Popping a larger letter that never returns is the classic mistake. That letter is then lost for good, and the result misses a distinct letter entirely. The last occurrence check is what makes a pop safe, and it is never optional.

An empty string gives an empty result. A string already in rising order with no repeats returns itself unchanged, because no letter on its stack is ever larger than the next letter.

Edge cases, run

def smallest(s):
    last = {}
    for index, ch in enumerate(s):
        last[ch] = index
    stack = []
    in_stack = set()
    for index, ch in enumerate(s):
        if ch in in_stack:
            continue
        while stack and stack[-1] > ch and last[stack[-1]] > index:
            in_stack.remove(stack.pop())
        stack.append(ch)
        in_stack.add(ch)
    return ''.join(stack)
print('empty:', repr(smallest('')))
print('single:', smallest('q'))
print('rising:', smallest('abcd'))
print('falling, no repeats return:', smallest('zyx'))
print('all same:', smallest('aaaa'))

The falling case returns itself, and that answer is correct. No pop in it is safe, so the stack never shrinks. Safety, not smallness alone, decides every pop.

Exercise

The stack top is c, the current letter is a, and c appears again later. What happens to c?