讀完本文,你可以去力扣拿下如下題目:
-----------
關於去重算法,應該沒什么難度,往哈希集合里面塞不就行了么?
最多給你加點限制,問你怎么給有序數組原地去重,這個我們舊文 如何高效地給有序數組/鏈表去重。
本文講的問題應該是去重相關算法中難度最大的了,把這個問題搞懂,就再也不用怕數組去重問題了。
這是力扣第 316 題「去除重復字母」,題目如下:
PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路后投再入題海就如魚得水了。
這道題和第 1081 題「不同字符的最小子序列」的解法是完全相同的,你可以把這道題的解法代碼直接粘過去把 1081 題也干掉。
題目的要求總結出來有三點:
要求一、要去重。
要求二、去重字符串中的字符順序不能打亂 s
中字符出現的相對順序。
要求三、在所有符合上一條要求的去重字符串中,字典序最小的作為最終結果。
上述三條要求中,要求三可能有點難理解,舉個例子。
比如說輸入字符串 s = "babc"
,去重且符合相對位置的字符串有兩個,分別是 "bac"
和 "abc"
,但是我們的算法得返回 "abc"
,因為它的字典序更小。
按理說,如果我們想要有序的結果,那就得對原字符串排序對吧,但是排序后就不能保證符合 s
中字符出現順序了,這似乎是矛盾的。
其實這里會借鑒前文 單調棧解題框架 中講到的「單調棧」的思路,沒看過也無妨,等會你就明白了。
我們先暫時忽略要求三,用「棧」來實現一下要求一和要求二,至於為什么用棧來實現,后面你就知道了:
String removeDuplicateLetters(String s) {
// 存放去重的結果
Stack<Character> stk = new Stack<>();
// 布爾數組初始值為 false,記錄棧中是否存在某個字符
// 輸入字符均為 ASCII 字符,所以大小 256 夠用了
boolean[] inStack = new boolean[256];
for (char c : s.toCharArray()) {
// 如果字符 c 存在棧中,直接跳過
if (inStack[c]) continue;
// 若不存在,則插入棧頂並標記為存在
stk.push(c);
inStack[c] = true;
}
StringBuilder sb = new StringBuilder();
while (!stk.empty()) {
sb.append(stk.pop());
}
// 棧中元素插入順序是反的,需要 reverse 一下
return sb.reverse().toString();
}
這段代碼的邏輯很簡單吧,就是用布爾數組 inStack
記錄棧中元素,達到去重的目的,此時棧中的元素都是沒有重復的。
如果輸入 s = "bcabc"
,這個算法會返回 "bca"
,已經符合要求一和要求二了,但是題目希望要的答案是 "abc"
對吧。
那我們想一想,如果想滿足要求三,保證字典序,需要做些什么修改?
在向棧 stk
中插入字符 'a'
的這一刻,我們的算法需要知道,字符 'a'
的字典序和之前的兩個字符 'b'
和 'c'
相比,誰大誰小?
如果當前字符 'a'
比之前的字符字典序小,就有可能需要把前面的字符 pop 出棧,讓 'a'
排在前面,對吧?
那么,我們先改一版代碼:
String removeDuplicateLetters(String s) {
Stack<Character> stk = new Stack<>();
boolean[] inStack = new boolean[256];
for (char c : s.toCharArray()) {
if (inStack[c]) continue;
// 插入之前,和之前的元素比較一下大小
// 如果字典序比前面的小,pop 前面的元素
while (!stk.isEmpty() && stk.peek() > c) {
// 彈出棧頂元素,並把該元素標記為不在棧中
inStack[stk.pop()] = false;
}
stk.push(c);
inStack[c] = true;
}
StringBuilder sb = new StringBuilder();
while (!stk.empty()) {
sb.append(stk.pop());
}
return sb.reverse().toString();
}
PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路后投再入題海就如魚得水了。
這段代碼也好理解,就是插入了一個 while 循環,連續 pop 出比當前字符小的棧頂字符,直到棧頂元素比當前元素的字典序還小為止。只是不是有點「單調棧」的意思了?
這樣,對於輸入 s = "bcabc"
,我們可以得出正確結果 "abc"
了。
但是,如果我改一下輸入,假設 s = "bcac"
,按照剛才的算法邏輯,返回的結果是 "ac"
,而正確答案應該是 "bac"
,分析一下這是怎么回事?
很容易發現,因為 s
中只有唯一一個 'b'
,即便字符 'a'
的字典序比字符 'b'
要小,字符 'b'
也不應該被 pop 出去。
那問題出在哪里?
我們的算法在 stk.peek() > c
時才會 pop 元素,其實這時候應該分兩種情況:
情況一、如果 stk.peek()
這個字符之后還會出現,那么可以把它 pop 出去,反正后面還有嘛,后面再 push 到棧里,剛好符合字典序的要求。
情況二、如果 stk.peek()
這個字符之后不會出現了,前面也說了棧中不會存在重復的元素,那么就不能把它 pop 出去,否則你就永遠失去了這個字符。
回到 s = "bcac"
的例子,插入字符 'a'
的時候,發現前面的字符 'c'
的字典序比 'a'
大,且在 'a'
之后還存在字符 'c'
,那么棧頂的這個 'c'
就會被 pop 掉。
while 循環繼續判斷,發現前面的字符 'b'
的字典序還是比 'a'
大,但是在 'a'
之后再沒有字符 'b'
了,所以不應該把 'b'
pop 出去。
那么關鍵就在於,如何讓算法知道字符 'a'
之后有幾個 'b'
有幾個 'c'
呢?
也不難,只要再改一版代碼:
String removeDuplicateLetters(String s) {
Stack<Character> stk = new Stack<>();
// 維護一個計數器記錄字符串中字符的數量
// 因為輸入為 ASCII 字符,大小 256 夠用了
int[] count = new int[256];
for (int i = 0; i < s.length(); i++) {
count[s.charAt(i)]++;
}
boolean[] inStack = new boolean[256];
for (char c : s.toCharArray()) {
// 每遍歷過一個字符,都將對應的計數減一
count[c]--;
if (inStack[c]) continue;
while (!stk.isEmpty() && stk.peek() > c) {
// 若之后不存在棧頂元素了,則停止 pop
if (count[stk.peek()] == 0) {
break;
}
// 若之后還有,則可以 pop
inStack[stk.pop()] = false;
}
stk.push(c);
inStack[c] = true;
}
StringBuilder sb = new StringBuilder();
while (!stk.empty()) {
sb.append(stk.pop());
}
return sb.reverse().toString();
}
我們用了一個計數器 count
,當字典序較小的字符試圖「擠掉」棧頂元素的時候,在 count
中檢查棧頂元素是否是唯一的,只有當后面還存在棧頂元素的時候才能擠掉,否則不能擠掉。
至此,這個算法就結束了,時間空間復雜度都是 O(N)。
PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路后投再入題海就如魚得水了。
你還記得我們開頭提到的三個要求嗎?我們是怎么達成這三個要求的?
要求一、通過 inStack
這個布爾數組做到棧 stk
中不存在重復元素。
要求二、我們順序遍歷字符串 s
,通過「棧」這種順序結構的 push/pop 操作記錄結果字符串,保證了字符出現的順序和 s
中出現的順序一致。
這里也可以想到為什么要用「棧」這種數據結構,因為先進后出的結構允許我們立即操作剛插入的字符,如果用「隊列」的話肯定是做不到的。
要求三、我們用類似單調棧的思路,配合計數器 count
不斷 pop 掉不符合最小字典序的字符,保證了最終得到的結果字典序最小。
當然,由於棧的結構特點,我們最后需要把棧中元素取出后再反轉一次才是最終結果。
說實話,這應該是數組去重的最高境界了,沒做過還真不容易想出來。你學會了嗎?
_____________
我的 在線電子書 有 100 篇原創文章,手把手帶刷 200 道力扣題目,建議收藏!對應的 GitHub 算法倉庫 已經獲得了 70k star,歡迎標星!