Chương 0·Bài 0.10.2

Monotonic Stack and Queue

Cho dãy n≤2⋅105n \le 2 \cdot 10^5 số a1,a2,…,ana_1, a_2, \dots, a_n. Với mỗi vị trí ii, tìm vị trí j<ij < i gần ii nhất có aj<aia_j < a_i, trong O(n)O(n).

Ta đi từ trái sang phải và giữ một danh sách các vị trí đã đi qua mà còn có thể là đáp án cho những vị trí phía sau. Mỗi khi xét tới aia_i, mọi vị trí ở cuối danh sách có giá trị không nhỏ hơn aia_i đều bị bỏ đi, vì từ nay vị trí ii vừa gần hơn vừa không lớn hơn chúng. Vị trí còn lại ở cuối danh sách chính là đáp án của ii. Danh sách chỉ thêm và bớt ở cuối, nên nó là một ngăn xếp (stack); các giá trị trong đó luôn tăng dần, nên cách giữ này được gọi là ngăn xếp đơn điệu (monotonic stack). Mỗi vị trí được đưa vào và lấy ra nhiều nhất một lần, nên cả dãy chỉ tốn O(n)O(n). Khi cần bỏ phần tử ở cả hai đầu, cùng ý tưởng ấy cho ta hàng đợi đơn điệu, dùng để tìm số lớn nhất, nhỏ nhất trên mọi đoạn trượt.

INgăn xếp và dãy ngoặc đúng

Ngăn xếp là một dãy chỉ được thêm và bớt phần tử ở một đầu, gọi là đỉnh: phần tử vào sau cùng được lấy ra trước tiên, giống một chồng đĩa. Thư viện chuẩn có sẵn stack với ba thao tác push (đẩy vào), top (xem đỉnh), pop (bỏ đỉnh). Trong thi đấu, người ta thường dùng luôn vector với push_back, back, pop_back, hoặc string khi phần tử là ký tự, vì viết gọn hơn và cũng nhanh hơn. Cả ba thao tác đều O(1)O(1).

Cần cẩn thận hai điều. Gọi top() hay pop() khi ngăn xếp rỗng không báo lỗi mà làm chương trình chạy sai một cách khó đoán, nên luôn kiểm tra empty() trước. Và pop() không trả về giá trị: muốn lấy đỉnh ra dùng thì đọc top() trước rồi mới pop().

