數據結構必知 --- 單調棧(案例分析)

寫在前

單調棧(monotone-stack)是指棧內元素(棧底到棧頂)都是(嚴格)單調遞增或者單調遞減的。

如果有新的元素入棧,棧調整過程中 會將所有破壞單調性的棧頂元素出棧,并且出棧的元素不會再次入棧 。由于每個元素只有一次入棧和出棧的操作,所以 單調棧的維護時間復雜度是O(n) 。

單調棧性質:

  • 單調棧里的元素具有單調性。
  • 遞增(減)棧中可以找到元素左右兩側比自身小(大)的第一個元素。

我們主要使用第二條性質,該性質主要體現在棧調整過程中,下面以自棧底到棧頂遞增為例(假設所有元素都是唯一),當新元素入棧。

  • 對于出棧元素來說:找到右側第一個比自身小的元素。
  • 對于新元素來說:等待所有破壞遞增順序的元素出棧后,找到左側第一個比自身小的元素。

1.單調棧結構

問題描述:給定不含重復值的數組arr,找到每個i位置左邊和右邊距離i最近的且值比i小的位置(沒有返回-1),返回所有的位置信息。

進階問題:數組中含有重復值。

示例

arr = {3, 4, 1, 0}
{
    {-1, 2},
    {0, 2},
    {-1, 3},
    {-1, -1}
}

思路:常規時間復雜度O(n^2)實現簡單,每個位置向左和向右遍歷一遍。

單調棧實現:尋找兩邊距離arr[i]最近且arr[i]小的索引,保持棧頂到棧底單調遞減(尋找比arr[i]大的值,單調遞增),棧中存放索引值。

對于進階問題,區別在于重復索引值用集合進行連接,棧中存放的是一個ArrayList。注意兩點:

  • arr[i]左邊應該是上一個位置最晚加入的那個(如果有多個元素)
  • 相等的情況直接在尾部加入,獲取值的時候循環的獲取該集合中的所有值(集合中元素值相等,索引值不同)

代碼:原問題

public int[][] getNearLessNoRepeat(int[] arr) {
    int[][] ans = new int[arr.length][2];
    Stack<Integer> stack = new Stack<>();
    // 遍歷數組,入棧
    for (int i = 0; i < arr.length; ++i) {
        while (!stack.isEmpty() && arr[i] < arr[stack.peek()]) {
            int popIndex = stack.pop();
            int leftLessIndex = stack.isEmpty() ? -1 : stack.peek();
            ans[popIndex][0] = leftLessIndex;
            ans[popIndex][1] = i;
        }
        stack.push(i);
    }
    
    while (!stack.isEmpty()) {
        int popIndex = stack.pop();
        int leftLessIndex = stack.isEmpty() ? -1 : stack.peek();
        ans[popIndex][0] = leftLessIndex;
        // 說明該索引右邊沒有比當前小的元素,有的話該索引在上邊循環就彈出了
        ans[popIndex][1] = -1;
    }
    return ans;
}

代碼:進階問題

public int[][] getNearLess(int[] arr) {
    int[][] ans = new int[arr.length][2];
    Stack<List<Integer>> stack = new Stack<>();
    // 遍歷數組,入棧
    for (int i = 0; i < arr.length; ++i) {
        while (!stack.isEmpty() && arr[i] < arr[stack.peek().get(0)]) {
            List<Integer> popIs = stack.pop();
            int leftLessIndex = stack.isEmpty() ? -1 : stack.peek().get(stack.peek().size() - 1);
            for (int popi : popIs) {
                ans[popi][0] = leftLessIndex;
                ans[popi][1] = i;
            }
        }
        if (!stack.isEmpty() && arr[i] == arr[stack.peek().get(0)]) {
            stack.peek().add(Integer.valueOf(i));
        } else {
            ArrayList<Integer> list = new ArrayList<>();
            list.add(i);
            stack.push(list);
        }
    }
    
    while (!stack.isEmpty()) {
        List<Integer> popIs = stack.pop();
        int leftLessIndex = stack.isEmpty() ? -1 : stack.peek().get(stack.peek().size() - 1);
        for (int popi : popIs) {
            ans[popi][0] = leftLessIndex;
            ans[popi][1] = -1;
        }
    }
    return ans;
}

