LeetCode 316. 去除重复字母
题目描述

题意分析
从字符串中删除部分字符,使原串出现过的每种字母恰好保留一次,并让最终字符串的字典序最小。保留下来的字符必须维持原来的先后顺序,因此结果是原串的子序列,不能先排序再去重。
所有合法结果都包含同样的字母种类,长度相同。字典序由第一个不同位置的字符决定,所以需要在仍能保留全部字母的前提下,尽量让较小字符靠前。
解法:单调栈 + 剩余频次
核心思路
[!blue]
从左到右扫描,用栈保存当前选出的子序列。更小的新字母到来时,可以考虑撤销栈尾较大的字母,让新字母出现在更靠前的位置;但撤销的字母仍必须在后面出现,否则结果会永久缺少这一种字母。
先统计各字母总次数。扫描当前字符时先将对应
remain减一,使它准确表示当前位置之后的剩余次数;used则表示这个字母是否已在栈内。已经选过的字母不再入栈,但这次出现仍必须从剩余次数中扣除。对尚未选入的当前字母,只有栈顶比它大且后面还有栈顶字母时,才允许弹出。弹出后清除那个字母的
used,使其后续副本能够重新进入。持续检查新栈顶,直到前面的字符不值得撤销,或者再撤销就无法补全字母种类。这个贪心选择同时满足两个条件:用更小字母提前,改善了结果最早发生变化的位置;被推迟的字母仍可从后面补回,不破坏可完成性。扫描顺序和只撤销栈尾的操作保证结果始终是原串子序列。若较大栈顶已无后续副本,它就必须留下,因此最终栈不一定整体递增。
最后所有字母都被至少一次尝试加入,
used防止重复,remain防止某类字母彻底丢失,栈中的字符串就是字典序最小的合法选择。
解题步骤
- 统计小写字母总次数到
remain,初始化used和空栈。- 读取当前字母,先减少它的剩余次数;若它已经在栈中,跳过本次加入。
- 栈非空时,检查栈顶是否更大且以后还能出现;满足则弹出,并将其
used设为假,继续检查。- 将当前字母入栈,标记为已使用。
- 扫描结束后,按栈中从底到顶的顺序返回字符串。
代码实现
class Solution {
public String removeDuplicateLetters(String s) {
int[] remain = new int[26];
for (int i = 0; i < s.length(); i++) {
remain[s.charAt(i) - 'a']++;
}
boolean[] used = new boolean[26];
StringBuilder stack = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
char ch = s.charAt(i);
int index = ch - 'a';
remain[index]--;
if (used[index]) {
continue;
}
while (stack.length() > 0) {
char top = stack.charAt(stack.length() - 1);
// 只有栈顶更大且后面还能补回它,才允许用当前较小字符替换。
if (top <= ch || remain[top - 'a'] == 0) {
break;
}
// 弹出后撤销在栈标记,之后再次遇到该字符才能重新加入。
used[top - 'a'] = false;
stack.deleteCharAt(stack.length() - 1);
}
stack.append(ch);
used[index] = true;
}
return stack.toString();
}
}
func removeDuplicateLetters(s string) string {
remain := make([]int, 26)
for i := range s {
remain[s[i]-'a']++
}
used := make([]bool, 26)
stack := make([]byte, 0, 26)
for i := range s {
ch := s[i]
index := ch - 'a'
remain[index]--
if used[index] {
continue
}
for len(stack) > 0 {
top := stack[len(stack)-1]
// 只有栈顶更大且后面还能补回它,才允许用当前较小字符替换。
if top <= ch || remain[top-'a'] == 0 {
break
}
// 弹出后撤销在栈标记,之后再次遇到该字符才能重新加入。
used[top-'a'] = false
stack = stack[:len(stack)-1]
}
stack = append(stack, ch)
used[index] = true
}
return string(stack)
}
复杂度分析
- 时间复杂度:$O(n)$。统计和扫描各一遍,每个字符出现位置至多入栈一次、弹出一次,所有弹栈操作合计为线性数量。
- 空间复杂度:$O(1)$。输入仅有
26种小写字母,频次、使用标记和最多含26个字母的栈都不随字符串长度增长。
关键点总结
[!green]
- 字典序优先改善靠前位置,但每次撤销都要保留完成全部字母覆盖的可能。
remain管未来还能否补回,used管当前是否已经选入,两者互相配合。- 弹出栈尾不会打乱已有子序列顺序;后续再遇到对应字母时可以重新选择。
- 这里的栈带有可补回条件,不能把它误解为必须全局严格递增的栈。
易错点总结
[!yellow]
- 只比较字符大小,不检查未来次数,会删除某类字母的最后一次可用出现。
- 跳过已使用字母后才减少
remain,被跳过的出现次数没有消费,后面就可能误判还能补回。- 弹栈后不清除
used,被撤销字母的后续出现仍会被拒绝,结果缺少字母。- 只弹一次就停止,可能留下多个可以被当前较小字母推迟的栈尾字符。
- 对原串排序后直接去重,虽然字母齐全,却不一定是原串的合法子序列。
- 要求最终结果严格递增,忽略了原串顺序和最后出现位置对选择的限制。
相似题目
| 题目 | 难度 | 关联与区别 |
|---|---|---|
| 402. 移掉 K 位数字 | 中等 | 同样在更优字符出现时尝试弹栈,本题还要求被弹字符后面能补回且每种保留一次。 |
| 321. 拼接最大数 | 困难 | 同样用单调栈选字典序最优子序列,原题限制选择长度,本题限制字符覆盖种类。 |
| 1081. 不同字符的最小子序列 | 中等 | 保持单调候选并在后续仍可补足时弹出较差元素;本题每种字符保留一次并追踪剩余次数,该题选择每种不同字符一次的最小字典序子序列。 |
| 1673. 找出最具竞争力的子序列 | 中等 | 保持单调候选并在后续仍可补足时弹出较差元素;本题每种字符保留一次并追踪剩余次数,该题保留固定长度的最具竞争力子序列。 |
| 补充题 166. 去除重复字母的最大字典序子序列 | 中等 | 都用单调栈和剩余次数保证每种字母只留一次;补充题反转字典序比较方向。 |
转载与许可
许可
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!