Bài toán quen thuộc nhất của ngăn xếp là kiểm tra dãy ngoặc. Cho một xâu chỉ gồm các ký tự (, ), [, ], {, }. Xâu được gọi là dãy ngoặc đúng nếu có thể ghép mỗi ngoặc mở với một ngoặc đóng cùng loại đứng sau nó, sao cho hai cặp bất kỳ hoặc nằm rời nhau, hoặc cặp này nằm trọn trong cặp kia. Chẳng hạn ([]{}) là dãy ngoặc đúng; còn (() thiếu một ngoặc đóng, và ([)] có hai cặp cắt nhau nên đều không đúng.

Cách dễ nghĩ ra nhất là lặp lại việc xóa các cặp (), [], {} đứng liền nhau cho tới khi không xóa được nữa; xâu là dãy ngoặc đúng khi xóa được hết. Mỗi lượt quét tốn O(n)O(n) và có thể cần tới n/2n/2 lượt, nên cả cách làm là O(n2)O(n^2).

Ta có thể thấy được điểm lãng phí của cách làm trên: sau khi xóa một cặp, chỉ có ký tự ngay trước cặp đó và ký tự ngay sau cặp đó là mới đứng cạnh nhau, nên cặp mới chỉ có thể xuất hiện ở đúng chỗ ấy. Vì vậy ta chỉ cần duyệt xâu một lần từ trái sang phải và giữ trong ngăn xếp các ngoặc mở chưa được đóng. Gặp ngoặc mở thì đẩy vào. Gặp ngoặc đóng thì ngoặc mở trong cùng chưa được đóng, tức là đỉnh ngăn xếp, phải cùng loại với nó; nếu đúng thì bỏ đỉnh, nếu sai hoặc ngăn xếp rỗng thì xâu không đúng. Duyệt hết xâu mà ngăn xếp vẫn còn ngoặc mở thì xâu cũng không đúng.

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long
string s, st;

bool ok() {
    for (char c : s) {
        if (c == '(' || c == '[' || c == '{') st += c;
        else {
            if (st.empty()) return false;
            char o = st.back();
            st.pop_back();
            if ((c == ')' && o != '(') || (c == ']' && o != '[') || (c == '}' && o != '{')) return false;
        }
    }
    return st.empty();
}

signed main() {
    cin >> s;
    cout << (ok() ? "YES" : "NO") << '\n';
}
([]{})

Độ phức tạp: O(n)O(n), với xâu dài 10610^6 cần khoảng 10610^6 phép tính.

Khi xâu chỉ có một loại ngoặc, ngăn xếp chỉ chứa toàn ngoặc (, nên không cần lưu cả ngăn xếp mà chỉ cần đếm số ngoặc đang mở: gặp ( thì tăng, gặp ) thì giảm. Xâu đúng khi số đếm không bao giờ âm và cuối cùng bằng 00.

Hỏi nhanh

Một bạn kiểm tra xâu có ba loại ngoặc bằng cách đếm riêng từng loại: với mỗi loại, số ngoặc mở trừ số ngoặc đóng không lúc nào âm và cuối cùng bằng 00. Xâu nào dưới đây vượt qua cách kiểm tra đó nhưng không phải dãy ngoặc đúng?

Cùng khuôn “giữ những gì còn mở, xử lý ngay khi đỉnh thay đổi” còn giải được nhiều bài xử lý xâu, như ví dụ dưới đây.

Ví dụXóa Mẫu ABC

Đề. Cho xâu SS dài không quá 2⋅1052 \cdot 10^5, chỉ gồm các chữ A, B, C. Chừng nào SS còn chứa xâu con ABC, xóa lần xuất hiện đứng trái nhất của nó. In xâu cuối cùng.

Lời giải. Tìm lại từ đầu sau mỗi lần xóa là O(n2)O(n^2). Giống bài dãy ngoặc, sau một lần xóa chỉ có phần đứng trước và phần đứng sau là mới nối vào nhau, nên mẫu mới chỉ có thể xuất hiện ngay tại chỗ nối. Ta dùng một ngăn xếp các ký tự được giữ lại: đẩy lần lượt từng ký tự vào, và sau mỗi lần đẩy, nếu ba ký tự trên đỉnh là ABC thì bỏ cả ba. Mẫu luôn bị xóa ngay khi nó vừa hoàn chỉnh, nên đó cũng chính là lần xuất hiện trái nhất. Mỗi ký tự vào và ra nhiều nhất một lần.

Ô đỏ là ba ký tự ABC sắp bị bỏ khỏi đỉnh ngăn xếp.
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
string s, st;

signed main() {
    cin >> s;
    for (char c : s) {
        st += c;
        ll m = st.size();
        if (m >= 3 && st[m - 3] == 'A' && st[m - 2] == 'B' && st[m - 1] == 'C') st.resize(m - 3);
    }
    cout << st << '\n';
}
AABCABCBA

Độ phức tạp: O(n)O(n), với n=2⋅105n = 2 \cdot 10^5 cần khoảng 4⋅1054 \cdot 10^5 phép tính.

IIVị trí nhỏ hơn gần nhất

Trở lại bài toán ở đầu bài: với mỗi vị trí ii, tìm vị trí j<ij < i lớn nhất có aj<aia_j < a_i; nếu không có thì đáp án là 00. Cách ngây thơ là với mỗi ii, đi lùi từ i−1i - 1 cho tới khi gặp một số nhỏ hơn aia_i.

C++
for (ll i = 1; i <= n; i++) {
    ll j = i - 1;
    while (j >= 1 && a[j] >= a[i]) j--;
    ans[i] = j;
}

Độ phức tạp: O(n2)O(n^2), với dãy giảm dần n=2⋅105n = 2 \cdot 10^5 cần khoảng 2⋅10102 \cdot 10^{10} phép tính.

Ta có thể thấy được điểm lãng phí của cách làm trên. Giả sử có hai vị trí j<kj < k với aj≥aka_j \ge a_k. Với mọi vị trí ii đứng sau kk, nếu aj<aia_j < a_i thì cũng có ak≤aj<aia_k \le a_j < a_i, mà kk lại gần ii hơn jj. Vậy từ lúc đi qua kk, vị trí jj không bao giờ còn là đáp án của ai nữa, nhưng cách làm trên vẫn đi lùi qua nó mỗi lần.

Ta chỉ giữ lại những vị trí còn có thể là đáp án, gọi là các ứng viên, trong một ngăn xếp theo thứ tự từ trái sang phải. Khi xét tới ii:

  1. Bỏ khỏi đỉnh mọi ứng viên có giá trị lớn hơn hoặc bằng aia_i: theo nhận xét trên, từ nay chúng không còn là đáp án của ai.
  2. Nếu ngăn xếp rỗng thì đáp án của ii là 00; nếu không thì đáp án là ứng viên đang ở đỉnh.
  3. Đẩy ii vào ngăn xếp, vì ii có thể là đáp án của những vị trí phía sau.

Sau bước 1, mọi ứng viên còn lại đều nhỏ hơn aia_i; rồi ii được đẩy lên trên chúng. Vì vậy giá trị trong ngăn xếp luôn tăng dần từ đáy lên đỉnh. Hình dưới chạy tay trên dãy 6,2,5,5,1,4,3,7,26, 2, 5, 5, 1, 4, 3, 7, 2:

Dãy 6 2 5 5 1 4 3 7 2: sau mỗi bước, ngăn xếp chỉ còn các số tăng dần; ô cam là vị trí vừa đẩy vào.ngăn xếp (đáy bên trái, đỉnh bên phải)i = 16đáp án 0i = 22đáp án 0 · bỏ 6i = 325đáp án 2i = 425đáp án 2 · bỏ 5i = 51đáp án 0 · bỏ 5, 2i = 614đáp án 5i = 713đáp án 5 · bỏ 4i = 8137đáp án 7i = 912đáp án 5 · bỏ 7, 3
Dãy 6 2 5 5 1 4 3 7 2: sau mỗi bước, ngăn xếp chỉ còn các số tăng dần; ô cam là vị trí vừa đẩy vào.

Ở bước i=4i = 4, số 55 ở đỉnh không nhỏ hơn a4=5a_4 = 5 nên bị bỏ, và đáp án là vị trí 22 (số 22). Ở bước i=5i = 5, số 11 nhỏ hơn mọi ứng viên nên cả ngăn xếp bị bỏ hết, đáp án là 00. Nối mỗi vị trí với đáp án của nó, ta được:

Mỗi mũi tên đi từ một vị trí về vị trí gần nhất bên trái có giá trị nhỏ hơn; vị trí 1, 2, 5 không có nên đáp án là 0.612253541546377829000
Mỗi mũi tên đi từ một vị trí về vị trí gần nhất bên trái có giá trị nhỏ hơn; vị trí 1, 2, 5 không có nên đáp án là 0.
Chứng minh

Đáp án đúng. Ta chứng minh: trước khi xét ii, ngăn xếp chứa đúng các vị trí j<ij < i mà không có vị trí kk nào nằm giữa jj và ii có ak≤aja_k \le a_j. Điều này đúng lúc bắt đầu (ngăn xếp rỗng). Nếu nó đúng trước khi xét ii, thì bước 1 bỏ đúng những ứng viên jj có aj≥aia_j \ge a_i, tức là những jj mà ii trở thành một vị trí “nằm giữa” có giá trị không lớn hơn; đẩy thêm ii vào thì điều trên đúng cho i+1i + 1.

Một vị trí jj không có trong ngăn xếp khi xét ii thì có một kk nằm giữa jj và ii với ak≤aja_k \le a_j; nếu aj<aia_j < a_i thì ak<aia_k < a_i và kk gần ii hơn, nên jj không phải đáp án. Vậy đáp án, nếu có, nằm trong ngăn xếp sau bước 1, nơi mọi ứng viên đều nhỏ hơn aia_i; vị trí gần ii nhất trong chúng là đỉnh.

Chỉ O(n)O(n). Mỗi vị trí được đẩy vào đúng một lần và bị bỏ ra nhiều nhất một lần, nên tổng số lần chạy vòng while trên cả chương trình không quá nn.

Cũng như vòng while của hai con trỏ ở bài 0.7, vòng lặp bỏ đỉnh có thể chạy nhiều lần ở một bước, nhưng cộng trên cả chương trình thì không quá nn lần.

Ví dụChỉ Số Nhỏ Hơn Gần Nhất

Đề. Cho dãy n≤2⋅105n \le 2 \cdot 10^5 số ai≤109a_i \le 10^9. Với mỗi vị trí ii, in chỉ số j<ij < i lớn nhất có aj<aia_j < a_i, hoặc 00 nếu không có.

Lời giải. Đúng thuật toán vừa trình bày. Ngăn xếp lưu chỉ số chứ không lưu giá trị, vì đề hỏi chỉ số; giá trị thì đọc lại qua mảng aa.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, a[N];
vector<ll> st;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    for (ll i = 1; i <= n; i++) {
        while (!st.empty() && a[st.back()] >= a[i]) st.pop_back();
        cout << (st.empty() ? 0 : st.back()) << ' ';
        st.push_back(i);
    }
    cout << '\n';
}
9
6 2 5 5 1 4 3 7 2

Độ phức tạp: O(n)O(n), với n=2⋅105n = 2 \cdot 10^5 cần khoảng 4⋅1054 \cdot 10^5 phép tính.

Bài toán có bốn dạng hay gặp, đều chỉ khác điều kiện bỏ đỉnh và chiều duyệt:

  • Nhỏ hơn gần nhất bên trái: duyệt từ trái sang, bỏ đỉnh khi a[st.back()] >= a[i].
  • Nhỏ hơn hoặc bằng gần nhất bên trái: bỏ đỉnh khi a[st.back()] > a[i].
  • Lớn hơn gần nhất bên trái: bỏ đỉnh khi a[st.back()] <= a[i], ngăn xếp khi đó giảm dần.
  • Bên phải: làm y hệt nhưng duyệt từ phải sang trái.

Một cách nhớ cho khỏi nhầm: điều kiện bỏ đỉnh là phủ định của điều kiện “là đáp án”. Đáp án cần nhỏ hơn hẳn aia_i, nên ta bỏ các đỉnh lớn hơn hoặc bằng aia_i.

Hỏi nhanh

Một bạn viết điều kiện bỏ đỉnh là a[st.back()] > a[i] cho bài tìm vị trí nhỏ hơn hẳn gần nhất bên trái. Bạn có thể bấm Chạy để xem chương trình in gì với dãy 3,3,33, 3, 3:

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, a[N];
vector<ll> st;

signed main() {
    cin >> n;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    for (ll i = 1; i <= n; i++) {
        while (!st.empty() && a[st.back()] > a[i]) st.pop_back();
        cout << (st.empty() ? 0 : st.back()) << ' ';
        st.push_back(i);
    }
    cout << '\n';
}
3
3 3 3

Đáp án đúng là 0 0 0. Vì sao chương trình in 0 1 2?

IIIHình chữ nhật lớn nhất dưới biểu đồ cột

Bài toán: có nn cột đứng liền nhau, mỗi cột rộng 11, cột ii cao hih_i. Tìm diện tích lớn nhất của một hình chữ nhật nằm trọn trong vùng các cột. Thử mọi cặp cột trái và phải rồi lấy chiều cao bằng cột thấp nhất ở giữa tốn O(n2)O(n^2).

Xét một hình chữ nhật lớn nhất. Chiều cao của nó bằng chiều cao của cột thấp nhất mà nó phủ, gọi là cột ii; nếu thấp hơn thì nâng lên được. Hình đó cũng không thể kéo rộng thêm, nên hai bên nó là hai cột thấp hơn hih_i, hoặc là hai đầu dãy. Gọi LiL_i là vị trí gần nhất bên trái có h<hih < h_i (bằng 00 nếu không có) và RiR_i là vị trí gần nhất bên phải có h<hih < h_i (bằng n+1n + 1 nếu không có). Khi đó hình chữ nhật lấy cột ii làm cột thấp nhất và rộng nhất có thể là

hi⋅(Ri−Li−1).h_i \cdot (R_i - L_i - 1).

Đáp án là giá trị lớn nhất của biểu thức này qua mọi ii. Cả LiL_i lẫn RiR_i đều là vị trí nhỏ hơn gần nhất ở mục trên, tính bằng hai lượt ngăn xếp: một lượt từ trái sang, một lượt từ phải sang.

Biểu đồ cột 2 1 4 5 1 3 3: hình cam lấy cột cao 4 làm cột thấp nhất và mở rộng tới hai cột thấp hơn ở hai bên.21451334 × 2 = 83 × 2
Biểu đồ cột 2 1 4 5 1 3 3: hình cam lấy cột cao 4 làm cột thấp nhất và mở rộng tới hai cột thấp hơn ở hai bên.

Với biểu đồ 2,1,4,5,1,3,32, 1, 4, 5, 1, 3, 3 ở hình trên, cột 33 cao 44 có L3=2L_3 = 2 và R3=5R_3 = 5, cho hình rộng 22, diện tích 88. Hai cột cuối cùng cao 33 cho hình rộng 22 (viền xanh), diện tích 66.

Ví dụHình Chữ Nhật Dưới Đường Cột

Đề. Biểu đồ có N≤105N \le 10^5 cột, cột ii cao hi≤109h_i \le 10^9. Tìm diện tích lớn nhất của hình chữ nhật nằm trong biểu đồ.

Lời giải. Tính LiL_i bằng một lượt ngăn xếp từ trái sang, RiR_i bằng một lượt từ phải sang, rồi lấy giá trị lớn nhất của hi(Ri−Li−1)h_i (R_i - L_i - 1). Khi có nhiều cột cao bằng nhau, cả hai phía đều dùng điều kiện nhỏ hơn hẳn, nên các cột bằng nhau cùng mở rộng qua nhau và cho cùng một hình; tính trùng một hình nhiều lần không ảnh hưởng tới giá trị lớn nhất. Diện tích có thể tới 109⋅105=101410^{9} \cdot 10^5 = 10^{14}, nên cần kiểu long long.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e5 + 5;
ll n, h[N], lf[N], rg[N];
vector<ll> st;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n;
    for (ll i = 1; i <= n; i++) cin >> h[i];
    for (ll i = 1; i <= n; i++) {
        while (!st.empty() && h[st.back()] >= h[i]) st.pop_back();
        lf[i] = st.empty() ? 0 : st.back();
        st.push_back(i);
    }
    st.clear();
    for (ll i = n; i >= 1; i--) {
        while (!st.empty() && h[st.back()] >= h[i]) st.pop_back();
        rg[i] = st.empty() ? n + 1 : st.back();
        st.push_back(i);
    }
    ll ans = 0;
    for (ll i = 1; i <= n; i++) ans = max(ans, h[i] * (rg[i] - lf[i] - 1));
    cout << ans << '\n';
}
7
2
1
4
5
1
3
3