2.下一個更大的元素(leetcode496-易)

題目描述:給你兩個 沒有重復元素 的數組 nums1nums2 ,其中nums1nums2 的子集。

請你找出 nums1 中每個元素在 nums2 中的下一個比其大的值。

nums1 中數字 x 的下一個更大元素是指 xnums2 中對應位置的右邊的第一個比 x 大的元素。如果不存在,對應位置輸出 -1

示例

輸入: nums1 = [4,1,2], nums2 = [1,3,4,2].
輸出: [-1,3,-1]
解釋:
    對于 num1 中的數字 4 ,你無法在第二個數組中找到下一個更大的數字,因此輸出 -1 。
    對于 num1 中的數字 1 ,第二個數組中數字1右邊的下一個較大數字是 3 。
    對于 num1 中的數字 2 ,第二個數組中沒有下一個更大的數字,因此輸出 -1 。

思路:維護一個從棧頂到棧底的嚴格單調遞增的棧,先遍歷大數組記錄每個元素右邊第一個比當前元素大的值,然后遍歷小數組輸出結果。這里用一個hashmap映射兩個數組的元素,棧中元素這里仍是索引,也可用元素。

代碼

public int[] nextGreaterElement(int[] nums1, int[] nums2) {
    Stack<Integer> stack = new Stack<>();
    HashMap<Integer, Integer> map = new HashMap<>();
    for (int i = 0; i < nums2.length; ++i) {
        while (!stack.isEmpty() && nums2[i] > nums2[stack.peek()]) {
            int cur = stack.pop();
            int rightMaxIdx = i;
            map.put(nums2[cur], nums2[rightMaxIdx]);
        }
        stack.push(i);
    }
    
    int[] ans = new int[nums1.length];
    for (int j = 0; j < nums1.length; ++j) {
        ans[j] = map.getOrDefault(nums1[j], -1);
    }
    return ans;
}

3.柱狀圖中最大的矩形(leetcode84-難)

問題描述:給定 n 個非負整數,用來表示柱狀圖中各個柱子的高度。每個柱子彼此相鄰,且寬度為 1 。

求在該柱狀圖中,能夠勾勒出來的矩形的最大面積。

示例

輸入: [2,1,5,6,2,3]
輸出: 10

思路:有了單調棧的基本認識,我們可以遍歷每根柱子,以當前柱子 i 的高度作為矩形的高,那么矩形的寬度邊界即為向左找到第一個高度小于當前柱體 i 的柱體,向右找到第一個高度小于當前柱體 i 的柱體。對于每個柱子我們都如上計算一遍以當前柱子作為高的矩形面積,最終比較出最大的矩形面積即可。

單調棧實現:尋找兩邊距離arr[i]最近且arr[i]小的索引,保持棧頂到棧底單調遞減,棧中存放索引值。

注意:頭0如果不添加,尋找左邊元素需要判斷棧是否為空;尾0如果不添加,需要重新寫一個循環彈出棧內元素。

代碼:原問題

class Solution {

    // 單調棧(棧底到棧頂單調遞增)
    public int largestRectangleArea(int[] heights) {
        int n = heights.length;
        Deque<Integer> stack = new LinkedList<>();
        int ans = 0;

        for (int i = 0; i < n; i++) {
            while (!stack.isEmpty() && heights[i] < heights[stack.peek()]) {
                int curIndex = stack.pop();

                while (!stack.isEmpty() && heights[stack.peek()] == heights[curIndex]) {
                    stack.pop();
                }
                int leftIndex = stack.isEmpty() ? -1 : stack.peek();
                ans = Math.max(ans, (i - leftIndex - 1) * heights[curIndex]);
            }
            stack.push(i);
        }

        while (!stack.isEmpty()) {
            int curIndex = stack.pop();
            while (!stack.isEmpty() && heights[stack.peek()] == heights[curIndex]) {
                stack.pop();
            }
            
            int width = stack.isEmpty() ? n : n - stack.peek() - 1;
            ans = Math.max(ans, width * heights[curIndex]);
        }
        return ans;
    } 

