[每日一題] 第二十六題:滑動視窗的最大值

DRose發表於2020-08-10

給定一個陣列 nums 和滑動視窗的大小 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 總是有效的,在輸入陣列不為空的情況下,1 ≤ k ≤ 輸入陣列的大小。

方法一:單調佇列

解題思路:

設視窗區間為 [i,j],最大值為 Xj。當視窗向前移動一格,則區間變為 [i+1,j+1],即新增了 nums[j+1],刪除了 nums[i]。

若只向視窗 [i,j] 右邊新增數字 nums[j+1],則新視窗最大值可以 通過一次對比 使用 O(1) 時間得到,即:X j+1 = max(Xj,nums[j+1])

注:j+1 為下標,下同。

而由於刪除的 nums[i] 可能恰好是視窗內唯一的最大值 Xj,因此不能通過以上方法計算 X j+1,而必須使用 O(j-i) 時間,遍歷整個視窗區間 獲取最大值,即:X j-1 = max(nums[i+1],……,nums[j+1])

根據以上分析,可得 暴力法 的時間複雜度為 O(n-k+1)k),約等於 O(nk)。

  • 設陣列 nums 的長度為 n,則共有(n-k+1)個視窗。
  • 獲取每個視窗最大值需線性遍歷,時間複雜度為 O(k)。

[每日一題] 第二十六題:滑動視窗的最大值

本題難點:如何在每次視窗滑動後,將“獲取視窗內最大值”的時間複雜度從 O(k) 降低至 O(1)。

可以使用 單調棧 實現隨意入棧,出棧情況下的 O(1) 時間獲取“棧內最小值”。

視窗對應的資料結構為 雙端佇列,本題使用 單調佇列 即可解決以上問題。遍歷陣列時,每輪保證單調佇列 deque:

  1. deque 內 僅包含視窗內的元素 => 每輪視窗滑動移除了元素 nums[i-1],需要將 deque 內的對應元素一起刪除。
  2. deque 內的元素 非嚴格遞減 => 每輪視窗滑動新增了元素 nums[j+1],需要將 deque 內所有 < nums[j+1] 的元素刪除。

演算法流程:

  1. 初始化:雙端佇列 deque,結果列表 res,陣列長度 n;

  2. 滑動視窗:左邊界範圍 i 屬於 [1-k,n+1-k],右邊界範圍 j 屬於 [0,n-1];

    1. 若 i > 0 且隊首元素 deque[0] = 被刪除元素 nums[i-1]:則隊首元素出隊;
    2. 刪除 deque 內所有 < nums[j] 的元素,以保持 deque 遞減;
    3. 將 nums[j] 新增至 deque 尾部;
    4. 若已形成視窗(即 i >= 0):將視窗最大值(即隊首元素 deque[0])新增至列表 res。
  3. 返回值:返回結果列表 res。

程式碼

class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        if(nums.length == 0 || k == 0) return new int[0];
        Deque<Integer> deque = new LinkedList<>();
        int[] res = new int[nums.length - k + 1];
        for(int j = 0, i = 1 - k; j < nums.length; i++, j++) {
            if(i > 0 && deque.peekFirst() == nums[i - 1])
                deque.removeFirst(); // 刪除 deque 中對應的 nums[i-1]
            while(!deque.isEmpty() && deque.peekLast() < nums[j])
                deque.removeLast(); // 保持 deque 遞減
            deque.addLast(nums[j]);
            if(i >= 0)
                res[i] = deque.peekFirst();  // 記錄視窗最大值
        }
        return res;
    }
}

可以將 “未形成視窗”和“形成視窗後”兩個階段拆分到兩個迴圈裡實現。程式碼雖變長,但減少了冗餘的判斷操作。

class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        if(nums.length == 0 || k == 0) return new int[0];
        Deque<Integer> deque = new LinkedList<>();
        int[] res = new int[nums.length - k + 1];
        for(int i = 0; i < k; i++) { // 未形成視窗
            while(!deque.isEmpty() && deque.peekLast() < nums[i])
                deque.removeLast();
            deque.addLast(nums[i]);
        }
        res[0] = deque.peekFirst();
        for(int i = k; i < nums.length; i++) { // 形成視窗後
            if(deque.peekFirst() == nums[i - k])
                deque.removeFirst();
            while(!deque.isEmpty() && deque.peekLast() < nums[i])
                deque.removeLast();
            deque.addLast(nums[i]);
            res[i - k + 1] = deque.peekFirst();
        }
        return res;
    }
}