Độ phức tạp: O(n)O(n), với n=105n = 10^5 cần khoảng 5⋅1055 \cdot 10^5 phép tính.

IVTổng phần tử nhỏ nhất của mọi đoạn

Bài toán: cho dãy a1,…,ana_1, \dots, a_n với n≤2⋅105n \le 2 \cdot 10^5 và 1≤ai≤1061 \le a_i \le 10^6, tính tổng giá trị nhỏ nhất của tất cả n(n+1)2\frac{n(n+1)}{2} đoạn con. Duyệt mọi đầu trái rồi kéo đầu phải, vừa kéo vừa cập nhật số nhỏ nhất, tốn O(n2)O(n^2).

Thay vì hỏi “đoạn này có số nhỏ nhất là bao nhiêu”, ta hỏi ngược lại: “số aia_i là số nhỏ nhất của bao nhiêu đoạn”. Cách đếm này gọi là đếm đóng góp của từng phần tử. Theo cách đặt LiL_i, RiR_i như mục trước, aia_i là số nhỏ nhất của đoạn [l,r][l, r] khi đoạn chứa ii và không chứa vị trí nào nhỏ hơn aia_i, tức là Li<l≤iL_i < l \le i và i≤r<Rii \le r < R_i. Có i−Lii - L_i cách chọn ll và Ri−iR_i - i cách chọn rr, nên