    // 優化1:添加尾0(推薦)
    public int largestRectangleArea(int[] heights) {
        int n = heights.length;
        heights = Arrays.copyOf(heights, n + 1);
        Deque<Integer> stack = new LinkedList<>();
        int ans = 0;

        for (int i = 0; i < n + 1; i++) {
            while (!stack.isEmpty() && heights[i] < heights[stack.peek()]) {
                int curIndex = stack.pop();

                while (!stack.isEmpty() && heights[stack.peek()] == heights[curIndex]) {
                    stack.pop();
                }
                int leftIndex = stack.isEmpty() ? -1 : stack.peek();
                ans = Math.max(ans, (i - leftIndex - 1) * heights[curIndex]);
            }
            stack.push(i);
        }
        return ans;
    }

    // 優化2:首尾都擴容0
    public int largestRectangleArea(int[] heights) {
        int n = heights.length;
        int[] tmp = new int[n + 2];
        System.arraycopy(heights, 0, tmp, 1, n);
        Deque<Integer> stack = new LinkedList<>();
        int ans = 0;

        for (int i = 0; i < n + 2; i++) {
            while (!stack.isEmpty() && tmp[i] < tmp[stack.peek()]) {
                int curIndex = stack.pop();

                while (!stack.isEmpty() && tmp[stack.peek()] == tmp[curIndex]) {
                    stack.pop();
                }
                int leftIndex = stack.peek();
                ans = Math.max(ans, (i - leftIndex - 1) * tmp[curIndex]);
            }
            stack.push(i);
        }
        return ans;
    }
}

4. 最大矩形(leetcode85-難)

題目描述:給定一個僅包含 01 、大小為 rows x cols 的二維二進制矩陣,找出只包含 1 的最大矩形,并返回其面積。

示例

image.png
輸入:matrix = [["1","0","1","0","0"],["1","0","1","1","1"],["1","1","1","1","1"],["1","0","0","1","0"]]
輸出:6
解釋:最大矩形如上圖所示。

思路:本題與上題思路相同,這里我們每遍歷一行,更新代表柱子高度的函數heights。當前單元為0,高度為0;當前單元為1,高度+1.

利用動態規劃的思想,我們不需要重新遍歷之前走過的行,每遍歷一行更新一下矩陣的最大面積。計算當前區域的最大矩形面積可以直接調用T84。

代碼

public int maximalRectangle(char[][] matrix) {
    if (matrix == null || matrix.length == 0 || matrix[0].length == 0) return 0;
    int row = matrix.length, col = matrix[0].length;
    int[] heights = new int[col];
    int ans = 0;

    for (int i = 0; i < row; ++i) {
        for (int j = 0; j < col; ++j) {
            if (matrix[i][j] == '0') heights[j] = 0;
            else ++heights[j];
        }
        ans = Math.max(ans, largestRectangleArea(heights));
    }
    return ans;
}

public int largestRectangleArea(int[] heights) {
    int[] tmp = new int[heights.length + 2];
    System.arraycopy(heights, 0, tmp, 1, heights.length);

    int maxArea = 0;
    Deque<Integer> stack = new LinkedList<>();
    for (int i = 0; i < tmp.length; ++i) {
        while (!stack.isEmpty() && tmp[i] < tmp[stack.peek()]) {
            int h = tmp[stack.pop()];
            maxArea = Math.max(maxArea, (i - stack.peek() - 1) * h);
        }
        stack.push(i);
    } 
    return maxArea;
}

5.接雨水(leetcode42-難)

題目描述:給定 n 個非負整數表示每個寬度為 1 的柱子的高度圖,計算按此排列的柱子,下雨之后能接多少雨水。

示例

輸入:height = [0,1,0,2,1,0,1,3,2,1,2,1]
輸出:6
解釋:上面是由數組 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度圖,在這種情況下,可以接 6 個單位的雨水(藍色部分表示雨水)。