複雜度分析

  • 時間複雜度O(N) :其中 n 為陣列 nums 長度;線性遍歷 nums 佔用 O(N);每個元素最多僅入隊和出隊一次,因此單調佇列 deque 佔用 O(2N)。
  • 空間複雜度 O(k) : 雙端佇列 deque 最多同時儲存 k 個元素(即視窗大小)。

個人理解

  1. Queue 是佇列,只能一頭進,另一頭出。而 Deque 允許兩頭都進,兩頭都出,叫做雙端佇列(Double Ended Queue)。

  2. peekFirst 方法是取隊首元素但不刪除,peekLast 方法是取隊尾元素但不刪除。

  3. removeLast 方法是取隊首元素並刪除。

  4. addLast 方法是新增元素到隊尾。

  5. 需要刪除佇列內所有小於 nums[i] 的元素,所以是一個 while 迴圈。

  6. 為什麼這樣做可以實現呢? 我們來舉一個小栗子。

    1. 首先未形成視窗的時候我們應該可以理解,將數字一個一個放入佇列中,然後比較。這個時候佇列中的資料是什麼呢?其實是一個非嚴格遞減的佇列。為什麼這麼說呢,我們看一下未形成視窗時的程式碼,他是比較當前元素和佇列尾部中的最後一個元素的大小,如果小的話,將他移除,直到比較到第一個。所以這個佇列是從尾部插入,從頭部彈出的一個非嚴格遞減的雙端佇列
    2. 為什麼要這麼存資料呢?因為我們要實現的是獲取視窗最大值的時間複雜度為 O(1)。那麼會遇到一個這麼個問題,如果當前視窗的最大值恰好是視窗的最左邊的元素,當視窗向右移動的時候,最左邊的值就會被刪除,我們其實不知道剩下的幾個元素中最大的值是什麼。使用了上述方法呢,我們其實維護了一個非嚴格遞減的佇列,又提到這個名詞了。我的理解是不拿實際例子來舉例,實際例子舉例的話可能一下子就明白了,但是記得不紮實,下次可能還忘,我們在腦海中演示這個過程。
    3. 過程演示。當我們在未形成視窗的時候遍歷,這個時候最大值肯定是在隊首,那麼次大值呢?應該排在隊首後面,因為我們需要這個值,假設隊首是最左元素,下次視窗移動的時候,最大值會彈出,所以我們可以取次大值進行比較。
    4. 這個非嚴格遞減佇列有什麼規律呢?首先他並不是一個視窗的遞減排序,遞減排序沒有意義對於我們這道題。那這個值應該怎麼取?我們這樣想,我們要一個一個比較,不需要重複比,所以最大值這一部分肯定是沒問題的,假設我們取到最大值,那麼最大值前面的數字還有意義嗎?其實是沒有意義了,滑動視窗從 index - k 到 最大值下標 index 這段範圍內,最大值都不會是前面這幾個數字了。按照這個思路,假設我們找到當前比較的最大值了,後面的值應該怎麼處理?如果比當前值大,就替換,並拋棄掉前面的這些值,如果不比當前最大值大,就留著,插入隊尾,當次大值使用,如果刪除了最大值,次大值就是最大值。接下來的所有比較也是如此,如果佇列中的值比當前值小,就全部刪除,因為當前值能保證 [index-k,index] 這段範圍內都有最大值。
  7. 注意:形成視窗後,一定要判斷佇列中最大值和視窗中最左邊的值是否相等,如果相等則刪除,這是形成佇列和未形成佇列的主要區別。

作者:jyd
連結:leetcode-cn.com/problems/hua-dong-...
來源:力扣(LeetCode)
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。`

題解來源

作者:jyd
連結:leetcode-cn.com/problems/hua-dong-...
來源:力扣(LeetCode)

來源:力扣(LeetCode)
連結:leetcode-cn.com/problems/hua-dong-...

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章