Tác giả: Nguyễn Hà Duy - THPT Chuyên Hà Nội - Amsterdam
Reviewer: Hoàng Xuân Nhật
Stack là một danh sách được bổ sung 2 thao tác: thêm một phần tử vào cuối danh sách, và loại bỏ một phần tử ở cuối danh sách. Ví trí cuối của Stack được gọi là đỉnh (top).
Có thể hình dung Stack như một chồng sách. Việc đặt một quyển sách lên trên cùng chính là thao tác thêm phần tử, và lấy ra quyển sách ở trên đầu là thao tác loại bỏ phần tử. Như vậy, quyển sách được đặt vào sau cùng sẽ luôn được lấy ra trước tiên. Vì tính chất này, Stack còn được gọi là danh sách LIFO (Last In - First Out, hay vào sau - ra trước).
Hình ảnh minh họa cho Stack chứa các phần tử kiểu char
:
Stack có khá nhiều ứng dụng trong lập trình thi đấu. Bài viết này sẽ xem xét các ứng dụng điển hình của Stack.
Ta có thể biểu diễn Stack bằng một mảng, cùng với biến top
biểu diễn vị trí của phần tử nằm ở đỉnh Stack. Dưới đây là một cách cài đặt Stack chứa các phần tử thuộc kiểu int
:
const int MAXN = 1e5 + 2;
int st[MAXN];
int top = 0;
void push(int x) // thêm x vào cuối Stack
{
++top;
st[top] = x;
}
void pop() // loại bỏ phần tử ở cuối Stack
{
assert(top); // đảm bảo Stack đang chứa ít nhất 1 phần tử
--top;
}
int peek() // trả về giá trị của phần tử ở đỉnh Stack
{
return st[top];
}
Thư viện chuẩn của C++ cho phép ta sử dụng Stack qua kiểu dữ liệu stack
trong header cùng tên. Các thao tác chính trên stack
là:
push
: thêm phần tử vào cuối danh sáchtop
: trả về giá trị phần tử ở cuối danh sáchpop
: loại bỏ phần tử ở cuối danh sáchNgoài ra, stack
cũng hỗ trợ các thao tác:
size
: trả về số phần tử hiện có trong stackempty
: trả về trạng thái của stack (true
nếu stack rỗng, false
nếu stack có ít nhất 1 phần tử)Ví dụ:
#include <iostream>
#include <stack>
using namespace std;
int main()
{
stack<int> st;
st.push(5); // thêm 5 vào stack
st.push(10); // thêm 10 vào stack
cout << st.top() << endl; // In ra 10
st.pop(); // loại bỏ phần tử ở cuối
cout << st.top() << endl; // In ra 5
return 0;
}
Ngoài ra, ta có thể dùng vector
để biểu diễn một Stack. Các hàm push
, top
và pop
sẽ được thay bằng push_back
, pop_back
và back
khi sử dụng vector
.
Do Stack có thể được cài đặt bằng vector
nên các thao tác trên Stack cũng có cùng độ phức tạp với vector
.
Các hàm push
, pop
, top
, size
và empty
của Stack đều hoạt động trong . Hơn nữa, như ta đã thấy ở cách cài đặt thủ công, bản chất của Stack chính là mảng, nên tất cả các thao tác trên Stack đều hoạt động trong .
Độ phức tạp bộ nhớ của Stack là , với là số phần tử được đưa vào Stack.
Cho xâu chỉ gồm các số nguyên dương và các dấu , , , , trong không có dấu khoảng trống. Bạn cần tính giá trị của biểu thức được biểu diễn bởi xâu đó.
Đây là bài toán dễ hơn của Expression Parsing.
Vấn đề chính của bài toán là các toán tử , , và không có cùng độ ưu tiên. Cụ thể, ta cần tính kết quả của các cụm dấu và trước, do nhân và chia có độ ưu tiên cao hơn cộng và trừ.
Xét bài toán đơn giản hơn: trong xâu chỉ có các dấu và . Rõ ràng, do và có cùng độ ưu tiên nên ta có thể xử lý bài toán bằng cách duyệt các toán hạng và toán tử từ trái sang phải và tính trực tiếp kết quả sau mỗi phép cộng hoặc trừ. Ta có thể cài đặt như sau: duy trì một danh sách chứa số (toán hạng) và một danh sách chứa toán tử. Duyệt xâu từ trái qua phải, nếu ký tự đang xét là chữ số thì ta đẩy nó vào danh sách chứa số. Nếu đó là toán tử, ta đẩy ký tự vào danh sách chứa toán tử. Cuối cùng, ta lần lượt tính kết quả dựa vào danh sách toán hạng và toán tử đã xây dựng.
// xử lý toán tử và cập nhật trực tiếp vào mảng val
void process_op(vector<int>& val, char op)
{
// l, r là 2 toán hạng, op là dấu giữa chúng
int r = val.back(); val.pop_back();
int l = val.back(); val.pop_back();
switch(op)
{
case '+': val.push_back(l + r); break;
case '-': val.push_back(l - r); break;
}
}
// tính giá trị của biểu thức biểu diễn bởi s
int evaluate(string s)
{
vector<int> val;
vector<char> op;
for (int i = 0; i < (int)s.size(); ++i)
{
if (isdigit(s[i]))
{
int number = 0; // giá trị của toán hạng đang xét
while (i < (int)s.size() && isdigit(s[i]))
{
number = number * 10 + s[i] - '0';
++i;
}
val.push_back(number);
--i; // cẩn thận với index!
}
else
{
if (!op.empty())
{
process_op(val, op.back()); // xử lý biểu thức từ trái sang phải
op.pop_back();
}
op.push_back(s[i]);
}
}
if (!op.empty())
{
process_op(val, op.back());
op.pop_back();
}
return val.back();
}
Do các toán tử có cùng độ ưu tiên, ta có thể xử lý chúng lần lượt theo danh sách đã xây dựng. Tuy nhiên, nếu danh sách có các toán tử khác độ ưu tiên, thì ta vẫn có thể xử lý bài toán bằng việc luôn giữ cho danh sách toán tử chứa các toán tử có độ ưu tiên tăng dần, sau đó xử lý danh sách toán tử từ dưới lên để đảm bảo các dấu có độ ưu tiên lớn hơn luôn được xử lý trước.
Trong bài toán gốc, xâu còn chứa ký tự và . Ta cần xử lý danh sách toán tử sao cho các phép tính có độ ưu tiên lớn hơn luôn được thực hiện trước.
Ta định nghĩa một thứ tự ưu tiên cho các dấu:
// Trả về 2 nếu là nhân hoặc chia, 1 nếu là cộng hoặc trừ
int priority(char op)
{
if (op == '+' || op == '-') return 1;
else return 2;
}
Giống với bài toán đơn giản, ta vẫn duyệt xâu từ trái sang phải. Khi gặp toán hạng, ta vẫn đẩy chúng vào danh sách. Tuy nhiên, khi gặp toán tử, ta cần xem xét thứ tự ưu tiên của toán tử ngay trước nó. Nếu toán tử trước có độ ưu tiên lớn hơn hoặc bằng toán tử đang xétxét, ta cần phải xử lý nó trước, và loại bỏ toán tử đứng trước đó khỏi danh sách. Ta sẽ lặp lại việc xử lý toán tử đứng trước này cho đến khi nó có độ ưu tiên không lớn hơn toán tử đang xét. Điều này đảm bảo phép tính sẽ luôn được thực hiện theo mức độ ưu tiên.
Để ý rằng bản chất của danh sách ta sử dụng chính là Stack. Việc thêm toán tử/toán hạng vào danh sách chính là thao tác push
; cách ta xử lý toán tử gợi đến việc top
và pop
do trong các bước của lời giải, ta chỉ cần quan tâm tới những phần tử ở cuối danh sách. Lời giải sử dụng vector
để biểu diễn Stack.
void process_op(vector<int>& val, char op)
{
int r = val.back(); val.pop_back();
int l = val.back(); val.pop_back();
switch(op)
{
case '+': val.push_back(l + r); break;
case '-': val.push_back(l - r); break;
case '*': val.push_back(l * r); break;
case '/': val.push_back(l / r); break;
}
}
int evaluate(string s)
{
// ...
for (int i = 0; i < (int)s.size(); ++i)
{
if (isdigit(s[i]))
{
// ...
}
else
{
char cur_op = s[i]; // toán tử hiện tại
while (!op.empty() && priority(op.back()) >= priority(cur_op))
// xử lý các toán tử ngay trước nó có độ ưu tiên bằng hoặc lớn hơn
// chú ý rằng nếu thay dấu >= bằng dáu > thì đápđáp
{
process_op(val, op.back());
op.pop_back();
}
op.push_back(cur_op);
}
}
// do danh sách toán tử có độ ưu tiên tăng dần nên ta có thể xử lý
// lần lượt như trong bài toán chỉ có + và -
// chú ý là ta xử lý ngược từ đáy về đầu, nên các toán tử được xử lý
// sẽ tạo thành một dãy giảm theo thứ tự ưu tiên
while (!op.empty())
{
process_op(val, op.back());
op.pop_back();
}
return val.back();
}
Xét ví dụ: .
Giá trị của và sau khi xử lý xâu :
Quá trình xử lý danh sách toán tử :
Cho xâu chỉ gồm ký tự và . Bạn cần kiểm tra xem có phải là dãy ngoặc đúng không.
Nếu là dãy ngoặc đúng, với mỗi vị trí trong bạn cần in ra vị trí của dấu ngoặc tương ứng.
Định nghĩa:
Hình ảnh minh họa cho một dãy ngoặc đúng. Các cặp dấu ngoặc tương ứng được tô cùng màu:
Ta định nghĩa thêm: dãy ngoặc đúng cơ bản là dãy ngoặc đúng không thể tách được thành tổng của các dãy ngoặc đúng nhỏ hơn. Ví dụ, là dãy ngoặc đúng cơ bản, và không phải là dãy ngoặc đúng cơ bản, do nó có thể tách được thành .
Từ các định nghĩa, có thể rút ra các tính chất của dãy ngoặc đúng:
Ta có các bổ đề và hệ quả sau:
Trong dãy ngoặc đúng, mỗi dấu ngoặc tương ứng với một và chỉ một dấu ngoặc khác.
Giả sử tồn tại một dấu ngoặc có thể tương ứng với nhiều hơn một dấu ngoặc khác. Khi đó, sẽ có nhiều hơn một cách chia dãy ngoặc thành các cặp dấu ngoặc tương ứng. Do đó, tồn tại một dấu ngoặc đóng trong dãy tương ứng với nhiều hơn một dấu ngoặc khác.
Giả sử dấu ngoặc ở vị trí là dấu ngoặc đóng và có thể tương ứng với các dấu ngoặc mở ở vị trí và , với . Theo tính chất 1, ta có:
Theo tính chất 3, dãy ngoặc từ vị trí đến cũng là dãy ngoặc đúng. Mà dãy ngoặc từ vị trí đến lại kết thúc bằng dấu mở ngoặc, trái với tính chất 2. Vậy giả thiết tồn tại một dấu ngoặc tương ứng với nhiều hơn một dấu ngoặc khác là sai.
Vậy ta có điều phải chứng minh.
Dãy ngoặc đúng khi và chỉ khi số dấu ngoặc mở bằng số dấu ngoặc đóng, và trong mọi tiền tố của dãy, số dấu ngoặc mở không nhỏ hơn số dấu ngoặc đóng.
Chiều thuận: dãy ngoặc đúng có số dấu ngoặc mở bằng số dấu ngoặc đóng, và trong mọi tiền tố của dãy, số dấu ngoặc mở không nhỏ hơn số dấu ngoặc đóng. Theo tính chất 2, dãy ngoặc mở có số dấu đóng ngoặc bằng số dấu mở ngoặc. Mặt khác, nếu tồn tại một tiền tố của dãy ngoặc đúng có số dấu mở ngoặc nhỏ hơn số dấu đóng ngoặc thì rõ ràng tồn tại ít nhất 1 dấu đóng ngoặc không tương ứng với dấu mở ngoặc nào, trái với bổ đề 1. Vậy ta chứng minh được chiều thuận.
Chiều đảo: trong mọi tiền tố của dãy, số dấu ngoặc mở không nhỏ hơn số dấu ngoặc đóng đảm bảo rằng mỗi dấu ngoặc đóng đều có dấu ngoặc mở tương ứng với nó. Hơn nữa, số dấu ngoặc mở bằng số dấu ngoặc đóng đảm bảo không có dấu ngoặc mở bị thừa ra (hay không tương ứng với dấu ngoặc nào) trong dãy ngoặc. Do đó, dãy có số dấu ngoặc mở bằng số dấu ngoặc đóng và trong mọi tiền tố của dãy, số dấu ngoặc mở không nhỏ hơn số dấu ngoặc đóng là dãy ngoặc đúng.
Dãy ngoặc không đúng sẽ có số dấu ngoặc mở lớn hơn số ngoặc đóng, hoặc có một tiền tố mà số dấu ngoặc mở nhỏ hơn số dấu ngoặc đóng.
Cho một Stack chứa các phần tử kiểu char
, đang ở trạng thái rỗng. Xét quá trình sau:
Trước hết, quá trình này sẽ luôn xác định xem xâu có phải là dãy ngoặc đúng hay không. Nhắc lại, quá trình sẽ kết luận xâu không phải dãy ngoặc đúng khi:
Trường hợp 1 xảy ra khi xâu có một tiền tố mà số dấu ngoặc đóng nhiều hơn số dấu ngoặc mở. Trường hợp 2 xảy ra khi xâu có số dấu ngoặc mở nhiều hơn số dấu ngoặc đóng. Cả hai trường hợp trên đều là dấu hiệu của dãy ngoặc không đúng nêu trong hệ quả 1. Vậy quá trình sẽ luôn xác định là dãy đúng hay không.
Từ bây giờ, ta mặc định là dãy ngoặc đúng, và chứng minh rằng việc sử dụng Stack sẽ luôn chỉ ra các cặp dấu ngoặc tương ứng.
Quan sát quá trình trên, ta có một nhận xét quan trọng: sau khi xử lý một dãy ngoặc đúng trên Stack thì Stack sẽ giữ nguyên trạng thái. Điều này là đúng do mỗi dấu mở ngoặc được push
vào Stack và pop
khỏi Stack đúng một lần.
Theo tính chất 4, có thể được tách thành tổng của các dãy ngoặc đúng cơ bản . Theo tính chất 5, , với là dãy ngoặc đúng, có thể là xâu rỗng. Vì sau khi xử lý các dãy thì Stack sẽ trở về trạng thái rỗng, nên ta chỉ cần xem xét quá trình với dãy ngoặc đúng cơ bản :
Như vậy, quá trình sẽ xác định tất cả các cặp dấu ngoặc tương ứng.
Ta có thể cài đặt bài toán đúng như mô tả của quá trình nêu trên:
stack<int> st;
vector<pair<int, int>> matches; // lưu các cặp dấu ngoặc tương ứng
bool solve(string s)
{
int n = (int)s.size();
for (int i = 0; i < n; ++i)
{
if (s[i] == '(') // chỉ đẩy dấu ngoặc mở vào Stack
st.push(i);
else
{
if (st.empty()) return false; // dãy không phải là dãy ngoặc đúng
matches.push_back({st.top(), i}); // tìm thấy một cặp tương ứng
st.pop();
}
}
if (!st.empty()) return false; // nếu Stack không rỗng thì dãy không đúng
return true;
}
Bài toán 2 có thể được mở rộng thêm: dãy có thể có cả ngoặc vuông và ngoặc nhọn. Rõ ràng, ta có thể xử lý các loại ngoặc như cách ta làm với bài toán 2. Lưu ý duy nhất là ta cần phải kiểm soát thêm cả kiểu loại của dấu.
Minh họa cho quá trình với "":
Vì tính chất LIFO của Stack, nó có thể được sử dụng để khử các hàm đệ quy. Trên thực tế, khi ta dùng hàm đệ quy, hệ thống sẽ tự động tạo một cấu trúc LIFO như Stack để chứa và thực hiện các lời gọi hàm.
Xét thuật toán DFS với cách cài đặt sử dụng đệ quy:
void dfs(int start)
{
visited[start] = true; // đánh dầu đỉnh đã thăm
for (int u : adj[start]) // với các đỉnh thuộc danh sách kề của đỉnh đang xét
{
if (visited[u])
continue;
dfs(u); // đệ quy: thăm u
}
}
Ta có thể cài đặt DFS sử dụng Stack:
void dfs(int start)
{
stack<int> st;
st.push(start); // ta bắt đầu từ đỉnh "start"
while (!st.empty())
{
int v = st.top(); // thăm đỉnh v ở đỉnh Stack
st.pop(); // loại bỏ đỉnh v khỏi Stack do đã thăm
visited[v] = true; // đánh dấu v đã thăm
for (int u : adj[v]) // xét các đỉnh của đồ thị chung cạnh với v
{
if (visited[u]) // nếu đã thăm thì thôi
continue;
st.push(u); // đẩy đỉnh u vào Stack thay vì gọi đệ quy đến u
}
}
}
Trong cách cài đặt DFS đệ quy, một nhánh đệ quy luôn phải được xử lý trọn vẹn trước khi ta gọi đệ quy đến nhánh khác. Nói cách khác, khi ta thăm một đỉnh thì ta phải thăm toàn bộ những đỉnh kề với nó, và cứ tiếp tục như vậy đến khi không còn đỉnh nào thăm được nữa. Stack hoạt động trên nguyên lý tương tự: những đỉnh được đẩy vào Stack sau đều được xử lý trọn vẹn trước những đỉnh được đẩy vào sớm hơn. Do đó, lời giải trên là đúng đắn.
Việc dùng Stack để khử đệ quy có thể áp dụng với mọi hàm có tính chất đệ quy. Trên lý thuyết, độ phức tạp thời gian và bộ nhớ của 2 cách cài đặt dùng Stack và dùng đệ quy là như nhau, nhưng trên thực tế, cách cài đặt dùng Stack thường hiệu quả hơn về mặt bộ nhớ, do việc gọi đệ quy chịu ảnh hưởng bởi Function Overhead. Đổi lại, cách cài đặt dùng Stack thường khiến code trở nên phức tạp và thiếu trực quan hơn.
Trong lập trình thi đấu, ta có thể sử dụng đệ quy thông thường trong hầu hết các trường hợp. Ta chỉ cần dùng Stack khi hàm đệ quy quá sâu và có nguy cơ bị tràn bộ nhớ. Theo kinh nghiệm của người viết, ta cần khử đệ quy khi hàm có thể đạt độ sâu khoảng .
Stack đơn điệu là ngăn xếp mà các phần tử của nó xét từ đáy của Stack đến đỉnh Stack tạo thành một dãy số đơn điệu.
Hình ảnh minh họa cho một Stack đơn điệu giảm:
Cho mảng có phần tử , . Với mỗi từ đến ta cần tìm sao cho , và nhỏ nhất. Nếu không tồn tại , in ra .
Ta sẽ bài toán đơn giản hơn: với mỗi ta chỉ cần tìm thỏa mãn điều kiện gốc, mà . Rõ ràng, nếu ta giải được bài toán này thì bài toán gốc cũng có thể dễ dàng giải được, vì nếu thì ta có thể duyệt ngược lại mảng , đưa bài toán về dạng đơn giản như đã nói.
Do nên cách giải hồn nhiên: với mỗi ta lại xét từ đến là chưa đủ để giải quyết bài toán, do độ phức tạp thời gian lên tới .
Xét mô hình sau:
Theo mô hình này, giá trị gần nhất mà chính là chỉ số của người gần nhất cao hơn người mà anh ta có thể nhìn được.
Hình ảnh minh họa, số ở hàng trên là chiều cao mỗi người, ở hàng dưới là chỉ số người gần nhất ở bên trái cao hơn họ:
Ta có thể cải tiến mô hình bằng việc chỉnh sửa cách thức xếp hàng.
Khi người thứ xếp hàng, họ sẽ thực hiện các thao tác sau:
Ta sẽ chứng minh số mà mỗi người ghi lại chính là chỉ số của người gần nhất đứng trước mà cao hơn họ.
Lúc đầu, cho một người có chỉ số , chiều cao . Việc đứng đầu hàng có thể coi như đứng ngay sau người chỉ số này. Xét một người bất kỳ ghi lại số , . Trước hết, chắc chắn phải lớn hơn . Giả sử ngược lại, không phải là người gần nhất mà cao hơn người , mà mới là người như vậy. Ta có và . Tại thời điểm người xếp vào hàng thì người này không còn trong hàng nữa (nếu còn thì họ sẽ ghi lại người chứ không phải ). Do đó, người đã bị đuổi khỏi hàng bởi một người nào đó đứng sau và trước . Gọi chỉ số của người đó là . Để đuổi người thì . Từ đó ta có , trái với giả thiết là người gần nhất mà cao hơn .
Như vậy, số mà mỗi người nhớ lại chính là chỉ số của người gần nhất cao hơn họ ( nếu không tồn tại người như vậy). Đây chính là giá trị cần tìm với mỗi .
Giả sử mảng . Các bước diễn ra như sau:
Dễ thấy chiều cao của người trong hàng luôn tạo thành một dãy đơn điệu.
Ta có thể biểu diễn mô hình nêu trên dưới dạng một Stack đơn điệu như sau:
push
pop
Từ các bước nêu trong mô hình, có thể cài đặt lời giải như sau:
stack<int> st;
for (int i = 1; i <= n; ++i)
{
while (!st.empty() && a[st.top()] <= a[i])
st.pop();
int ans = -1;
if (!st.empty())
ans = st.top();
cout << ans << ' ';
st.push(i);
}
Độ phức tạp bộ nhớ của lời giải là do sử dụng Stack và một mảng chứa phần tử.
Thoạt nhìn, độ phức tạp tính toán của lời giải có vẻ là do có vòng lặp while
lồng trong vòng for
. Tuy nhiên, để ý rằng mỗi phần tử đều được push()
vào Stack đúng một lần, và bị pop()
khỏi Stack tối đa 1 lần, nên độ phức tạp tính toán vẫn là
Bài toán gốc nêu trên có nhiều ứng dụng và mở rộng. Sau đây là một vài ví dụ.
Vẽ cột hình chữ nhật sát nhau. Cột thứ có chiều rộng và chiều cao . Tìm hình chữ nhật có diện tích lớn nhất tạo bởi các cột.
Ví dụ:
Hình chữ nhật lớn nhất có diện tích
Để ý rằng hình chữ nhật lớn nhất luôn có chiều cao bằng với chiều cao của một cột đã có.
Với mỗi cột , ký hiệu , là cột gần nhất ở bên trái/phải có chiều cao nhỏ hơn . Việc tìm cột gần nhất ở bên trái/phải cột mà thấp hơn chính là bài toán gốc nêu ở mục trước. Ta chỉ cần sửa đổi thay vì tìm cột cao hơn thì phải tìm cột thấp hơn.
Khi đã xác định các mảng và , ta có thể xét từng cột từ đến . Giả sử hình chữ nhật có chiều cao bằng với cột đang xét. Khi đó, chiều dài lớn nhất của hình chữ nhật là từ cột gần nhất bên trái thấp hơn cột đến cột gần nhất bên phải thấp hơn cột . Diện tích của hình chữ nhật lớn nhất qua cột là: .
Vậy diện tích hình chữ nhật lớn nhất chính là giá trị lớn nhất với mọi từ đến .
Độ phức tạp thời gian và bộ nhớ của lời giải là .
Đây là một mở rộng của bài toán trước. Cho lưới ô vuông , các ô có giá trị hoặc . Ta cần tìm hình chữ nhật có diện tích lớn nhất có tất cả ô vuông có cùng giá trị.
Ta có thể chia bài toán thành hai trường hợp riêng biệt: tìm hình chữ nhật chỉ gồm các ô giá trị và chỉ gồm các ô giá trị . Nếu giải được một trường hợp, ta cũng có thể dễ dàng giải trường hợp còn lại. Từ giờ, ta sẽ giải bài toán tìm hình chữ nhật lớn nhất chỉ chứa giá trị .
Ta xét lưới ô vuông con của lưới gốc, kích thước . Nói cách khác, lưới ô vuông con này chính là hàng đầu tiên của lưới gốc. Với mỗi từ đến , ta xét chiều cao của các cột chứa toàn số dựng trên từng vị trí trong hàng. Sau đó, ta có thể áp dụng bài toán tìm hình chữ nhật lớn nhất tạo bởi các cột để giải quyết vấn đề.
for (int i = 1; i <= m; ++i) h[i] = 0;
for (int k = 1; k <= n; ++k)
{
for (int j = 1; j <= m; ++j)
{
if (grid[k][j] == 1) ++h[j];
else h[j] = 0;
}
// giải bài toán tìm hình chữ nhật lớn nhất trong các cột...
}
Độ phức tạp thời gian và bộ nhớ của lời giải là .