思路:由示例我們可以看出上述可以劃分為四部分積水區域,積水槽一定在兩個柱子之間。只有左右元素都大于當前元素才能形成槽,那么可以維護從棧底到棧頂單調遞減的單調棧:

  • 這樣可以找到左邊第一個大于當前元素的值,當右邊即將加入的值也大于它就形成了槽
  • 棧中存放柱子對應的索引值。
  • 注意:高的取值,邊界較小的與當前槽高度的差值

代碼

class Solution {
    
    // 單調棧(從棧底到棧頂單調遞減)
    public int trap(int[] height) {
        if (height == null || height.length == 0) {
            return 0;
        }
        Deque<Integer> stack = new LinkedList<>();
        int ans = 0;

        for (int i = 0; i < height.length; i++) {
            while (!stack.isEmpty() && height[i] > height[stack.peek()]) {
                int curIndex = stack.pop();

                // 優化:如果棧頂元素相等,則一直彈出,只留一個
                while (!stack.isEmpty() && height[stack.peek()] == height[curIndex]) {
                    stack.pop();
                }

                if (!stack.isEmpty()) {
                    int leftIndex = stack.peek();
                    ans += (i - leftIndex - 1) * (Math.min(height[leftIndex], height[i]) - height[curIndex]);
                }
            }
            stack.push(i);
        }
        return ans;
    }
}

6.求區間最小數乘區間和的最大值(補充:字節高頻面試題)

題目描述:給定一個數組,要求選出一個區間, 使得該區間是所有區間中經過如下計算的值最大的一個:區間中的最小數 * 區間所有數的和。注:數組中的元素都是非負數。

示例

輸入兩行,第一行n表示數組長度,第二行為數組序列。輸出最大值。

輸入
3
6 2 1
輸出
36
解釋:滿足條件區間是[6] = 6 * 6 = 36;

思路:最優解單調棧,注意單調棧內存的是索引

法1:使用暴力解,我們可以枚舉數組中的最小值,然后向兩邊進行擴展,找到第一個比x小的元素,在尋找區間的過程中計算區間和。

法2:空間換時間,我們找邊界的過程中可以使用單調棧,每個元素只進棧出棧一次,算法復雜度降到O(N)。這里在計算區間和時可以使用前綴和。

代碼

import java.util.Deque;
import java.util.LinkedList;
import java.util.Scanner;

public class Solution004 {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        int n = sc.nextInt();
        int[] nums = new int[n];
        int i = 0;
        while (n-- > 0) {
            nums[i++] = sc.nextInt();
        }
//        System.out.println(solution(nums));
        System.out.println(solution1(nums));
    }

    public static int solution(int[] nums) {
        int n = nums.length;
        int l = 0, r = 0;
        int sum = 0;
        int max = 0;

        for (int i = 0; i < n; i++) {
            sum = nums[i];
            l = i - 1;
            r = i + 1;
            while (l >= 0 && nums[l] >= nums[i]) {
                sum += nums[l--];
            }
            while (r < n && nums[r] >= nums[i]) {
                sum += nums[r++];
            }
            max = Math.max(max, sum * nums[i]);
        }
        return max;
    }

    // 單調棧優化
    public static int solution1(int[] nums) {
        int n = nums.length;
        int l = 0, r = 0;
        int max = 0, cur = 0;
        Deque<Integer> stack = new LinkedList<>();

        //前綴和便于快速求區間和,例如求[l,r]區間和=dp[r+1]-dp[l]。l和r的取值范圍是[0,n)
        int[] sums = new int[n + 1];
        for (int i = 1; i <= n; i++) {
            sums[i] = sums[i - 1] + nums[i - 1];
        }

        for (int i = 0; i < n; i++) {
            while (!stack.isEmpty() && nums[i] <= nums[stack.peek()]) {
                cur = nums[stack.pop()];
                //l和r是邊界,因此區間是[l+1,r-1],其區間和dp[r]-dp[l+1]
                l = stack.isEmpty() ? -1 : stack.peek();
                r = i;
                max = Math.max(max, cur * (sums[r] - sums[l + 1]));
            }
            stack.push(i);
        }
        while (!stack.isEmpty()) {
            cur = nums[stack.pop()];
            l = stack.isEmpty() ? -1 : stack.peek();
            r = n;
            max = Math.max(max, cur * (sums[r] - sums[l + 1]));
        }
        return max;
    }
}

