这两个题目是类似的,都是去掉一些元素使得剩下的元素组合值最小。都是使用单调栈来解决
316. 去除重复字母
题目
给你一个字符串 s ,请你去除字符串中重复的字母,使得每个字母只出现一次。需保证 返回结果的字典序最小(要求不能打乱其他字符的相对位置)。
注意:该题与 1081 相同
示例 1:
输入:s = “bcabc”
输出:“abc”
示例 2:
输入:s = “cbacdcbc”
输出:“acdb”
解法
字典序是指按照单词出现在字典的顺序比较两个字符串的方法。例如“abc”的字典序在“acbd”的前面。
如果一个字母只出现一次,这个字母必须被选取。
按照字母升序排列的字符串肯定是字典序最小的。
在示例2中,最后的栈中呈现了分段单调递增的情况。
- 需要使用栈作为辅助结构
- 需要记录每一个字符最后一次出现的下标。
- 需要判断当前读到的字母是否已经出现。
public String removeDuplicateLetters(String s) {
int len = s.length();
char[] charArray = s.toCharArray();
int[] lastIndex = new int[26];
// 记录每个字母最后出现的位置
for (int i = 0; i < len; i++) {
lastIndex[charArray[i] - 'a'] = i;
}
Deque<Character> stack = new ArrayDeque<>();
// 记录每一个字符在栈中是否出现
boolean[] visited = new boolean[26];
for (int i = 0; i < len; i++) {
// 如果当前遍历到的字符在栈中已经出现就跳过
if (visited[charArray[i] - 'a']) {
continue;
}
// 如果当前栈顶元素字典序比遍历到的字母大并且栈顶元素在以后还会出现则出栈
while (!stack.isEmpty() && stack.peekLast() > charArray[i] && lastIndex[stack.peekLast() - 'a'] > i) {
Character top = stack.removeLast();
visited[top - 'a'] = false;
}
// 否则将当前遍历到的元素添加到栈中
stack.addLast(charArray[i]);
visited[charArray[i] - 'a'] = true;
}
StringBuilder sb = new StringBuilder();
for (char c : stack) {
sb.append(c);
}
return sb.toString();
}
402. 移掉K位数字
题目
给定一个以字符串表示的非负整数 num,移除这个数中的 k 位数字,使得剩下的数字最小。
注意:
num 的长度小于 10002 且 ≥ k。
num 不会包含任何前导零。
示例 1 :
输入: num = “1432219”, k = 3
输出: “1219”
解释: 移除掉三个数字 4, 3, 和 2 形成一个新的最小的数字 1219。
示例 2 :
输入: num = “10200”, k = 1
输出: “200”
解释: 移掉首位的 1 剩下的数字为 200. 注意输出不能有任何前导零。
示例 3 :
输入: num = “10”, k = 2
输出: “0”
解释: 从原数字移除所有的数字,剩余为空就是0。
解法
贪心 + 单调栈
对于两个相同长度的数字序列,最左边不同的数字决定了这两个数字的大小,例如,对于 A = 1axxx,B = 1bxxx,如果 a > b则 A >B。
基于此,我们可以知道,若要使得剩下的数字最小,需要保证靠前的数字尽可能小。
让我们从一个简单的例子开始。给定一个数字序列,例如 425,如果要求我们只删除一个数字,那么从左到右,我们有 4、2 和 5 三个选择。我们将每一个数字和它的左邻居进行比较。从 2 开始,2 小于它的左邻居 4。假设我们保留数字 4,那么所有可能的组合都是以数字 4(即 42,45)开头的。相反,如果移掉 4,留下 2,我们得到的是以 2开头的组合(即 25),这明显小于任何留下数字 4 的组合。因此我们应该移掉数字 4。如果不移掉数字 4,则之后无论移掉什么数字,都不会得到最小数。
基于上述分析,我们可以得出「删除一个数字」的贪心策略:
从左往右找到第一个位置 i(i>0)使得 D_i<D_{i-1},并删去 D_{i-1} ;如果不存在,说明整个数字序列单调不降,删去最后一个数字即可。
基于此,我们可以每次对整个数字序列执行一次这个策略;删去一个字符后,剩下的 n-1长度的数字序列就形成了新的子问题,可以继续使用同样的策略,直至删除 k 次。
然而暴力的实现复杂度最差会达到 O(nk)(考虑整个数字序列是单调不降的),因此我们需要加速这个过程。
考虑从左往右增量的构造最后的答案。我们可以用一个栈维护当前的答案序列,栈中的元素代表截止到当前位置,删除不超过 k 次个数字后,所能得到的最小整数。根据之前的讨论:在使用 k个删除次数之前,栈中的序列从栈底到栈顶单调不降。
因此,对于每个数字,如果该数字小于栈顶元素,我们就不断地弹出栈顶元素,直到栈为空或者新的栈顶元素不大于当前数字或者我们已经删除了 k 位数字。
上述步骤结束后我们还需要针对一些情况做额外的处理:
- 如果我们删除了 m 个数字且 m<k,这种情况下我们需要从序列尾部删除额外的 k−m 个数字。
- 如果最终的数字序列存在前导零,我们要删去前导零。
- 如果最终数字序列为空,我们应该返回 0。
最终,从栈底到栈顶的答案序列即为最小数。
考虑到栈的特点是后进先出,如果通过栈实现,则需要将栈内元素依次弹出然后进行翻转才能得到最小数。为了避免翻转操作,可以使用双端队列代替栈的实现。
public String removeKdigits(String num, int k) {
// 使用双端队列来避免翻转操作
Deque<Character> stack = new LinkedList<>();
// 维护一个单调栈
for (char c : num.toCharArray()) {
while (!stack.isEmpty() && k > 0 && stack.peekLast() > c) {
stack.removeLast();
k--;
}
stack.addLast(c);
}
// 如果删除的个数小于k则需要额外的删除剩余的元素
while (k-- != 0) {
stack.removeLast();
}
StringBuilder ret = new StringBuilder();
// 如果是前置0则跳过
boolean leadingZero = true;
for (char c : stack) {
if (leadingZero && c == '0') {
continue;
}
leadingZero = false;
ret.append(c);
}
return ret.length() == 0 ? "0" : ret.toString();
}