đaˊp aˊn=∑i=1nai⋅(i−Li)⋅(Ri−i).\text{đáp án} = \sum_{i=1}^{n} a_i \cdot (i - L_i) \cdot (R_i - i).

Cần cẩn thận khi dãy có các số bằng nhau. Một đoạn có hai vị trí cùng đạt giá trị nhỏ nhất sẽ được cả hai vị trí đếm, tức là bị cộng hai lần. Cách sửa quen thuộc là cho một phía dùng “nhỏ hơn hẳn”, phía kia dùng “nhỏ hơn hoặc bằng”: LiL_i là vị trí gần nhất bên trái có giá trị nhỏ hơn aia_i, còn RiR_i là vị trí gần nhất bên phải có giá trị nhỏ hơn hoặc bằng aia_i. Khi đó mỗi đoạn chỉ được đếm cho vị trí đạt giá trị nhỏ nhất đứng xa nhất về bên phải trong đoạn.

C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll n, a[N], lf[N], rg[N];
vector<ll> st;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    for (ll i = 1; i <= n; i++) {
        while (!st.empty() && a[st.back()] >= a[i]) st.pop_back();
        lf[i] = st.empty() ? 0 : st.back();
        st.push_back(i);
    }
    st.clear();
    for (ll i = n; i >= 1; i--) {
        while (!st.empty() && a[st.back()] > a[i]) st.pop_back();
        rg[i] = st.empty() ? n + 1 : st.back();
        st.push_back(i);
    }
    ll ans = 0;
    for (ll i = 1; i <= n; i++) ans += a[i] * (i - lf[i]) * (rg[i] - i);
    cout << ans << '\n';
}
4
3 1 2 4

