特殊數據結構:單調棧


引言

棧(stack)是很簡單的一種數據結構,先進后出的邏輯順序,符合某些問題的特點,比如說函數調用棧。

單調棧實際上就是棧,只是利用了一些巧妙的邏輯,使得每次新元素入棧后,棧內的元素都保持有序(單調遞增或單調遞減)。

用簡潔的話來說就是:單調棧就是 棧內元素單調遞增或者單調遞減 的棧,單調棧只能在棧頂操作

聽起來有點像堆(heap)?不是的,單調棧用途不太廣泛,只處理一種典型的問題,叫做 Next Greater Element。本文用講解單調隊列的算法模版解決這類問題,並且探討處理「循環數組」的策略。

介紹

首先,講解 Next Greater Number 的原始問題:給你一個數組,返回一個等長的數組,對應索引存儲着下一個更大元素,如果沒有更大的元素,就存 -1。不好用語言解釋清楚,直接上一個例子:

給你一個數組$ [2,1,2,4,3]$,你返回數組 \([4,2,4,-1,-1]\)

解釋:第一個 2 后面比 2 大的數是 4; 1 后面比 1 大的數是 2;第二個 2 后面比 2 大的數是 4; 4 后面沒有比 4 大的數,填 -1;3 后面沒有比 3 大的數,填 -1。

這道題的暴力解法很好想到,就是對每個元素后面都進行掃描,找到第一個更大的元素就行了。但是暴力解法的時間復雜度是$ O(n^2)$。

這個問題可以這樣抽象思考:把數組的元素想象成並列站立的人,元素大小想象成人的身高。這些人面對你站成一列,如何求元素「2」的 Next Greater Number 呢?很簡單,如果能夠看到元素「2」,那么他后面可見的第一個人就是「2」的 Next Greater Number,因為比「2」小的元素身高不夠,都被「2」擋住了,第一個露出來的就是答案。

img

這個情景很好理解吧?帶着這個抽象的情景,先來看下代碼。

vector<int> nextGreaterElement(vector<int>& nums) {
    vector<int> ans(nums.size()); // 存放答案的數組
    stack<int> s;
    for (int i = nums.size() - 1; i >= 0; i--) { // 倒着往棧里放
        while (!s.empty() && s.top() <= nums[i]) { // 判定個子高矮
            s.pop(); // 矮個起開,反正也被擋着了。。。
        }
        ans[i] = s.empty() ? -1 : s.top(); // 這個元素身后的第一個高個
        s.push(nums[i]); // 進隊,接受之后的身高判定吧!
    }
    return ans;
}

另外這里有洛谷模板題

模板AC代碼
#include <bits/stdc++.h>
using namespace std;
int main() {
    ios::sync_with_stdio(false), cin.tie(0);
    int n;
    cin >> n;
    vector<int> V(n + 1), ans(n + 1);
    for (int i = 1; i <= n; ++i) cin >> V[i];
    stack<int> S;
    for (int i = 1; i <= n; ++i) {
        while (!S.empty() && V[S.top()] < V[i]) {
            ans[S.top()] = i;
            S.pop();
        }
        S.push(i);
    }
    for (int i = 1; i <= n; ++i) cout << ans[i] << " ";
    return 0;
}

這就是單調隊列解決問題的模板。for 循環要從后往前掃描元素,因為我們借助的是棧的結構,倒着入棧,其實是正着出棧。while 循環是把兩個“高個”元素之間的元素排除,因為他們的存在沒有意義,前面擋着個“更高”的元素,所以他們不可能被作為后續進來的元素的 Next Great Number 了。

這個算法的時間復雜度不是那么直觀,如果你看到 for 循環嵌套 while 循環,可能認為這個算法的復雜度也是 O(n^2),但是實際上這個算法的復雜度只有 O(n)。

分析它的時間復雜度,要從整體來看:總共有 n 個元素,每個元素都被 push 入棧了一次,而最多會被 pop 一次,沒有任何冗余操作。所以總的計算規模是和元素規模 n 成正比的,也就是 O(n) 的復雜度。

現在,你已經掌握了單調棧的使用技巧,來一個簡單的變形來加深一下理解。

給你一個數組 T = [73, 74, 75, 71, 69, 72, 76, 73],這個數組存放的是近幾天的天氣氣溫(這氣溫是鐵板燒?不是的,這里用的華氏度)。你返回一個數組,計算:對於每一天,你還要至少等多少天才能等到一個更暖和的氣溫;如果等不到那一天,填 0 。

