1. 程式人生 > 其它 >【刷穿 LeetCode】480. 滑動視窗中位數(困難)

【刷穿 LeetCode】480. 滑動視窗中位數(困難)

技術標籤:LeetCode 題解演算法與資料結構刷穿 LeetCode演算法java資料結構面試

點選 這裡 可以檢視更多演算法面試相關內容~

題目描述

中位數是有序序列最中間的那個數。

如果序列的長度是偶數,則沒有最中間的數;此時中位數是最中間的兩個數的平均數。

例如:

  • [2,3,4],中位數是 3
  • [2,3],中位數是 (2 + 3) / 2 = 2.5

給你一個數組 nums,有一個長度為 k 的視窗從最左端滑動到最右端。

視窗中有 k 個數,每次視窗向右移動 1 位。

你的任務是找出每次視窗移動後得到的新視窗中元素的中位數,並輸出由它們組成的陣列。

示例:

給出 nums = [1,3,-1,-3,5,3,6,7]

,以及 k = 3。

視窗位置                      中位數
---------------               -----
[1  3  -1] -3  5  3  6  7       1
 1 [3  -1  -3] 5  3  6  7      -1
 1  3 [-1  -3  5] 3  6  7      -1
 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]      6

提示:

  • 你可以假設 k 始終有效,即:k 始終小於輸入的非空陣列的元素個數。
  • 與真實值誤差在 1 0 − 5 10 ^ {-5} 105 以內的答案將被視作正確答案。

樸素解法

一個直觀的做法是:對每個滑動視窗的數進行排序,獲取排序好的陣列中的第 k / 2(k - 1) / 2 個數(避免奇偶數討論),計算中位數。

我們大概分析就知道這個做法至少 O ( n ∗ k ) O(n * k) O(nk) 的,算上排序的話應該是 O ( n ∗ ( k + k log ⁡ k ) ) O(n * (k + k\log{k})) O(n(k+klogk))

比較無奈的是,這道題不太正規,沒有給出資料範圍。我們無法根據判斷這樣的做法會不會超時。

PS. 實際上這道題樸素解法是可以過的,有藍橋杯內味了 ~

樸素做法通常是優化的開始,所以我還是提供一下樸素做法的程式碼:

class Solution {
    public double[] medianSlidingWindow(int[] nums, int k) {
        int n = nums.length;
        int cnt = n - k + 1;
        double[] ans = new double[cnt];
        int[] tmp = new int[k];
        for (int l = 0, r = l + k - 1; r < n; l++, r++) {
            for (int i = l; i <= r; i++) tmp[i - l] = nums[i];
            Arrays.sort(tmp);
            ans[l] = (tmp[k / 2] / 2.0) + (tmp[(k - 1) / 2] / 2.0);
        }
        return ans;
    }
}
  • 時間複雜度:最多有 n 個視窗需要滑動計算。每個視窗,需要先插入資料,複雜度為 O ( k ) O(k) O(k),插入後需要排序,複雜度為 O ( k log ⁡ k ) O(k\log{k}) O(klogk)。整體複雜度為 O ( n ∗ ( k + k log ⁡ k ) ) O(n * (k + k\log{k})) O(n(k+klogk))
  • 空間複雜度:使用了長度為 k 的臨時陣列。複雜度為 O ( k ) O(k) O(k)

優先佇列(堆)解法

從樸素解法中我們可以發現,其實我們需要的就是滑動視窗中的第 k / 2 小的值和第 (k - 1) / 2 小的值。

我們知道滑動視窗求最值的問題,可以使用優先佇列來做。

但這裡我們求的是第 k 小的數,而且是需要兩個值。還能不能使用優先佇列來做呢?

我們可以維護兩個堆:

  • 一個大根堆維護著滑動視窗中一半較小的值(此時堆頂元素為滑動視窗中的第 (k - 1) / 2 小的值)
  • 一個小根堆維護著滑動視窗中一半較大的值(此時堆頂元素為滑動視窗中的第 k / 2 小的值)