Độ phức tạp: O(n)O(n), với n=2⋅105n = 2 \cdot 10^5 cần khoảng 10610^6 phép tính.

Có khoảng 2⋅10102 \cdot 10^{10} đoạn, nên tổng có thể tới 106⋅2⋅1010=2⋅101610^6 \cdot 2 \cdot 10^{10} = 2 \cdot 10^{16}, vẫn vừa kiểu long long. Khi giá trị lớn hơn nữa, tổng vượt giới hạn của long long và đề thường yêu cầu lấy theo modulo.

Với dãy 3,1,2,43, 1, 2, 4: số 11 ở vị trí 22 là số nhỏ nhất của 2⋅3=62 \cdot 3 = 6 đoạn, đóng góp 66; số 33, 22, 44 lần lượt đóng góp 3⋅1⋅13 \cdot 1 \cdot 1, 2⋅1⋅22 \cdot 1 \cdot 2, 4⋅1⋅14 \cdot 1 \cdot 1; tổng là 6+3+4+4=176 + 3 + 4 + 4 = 17.

Hỏi nhanh

Trong chương trình trên, lượt từ phải sang dùng điều kiện bỏ đỉnh a[st.back()] > a[i]. Nếu đổi thành >=, cho cả hai phía cùng dùng “nhỏ hơn hẳn”, thì với dãy 2,22, 2 chương trình in ra bao nhiêu? Đáp án đúng là 2+2+2=62 + 2 + 2 = 6.