舉例:給你 T = [73, 74, 75, 71, 69, 72, 76, 73],你返回 [1, 1, 4, 2, 1, 1, 0, 0]。

解釋:第一天 73 華氏度,第二天 74 華氏度,比 73 大,所以對於第一天,只要等一天就能等到一個更暖和的氣溫。后面的同理。

你已經對 Next Greater Number 類型問題有些敏感了,這個問題本質上也是找 Next Greater Number,只不過現在不是問你 Next Greater Number 是多少,而是問你當前距離 Next Greater Number 的距離而已。

相同類型的問題,相同的思路,直接調用單調棧的算法模板,稍作改動就可以啦,直接上代碼把。

vector<int> dailyTemperatures(vector<int>& T) {
    vector<int> ans(T.size());
    stack<int> s; // 這里放元素索引,而不是元素
    for (int i = T.size() - 1; i >= 0; i--) {
        while (!s.empty() && T[s.top()] <= T[i]) {
            s.pop();
        }
        ans[i] = s.empty() ? 0 : (s.top() - i); // 得到索引間距
        s.push(i); // 加入索引,而不是元素
    }
    return ans;
}

單調棧講解完畢。下面開始另一個重點:如何處理「循環數組」。

同樣是 Next Greater Number,現在假設給你的數組是個環形的,如何處理?

給你一個數組 [2,1,2,4,3],你返回數組 [4,2,4,-1,4]。擁有了環形屬性,最后一個元素 3 繞了一圈后找到了比自己大的元素 4 。

img

首先,計算機的內存都是線性的,沒有真正意義上的環形數組,但是我們可以模擬出環形數組的效果,一般是通過 % 運算符求模(余數),獲得環形特效:

int[] arr = {1,2,3,4,5};
int n = arr.length, index = 0;
while (true) {
    print(arr[index % n]);
    index++;
}

回到 Next Greater Number 的問題,增加了環形屬性后,問題的難點在於:這個 Next 的意義不僅僅是當前元素的右邊了,有可能出現在當前元素的左邊(如上例)。

明確問題,問題就已經解決了一半了。我們可以考慮這樣的思路:將原始數組“翻倍”,就是在后面再接一個原始數組,這樣的話,按照之前“比身高”的流程,每個元素不僅可以比較自己右邊的元素,而且也可以和左邊的元素比較了。

img

怎么實現呢?你當然可以把這個雙倍長度的數組構造出來,然后套用算法模板。但是,我們可以不用構造新數組,而是利用循環數組的技巧來模擬。直接看代碼吧:

vector<int> nextGreaterElements(vector<int>& nums) {
    int n = nums.size();
    vector<int> res(n); // 存放結果
    stack<int> s;
    // 假裝這個數組長度翻倍了
    for (int i = 2 * n - 1; i >= 0; i--) {
        while (!s.empty() && s.top() <= nums[i % n])
            s.pop();
        res[i % n] = s.empty() ? -1 : s.top();
        s.push(nums[i % n]);
    }
    return res;
}

初步來總結一下單調棧吧,單調棧其實是一個看似原理簡單,但是可以變得很難的解法。線性的時間復雜度是其最大的優勢,每個數字只進棧並處理一次,而解決問題的核心就在處理這塊,當前數字如果破壞了單調性,就會觸發處理棧頂元素的操作,而觸發數字有時候是解決問題的一部分,比如在 Trapping Rain Water 中作為右邊界。有時候僅僅觸發作用,比如在 Largest Rectangle in Histogram 中是為了開始處理棧頂元素,如果僅作為觸發,可能還需要在數組末尾增加了一個專門用於觸發的數字。另外需要注意的是,雖然是遞增或遞減棧,但里面實際存的數字並不一定是遞增或遞減的,因為我們可以存坐標,而這些坐標帶入數組中才會得到遞增或遞減的數。所以對於玩數組的題,如果相互之間關聯很大,那么就可以考慮考慮單調棧能否解題。


另外,單調棧也可以用於離線解決 RMQ 問題

我們可以把所有詢問按右端點排序,然后每次在序列上從左往右掃描到當前詢問的右端點處,並把掃描到的元素插入到單調棧中。這樣,每次回答詢問時,單調棧中存儲的值都是位置 \(\le r\) 的、可能成為答案的決策點,並且這些元素滿足單調性質。此時,單調棧上第一個位置 \(\ge l\) 的元素就是當前詢問的答案,這個過程可以用二分查找實現。使用單調棧解決 RMQ 問題的時間復雜度為 \(O(q\log q + q\log n)\) ,空間復雜度為 \(O(n)\)

參考資料

CSDN:單調棧介紹

