Smallest Subsequence of Distinct Characters
MedPrompt
Given a string s, return the lexicographically smallest subsequence of s that contains all the distinct characters of s exactly once.
Example 1:
Input: s = "bcabc"
Output: "abc"Example 2:
Input: s = "cbacdcbc"
Output: "acdb"
Constraints:
1 <= s.length <= 1000sconsists of lowercase English letters.
Note: This question is the same as 316: https://leetcode.com/problems/remove-duplicate-letters/
Approaches
2 approaches with complexity analysis and trade-offs.
This approach builds the result string one character at a time using recursion. The core idea is to greedily pick the smallest possible character for the current position in the result, while ensuring that the remaining necessary characters can still be found in the rest of the string.
Algorithm
- Define a recursive function, say
solve(s). - Base Case: If
sis empty, return an empty string. - Find the last occurrence index for each character in
s. - Find the index
posof the lexicographically smallest character insup to the point where a character appears for the last time.- Iterate through
sfrom left to right. Keep track of the indexposof the smallest character seen so far. - If we encounter a character
cwhich is the last of its kind ins(i.e., its last occurrence index is the current index), we must make a choice from the prefix ofswe have scanned so far. The best choice is the character atpos.
- Iterate through
- Let the chosen character be
ch = s.charAt(pos). - Form a new string
s_remby taking the substring ofsafterposand removing all occurrences ofch. - Return
ch + solve(s_rem).
Walkthrough
The function findSmallest(s) aims to find the smallest subsequence for a given string s. At each step, we must decide which character to pick first. The ideal first character is the smallest one ('a'), but we can only pick it if the remaining unique characters all appear later in the string. We generalize this: we find the smallest character c in s such that the suffix of s after c contains all other required unique characters.
To implement this, we can find the last occurrence of every character. Then, we iterate through the string to find the smallest character up to the earliest last-occurrence position. This character is the guaranteed best first character for our subsequence. For example, in "cbacdcbc", the last 'a' is at index 2. We must pick a character from "cba". The smallest is 'a', so we pick 'a'.
Once we've chosen the first character c (at index i), we append it to our result. Then, we recursively call the function on the rest of the string s.substring(i+1), but we must remove all occurrences of c from this substring since we've already used it. The base case for the recursion is when the input string is empty.
class Solution { public String smallestSubsequence(String s) { if (s.isEmpty()) { return ""; } // Find the last position of each character int[] lastPos = new int[26]; for (int i = 0; i < s.length(); i++) { lastPos[s.charAt(i) - 'a'] = i; } int pos = 0; // Position of the smallest character for the first part of the result for (int i = 0; i < s.length(); i++) { // Find the lexicographically smallest character if (s.charAt(i) < s.charAt(pos)) { pos = i; } // If we are at the last occurrence of a character, we must decide. // The smallest character in s[0...i] must be chosen. if (i == lastPos[s.charAt(i) - 'a']) { break; } } char ch = s.charAt(pos); // Recursively call on the substring after the chosen character, // removing all other occurrences of the chosen character. String remainingString = s.substring(pos + 1).replaceAll(String.valueOf(ch), ""); return ch + smallestSubsequence(remainingString); }}Complexity
Time
O(K * N), where `N` is the length of the string and `K` is the number of unique characters (at most 26). The recursion depth is `K`. In each call, we iterate through a part of the string (O(N)), and `replaceAll()` also takes O(N).
Space
O(K * N) in the worst case, due to the recursion stack depth (`K`) and the creation of new substrings at each level (up to `N` characters each).
Trade-offs
Pros
It's a conceptually clear divide-and-conquer approach.
The greedy choice at each step is easy to understand.
Cons
Inefficient due to repeated string scanning and creation of new string objects in each recursive call.
Can lead to a
StackOverflowErrorfor problems with a larger set of unique characters, althoughKis limited to 26 here.
This is the most optimal approach. It uses a stack to build the result string and a greedy strategy to ensure it's lexicographically the smallest. We iterate through the input string s and decide for each character whether to add it to our result.
Algorithm
- Create an array
lastIndexof size 26 to store the last occurrence index of each character ins. - Initialize an empty stack
stackto build the result. - Initialize a boolean array
seenof size 26 to keep track of characters already in the stack. - Iterate through the input string
sfrom left to right with indexiand characterc:- If
seen[c - 'a']is true, continue to the next character. - While the stack is not empty, the character at the top of the stack is lexicographically greater than
c, AND the last occurrence of the top character is after the current indexi:- Pop the character from the stack.
- Mark the popped character as not seen (e.g.,
seen[popped_char - 'a'] = false).
- Push the current character
conto the stack. - Mark
cas seen (e.g.,seen[c - 'a'] = true).
- If
- After the loop, build a string from the characters in the stack. This string is the result.
Walkthrough
The core idea is to maintain a candidate result (using a stack) that is always the lexicographically smallest possible subsequence of the prefix of s we have processed so far.
We iterate through the string s. For each character c:
- If
chas already been included in our result stack, we skip it. This ensures each character appears only once. - If
cis not in the stack, we need to add it. Before adding, we check if the character at the top of the stack is greater than the current characterc. If it is, and if that top character appears again later in the strings, we can pop it from the stack. Popping a larger character in favor of a smaller one (c) helps in creating a lexicographically smaller result. We repeat this popping process as long as the conditions are met. - After the popping phase, we push the current character
conto the stack.
To efficiently check if a character appears again later, we can pre-process the string to find the last occurrence index of each character. We also use a boolean array or a set to keep track of the characters currently in the stack for O(1) lookup. After iterating through the entire string, the characters in the stack, when joined together, form the desired smallest subsequence.
import java.util.Stack; class Solution { public String smallestSubsequence(String s) { int[] lastIndex = new int[26]; for (int i = 0; i < s.length(); i++) { lastIndex[s.charAt(i) - 'a'] = i; } boolean[] seen = new boolean[26]; Stack<Character> stack = new Stack<>(); for (int i = 0; i < s.length(); i++) { char c = s.charAt(i); if (seen[c - 'a']) { continue; } while (!stack.isEmpty() && c < stack.peek() && i < lastIndex[stack.peek() - 'a']) { seen[stack.pop() - 'a'] = false; } stack.push(c); seen[c - 'a'] = true; } StringBuilder sb = new StringBuilder(); for (char c : stack) { sb.append(c); } return sb.toString(); }}Complexity
Time
O(N), where `N` is the length of the string. Although there is a nested `while` loop, each character is pushed onto and popped from the stack at most once. Therefore, the total number of operations is proportional to `N`.
Space
O(K), where `K` is the number of unique characters (at most 26). This space is used for the `lastIndex` array, the `seen` array, and the stack. The stack size will not exceed `K`.
Trade-offs
Pros
Highly efficient with linear time and space complexity.
Single-pass solution (after pre-computation of last indices).
Cons
The logic can be slightly less intuitive to grasp initially compared to a direct recursive approach.
Solutions
Solution
class Solution {public String smallestSubsequence(String text) { int[] cnt = new int[26]; for (char c : text.toCharArray()) { ++cnt[c - 'a']; } boolean[] vis = new boolean[26]; char[] cs = new char[text.length()]; int top = -1; for (char c : text.toCharArray()) { --cnt[c - 'a']; if (!vis[c - 'a']) { while (top >= 0 && c < cs[top] && cnt[cs[top] - 'a'] > 0) { vis[cs[top--] - 'a'] = false; } cs[++top] = c; vis[c - 'a'] = true; } } return String.valueOf(cs, 0, top + 1); }}class Solution {public: string smallestSubsequence(string s) { int n = s.size(); int last[26] = {0}; for (int i = 0; i < n; ++i) { last[s[i] - 'a'] = i; } string ans; int mask = 0; for (int i = 0; i < n; ++i) { char c = s[i]; if ((mask >> (c - 'a')) & 1) { continue; } while (!ans.empty() && ans.back() > c && last[ans.back() - 'a'] > i) { mask ^= 1 << (ans.back() - 'a'); ans.pop_back(); } ans.push_back(c); mask |= 1 << (c - 'a'); } return ans; }};class Solution: def smallestSubsequence(self, s: str) -> str: last = {c: i for i, c in enumerate(s)} stk = [] vis = set() for i, c in enumerate(s): if c in vis: continue while stk and stk[- 1] > c and last[stk[- 1]] > i: vis . remove(stk . pop()) stk . append(c) vis . add(c) return "" . join(stk)Video walkthrough
Newsletter
One sharp idea, every week
System design and interview prep — short enough to finish.
No spam. Unsubscribe anytime.
Practice
Same difficulty — related problems to reinforce the pattern.