ACM金牌選手講解LeetCode演算法《棧和佇列的高階應用》

公眾號【程式設計熊】 發表於 2021-07-22
演算法 LeetCode

大家好,我是程式設計熊,雙非逆襲選手,位元組跳動、曠視科技前員工,ACM金牌,保研985,《ACM金牌選手講解LeetCode演算法系列》作者。

上一篇文章講解了《線性表》中的陣列、連結串列、棧和佇列的概念和基本應用,本文講解棧和佇列的高階應用。

  • 單調棧
  • 雙端佇列
  • 滑動視窗

單調棧

介紹

單調棧 = 單調 + 棧,因此其同時滿足兩個特性: 單調性、棧的特點。

  • 單調性: 單調棧裡面所存放的資料是有序的(單調遞增或遞減)。
  • 棧: 後進先出。

因其滿足單調性和每個數字只會入棧一次,所以可以在時間複雜度 O(n) 的情況下解決一些問題。

下圖是單調棧的圖解,棧內數字滿足單調性,且滿足棧的後進先出的特性。

ACM金牌選手講解LeetCode演算法《棧和佇列的高階應用》

例題

LeetCode 739. 每日溫度

題意

給定每天的溫度,求對於每一天需要等幾天才可以等到更暖和的一天。如果該天之後不存在 更暖和的天氣,則記為 0。

輸出一個一維陣列,表示每天需要等待的天數。

示例
輸入: temperatures = [73,74,75,71,69,72,76,73]
輸出: [1,1,4,2,1,1,0,0]
題解

建立單調(非增)棧,棧存放每天的溫度,為了方便計算天數,棧中儲存的每天的溫度在陣列中下標,可以通過下標得到對應天的溫度。

設溫度陣列為 a,從左向右依次遍歷陣列 a,假設當前遍歷到陣列位置為 j,則對應的天溫度為 a[j],設棧頂元素的位置為 i,則對應的天的溫度a[i] ,分為兩種情況討論。

  • 如果 a[j] > a[i],執行以下三步。
    • 表明比第 i 天更暖和的一天為第 j 天,則第 i 天的答案為 j-i,那麼可以將棧頂元素彈出。
    • 重複檢查棧頂元素,直至棧頂元素的 a[j] <= a[i] 或者 棧為空。
    • j 入棧。
  • 如果 a[j] <= a[i]
    • 表明第 i 天沒有找到更暖和的一天,無需對棧操作。
    • j 入棧。

然後繼續遍歷溫度陣列 a,考慮下一天,直至結束。

遍歷結束,若棧不為空,則說明棧內的天找不到更暖和的一天,記為 0

程式碼
class Solution {
    public int[] dailyTemperatures(int[] T) {
        int[] ans = new int[T.length];
        Deque<Integer> s = new LinkedList<Integer>();
        for(int i = 0; i < T.length; i++) {
            while(!s.isEmpty() && T[i] > T[s.peek()]) {
                ans[s.peek()] = i - s.pop();
            }
            s.push(i);
        }
        return ans;
    }
}

LeetCode 316. 去除重複字母

題意

給你一個字串 s ,請你去除字串中重複的字母,使得每個字母只出現一次。需保證 返回結果的字典序最小(要求不能打亂其他字元的相對位置)。

示例
輸入:s = "bcabc"
輸出:"abc"
題解

首先思考這個問題的一個簡單版本。給一個字串刪除一個字元,使得字典序最小。

  • 解法: 字典序就是字母的大小順序,我們想字典序最小,那應刪除滿足 s[i] > s[i+1] 的最小位置 i 上的字元。

回到這個問題,我們也是想盡可能的刪除滿足 s[i] > s[i+1] 的最小位置 i 上的字元,如果每次都是遍歷一遍字串刪除一個字元,這樣時間複雜度可能退化到 O(n^2)

優化方法: 單調棧。

單調棧中存放的是字元,從左往右遍歷字串 s, 設當前遍歷到字串的位置 i,棧頂字元為c,考慮 s[i] 和 棧頂字元的大小關係、位置 i 的字元不在棧中,可分為兩種。

  • c > s[i] 並且 位置 i 的字元不在棧中 並且 在位置 i 後面還存在字元 c,那麼將 c 從棧中彈出。重複這個過程,直到 c > s[i] 不成立 或者 棧為空。
  • 不滿足上述條件,直接將 s[i] 放入棧中。

繼續遍歷字串 s,直至結束,最後棧中的字元就是題目要求的字典序最小的字串。

程式碼
class Solution {
    public String removeDuplicateLetters(String s) {
        int[] count = new int[30];
        for (int i = 0; i < s.length(); i++) {
            count[s.charAt(i) - 'a']++;
        }
        boolean[] vis = new boolean[30];
        StringBuffer ans = new StringBuffer();
        for (int i = 0; i < s.length(); i++) {
            int c = s.charAt(i) - 'a';
            if (!vis[c]) {
                while ((ans.length() > 0) && (count[ans.charAt(ans.length() - 1) - 'a'] > 0) 
                			&& ((ans.charAt(ans.length() - 1) - 'a') > c)) {
                    vis[ans.charAt(ans.length() - 1) - 'a'] = false;
                    ans.deleteCharAt(ans.length() - 1);
                }
                vis[c] = true;
                ans.append(s.charAt(i));
            }
            count[c]--;
        }
        return ans.toString();
    }
}

