LeetCode 1081. 不同字符的最小子序列
题目描述

题意分析
从小写字母字符串中选出字典序最小的子序列,要求原串中每种出现过的字符都恰好保留一次。可以删除字符,但保留字符的相对顺序不能改变,因此不能直接把不同字母排序。
解法:单调栈维护候选
核心思路
[!blue]
字典序由第一个不同的位置决定,所以应尽量让较小的字符排在前面。用栈保存当前选出的子序列,用
used[c]表示字符c是否已在栈中,预先计算last[c]表示它最后一次出现的位置。扫描到下标
i的字符c时,若它已经在栈中,就跳过这次出现;保留较早的同一字符能给后面的选择留下更多位置。若尚未选择它,就检查栈顶top:只有top > c且last[top] > i,才能弹出栈顶。前一个条件说明让c提前会使字典序更小,后一个条件保证top以后还能补回,不会缺少某种字符。弹掉这样的栈顶后,未改变的前缀相同,第一个变化的位置由较大的
top换成了较小的c,结果一定更优。因此连续弹出所有满足条件的栈顶,再加入c。每次弹出都要清除对应的used标记,允许该字符在之后重新入选。如果栈顶没有后续副本,它必须留在当前字符前;如果栈顶更小,保留它在前更优。这两种情况都应停止弹栈,所以候选栈不一定始终递增。扫描结束后,每种字符都已保留一次,栈底到栈顶就是满足原顺序的最小字典序答案。
解题步骤
- 扫描一次字符串,记录各字符的最后出现位置
last。- 再次从左到右扫描。当前字符已被
used标记时直接跳过。- 栈非空、栈顶比当前字符大且以后还会出现时,弹出栈顶,并将其
used设为false。- 将当前字符入栈,并将其
used设为true。- 扫描完成后按栈中现有顺序返回字符串,无需反转。
代码实现
class Solution {
public String smallestSubsequence(String s) {
int[] last = new int[26];
for (int i = 0; i < s.length(); i++) {
last[s.charAt(i) - 'a'] = i;
}
boolean[] used = new boolean[26];
StringBuilder stack = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
int idx = c - 'a';
// 当前已保留的字符不再重复加入。
if (used[idx]) {
continue;
}
while (stack.length() > 0) {
char top = stack.charAt(stack.length() - 1);
// 只有替换更优且以后能补回,才撤销栈顶。
if (top > c && last[top - 'a'] > i) {
// 撤销后允许这个字符在后续位置重新入选。
used[top - 'a'] = false;
stack.deleteCharAt(stack.length() - 1);
} else {
break;
}
}
stack.append(c);
used[idx] = true;
}
return stack.toString();
}
}
func smallestSubsequence(s string) string {
last := make([]int, 26)
for i := 0; i < len(s); i++ {
last[s[i]-'a'] = i
}
used := make([]bool, 26)
stack := make([]byte, 0)
for i := 0; i < len(s); i++ {
c := s[i]
idx := c - 'a'
// 当前已保留的字符不再重复加入。
if used[idx] {
continue
}
for len(stack) > 0 {
top := stack[len(stack)-1]
// 只有替换更优且以后能补回,才撤销栈顶。
if top > c && last[top-'a'] > i {
// 撤销后允许这个字符在后续位置重新入选。
used[top-'a'] = false
stack = stack[:len(stack)-1]
} else {
break
}
}
stack = append(stack, c)
used[idx] = true
}
return string(stack)
}
复杂度分析
- 时间复杂度:$O(n)$,其中
n为字符串长度;每次字符出现至多入栈、出栈各一次,两次扫描总体为线性。- 空间复杂度:$O(1)$,字母表固定为 26 个小写字母,两个辅助数组和栈都只需常数空间。
关键点总结
[!green]
- 弹栈要同时满足字典序收益与后续仍可补回。
- 栈底到顶就是答案顺序,不需要反转。
易错点总结
[!yellow]
- 只看大小就弹,会丢掉某种字符的最后机会。
- 弹栈不恢复 used,会阻止该字符再次加入。
- 直接把不同字符排序,可能得到不是原串子序列的结果。
used表示当前是否在栈中,不是这个字符是否曾经出现或入栈过。
相似题目
| 题目 | 难度 | 关联与区别 |
|---|---|---|
| 402. 移掉 K 位数字 | 中等 | 同样贪心弹出不利栈顶,本题要求每种字符恰好一次,弹出前要确保后面可补回。 |
| 321. 拼接最大数 | 困难 | 同样选择最优子序列,原题限制最终长度,本题限制字符种类覆盖及不重复。 |
| 316. 去除重复字母 | 中等 | 保持单调候选并在后续仍可补足时弹出较差元素;本题选择每种不同字符一次的最小字典序子序列,该题每种字符保留一次并追踪剩余次数。 |
| 1673. 找出最具竞争力的子序列 | 中等 | 保持单调候选并在后续仍可补足时弹出较差元素;本题选择每种不同字符一次的最小字典序子序列,该题保留固定长度的最具竞争力子序列。 |
转载与许可
许可
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!