答案必须同时满足三个条件:每种字母恰好出现一次、保持原字符串的相对顺序、字典序最小。前两个条件说明答案是 s 的某个子序列,第三个条件决定选哪一个。
用栈从左到右构造答案,每读到一个字母 letter:
letter 提前。一句话记忆:当前字母未入栈时,弹出所有比它大且后面还能补回的栈顶。
支撑这套规则只需要两个状态:
remaining:每种字母在尚未扫描部分还剩多少次,用来判断"弹出后还能不能补回来"。stack:当前构造出的最小合法前缀。栈最多 26 个字母,判断重复直接用 stack.includes,不需要额外标记。给定只包含小写英文字母的字符串 s,删除重复字母,使每种字母只出现一次。不能改变剩余字符的相对顺序,并且返回结果的字典序必须最小。
示例 1:
示例 2:
题目约束:
1 <= s.length <= 10^4。s 只包含小写英文字母。删除字符不会改变其余字符的相对顺序,所以最终答案一定是 s 的子序列,而不是任意排列。
以 s = "bcabc" 为例,字母集合是 {a, b, c},答案长度固定为 3,合法结果有 "abc"、"bac"、"bca"、"cab"。其中 "abc" 在第一个不同位置用了最小的 "a",字典序最小。
这带来两个推论:
"cba" 得到的 "abc" 无法从原串中按顺序选出。扫描到当前字母 letter、栈顶为 top 时,只有同时满足下面两个条件才能弹出 top:
两个条件分别回答"有没有收益"和"是否安全",缺一不可。
top > letter,弹栈才有收益只有当前字母更小时,把它提前才会让第一个变化位置变小,字典序严格改善。如果 top <= letter,保留不更大的栈顶不会更差,弹栈没有意义。
remaining[top] > 0,弹栈才安全答案必须包含每种字母。弹出 top 的前提是后面还有它,可以补回来;否则结果会缺少这种字母。
例如 s = "bac":扫描到 "a" 时虽然 "b" > "a",但 "b" 后面不再出现(remaining["b"] === 0)。一旦弹出 "b" 就无法补回,所以答案只能保留 "ba" 的相对顺序,返回 "bac"。
while 而不是if当前字母可能连续淘汰多个"更大且可替代"的栈顶。在 "bcabc" 中扫描到 "a" 时:
每弹出一个,新栈顶仍可能满足弹栈条件,所以要循环到不能再弹为止,才把 letter 放到它最早的安全位置。
remaining[i]:字母 i 在当前扫描位置之后还会出现多少次。每读到一个字母就先 remaining[i]--(即使它随后被跳过),这样弹栈判断可以直接写成 remaining[topIndex] > 0。stack:当前选择的字母顺序,栈底到栈顶即答案顺序。判断"当前字母是否已选"直接用 stack.includes(letter),栈最多 26 个字母,线性扫描也是常数。注意这个栈不一定整体递增。某个较大字母如果后面不再出现就不能弹出,栈里会保留下降低关系,比如答案 "acdb" 中的 "d" > "b"。所以更准确的名字是"带可行性约束的单调栈"。
扫描任意前缀后:
stack 中没有重复字母,每种字母至多入选一次。扫描结束时 remaining 全为 0,任何不在栈中的字母都不可能再补回;而算法只在确保未来还有出现时才弹栈,所以最终每种字母恰好保留一次。
s = "bcabc"| 当前字母 | 扫描后剩余次数 | 操作 | 栈 |
|---|---|---|---|
b | 后面还有 b | 入栈 | b |
c | 后面还有 c | 入栈 | bc |
a | b、c 后面都还有 | 依次弹出 c、b,压入 a | a |
b | 后面没有 b | 入栈 | ab |
c | 后面没有 c | 入栈 | abc |
s = "cbacdcbc"| 当前字母 | 操作 | 栈 |
|---|---|---|
c | 入栈 | c |
b | c 以后还会出现,弹出 c | b |
a | b 以后还会出现,弹出 b | a |
c | 入栈 | ac |
d | 入栈 | acd |
c | 已在栈中,跳过 | acd |
b | d 后面不再出现,不能弹出;压入 b | acdb |
c | 已在栈中,跳过 | acdb |
最终答案是 "acdb"。
思路参考:JoshCrozier/leetcode-javascript。本文将未来位置与成员关系的重复字符串扫描改为计数数组和布尔数组,使状态含义和线性复杂度更直接;原项目采用 MIT License。
charCode 的 Map 写法charCodeAt 只是把字母映射成数组下标的手段,不是必需的。用 Map 计数,字母本身就是键,逻辑完全一致;字母之间的大小比较用字符串比较即可,字典序天然正确。
两种写法复杂度相同,取舍在于:
| 代码 | 作用 |
|---|---|
第一轮统计 remaining | 预先知道每种字母的总出现次数 |
remaining[index]-- | 将当前出现从“未来次数”中扣除 |
stack.includes(letter) | 跳过已经选择的重复字母 |
top > letter | 条件一:当前较小字母提前后能改善字典序 |
remaining[topIndex] > 0 | 条件二:被弹出的字母以后仍能补回 |
stack.join('') | 栈内顺序就是最终最小子序列 |
入栈前的 stack.includes 检查阻止同一种字母重复入栈,所以每种字母至多出现一次。
一个字母只有在 remaining > 0 时才会被弹出,说明后面至少还有一次出现;扫描到它最后一次出现时它会重新入栈,此后 remaining === 0,不会再被弹出。因此每种出现过的字母最终至少一次、至多一次,即恰好一次。
弹栈发生时(top > letter 且 top 可补回),把 top 延后、让更小的 letter 提前:既保持相对顺序、不丢失字母,又让第一个变化位置严格变小,所以每次弹栈都是安全且更优的交换。
停止弹栈时(top <= letter 或 top 不可补回),继续弹要么没有收益、要么破坏合法性,因此当前栈前缀已是可完成全部字母前提下的最小字典序。逐字符应用该选择,最终结果就是全局最小合法子序列。
"aaaa" 返回 "a"。"cba" 仍返回 "cba",因为每个字母都没有后续替代。"bcabc" 中前面的 "b"、"c" 可以为 "a" 让位。remaining[index]-- 必须在跳过重复字母之前执行,否则会把已经扫过的出现误算成未来次数。设 n = s.length:
O(n)。while 总执行次数是线性规模。O(n)。O(1)(推广到大小为 Σ 的字符集时为 O(Σ))。作为对照,参考的原实现在主循环中用 s.indexOf() 搜索未来位置,结果正确但隐藏了对剩余字符串的重复扫描。改用 remaining 后,未来位置查询是明确的 O(1);成员判断用的 stack.includes 栈长不超过 26,同样是常数。
预处理每种字母最后一次出现的下标:
扫描到位置 i 时,弹栈条件二改为 lastIndex[top] > i。它与剩余次数方案等价,同样是 O(n) 时间、固定字母表下 O(1) 空间。
找到一个最短前缀,使它已经包含后缀完成答案所需的所有字母;在其中选字典序最小字符作为答案首位,再从其后缀中删除该字符并递归。思路能解释贪心首字符的来由,但字符串切片和重复扫描成本高,不如单调栈直接。
删除字符不能改变剩余字符的相对顺序,答案必须是原字符串的子序列。排序结果未必能从原字符串中按顺序选出。
stack.includes 判断重复,而不需要inStack 标记数组?题目要求每种字母只出现一次,入栈前必须知道它是否已选。栈最多 26 个字母,includes 的线性扫描上限是常数,不影响整体 O(n),还省掉一份必须与栈严格同步的状态。
如果字符集泛化到任意 Σ,includes 变成 O(Σ),此时可以换用 Set 或布尔数组做显式 O(1) 查询;代价是要在入栈、弹栈时同步维护标记,忘记同步反而会引入新错误。本题 26 个小写字母的前提下,includes 是更简洁的选择。
最终答案必须包含这种字母。如果当前栈顶已经是它的最后一次可用出现,弹出后就无法补回,结果会变得非法。
remaining[index]-- 要在跳过重复字母之前执行?即使当前字母已经在栈中,这次出现也已经被扫描,不能继续算作未来次数。否则后续可能错误认为还有替代出现。
每弹出一个较大且可替代的栈顶后,新的栈顶仍可能大于当前字母。连续交换能让当前较小字母移动到最早的安全位置。
字典序优化受“每种字母必须保留一次”约束。如果较大栈顶以后不再出现,即使当前字母更小也不能弹出它,因此可能保留下降关系。
O(n),而不是两层循环的O(n²)?内层每次循环都会永久弹出一个已经入栈的字符出现。每个出现至多入栈、出栈各一次,所以所有 while 的总执行次数不超过线性规模。
两题都用栈撤销前面较大的选择以获得更小字典序。本题必须保留每种字母且只保留一次,弹栈依赖未来是否还能补回;移掉 K 位数字则由剩余删除次数约束。
remaining,使未来次数失真。先只回答第 1 题,再展开后续问题:
"bcabc" 中读到 "a" 时,为什么可以连续弹出 "c" 和 "b"?"bac" 中读到 "a" 时,为什么不能弹出 "b"?stack.includes 判断重复不影响时间复杂度?remaining 改写成 lastIndex,并写出对应弹栈条件。