給定一個陣列 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:
- deque 內 僅包含視窗內的元素 => 每輪視窗滑動移除了元素 nums[i-1],需要將 deque 內的對應元素一起刪除。
- deque 內的元素 非嚴格遞減 => 每輪視窗滑動新增了元素 nums[j+1],需要將 deque 內所有 < nums[j+1] 的元素刪除。
演算法流程:
初始化:雙端佇列 deque,結果列表 res,陣列長度 n;
滑動視窗:左邊界範圍 i 屬於 [1-k,n+1-k],右邊界範圍 j 屬於 [0,n-1];
- 若 i > 0 且隊首元素 deque[0] = 被刪除元素 nums[i-1]:則隊首元素出隊;
- 刪除 deque 內所有 < nums[j] 的元素,以保持 deque 遞減;
- 將 nums[j] 新增至 deque 尾部;
- 若已形成視窗(即 i >= 0):將視窗最大值(即隊首元素 deque[0])新增至列表 res。
返回值:返回結果列表 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 個元素(即視窗大小)。
個人理解
Queue
是佇列,只能一頭進,另一頭出。而Deque
允許兩頭都進,兩頭都出,叫做雙端佇列(Double Ended Queue)。peekFirst
方法是取隊首元素但不刪除,peekLast
方法是取隊尾元素但不刪除。removeLast
方法是取隊首元素並刪除。addLast
方法是新增元素到隊尾。需要刪除佇列內所有小於 nums[i] 的元素,所以是一個 while 迴圈。
為什麼這樣做可以實現呢? 我們來舉一個小栗子。
- 首先未形成視窗的時候我們應該可以理解,將數字一個一個放入佇列中,然後比較。這個時候佇列中的資料是什麼呢?其實是一個非嚴格遞減的佇列。為什麼這麼說呢,我們看一下未形成視窗時的程式碼,他是比較當前元素和佇列尾部中的最後一個元素的大小,如果小的話,將他移除,直到比較到第一個。所以這個佇列是從尾部插入,從頭部彈出的一個非嚴格遞減的雙端佇列。
- 為什麼要這麼存資料呢?因為我們要實現的是獲取視窗最大值的時間複雜度為 O(1)。那麼會遇到一個這麼個問題,如果當前視窗的最大值恰好是視窗的最左邊的元素,當視窗向右移動的時候,最左邊的值就會被刪除,我們其實不知道剩下的幾個元素中最大的值是什麼。使用了上述方法呢,我們其實維護了一個非嚴格遞減的佇列,又提到這個名詞了。我的理解是不拿實際例子來舉例,實際例子舉例的話可能一下子就明白了,但是記得不紮實,下次可能還忘,我們在腦海中演示這個過程。
- 過程演示。當我們在未形成視窗的時候遍歷,這個時候最大值肯定是在隊首,那麼次大值呢?應該排在隊首後面,因為我們需要這個值,假設隊首是最左元素,下次視窗移動的時候,最大值會彈出,所以我們可以取次大值進行比較。
- 這個非嚴格遞減佇列有什麼規律呢?首先他並不是一個視窗的遞減排序,遞減排序沒有意義對於我們這道題。那這個值應該怎麼取?我們這樣想,我們要一個一個比較,不需要重複比,所以最大值這一部分肯定是沒問題的,假設我們取到最大值,那麼最大值前面的數字還有意義嗎?其實是沒有意義了,滑動視窗從
index - k
到 最大值下標index
這段範圍內,最大值都不會是前面這幾個數字了。按照這個思路,假設我們找到當前比較的最大值了,後面的值應該怎麼處理?如果比當前值大,就替換,並拋棄掉前面的這些值,如果不比當前最大值大,就留著,插入隊尾,當次大值使用,如果刪除了最大值,次大值就是最大值。接下來的所有比較也是如此,如果佇列中的值比當前值小,就全部刪除,因為當前值能保證 [index-k,index] 這段範圍內都有最大值。
注意:形成視窗後,一定要判斷佇列中最大值和視窗中最左邊的值是否相等,如果相等則刪除,這是形成佇列和未形成佇列的主要區別。
作者:jyd
連結:leetcode-cn.com/problems/hua-dong-...
來源:力扣(LeetCode)
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。`
題解來源
作者:jyd
連結:leetcode-cn.com/problems/hua-dong-...
來源:力扣(LeetCode)
來源:力扣(LeetCode)
連結:leetcode-cn.com/problems/hua-dong-...
本作品採用《CC 協議》,轉載必須註明作者和本文連結