1081. Smallest Subsequence of Distinct Characters
題目 / Problem
中文: 給定一個字串 s,回傳一個「字典序最小」的子序列,且該子序列要包含 s 中所有不同的字元,每個字元恰好出現一次。
「子序列」指的是:從原字串中刪掉零個或多個字元、但保持其餘字元原本的相對順序所得到的字串(不可以重新排列)。
English: Given a string s, return the lexicographically smallest subsequence of s that contains every distinct character of s exactly once.
A subsequence is what you get by deleting zero or more characters from the original string while keeping the remaining characters in their original relative order (you may not reorder them).
Constraints / 限制
- 1 <= s.length <= 1000
- s 只由小寫英文字母組成 / s consists of lowercase English letters.
Worked example / 範例
- Input: s = "cbacdcbc"
- Output: "acdb"
- 不同字元有 a, b, c, d 四個,輸出把每個各放一次,並且在所有合法排法中字典序最小。/ The distinct letters are a, b, c, d; the answer uses each once and is the smallest possible ordering that is still a valid subsequence.
名詞解釋 / Glossary
- 子序列 / Subsequence:保留原字串字元的相對順序,刪掉一些字元後得到的字串。例如
"ace"是"abcde"的子序列,但"aec"不是(順序被打亂了)。 - 字典序 / Lexicographic order:像字典查字一樣,逐字元由左往右比較。第一個不同的位置上,字元較小者整體較小。例如
"acb" < "acd",因為第三個字元b < d。 - 貪心 / Greedy:每一步都做「當下看起來最好」的選擇,並證明這種局部最優能導向全域最優。
- 單調堆疊 / Monotonic Stack:一個堆疊(後進先出的容器),我們在放入元素前先彈出「破壞單調性」的元素,使堆疊內容維持某種遞增/遞減趨勢。這裡用來維持結果盡量遞增。
- 堆疊 / Stack:一種資料結構,只能從一端加入(push)與取出(pop),最後放入的最先取出(LIFO)。這裡我們把它當成正在建構的答案。
- 計數陣列 / Count array:一個長度 26 的陣列,記錄每個字母「後面還剩幾個」,用來判斷現在能不能安全地丟棄某字母。
- visited 標記 / in-stack flag:一個布林陣列,記錄某字母是否已經在答案堆疊裡,避免重複加入。
思路
中文:最直覺的暴力法是枚舉所有「包含全部不同字元各一次」的排列,檢查哪些是 s 的合法子序列,再取字典序最小者。但不同字元最多 26 種,排列數是階乘等級,完全不可行。我們需要更聰明的方法。關鍵觀察是:答案要字典序最小,就希望「越小的字母越靠前」。所以當我們由左到右掃描字串、逐步建構答案時,如果目前答案結尾是一個較大的字母 x,而現在遇到一個較小的字母 c,並且 x 在後面還會再出現,那我們就應該把 x 從答案中拿掉、讓 c 排到前面去——因為 x 之後補回來即可,而把小字母提前能讓字典序更小。這正是「單調堆疊 + 貪心」。我們維護一個堆疊當作正在建構的答案,同時用一個計數陣列記錄每個字母「後面還剩幾個」,用一個 visited 陣列記錄字母是否已在堆疊中。掃描到字元 c 時:先把它的剩餘計數減一;若 c 已在堆疊就跳過(每個字母只能出現一次);否則,只要堆疊頂端字母比 c 大、且該頂端字母後面還會再出現(剩餘計數 > 0),就把它彈出(並清除 visited);最後把 c 推入堆疊。彈出的兩個條件缺一不可:頂端比 c 大,才有替換成更小字典序的好處;頂端後面還有,才保證彈掉它以後仍能把它補回來、不會遺失字元。
English: The brute-force idea—generate every permutation of the distinct letters and keep the smallest one that is a valid subsequence—blows up factorially, so it is hopeless even for 26 letters. The smarter route comes from one observation: to be lexicographically smallest we want smaller letters as far left as possible. So we scan the string left to right and build the answer on a stack. Whenever the letter currently on top of the stack is larger than the incoming letter c, and that top letter still appears again later in the string, we pop it off—we can always re-add it later, and pulling the smaller letter forward makes the result smaller. This is the monotonic-stack greedy. We keep three things: a stack holding the answer under construction, a count array telling us how many of each letter remain to the right, and an in-stack boolean array so no letter is added twice. Processing a character c: decrement its remaining count; if c is already in the stack, skip it (each letter appears once); otherwise, while the stack top is greater than c and that top letter still occurs later (remaining count > 0), pop it (and clear its flag); then push c. Both pop conditions are essential: "top is greater" gives the lexicographic win, and "top occurs later" guarantees we can safely restore it, so no distinct letter is ever lost.
逐步走查 / Walkthrough
Example input s = "bcabc". 初始計數 / initial counts: a=1, b=2, c=2。stack 初始為空 / empty. in = all false.
| i | 字元 c | 減計數後 remaining | c 已在堆疊? / in stack? | 彈出動作 / pops | 推入後 stack | in-stack set |
|---|---|---|---|---|---|---|
| 0 | b |
b=1 | no | 堆疊空,不彈 / empty | b |
{b} |
| 1 | c |
c=1 | no | top b < c,不彈 |
bc |
{b,c} |
| 2 | a |
a=0 | no | top c>a 且 c 後面還有(1)→彈 c;top b>a 且 b 後面還有(1)→彈 b;堆疊空停止 |
a |
{a} |
| 3 | b |
b=0 | no | top a < b,不彈 |
ab |
{a,b} |
| 4 | c |
c=0 | no | top b < c,不彈 |
abc |
{a,b,c} |
最終堆疊由底到頂為 a,b,c,輸出 "abc"。/ Reading the stack bottom-to-top gives "abc". ✓
Solution — C
// 演算法:單調堆疊 + 貪心。由左到右掃描,用堆疊建構答案;
// 當堆疊頂端字母比當前字母大、且該字母後面還會再出現時就彈出,
// 讓較小字母提前,得到字典序最小的結果。
// Algorithm: monotonic-stack greedy. Scan left to right building the answer on a
// stack; pop a larger top letter when it still appears later, pulling smaller
// letters forward to reach the lexicographically smallest subsequence.
char* smallestSubsequence(char* s) {
int remaining[26] = {0}; // 每個字母「後面還剩幾個」/ how many of each letter are still left
int inStack[26] = {0}; // 該字母是否已在堆疊中 / whether a letter is already on the stack
// 第一次掃描:統計每個字母的總出現次數。
// First pass: count total occurrences of each letter.
for (int i = 0; s[i] != '