Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

8.19 Remove Duplicate Letters

Source: src/main/kotlin/stack/RemoveDuplicateLetters.kt Pattern: monotonic stack with last-occurrence guards · Core page

The Problem

The smallest lexicographic string using each letter once, preserving order.

  • Constraints: n ≤ 10⁴; lowercase.

Examples

Input:  s = "bcabc"   -> Output: "abc"
Input:  s = "cbacdcbc" -> Output: "acdb"

Intuition — a monotonic stack that pops only when the letter can still appear later

Keep the result stack lexicographically smallest: on each char, pop bigger stack tops if they appear later (lastIndex[top] > i), and skip chars already placed:

val lastIndex = IntArray(26) { -1 }
val inStack = BooleanArray(26)
val stack = ArrayDeque<Char>()

for (i in s.indices) lastIndex[s[i] - 'a'] = i

for (i in s.indices) {
    val c = s[i]
    if (inStack[c - 'a']) continue                  // already placed: skip

    while (stack.isNotEmpty() && stack.last() > c && lastIndex[stack.last() - 'a'] > i) {
        inStack[stack.removeLast() - 'a'] = false   // pop: it appears again later
    }

    stack.add(c)
    inStack[c - 'a'] = true
}
return stack.joinToString("")

Why the lastIndex > i guard? Popping is only legal if the popped letter re-appears later — otherwise it’s lost forever. The guard is what makes the greedy safe; the stack is monotone increasing, the 8.5 monotonic-stack discipline with a data-dependency.

Why inStack? A letter already in the result can’t be re-added — duplicates skip. The boolean mirrors the stack’s contents (8.4 visited-map style).

Approach 1 — Greedy pick-min each round (O(26n))

Repeatedly find the smallest char whose prefix is removable: correct, slow.

Approach 2 — Monotonic stack + lastIndex (the repo’s version, optimal)

class RemoveDuplicateLetters {
    /**
     * @param s input string
     * @return  smallest lexicographic result with each letter once
     */
    fun removeDuplicateLetters(s: String): String {
        val lastIndex = IntArray(26) { -1 }
        val inStack = BooleanArray(26)
        val stack = ArrayDeque<Char>()

        for (i in s.indices) lastIndex[s[i] - 'a'] = i

        for (i in s.indices) {
            val c = s[i]

            if (inStack[c - 'a']) continue

            while (stack.isNotEmpty() && stack.last() > c && lastIndex[stack.last() - 'a'] > i) {
                inStack[stack.removeLast() - 'a'] = false
            }

            stack.add(c)
            inStack[c - 'a'] = true
        }
        return stack.joinToString("")
    }
}
import java.util.*;

public class RemoveDuplicateLetters {
    /**
     * @param s input string
     * @return  smallest lexicographic result with each letter once
     */
    public String removeDuplicateLetters(String s) {
        int[] last = new int[26];
        boolean[] placed = new boolean[26];
        Deque<Character> stack = new ArrayDeque<>();

        for (int i = 0; i < s.length(); i++) last[s.charAt(i) - 'a'] = i;

        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            if (placed[c - 'a']) continue;

            while (!stack.isEmpty() && stack.peek() > c && last[stack.peek() - 'a'] > i) {
                placed[stack.pop() - 'a'] = false;
            }

            stack.push(c);
            placed[c - 'a'] = true;
        }

        StringBuilder sb = new StringBuilder();
        for (char c : stack) sb.append(c);
        return sb.reverse().toString();
    }
}
#include <string>
#include <vector>

class RemoveDuplicateLetters {
public:
    /**
     * @param s input string
     * @return  smallest lexicographic result with each letter once
     */
    std::string removeDuplicateLetters(std::string s) {
        std::vector<int> last(26, -1);
        std::vector<bool> placed(26, false);
        std::string stack;

        for (int i = 0; i < (int)s.size(); i++) last[s[i] - 'a'] = i;

        for (int i = 0; i < (int)s.size(); i++) {
            char c = s[i];
            if (placed[c - 'a']) continue;

            while (!stack.empty() && stack.back() > c && last[stack.back() - 'a'] > i) {
                placed[stack.back() - 'a'] = false;
                stack.pop_back();
            }

            stack.push_back(c);
            placed[c - 'a'] = true;
        }
        return stack;
    }
};
def remove_duplicate_letters(s: str) -> str:
    """
    @param s: input string
    @return:  smallest lexicographic result with each letter once
    """
    last = {ch: i for i, ch in enumerate(s)}
    placed = set()
    stack = []

    for i, ch in enumerate(s):
        if ch in placed:
            continue

        while stack and stack[-1] > ch and last[stack[-1]] > i:
            placed.remove(stack.pop())

        stack.append(ch)
        placed.add(ch)

    return "".join(stack)
#![allow(unused)]
fn main() {
use std::collections::HashSet;

impl Solution {
    /// @param s input string
    /// @return  smallest lexicographic result with each letter once
    pub fn remove_duplicate_letters(s: String) -> String {
        let bytes: Vec<char> = s.chars().collect();
        let mut last = std::collections::HashMap::new();
        for (i, &ch) in bytes.iter().enumerate() { last.insert(ch, i); }

        let mut placed: HashSet<char> = HashSet::new();
        let mut stack: Vec<char> = Vec::new();

        for (i, &ch) in bytes.iter().enumerate() {
            if placed.contains(&ch) { continue; }

            while let Some(&top) = stack.last() {
                if top < ch || last[&top] < i { break; }
                placed.remove(&top);
                stack.pop();
            }

            stack.push(ch);
            placed.insert(ch);
        }
        stack.into_iter().collect()
    }
}
}

Dry run

Input: s = "bcabc".

last: b=3, c=4, a=2
i=0 'b': push.  stack [b].  placed {b}
i=1 'c': top b < c -> no pop.  push.  [b,c].  {b,c}
i=2 'a': top c > a && last[c]=4 > 2 -> pop c.  top b > a && last[b]=3 > 2 -> pop b.
  stack [].  push a.  [a].  {a}
i=3 'b': top a < b -> push.  [a,b].  {a,b}
i=4 'c': push.  [a,b,c].

Output: "abc" ✓

The pops at i=2 are the algorithm’s heart: both c and b are bigger than a and re-appear later (indices 4, 3) — popping them makes the result start with a, the lexicographic win. The lastIndex > i guard is what permits the pops; without it, “b” would be lost.

Complexity

Time. Each char pushed/popped once:

$$ T(n) = O(n) $$

Space. Stack + flags:

$$ S(n) = O(n) $$

Variants & follow-ups

  • Smallest Subsequence Of Distinct Characters — the identical problem (duplicate file).
  • Monotonic stack family (8.3, 8.5) — the pop-when-dominated engine.
  • Interview follow-up: “Why is the pop safe only with lastIndex > i?” Popping removes a letter from the result — if it never appears again, the result becomes impossible. The last-occurrence table answers “can I afford to defer it?” in O(1); that deferral is exactly what buys the lexicographic minimum.