Cho dãy số . Với mỗi vị trí , tìm vị trí gần nhất có , trong .
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 , mọi vị trí ở cuối danh sách có giá trị không nhỏ hơn đều bị bỏ đi, vì từ nay vị trí 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 . 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 . 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 .
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 và có thể cần tới lượt, nên cả cách làm là .
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.
#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';
}
([]{})
YES
Độ phức tạp: , với xâu dài cần khoảng 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 .
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 . 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?
Xâu ([)] có một cặp tròn và một cặp vuông, mỗi loại tự nó đều cân bằng, nên vượt qua cách đếm riêng; nhưng khi gặp ) thì ngoặc mở trong cùng là [, khác loại, nên xâu không đúng. Đếm riêng từng loại không nhìn thấy thứ tự lồng nhau giữa các loại, chỉ ngăn xếp mới thấy.
Xâu (() thiếu ngoặc đóng nên số đếm cuối khác ; xâu )( làm số đếm âm ngay ở ký tự đầu; còn {[]} là một 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 dài không quá , chỉ gồm các chữ A, B, C. Chừng nào 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à . 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.
#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
ABA
Độ phức tạp: , với cần khoảng 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í , tìm vị trí lớn nhất có ; nếu không có thì đáp án là . Cách ngây thơ là với mỗi , đi lùi từ cho tới khi gặp một số nhỏ hơn .
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: , với dãy giảm dần cần khoảng 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í với . Với mọi vị trí đứng sau , nếu thì cũng có , mà lại gần hơn . Vậy từ lúc đi qua , vị trí 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 :
- Bỏ khỏi đỉnh mọi ứng viên có giá trị lớn hơn hoặc bằng : theo nhận xét trên, từ nay chúng không còn là đáp án của ai.
- Nếu ngăn xếp rỗng thì đáp án của là ; nếu không thì đáp án là ứng viên đang ở đỉnh.
- Đẩy vào ngăn xếp, vì 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 ; rồi đượ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 :
Ở bước , số ở đỉnh không nhỏ hơn nên bị bỏ, và đáp án là vị trí (số ). Ở bước , số nhỏ hơn mọi ứng viên nên cả ngăn xếp bị bỏ hết, đáp án là . Nối mỗi vị trí với đáp án của nó, ta được:
Chứng minh
Đáp án đúng. Ta chứng minh: trước khi xét , ngăn xếp chứa đúng các vị trí mà không có vị trí nào nằm giữa và có . Đ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 , thì bước 1 bỏ đúng những ứng viên có , tức là những mà trở thành một vị trí “nằm giữa” có giá trị không lớn hơn; đẩy thêm vào thì điều trên đúng cho .
Một vị trí không có trong ngăn xếp khi xét thì có một nằm giữa và với ; nếu thì và gần hơn, nên 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 ; vị trí gần nhất trong chúng là đỉnh.
Chỉ . 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á .
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á lần.
Ví dụChỉ Số Nhỏ Hơn Gần Nhất
Đề. Cho dãy số . Với mỗi vị trí , in chỉ số lớn nhất có , hoặc 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 .
#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
0 0 2 2 0 5 5 7 5
Độ phức tạp: , với cần khoảng 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 , nên ta bỏ các đỉnh lớn hơn hoặc bằng .
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 :
#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
0 1 2
Đáp án đúng là 0 0 0. Vì sao chương trình in 0 1 2?
Với điều kiện >, một đỉnh có giá trị bằng được giữ lại và in ra làm đáp án, dù nó không nhỏ hơn hẳn . Ở vị trí , đỉnh là vị trí có giá trị , bằng , nên chương trình in . Muốn tìm vị trí nhỏ hơn hẳn thì phải bỏ cả những đỉnh bằng , tức là dùng >=.
Chương trình in đáp án trước khi đẩy vào, vòng while kiểm tra rỗng ở mọi lần lặp, và chỉ số không bị lệch: lỗi chỉ nằm ở dấu so sánh.
IIIHình chữ nhật lớn nhất dưới biểu đồ cột
Bài toán: có cột đứng liền nhau, mỗi cột rộng , cột cao . 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 .
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 ; 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 , hoặc là hai đầu dãy. Gọi là vị trí gần nhất bên trái có (bằng nếu không có) và là vị trí gần nhất bên phải có (bằng nếu không có). Khi đó hình chữ nhật lấy cột làm cột thấp nhất và rộng nhất có thể là
Đáp án là giá trị lớn nhất của biểu thức này qua mọi . Cả lẫn đề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.
Với biểu đồ ở hình trên, cột cao có và , cho hình rộng , diện tích . Hai cột cuối cùng cao cho hình rộng (viền xanh), diện tích .
Ví dụHình Chữ Nhật Dưới Đường Cột
Đề. Biểu đồ có cột, cột cao . 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 bằng một lượt ngăn xếp từ trái sang, bằng một lượt từ phải sang, rồi lấy giá trị lớn nhất của . 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 , nên cần kiểu long long.
#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
8
Độ phức tạp: , với cần khoảng 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 với và , tính tổng giá trị nhỏ nhất của tất cả đ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 .
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ố 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 , như mục trước, là số nhỏ nhất của đoạn khi đoạn chứa và không chứa vị trí nào nhỏ hơn , tức là và . Có cách chọn và cách chọn , nên
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”: là vị trí gần nhất bên trái có giá trị nhỏ hơn , còn là vị trí gần nhất bên phải có giá trị nhỏ hơn hoặc bằng . 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.
#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
17
Độ phức tạp: , với cần khoảng phép tính.
Có khoảng đoạn, nên tổng có thể tới , 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 : số ở vị trí là số nhỏ nhất của đoạn, đóng góp ; số , , lần lượt đóng góp , , ; tổng là .
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 chương trình in ra bao nhiêu? Đáp án đúng là .
Với >= ở cả hai phía, cả hai vị trí đều mở rộng được qua nhau: mỗi vị trí là số nhỏ nhất của đoạn, tổng . Đoạn đã bị cộng hai lần.
Nếu cả hai phía cùng dùng “nhỏ hơn hoặc bằng” thì ngược lại, đoạn không được ai đếm và chương trình in . Chỉ khi một phía chặt, một phía không chặt thì mỗi đoạn mới được đếm đúng một lần.
Sử dụng kiến thức này cũng có thể giải bài: với mỗi độ dài từ tới , tìm giá trị lớn nhất trong các số nhỏ nhất của mọi đoạn dài . Số là số nhỏ nhất của một đoạn dài tới , 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 . Nó có một cách dùng rất hay gặp.
Bài toán: cho dãy và số ; với mỗi đoạn gồm 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 , cả dãy , quá chậm khi và 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 và thì từ lúc vào đoạn, 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 về sau cũng chứa , vì ra khỏi đoạn trước . 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:
- Bỏ ở cuối mọi ứng viên có giá trị không lớn hơn , rồi thêm vào cuối.
- Bỏ ở đầu ứng viên đã ra khỏi đoạn .
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.
#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
12 12 12 8 8 7 7
Độ phức tạp: , với cần khoảng 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à 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 , xóa một bản sao của , đọc phần tử lớn nhất. Cách đó viết nhanh hơn, nhưng mỗi bước tốn cùng một lần cấp phát bộ nhớ. Đo trên một triệu số:
Hàng đợi đơn điệu nhanh hơn khoảng 18 lần. Với thì multiset vẫn chạy kịp; khi tới , 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ó bộ dữ liệu, mỗi bộ là một dãy với , tổng không quá . Dãy được gọi là an toàn nếu mọi đoạn đề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í , chỉ xét các đoạn mà là phần tử lớn nhất. Gọ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 . Các đoạn chứa và nằm trong khoảng đều có phần tử lớn nhất là , và ngược lại mỗi đoạn đều có một vị trí 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ố (bài 0.3), tổng đoạn là . Đoạn tốt nhất cho là đoạn có tổng lớn nhất, tức là chọn trong để lớn nhất và chọn trong để nhỏ nhất. Dãy không an toàn khi hiệu hai giá trị này lớn hơn ở một vị trí nào đó.
Còn lại là tìm nhanh nhỏ nhất trên khoảng . 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ử trong ngăn xếp giá trị , là nhỏ nhất trên khoảng của nó. Khi xét , các phần tử bị bỏ khỏi đỉnh lần lượt là , rồi , rồi , và cứ thế: phần tử nằm ngay dưới trong ngăn xếp chính là . Các khoảng của chúng nối liền nhau thành đúng , nên
Lượt từ phải sang làm đối xứng để có , là lớn nhất trên . Dãy an toàn khi với mọi . Các tổng có thể tới , nên dùng long long.
#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
YES
NO
YES
YES
Độ phức tạp: 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 không quá nên cần khoảng 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
dequelưu chỉ số, không lưu giá trị. Kiểm traempty()trước mọi lần gọiback(),front(),top().
Tóm tắt
- Ngăn xếp chỉ thêm, bớt ở đỉnh, mỗi thao tác ; 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 , dù vòng
whilenằm trong vòngfor. - Đ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à ; tổng số nhỏ nhất của mọi đoạn là 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
- Ngăn xếp (Stack)VNOI Wiki
- Deque và min/max trên đoạn tịnh tiếnVNOI Wiki
- Minimum stack / Minimum queuecp-algorithms
- 单调栈OI Wiki
- 单调队列OI Wiki
- StacksUSACO Guide