Sử dụng kiến thức này cũng có thể giải bài: với mỗi độ dài kk từ 11 tới nn, tìm giá trị lớn nhất trong các số nhỏ nhất của mọi đoạn dài kk. Số aia_i là số nhỏ nhất của một đoạn dài tới Ri−Li−1R_i - L_i - 1, nên nó là ứng viên cho mọi độ dài không quá con số đó.

VHàng đợi đơn điệu: lớn nhất trên mọi đoạn trượt

Hàng đợi hai đầu (deque) là dãy cho thêm và bớt phần tử ở cả hai đầu: push_back, pop_back, push_front, pop_front, xem hai đầu bằng front, back, mỗi thao tác O(1)O(1). Nó có một cách dùng rất hay gặp.

Bài toán: cho dãy a1,…,ana_1, \dots, a_n và số kk; với mỗi đoạn gồm kk số liền nhau, in số lớn nhất của đoạn. Đoạn này trượt dần sang phải giống cửa sổ ở bài 0.7: mỗi bước thêm một số bên phải và bỏ một số bên trái. Tính lại số lớn nhất của cả đoạn ở mỗi bước tốn O(k)O(k), cả dãy O(nk)O(nk), quá chậm khi nn và kk cùng lớn.

Ta lại dùng nhận xét như ở mục vị trí nhỏ hơn gần nhất. Nếu j<rj < r và aj≤ara_j \le a_r thì từ lúc ara_r vào đoạn, aja_j không bao giờ còn là số lớn nhất của đoạn nào nữa: mọi đoạn chứa aja_j về sau cũng chứa ara_r, vì aja_j ra khỏi đoạn trước ara_r. Vậy ta chỉ giữ các ứng viên, theo thứ tự vị trí, và giá trị của chúng giảm dần. Ứng viên đứng đầu là số lớn nhất của đoạn hiện tại. Mỗi bước:

  1. Bỏ ở cuối mọi ứng viên có giá trị không lớn hơn ara_r, rồi thêm rr vào cuối.
  2. Bỏ ở đầu ứng viên đã ra khỏi đoạn [r−k+1,r][r - k + 1, r].

Cách giữ này gọi là hàng đợi đơn điệu (monotonic queue). Ta cần bỏ phần tử ở cả hai đầu, nên dùng deque lưu chỉ số: lưu chỉ số để biết ứng viên đầu đã ra khỏi đoạn hay chưa.