滑動視窗的中位數就是兩個堆的堆頂元素的平均值。

實現細節:

  1. 初始化時,先讓 k 個元素直接入 right,再從 right 中倒出 k / 2 個到 left 中。這時候可以根據 leftright 得到第一個滑動視窗的中位值。

  2. 開始滑動視窗,每次滑動都有一個待新增和待移除的數:

    2.1 根據與右堆的堆頂元素比較,決定是插入哪個堆和從哪個堆移除

    2.2 之後調整兩堆的大小(確保只會出現 left.size() == right.size()right.size() - left.size() == 1,對應了視窗長度為偶數或者奇數的情況)

    2.3 根據 left 堆 和 right 堆得到當前滑動視窗的中位值

程式碼:

class Solution {
    public double[] medianSlidingWindow(int[] nums, int k) {
        int n = nums.length;
        int cnt = n - k + 1;
        double[] ans = new double[cnt];
        
        // 如果是奇數滑動視窗,讓 right 的數量比 left 多一個
        // 1.滑動視窗的左半部分
        PriorityQueue<Integer> left  = new PriorityQueue<>((a,b)->Integer.compare(b,a)); 
        // 2.滑動視窗的右半部分
        PriorityQueue<Integer> right = new PriorityQueue<>((a,b)->Integer.compare(a,b)); 
        for (int i = 0; i < k; i++) right.add(nums[i]);
        for (int i = 0; i < k / 2; i++) left.add(right.poll());
        ans[0] = getMid(left, right);
        
        for (int i = k; i < n; i++) {
            // 人為確保了 right 會比 left 多
            // 因此,刪除和新增都與 right 比較(left 可能為空)
            int add = nums[i], del = nums[i - k];
            if (add >= right.peek()) {
                right.add(add);
            } else {
                left.add(add);
            }
            if (del >= right.peek()) {
                right.remove(del);
            } else {
                left.remove(del);
            }
            adjust(left, right);
            ans[i - k + 1] = getMid(left, right);
        }
        return ans;
    }
    void adjust(PriorityQueue<Integer> left, 
                PriorityQueue<Integer> right) {
        while (left.size() > right.size()) 
          right.add(left.poll());
        while (right.size() - left.size() > 1) 
          left.add(right.poll());
    }
    double getMid(PriorityQueue<Integer> left, 
                  PriorityQueue<Integer> right) {
        if (left.size() == right.size()) {
            return (left.peek() / 2.0) + (right.peek() / 2.0);
        } else {
            return right.peek() * 1.0;
        }
    }
}
  • 時間複雜度:調整過程中堆大小最大為 k,因此堆操作複雜度為 O ( log ⁡ k ) O(\log{k}) O(logk);視窗數量最多為 n。整體複雜度為 O ( n ∗ log ⁡ k ) O(n * \log{k}) O(nlogk)
  • 空間複雜度:最多有 n 個元素在堆內。複雜度為 O ( n ) O(n) O(n)

最後

這是我們「刷穿 LeetCode」系列文章的第 No.* 篇,系列開始於 2021/01/01,截止於起始日 LeetCode 上共有 1916 道題目,部分是有鎖題,我們將先將所有不帶鎖的題目刷完。

在這個系列文章裡面,除了講解解題思路以外,還會盡可能給出最為簡潔的程式碼。如果涉及通解還會相應的程式碼模板。

由於 LeetCode 的題目隨著周賽 & 雙週賽不斷增加,為了方便我們統計進度,我們將按照系列起始時的總題數作為分母,完成的題目作為分子,進行進度計算。當前進度為 */1916

為了方便各位同學能夠電腦上進行除錯和提交程式碼,我建立了相關的倉庫:Github 地址 & Gitee 地址

在倉庫地址裡,你可以看到系列文章的題解連結、系列文章的相應程式碼、LeetCode 原題連結和一些其他的優選題解。

#演算法與資料結構
#LeetCode題解
#演算法面試

宮水三葉的刷題日記