7.子數組最小值之和(907-中)

題目描述:給定一個整數數組 arr,找到 min(b) 的總和,其中 b 的范圍為 arr 的每個(連續)子數組。

由于答案可能很大,因此 返回答案模 10^9 + 7 。

示例

輸入:arr = [3,1,2,4]
輸出:17
解釋:
子數組為 [3],[1],[2],[4],[3,1],[1,2],[2,4],[3,1,2],[1,2,4],[3,1,2,4]。 
最小值為 3,1,2,4,1,1,2,1,1,1,和為 17。

思路: 這道題的本質在于找到數組中的每一個數作為最小值的范圍,比如對于某個數nums[i]能夠最小值以這種形式表示:左邊連續m個數比nums[i]大,右邊連續n個數比nums[i]大。

其實就是找以每個數左邊和右邊的最小值,中間的數一定都是大于當前這個數的(已經出棧)根據下標計算出這兩個范圍,根據上述公式計算即可。注意,可以在尾部添加0,保證剩余元素可以被彈出計算。

注意:在進行計算時,先將每個元素轉成long型再計算,否則最后一個測試用例過不了。

代碼

private long mod = 1000000007;
public int sumSubarrayMins(int[] arr) {
    long ans = 0;
    int n = arr.length;
    arr = Arrays.copyOf(arr, n + 1);
    arr[n] = 0;
    Deque<Integer> stack = new LinkedList<>();
    for (int i = 0; i <= n; i++) {
        while (!stack.isEmpty() && arr[i] <= arr[stack.peek()]) {
            // 每個棧頂元素作為最小值
            int index = stack.pop();
            int lMin = !stack.isEmpty() ? stack.peek() : -1;
            int M = index - lMin - 1;
            int N = i - index - 1;
            ans += ((long)arr[index] * (M + 1) * (N + 1)) % mod;
        }
        stack.push(i);
    }
    return (int)(ans % mod);
}

8.每日溫度(739-中)

題目描述:請根據每日 氣溫 列表,重新生成一個列表。對應位置的輸出為:要想觀測到更高的氣溫,至少需要等待的天數。如果氣溫在這之后都不會升高,請在該位置用 0 來代替。

提示:氣溫 列表長度的范圍是 [1, 30000]。每個氣溫的值的均為華氏度,都是在 [30, 100] 范圍內的整數。

示例

給定一個列表 temperatures = [73, 74, 75, 71, 69, 72, 76, 73],
你的輸出應該是 [1, 1, 4, 2, 1, 1, 0, 0]。

思路: 本題顯然是計算當前元素與后邊第一個比他大的元素距離,單調棧的典型性應用。

  • 當前元素小于等于棧頂元素,入棧
  • 當前元素大于棧頂元素,出棧,計算此時棧頂元素與下一個最大元素即當前元素的距離

注意:本題棧內元素可以不用出棧。

代碼

public int[] dailyTemperatures(int[] temperatures) {
    // 維護:從棧頂到棧底嚴格遞增
    Deque<Integer> stack = new LinkedList<>();
    int n = temperatures.length;
    int[] ans = new int[n];

    for (int i = 0; i < n; i++) {
        while (!stack.isEmpty() && temperatures[i] > temperatures[stack.peek()]) {
            int peak = stack.pop();
            ans[peak] = i - peak;
        }
        stack.push(i);
    }
    return ans;
}
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,106評論 6 542
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,441評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,211評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,736評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,475評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,834評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,829評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,009評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,559評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,306評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,516評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,038評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,728評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,132評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,443評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,249評論 3 399
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,484評論 2 379

推薦閱讀更多精彩內容