習題推薦

  1. LeetCode 496. 下一個更大元素 I
  2. LeetCode 1475. 商品折扣後的最終價格
  3. LeetCode 503. 下一個更大元素 II

雙端佇列 & 滑動視窗

介紹

雙端佇列是普通佇列的加強版 ,區別於佇列只能從隊頭出隊,隊尾入隊;雙端佇列既可以在隊頭入隊和出隊,也可以在隊尾入隊和出隊。

下圖是雙端佇列的的圖解,可以看出,雙端佇列既可以在隊頭入隊和出隊,也可以在隊尾入隊和出隊。

ACM金牌選手講解LeetCode演算法《棧和佇列的高階應用》

例題

LeetCode 239. 滑動視窗最大值

題意

給你一個整數陣列 nums,有一個大小為 k 的滑動視窗,從陣列的最左側移動到陣列的最右側。你只可以看到在滑動視窗內的 k 個數字。滑動視窗每次只向右移動一位。返回滑動視窗移動過程中每個視窗中的最大值。

示例
輸入:nums = [1,3,-1,-3,5,3,6,7], k = 3
輸出:[3,3,5,5,6,7]
解釋:
滑動視窗的位置                最大值
---------------               -----
[1  3  -1] -3  5  3  6  7       3
 1 [3  -1  -3] 5  3  6  7       3
 1  3 [-1  -3  5] 3  6  7       5
 1  3  -1 [-3  5  3] 6  7       5
 1  3  -1  -3 [5  3  6] 7       6
 1  3  -1  -3  5 [3  6  7]      7
題解

滑動視窗經典題,維護一個單調的雙端佇列,為了方便,雙端佇列裡面的陣列的下標,從前往後遍歷陣列,需要實現兩個功能。

  • 隊頭位置下標當前遍歷位置下標 的距離大於 k,則刪除隊頭元素,保證了隊頭下標在當前滑動視窗內
  • 隊尾位置下標對應的值 小於 當前位置的值,則刪除隊尾元素,保證了隊頭下標對應的值是最大的

其次將當前遍歷位置下標放入雙端佇列,然後遍歷陣列的下一個位置,直至結束。

程式碼
class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        Deque<Integer> q = new LinkedList<>();
        int ans[] = new int[nums.length - k + 1];
        for (int i = 0; i < k; i++) {
            while (!q.isEmpty() && nums[q.getLast()] < nums[i]) {
                q.removeLast();
            }
            q.addLast(i);
        }
        ans[0] = nums[q.getFirst()];
        for (int i = k; i < nums.length; i++) {
            while(!q.isEmpty()  && (i - q.getFirst() >= k)) {
                q.removeFirst();
            }
            while(!q.isEmpty() && nums[q.getLast()] < nums[i]) {
                q.removeLast();
            }
            q.addLast(i);
            ans[i - k + 1] = nums[q.getFirst()];
        }
        return ans;
    }
}

LeetCode 3. 無重複字元的最長子串

題意

給定一個字串,請你找出其中不含有重複字元的 最長子串 的長度。

示例
輸入: s = "abcabcbb"
輸出: 3 
解釋: 因為無重複字元的最長子串是 "abc",所以其長度為 3。
題解

觀察樣例,我們可以發現,依次遞增地列舉子串的起始位置,那麼合法的結束為止一定是遞增的,因為對於起始位置 i-1 ,假設其不含有重複字元的最遠右位置 j;那麼對於起始位置為 i 的子串,因為 [i-1,j] 不含有重複字元,其不含有重複字元的最遠右位置一定大於等於 i,因此我們考慮使用滑動視窗來解決本題。

我們可以固滑動視窗的右邊界,找到最遠的不含有重複字元的左邊界 ,根據上面我們觀察得到的性質可以,不含有重複字元的左邊界是非遞減的。

程式碼具體實現上我們可以用 雙端佇列實現滑動視窗,輔助陣列 cnt 統計視窗內每個字元出現的次數 ,來判斷視窗是否有重複的字元。

程式碼
class Solution {
    public int lengthOfLongestSubstring(String s) {
        char[] cnt = new char[128];
        LinkedList<Character> q = new LinkedList<Character>();
        int ans = 0;
        for (int i = 0; i < s.length(); i++) {
            q.add(s.charAt(i));
            cnt[s.charAt(i)]++;
            while (cnt[s.charAt(i)] > 1) {
                char frontC = q.pollFirst();
                cnt[frontC]--;
            }
            ans = Math.max(ans, q.size());
        }
        return ans;
    }
}

習題推薦

LeetCode 209. 長度最小的子陣列

【下面是粉絲福利】

【計算機學習核心資源】: 涵蓋了所有計算機學習核心資源,多看看進大廠問題不大。

【github寶藏倉庫】: 對學習和麵試都非常有幫助,學完超過99%同齡人。