Deque giữ chỉ số có giá trị giảm dần; ô cam ở đầu là số lớn nhất của đoạn.
C++
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 1e6 + 5;
ll n, k, a[N];
deque<ll> q;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> k;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    for (ll r = 1; r <= n; r++) {
        while (!q.empty() && a[q.back()] <= a[r]) q.pop_back();
        q.push_back(r);
        if (q.front() <= r - k) q.pop_front();
        if (r >= k) cout << a[q.front()] << ' ';
    }
    cout << '\n';
}
9 3
4 2 12 3 8 5 7 1 6

Độ phức tạp: O(n)O(n), với n=106n = 10^6 cần khoảng 2⋅1062 \cdot 10^6 phép tính.

Mỗi chỉ số vào hàng đợi một lần và ra nhiều nhất một lần, nên cả dãy là O(n)O(n) như ngăn xếp đơn điệu. Ở mỗi bước, đoạn chỉ mất đúng một phần tử bên trái, nên chỉ cần một lệnh if để bỏ ở đầu. Muốn số nhỏ nhất thì đổi <= thành >=, khi đó các ứng viên tăng dần.

multiset (bài 0.10.1) cũng làm được: thêm ara_r, xóa một bản sao của ar−ka_{r-k}, đọc phần tử lớn nhất. Cách đó viết nhanh hơn, nhưng mỗi bước tốn O(log⁡k)O(\log k) cùng một lần cấp phát bộ nhớ. Đo trên một triệu số:

Lớn nhất của mọi cửa sổ độ dài 1000 trên một triệu số.multiset192 ms · gấp 18 lầndeque11 ms
Lớn nhất của mọi cửa sổ độ dài 1000 trên một triệu số, bằng multiset và bằng hàng đợi đơn điệu.

Hàng đợi đơn điệu nhanh hơn khoảng 18 lần. Với n=2⋅105n = 2 \cdot 10^5 thì multiset vẫn chạy kịp; khi nn tới 10610^6, hoặc phải làm nhiều lần, nên dùng deque.

Bài nâng cao

Xem 1 bài nâng cao

Ví dụTổng Không Vượt Đỉnh

Đề. Có tt bộ dữ liệu, mỗi bộ là một dãy a1,…,ana_1, \dots, a_n với −109≤ai≤109-10^9 \le a_i \le 10^9, tổng nn không quá 2⋅1052 \cdot 10^5. Dãy được gọi là an toàn nếu mọi đoạn [l,r][l, r] đều có tổng không vượt quá phần tử lớn nhất của đoạn. In YES nếu dãy an toàn, NO nếu không.

Lời giải. Có quá nhiều đoạn để thử hết. Ta làm như bài tổng số nhỏ nhất ở trên: với mỗi vị trí ii, chỉ xét các đoạn mà aia_i là phần tử lớn nhất. Gọi LiL_i, RiR_i là vị trí gần nhất bên trái, bên phải có giá trị lớn hơn hẳn aia_i. Các đoạn chứa ii và nằm trong khoảng (Li,Ri)(L_i, R_i) đều có phần tử lớn nhất là aia_i, và ngược lại mỗi đoạn đều có một vị trí ii như vậy (vị trí đạt giá trị lớn nhất). Ở đây không cần lo đếm trùng, vì ta chỉ kiểm tra có đoạn nào vi phạm hay không.

Với tổng tiền tố pk=a1+⋯+akp_k = a_1 + \dots + a_k (bài 0.3), tổng đoạn [l,r][l, r] là pr−pl−1p_r - p_{l-1}. Đoạn tốt nhất cho ii là đoạn có tổng lớn nhất, tức là chọn rr trong [i,Ri−1][i, R_i - 1] để prp_r lớn nhất và chọn l−1l - 1 trong [Li,i−1][L_i, i - 1] để pl−1p_{l-1} nhỏ nhất. Dãy không an toàn khi hiệu hai giá trị này lớn hơn aia_i ở một vị trí ii nào đó.

Còn lại là tìm nhanh pp nhỏ nhất trên khoảng [Li,i−1][L_i, i - 1]. Ngăn xếp giúp luôn việc này. Trong lượt từ trái sang, ta lưu kèm mỗi phần tử jj trong ngăn xếp giá trị mjm_j, là pp nhỏ nhất trên khoảng [Lj,j−1][L_j, j - 1] của nó. Khi xét ii, các phần tử bị bỏ khỏi đỉnh lần lượt là i−1i - 1, rồi Li−1L_{i-1}, rồi LLi−1L_{L_{i-1}}, và cứ thế: phần tử nằm ngay dưới jj trong ngăn xếp chính là LjL_j. Các khoảng của chúng nối liền nhau thành đúng [Li,i−2][L_i, i - 2], nên