博客園:單調棧小結

例題補充(來自知乎的Pecco學長)

Codeforce:1313C2. Skyscrapers (hard version)

這個題屬於單調棧優化DP

首先非常顯然,最后形成的一定是一個先單調增、再單調減的序列。那么這題有一個 \(O(n^2)\) 的做法就是枚舉最高點算出 \(n\) 個值求最小。這可以解決 \(n <= 1000\)easy version

那么現在 \(n <= 500000\) 怎么做呢,我們用dpl表示以某個點為最高點時答案數組的前綴和,dpr表示以某個點為最高點時答案的后綴和,dp表示以某個點為最高點時的答案,那么顯然dp[i] = dpl[i] + dpr[i] - A[i]

怎么算dpldpr呢。以dpl為例,當i為最高點時,我們從這個點往左走,如果遇到比A[i]大的,那么就必須把這個點削成A[i];如果遇到一個小於等於A[i]的,那么后面的部分都可以沿用以此點為最高點的安排了。也就是說,我們要確定左側最近的小於等於A[i]的點的位置last[i],那么有:

\[dpl[i] = A[i] > (i - last[i]) + dpl[last[i]] \]

而左側最近的小於等於A[i]的點可以用單調棧算出。同理,也用單調棧,我們可以對每個i算出右側最近的小於等於A[i]的點next[i],那么有:

\[dpr[i] = A[i] *(next[i] - i) + dpr[next[i]] \]

這就 \(O(n)\) 地解決了這個動態規划問題。

[洛谷P1823 COI2007] Patrik 音樂會的等待

此題體現了單調棧的另一種重要應用,即解決某些涉及到“兩元素間所有元素均(不)大/小於這兩者”的問題。

在本題中,我們用一個單調棧存儲那些“可以看到當前位置的人的人的編號”,它無疑是單調不增的。每當遍歷到一個新的位置時,就計算當前位置的人可以看到前面的多少人,並更新單調棧。由於這道題有身高相等的情況,所以需要合並相同身高的人(可以用pair),具體可以參考代碼:

#include <bits/stdc++.h>
using namespace std;
using ll = long long;
int main() {
    ios::sync_with_stdio(false), cin.tie(0);
    int n;
    cin >> n;
    ll ans = 0;
    vector<int> V(n);
    for (auto &e : V) cin >> e;
    stack<pair<int, int>> S;  // 這里pair的第二個成員表示相同元素的數量
    for (auto e : V) {
        int cnt = 0;
        while (!S.empty() && S.top().first <= e) {
            if (S.top().first == e) cnt = S.top().second;
            ans += S.top().second;
            S.pop();
        }
        if (!S.empty()) ans++;
        S.emplace(e, cnt + 1);
    }
    cout << ans << endl;
    return 0;
}

Codeforce:1470D. Discrete Centrifugal Jumps

這個題跟上面那個很類似,同樣是有關“兩元素間所有元素均(不)大/小於這兩者”的問題。

\(dp[i] = \underset{j \to i}{max}\ dp[i] + 1\) ,其中 \({j \to i}\) 表示 \(j\) 號位置可以跳到 \(i\) 號位置。而具體哪些點可以跳到當前點,可以簡單地用兩個單調棧進行維護。在這道題中,對於相等的點,我們可以直接用后面的覆蓋前面的——因為在一系列連續相等點中,只有最后一個能跳到更遠處。

#include <bits/stdc++.h>
using namespace std;
const int INF = numeric_limits<int>::max();
int main() {
    ios::sync_with_stdio(false);
    int n;
    cin >> n;
    vector<int> V(n), dp(n, INF);
    for (auto &e : V) cin >> e;
    dp[0] = 0;
    stack<int> S1, S2;
    for (int i = 0; i < n; ++i) {
        while (!S1.empty() && V[S1.top()] < V[i]) {
            dp[i] = min(dp[i], dp[S1.top()] + 1);
            S1.pop();
        }
        if (!S1.empty()) dp[i] = min(dp[i], dp[S1.top()] + 1);
        if (!S1.empty() && V[S1.top()] == V[i])
            S1.top() = i;
        else
            S1.push(i);

        while (!S2.empty() && V[S2.top()] > V[i]) {
            dp[i] = min(dp[i], dp[S2.top()] + 1);
            S2.pop();
        }
        if (!S2.empty()) dp[i] = min(dp[i], dp[S2.top()] + 1);
        if (!S2.empty() && V[S2.top()] == V[i])
            S2.top() = i;
        else
            S2.push(i);
    }
    cout << dp[n - 1];
    return 0;
}


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM