特殊數據結構:單調隊列

讀完本文,你能夠去力扣拿下以下題目:java

239.滑動窗口最大值算法

-----------數據結構

前文講了一種特殊的數據結構「單調棧」monotonic stack,解決了一類問題「Next Greater Number」,本文寫一個相似的數據結構「單調隊列」。框架

也許這種數據結構的名字你沒聽過,其實沒啥難的,就是一個「隊列」,只是使用了一點巧妙的方法,使得隊列中的元素單調遞增(或遞減)。這個數據結構有什麼用?能夠解決滑動窗口的一系列問題。函數

看一道 LeetCode 題目,難度 hard:spa

1、搭建解題框架

這道題不復雜,難點在於如何在 O(1) 時間算出每一個「窗口」中的最大值,使得整個算法在線性時間完成。在以前咱們探討過相似的場景,獲得一個結論:設計

在一堆數字中,已知最值,若是給這堆數添加一個數,那麼比較一下就能夠很快算出最值;但若是減小一個數,就不必定能很快獲得最值了,而要遍歷全部數從新找最值。code

回到這道題的場景,每一個窗口前進的時候,要添加一個數同時減小一個數,因此想在 O(1) 的時間得出新的最值,就須要「單調隊列」這種特殊的數據結構來輔助了。排序

一個普通的隊列必定有這兩個操做:隊列

class Queue {
    void push(int n);
    // 或 enqueue,在隊尾加入元素 n
    void pop();
    // 或 dequeue,刪除隊頭元素
}

一個「單調隊列」的操做也差很少:

class MonotonicQueue {
    // 在隊尾添加元素 n
    void push(int n);
    // 返回當前隊列中的最大值
    int max();
    // 隊頭元素若是是 n,刪除它
    void pop(int n);
}

固然,這幾個 API 的實現方法確定跟通常的 Queue 不同,不過咱們暫且無論,並且認爲這幾個操做的時間複雜度都是 O(1),先把這道「滑動窗口」問題的解答框架搭出來:

vector<int> maxSlidingWindow(vector<int>& nums, int k) {
    MonotonicQueue window;
    vector<int> res;
    for (int i = 0; i < nums.size(); i++) {
        if (i < k - 1) { //先把窗口的前 k - 1 填滿
            window.push(nums[i]);
        } else { // 窗口開始向前滑動
            window.push(nums[i]);
            res.push_back(window.max());
            window.pop(nums[i - k + 1]);
            // nums[i - k + 1] 就是窗口最後的元素
        }
    }
    return res;
}

圖示

這個思路很簡單,能理解吧?下面咱們開始重頭戲,單調隊列的實現。

2、實現單調隊列數據結構

首先咱們要認識另外一種數據結構:deque,即雙端隊列。很簡單:

class deque {
    // 在隊頭插入元素 n
    void push_front(int n);
    // 在隊尾插入元素 n
    void push_back(int n);
    // 在隊頭刪除元素
    void pop_front();
    // 在隊尾刪除元素
    void pop_back();
    // 返回隊頭元素
    int front();
    // 返回隊尾元素
    int back();
}

並且,這些操做的複雜度都是 O(1)。這其實不是啥稀奇的數據結構,用鏈表做爲底層結構的話,很容易實現這些功能。

「單調隊列」的核心思路和「單調棧」相似。單調隊列的 push 方法依然在隊尾添加元素,可是要把前面比新元素小的元素都刪掉:

class MonotonicQueue {
private:
    deque<int> data;
public:
    void push(int n) {
        while (!data.empty() && data.back() < n) 
            data.pop_back();
        data.push_back(n);
    }
};

你能夠想象,加入數字的大小表明人的體重,把前面體重不足的都壓扁了,直到遇到更大的量級才停住。

若是每一個元素被加入時都這樣操做,最終單調隊列中的元素大小就會保持一個單調遞減的順序,所以咱們的 max() API 能夠能夠這樣寫:

int max() {
    return data.front();
}

pop() API 在隊頭刪除元素 n,也很好寫:

void pop(int n) {
    if (!data.empty() && data.front() == n)
        data.pop_front();
}

之因此要判斷 data.front() == n,是由於咱們想刪除的隊頭元素 n 可能已經被「壓扁」了,這時候就不用刪除了:

至此,單調隊列設計完畢,看下完整的解題代碼:

class MonotonicQueue {
private:
    deque<int> data;
public:
    void push(int n) {
        while (!data.empty() && data.back() < n) 
            data.pop_back();
        data.push_back(n);
    }
    
    int max() { return data.front(); }
    
    void pop(int n) {
        if (!data.empty() && data.front() == n)
            data.pop_front();
    }
};

vector<int> maxSlidingWindow(vector<int>& nums, int k) {
    MonotonicQueue window;
    vector<int> res;
    for (int i = 0; i < nums.size(); i++) {
        if (i < k - 1) { //先填滿窗口的前 k - 1
            window.push(nums[i]);
        } else { // 窗口向前滑動
            window.push(nums[i]);
            res.push_back(window.max());
            window.pop(nums[i - k + 1]);
        }
    }
    return res;
}

3、算法複雜度分析

讀者可能疑惑,push 操做中含有 while 循環,時間複雜度不是 O(1) 呀,那麼本算法的時間複雜度應該不是線性時間吧?

單獨看 push 操做的複雜度確實不是 O(1),可是算法總體的複雜度依然是 O(N) 線性時間。要這樣想,nums 中的每一個元素最多被 push_back 和 pop_back 一次,沒有任何多餘操做,因此總體的複雜度仍是 O(N)。

空間複雜度就很簡單了,就是窗口的大小 O(k)。

4、最後總結

有的讀者可能以爲「單調隊列」和「優先級隊列」比較像,實際上差異很大的。

單調隊列在添加元素的時候靠刪除元素保持隊列的單調性,至關於抽取出某個函數中單調遞增(或遞減)的部分;而優先級隊列(二叉堆)至關於自動排序,差異大了去了。

趕忙去拿下 LeetCode 第 239 道題吧~

相關文章
相關標籤/搜索