mi=min⁡(pi−1,mj của mọi j bị bỏ).m_i = \min\left(p_{i-1}, m_{j} \text{ của mọi } j \text{ bị bỏ}\right).

Lượt từ phải sang làm đối xứng để có MiM_i, là pp lớn nhất trên [i,Ri−1][i, R_i - 1]. Dãy an toàn khi Mi−mi≤aiM_i - m_i \le a_i với mọi ii. Các tổng có thể tới 2⋅10142 \cdot 10^{14}, nên dùng long long.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const ll N = 2e5 + 5;
ll t, n, a[N], p[N], mn[N], mx[N];
vector<ll> st;

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> t;
    while (t--) {
        cin >> n;
        for (ll i = 1; i <= n; i++) {
            cin >> a[i];
            p[i] = p[i - 1] + a[i];
        }
        st.clear();
        for (ll i = 1; i <= n; i++) {
            mn[i] = p[i - 1];
            while (!st.empty() && a[st.back()] <= a[i]) {
                mn[i] = min(mn[i], mn[st.back()]);
                st.pop_back();
            }
            st.push_back(i);
        }
        st.clear();
        for (ll i = n; i >= 1; i--) {
            mx[i] = p[i];
            while (!st.empty() && a[st.back()] <= a[i]) {
                mx[i] = max(mx[i], mx[st.back()]);
                st.pop_back();
            }
            st.push_back(i);
        }
        bool ok = true;
        for (ll i = 1; i <= n; i++) if (mx[i] - mn[i] > a[i]) ok = false;
        cout << (ok ? "YES" : "NO") << '\n';
    }
}
4
3
4 -2 1
2
2 2
3
-3 -1 -2
3
5 -4 4

Độ phức tạp: O(n)O(n) cho mỗi bộ dữ liệu, vì mỗi vị trí vào và ra mỗi ngăn xếp một lần; tổng nn không quá 2⋅1052 \cdot 10^5 nên cần khoảng 10610^6 phép tính.

Khi đi thi

  • Đề hỏi “vị trí gần nhất bên trái, bên phải lớn hơn hay nhỏ hơn”, hay “trên mọi đoạn con, lấy số nhỏ nhất, lớn nhất” thì nghĩ tới ngăn xếp đơn điệu. Viết hai lượt riêng, một từ trái sang và một từ phải sang, và ghi rõ mỗi lượt dùng dấu so sánh nào.
  • Khi đếm hay cộng trên mọi đoạn, một phía dùng “hẳn”, phía kia dùng “hoặc bằng” để không đếm trùng; luôn thử với một dãy toàn số bằng nhau.
  • Đoạn trượt cần số lớn nhất, nhỏ nhất thì dùng deque lưu chỉ số, không lưu giá trị. Kiểm tra empty() trước mọi lần gọi back(), front(), top().

Tóm tắt

  • Ngăn xếp chỉ thêm, bớt ở đỉnh, mỗi thao tác O(1)O(1); dãy ngoặc nhiều loại được kiểm tra bằng cách giữ các ngoặc mở chưa đóng.
  • Ngăn xếp đơn điệu giữ các ứng viên còn có thể là đáp án; mỗi phần tử vào và ra nhiều nhất một lần nên cả dãy O(n)O(n), dù vòng while nằm trong vòng for.
  • Điều kiện bỏ đỉnh là phủ định của điều kiện là đáp án: tìm nhỏ hơn hẳn thì bỏ các đỉnh lớn hơn hoặc bằng.
  • Hình chữ nhật lớn nhất dưới biểu đồ cột là max⁡hi(Ri−Li−1)\max h_i (R_i - L_i - 1); tổng số nhỏ nhất của mọi đoạn là ∑ai(i−Li)(Ri−i)\sum a_i (i - L_i)(R_i - i) với một phía chặt, một phía không chặt.
  • Hàng đợi đơn điệu bỏ ứng viên ở cuối khi có số tốt hơn tới, bỏ ở đầu khi ứng viên ra khỏi đoạn; số lớn nhất của đoạn luôn đứng đầu.

Bài tập

Đọc thêm