本文已收錄至 Github《小白學算法》系列:https://github.com/vipstone/algorithm
這是一道比較基礎的算法題,涉及到的數據結構也是我們之前講過的,我這里先買一個關子。這道面試題最近半年在亞馬遜的面試中出現過 28 次,在字節跳動中出現過 7 次,數據來源於 LeetCode。
我們先來看題目的描述。
題目描述
給定一個數組 nums 和滑動窗口的大小 k,請找出所有滑動窗口里的最大值。
示例:
輸入: nums = [1,3,-1,-3,5,3,6,7], 和 k = 3
輸出: [3,3,5,5,6,7]
提示:
你可以假設 k 總是有效的,在輸入數組不為空的情況下,1 ≤ k ≤ 輸入數組的大小。
LeetCode:https://leetcode-cn.com/problems/hua-dong-chuang-kou-de-zui-da-zhi-lcof/
題目解析
上面的題目看不懂?沒關系,接下來來看這幅圖可以清楚的描述這道題:
從上述圖片可以看出,題目的意思為:給定一個數組,每次查詢 3 個元素中的最大值,數量 3 為滑動窗口的大小,之后依次向后移動查詢相鄰 3 個元素的最大值。圖片中的原始數組為 [1,3,-1,-3,5,3,6,7]
,最終滑動窗口的最大值為 [3,3,5,5,6,7]
。
看到這個題之后,我們的第一直覺就是暴力解法,用兩層循環依次查詢滑動窗口的最大值,實現代碼如下。
實現方法 1:暴力解法
暴力解法的實現思路和實現代碼很直觀,如下所示:
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
// 非空判斷
if (nums == null || k <= 0) return new int[0];
// 最終結果數組
int[] res = new int[nums.length - k + 1];
for (int i = 0; i < res.length; i++) {
// 初始化最大值
int max = nums[i];
// 循環 k-1 次找最大值
for (int j = i + 1; j < (i + k); j++) {
max = (nums[j] > max) ? nums[j] : max;
}
res[i] = max;
}
return res;
}
}
把以上代碼提交至 LeetCode,執行結果如下:
從上述結果可以看出,雖然代碼通過了測試,但執行效率卻很低,這種代碼是不能應用於生產環境中的,因此我們需要繼續找尋新的解決方案。
實現方法 2:改良版
接下來我們稍微優化一下上面的方法,其實我們並不需要每次都經過兩層循環,我們只需要一層循環拿到滑動窗口的最大值(之前循環元素的最大值),然后在移除元素時,判斷當前要移除的元素是否為滑動窗口的最大值,如果是,則進行第二層循環來找到新的滑動窗口的最大值,否則只需要將最大值和新增的元素比較大小即可,實現代碼如下:
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
// 非空判斷
if (nums == null || k <= 0) return new int[0];
// 最終結果數組
int[] res = new int[nums.length - k + 1];
// 上一輪循環移除的值
int r = -Integer.MAX_VALUE;
// 滑動窗口最大值(初始化)
int max = r;
for (int i = 0; i < res.length; i++) {
// 1.判斷移除的值,是否為滑動窗口的最大值
if (r == max) {
// 2.移除的是滑動窗口的最大值,循環找到新的滑動窗口的最大值
max = nums[i]; // 初始化最大值
// 循環找最大值
for (int j = i + 1; j < (i + k); j++) {
max = Math.max(max, nums[j]);
}
} else {
// 3.只需要用滑動窗口的最大值和新增值比較即可
max = Math.max(max, nums[i + k - 1]);
}
// 最終的返回數組記錄
res[i] = max;
// 記錄下輪要移除的元素
r = nums[i];
}
return res;
}
}
把以上代碼提交至 LeetCode,執行結果如下:
從上述結果可以看出,改造之后的性能基本已經符合我的要求了,那文章開頭說過這道題還可以使用我們之前學過的數據結構?那它說的是什么數據結構呢?
其實我們可以使用「隊列」來實現這道題目,它的實現思路也非常簡單,甚至比暴力解法更加方便,接下來我們繼續往下看。
實現方法 3:優先隊列
這個題的另一種經典的解法,就是使用最大堆的方式來解決,最大堆的結構如下所示:
最大堆的特性是堆頂是整個堆中最大的元素。
我們可以將滑動窗口的值放入最大堆中,這樣利用此數據結構的特點(它會將最大值放到堆頂),因此我們就可以直接獲取到滑動窗口的最大值了,實現代碼如下:
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
// 非空判斷
if (nums == null || k <= 0) return new int[]{};
// 最終結果數組
int[] res = new int[nums.length - k + 1];
// 優先隊列
PriorityQueue<Integer> queue = new PriorityQueue(res.length, new Comparator<Integer>() {
@Override
public int compare(Integer i1, Integer i2) {
// 倒序排列(從大到小,默認是從小到大)
return i2 - i1;
}
});
// 第一輪元素添加
for (int i = 0; i < k; i++) {
queue.offer(nums[i]);
}
res[0] = queue.peek();
int last = nums[0]; // 每輪要移除的元素
for (int i = k; i < nums.length; i++) {
// 移除滑動窗口之外的元素
queue.remove(last);
// 添加新元素
queue.offer(nums[i]);
// 存入最大值
res[i - k + 1] = queue.peek();
// 記錄每輪要移除的元素(滑動窗口最左邊的元素)
last = nums[i - k + 1];
}
return res;
}
}
代碼解讀
從上述代碼可以看出:最大堆在 Java 中對應的數據結構就是優先級隊列 PriorityQueue
,但優先級隊列默認的排序規則是從小到大進行排序的,因此我們需要創建一個 Comparator
來改變一下排序的規則(從大到小進行排序),之后將滑動窗口的所有元素放入到優先級隊列中,這樣我們就可以直接使用 queue.peek()
拿到滑動窗口的最大值了,然后再循環將滑動窗口的邊緣值移除掉,從而解決了本道題目。
把以上代碼提交至 LeetCode,執行結果如下:
PS:從上面的執行結果可以看出,使用優先隊列的執行效率很低,這是因為每次插入和刪除都需要重新維護最大堆的元素順序,因此整個執行的效率就會很低。
實現方法 4:雙端隊列
除了優先隊列之外,我們還可以使用雙端隊列來查詢滑動窗口的最大值,它的實現思路和最大堆的實現思路很像,但並不需要每次在添加和刪除時進行元素位置的維護,因此它的執行效率會很高。
雙端隊列實現思路的核心是將滑動窗口的最大值始終放在隊首位置(也就是隊列的最左邊),將小於最大值並在最大值左邊(隊首方向)的所有元素刪除。這個也很好理解,因為這些相對較小的值既沒有最大值大,又在最大值的前面,也就是它們的生命周期比最大值還短,因此我們可以直接將這些相對較小的元素進行刪除,如下圖所示:
像以上這種情況下,我們就可以將元素 1 和元素 2 刪掉。
雙端隊列實現查詢滑動窗口最大值的流程分為以下 4 步:
- 移除最左邊小於最大值的元素(保證滑動窗口的最大值在隊首位置);
- 從隊尾向前依次移除小於當前要加入到隊列元素的值(淘汰小值且生命周期短的元素);
- 將新元素加入到隊列末尾;
- 將最大值加入到最終結果的數組中。
實現代碼如下:
class Solution {
public int[] maxSlidingWindow(int[] nums, int k) {
// 非空判斷
if (nums == null || k <= 0) return new int[0];
// 最終結果數組
int[] res = new int[nums.length - k + 1];
// 存儲的數據為元素的下標
ArrayDeque<Integer> deque = new ArrayDeque();
for (int i = 0; i < nums.length; i++) {
// 1.移除左邊超過滑動窗口的下標
if (i >= k && (i - k) >= deque.peek()) deque.removeFirst();
// 2.從最后面開始移除小於 nums[i] 的元素
while (!deque.isEmpty() && nums[deque.peekLast()] < nums[i])
deque.removeLast();
// 3.下標加入隊列
deque.offer(i);
// 4.將最大值加入數組
int rindex = i - k + 1;
if (rindex >= 0) {
res[rindex] = nums[deque.peek()];
}
}
return res;
}
}
把以上代碼提交至 LeetCode,執行結果如下:
從上述結果可以看出,雙端隊列相比於優先級隊列來說,因為無需重新計算並維護元素的位置,所以執行效率還是挺高的。
總結
本文我們通過 4 種方式實現了查找滑動窗口最大值的功能,其中暴力解法通過兩層循環來實現此功能,代碼最簡單但執行效率不高,而通過最大堆也就是優先隊列的方式來實現(本題)雖然比較省事,但執行效率不高。因此我們可以選擇使用雙端隊列或改良版的代碼來實現查詢滑動窗口的最大值。