题目地址:
https://leetcode.com/problems/remove-duplicate-letters/
给定一个长 n n n的字符串 s s s,题目保证只含小写字母,要求在其中删去重复字母,使得每个字母恰好只出现一次。问所能得到的字典序最小的字符串是什么。
思路是单调栈。遍历 s s s,例如遍历到 s [ i ] s[i] s[i],如果遇到栈内已经存在过的字符,则直接略过。否则逐个将字符加入栈中。当遇到栈顶比 s [ i ] s[i] s[i]大,并且 s [ i ] s[i] s[i]可以被删掉(也就是 s s s后面还有该字符),那么栈顶应该出栈。显然栈顶删掉后的字符串字典序与不删除相比会变小。此外,字符串遍历完后,从栈底到栈顶的字符就形成了最终答案。
算法正确性证明:
数学归纳法。如果
s
s
s长度是
1
1
1或
2
2
2则结论显然。设对
s
s
s长度小于
n
n
n都成立,考虑
s
s
s长度等于
n
n
n的情况。主要考虑
s
[
0
]
s[0]
s[0]和
s
[
1
]
s[1]
s[1]是什么情况。如果
s
[
0
]
s[0]
s[0]在整个
s
s
s里只出现了一次,那
s
[
0
]
s[0]
s[0]是绝不可能出栈的,这时算法相当于是在求
s
[
1
:
n
−
1
]
s[1:n-1]
s[1:n−1]的最小字典序的、每个字母只包含一个的子序列是谁,由归纳假设,算法正确;如果
s
[
0
]
=
s
[
1
]
s[0]=s[1]
s[0]=s[1],那么
s
[
1
]
s[1]
s[1]被略过了,问题就化为对长
n
−
1
n-1
n−1的字符串的问题,由归纳假设,算法正确;如果
s
[
0
]
s[0]
s[0]出现多次,并且
s
[
0
]
>
s
[
1
]
s[0]>s[1]
s[0]>s[1],那去掉
s
[
0
]
s[0]
s[0]之后,算法相当于在求
s
[
1
:
n
−
1
]
s[1:n-1]
s[1:n−1]的满足条件的子序列,该子序列一定不会以
s
[
0
]
s[0]
s[0]开头(因为
s
[
1
]
<
s
[
0
]
s[1]<s[0]
s[1]<s[0],以
s
[
1
]
s[1]
s[1]开头更优,而由归纳假设,算法求出的答案必然要以一个小于等于
s
[
1
]
s[1]
s[1]的字母开头),所以
s
[
1
:
n
−
1
]
s[1:n-1]
s[1:n−1]的满足条件的子序列对于整个
s
s
s也是最优解,由归纳假设,算法求出了最优解,结论正确。如果
s
[
0
]
s[0]
s[0]出现多次,并且
s
[
0
]
<
s
[
1
]
s[0]<s[1]
s[0]<s[1],此时要分两个情况考虑:
1、如果
s
[
2
:
n
−
1
]
s[2:n-1]
s[2:n−1]中不存在小于
s
[
0
]
s[0]
s[0]的字母,那么算法从
s
[
1
]
s[1]
s[1]开始就是在求
s
[
1
:
n
−
1
]
s[1:n-1]
s[1:n−1]的去掉所有
s
[
0
]
s[0]
s[0]之后的满足条件的子序列(因为遇到
s
[
0
]
s[0]
s[0]这个字母的时候会直接略过),这个子序列连同开头的
s
[
0
]
s[0]
s[0]就是算法得出的解,可以证明这个解就是全局最优解,如果不然,则说明有另一个也以
s
[
0
]
s[0]
s[0]开头的子序列是更优解(这里以
s
[
0
]
s[0]
s[0]开头的意思是以这个字母开头,不是以在下标
0
0
0的那个
s
[
0
]
s[0]
s[0]开头),比如以
s
[
k
]
=
s
[
0
]
s[k]=s[0]
s[k]=s[0]开头,那么说明算法求出了
s
[
k
+
1
:
n
−
1
]
s[k+1:n-1]
s[k+1:n−1]中的不含
s
[
0
]
s[0]
s[0]这个字母的、且只包含所有字母(当然除了
s
[
0
]
s[0]
s[0])一次的字典序最小的子序列,显然该子序列一定不会比
s
[
1
:
n
−
1
]
s[1:n-1]
s[1:n−1]的只包含所有字母(除了
s
[
0
]
s[0]
s[0])一次的字典序最小的子序列更优,这就矛盾了,所以算法求出的就是最优解;
2、如果
s
[
2
:
n
−
1
]
s[2:n-1]
s[2:n−1]中存在小于
s
[
0
]
s[0]
s[0]的字母,设第一个小于
s
[
0
]
s[0]
s[0]的是
s
[
k
]
s[k]
s[k],那么如果
s
[
1
:
k
−
1
]
s[1:k-1]
s[1:k−1]含至少一个”独苗“字母,则独苗不能删,所以最优解一定是
s
[
0
]
s[0]
s[0]再拼上
s
[
1
:
n
−
1
]
s[1:n-1]
s[1:n−1]去掉字母
s
[
0
]
s[0]
s[0]后里的最优解(论证和上面类似),由归纳假设,算法能算出规模更少的情况下的最优解,所以算法正确;如果
s
[
1
:
k
−
1
]
s[1:k-1]
s[1:k−1]不含”独苗“字母,那算法会把
s
[
0
:
k
]
s[0:k]
s[0:k]全删掉,并以
s
[
k
]
s[k]
s[k]为新的起点求解,由归纳假设算法可以求得
s
[
k
:
n
−
1
]
s[k:n-1]
s[k:n−1]里的最优解,而该最优解一定是全局最优解(因为全局最优解一定不能以
s
[
0
:
k
−
1
]
s[0:k-1]
s[0:k−1]里的字母开头),所以算法也正确。
综上,算法正确。
代码里可以直接用一个StringBuilder来当成栈来用。代码如下:
import java.util.HashMap;
import java.util.Map;
public class Solution {
public String removeDuplicateLetters(String s) {
// 直接用sb作为栈
StringBuilder sb = new StringBuilder();
Map<Character, Integer> map = new HashMap<>();
boolean[] used = new boolean[26];
// 存每个字符出现的最后一个位置的下标
for (int i = 0; i < s.length(); i++) {
map.put(s.charAt(i), i);
}
for (int i = 0; i < s.length(); i++) {
char ch = s.charAt(i);
// 如果ch已经存在于sb中,则直接略过
if (used[ch - 'a']) {
continue;
}
char last = 0;
// 栈顶大于新来的字符,则栈顶出栈
while (sb.length() > 0 && (last = sb.charAt(sb.length() - 1)) > ch && map.get(last) > i) {
// 标记为false
used[last - 'a'] = false;
sb.setLength(sb.length() - 1);
}
sb.append(ch);
used[ch - 'a'] = true;
}
return sb.toString();
}
}
时空复杂度 O ( n ) O(n) O(n)。