Stack — LIFO từ chồng đĩa đến JVM call stack
Bấm Back trên browser: page xuất hiện ngược lại — đó là stack. Bài phân biệt khi nào pick stack thay queue, cách implement đúng, và pitfall dùng java.util.Stack legacy.
TL;DR: Stack là cấu trúc dữ liệu LIFO (Last In, First Out) — phần tử vào sau sẽ ra trước, thao tác chỉ xảy ra ở một đầu duy nhất. Push, pop, peek đều O(1). Cùng một cơ chế làm browser Back/Forward hoạt động, Ctrl+Z trong editor, balanced bracket check trong compiler, và JVM call stack. Trong Java, không bao giờ dùng java.util.Stack cũ — nó synchronized và kế thừa API sai từ Vector. Dùng Deque<T> stack = new ArrayDeque<>() thay thế.
Bạn mở browser, click liên tiếp 5 link: A → B → C → D → E. Bấm Back: E biến mất, D hiện ra. Bấm tiếp: C, rồi B, rồi A — luôn theo thứ tự ngược lại. Cùng cơ chế đó làm Ctrl+Z trong editor (lệnh cuối undo trước), làm function call return trong JVM (frame gần nhất được giải phóng trước), làm balanced bracket check trong compiler (đóng ngoặc phải khớp mở ngoặc gần nhất). Stack là CTDL O(1) insert và remove ở một đầu — bài này phân biệt khi nào pick stack thay queue hoặc list, cách implement đúng, và pitfall production khi dùng java.util.Stack legacy.
1. Analogy — Chồng đĩa
Hình dung chồng đĩa trong bếp:
- Đặt đĩa vào: bạn đặt lên trên cùng.
- Lấy đĩa ra: bạn lấy cái trên cùng.
- Không thể lấy đĩa ở giữa mà không xáo trộn cả chồng.
Đây là LIFO — Last In, First Out. Cái vào sau cùng ra trước tiên.
| Chồng đĩa | Stack |
|---|---|
| Đặt đĩa lên đỉnh | push(x) — thêm phần tử lên top |
| Lấy đĩa trên cùng | pop() — lấy và xóa top |
| Nhìn đĩa trên cùng không lấy | peek() — xem top, không xóa |
| Chồng đĩa rỗng | isEmpty() — kiểm tra stack trống |
| Đếm số đĩa | size() — số phần tử hiện có |
Stack: một đầu duy nhất. Vào sau, ra trước. Ngược với queue (FIFO — vào trước, ra trước).
2. Operations và complexity
| Operation | Mô tả | Time |
|---|---|---|
push(x) | Thêm phần tử lên top | O(1) |
pop() | Lấy và xóa top | O(1) |
peek() | Xem top không xóa | O(1) |
isEmpty() | Stack rỗng? | O(1) |
size() | Số phần tử | O(1) |
Tất cả đều O(1) vì thao tác chỉ xảy ra ở một đầu cố định — không cần traverse, không cần shift.
graph LR
A(["push(10)"])
B(["push(20)"])
C(["pop() → 20"])
D(["peek() → 10"])
A --> B --> C --> DSơ đồ trạng thái stack qua 4 thao tác cơ bản:
Trạng thái ban đầu: push(10): push(20): pop(): peek():
| | | | | [20] | | | | [10] |
| | | [10] | | [10] | | [10] | | [10] |
|______| |______| |______| |______| |______|
rỗng top=10 top=20 top=10 top=10
trả về 20 trả về 10
(không xoá)
3. Pseudocode implement bằng array
Array-backed stack dùng một index top trỏ vào phần tử trên cùng. top = -1 nghĩa là stack rỗng.
-- Stack cài trên array với tự động grow
Stack:
data[] -- backing array
top -- index của top element, -1 nếu rỗng
push(S, item):
if S.top + 1 = S.data.length:
grow(S) -- tăng 1.5x như ArrayList.grow() bài 07 Module 1
S.top <- S.top + 1
S.data[S.top] <- item
Time: O(1) amortized Space: O(1)
pop(S):
if S.top < 0: throw "Stack rỗng"
item <- S.data[S.top]
S.data[S.top] <- null -- giải phóng reference để GC thu hồi object
S.top <- S.top - 1
return item
Time: O(1) Space: O(1)
peek(S):
if S.top < 0: throw "Stack rỗng"
return S.data[S.top]
isEmpty(S):
return S.top < 0
size(S):
return S.top + 1
Time: O(1) cho mọi operation Space: O(n) tổng thể
data[top] <- null trong pop() là quan trọng: xóa reference giúp GC thu hồi object — không làm sẽ gây memory leak vì backing array vẫn giữ tham chiếu đến object đã "xóa". Cơ chế grow() tương tự ArrayList.grow() đã phân tích ở bài 07 Module 1 — tăng 1.5x để amortize cost.
4. Pseudocode implement bằng linked list (alternative)
Dùng singly linked list với head đóng vai trò top. Push và pop đều thao tác ở head — không cần traverse, luôn O(1).
-- Stack cài trên singly linked list
-- head đóng vai trò top stack
Node:
data
next
LinkedStack:
head -- node top (null nếu rỗng)
size
push(S, item):
newNode <- Node(item, next=S.head) -- node mới trỏ vào old head
S.head <- newNode
S.size <- S.size + 1
Time: O(1) Space: O(1) mỗi push (nhưng alloc Node object)
pop(S):
if S.head = null: throw "Stack rỗng"
item <- S.head.data
S.head <- S.head.next -- advance head, old node sẽ được GC
S.size <- S.size - 1
return item
Time: O(1) Space: O(1)
Cross-link: cấu trúc Node và trade-off singly linked list đã phân tích ở bài 02 Module 2.
So sánh hai cách implement:
| Tiêu chí | Array-backed | Linked list-backed |
|---|---|---|
| Memory layout | Liên tiếp — cache-friendly | Rải rác — pointer chasing |
| Grow cost | Amortized O(1), đôi khi copy | O(1) mỗi push, không copy |
| Memory overhead | Capacity slack tối đa ~50% | 1 Node object (~24 byte) mỗi phần tử |
| Production default | ArrayDeque (JDK) | Tự implement khi cần |
Với workload thông thường, array-backed thắng nhờ cache locality. Linked list thắng khi cần bounded memory nghiêm ngặt — không bao giờ alloc quá N phần tử — hoặc khi merge/split stack là thao tác thường xuyên.
5. Bài kinh điển — Balanced bracket check
Kiểm tra xâu ký tự có đóng mở ngoặc hợp lệ không là bài đặc trưng nhất cho stack: mỗi khi gặp mở ngoặc thì push, gặp đóng ngoặc thì pop và kiểm tra khớp.
isBalanced(s):
S <- Stack rỗng
for each ký tự c trong s:
if c là '(', '[', hoặc '{':
S.push(c) -- push mở ngoặc
else if c là ')', ']', hoặc '}':
if S.isEmpty(): return false
open <- S.pop()
if không khớp(open, c): return false
return S.isEmpty() -- hợp lệ nếu stack rỗng sau khi duyệt hết
khớp(open, close):
return (open='(' và close=')') hoặc
(open='[' và close=']') hoặc
(open='{' và close='}')
Time: O(n) Space: O(n) worst case (toàn mở ngoặc)
Trace ví dụ ({[]}) từng bước:
| Ký tự | Hành động | Trạng thái stack |
|---|---|---|
( | push ( | [(] |
{ | push { | [(, {] |
[ | push [ | [(, {, [] |
] | pop [, khớp [ và ] | [(, {] |
} | pop {, khớp { và } | [(] |
) | pop (, khớp ( và ) | [] |
| Hết | isEmpty() = true | balanced ✓ |
Ba edge case quan trọng:
- Input rỗng
""→ vòng lặp không chạy,isEmpty()= true → trả vềtrue. Đúng theo định nghĩa. - Thiếu đóng ngoặc
(()→ cuối vòng lặp stack còn[(],isEmpty()= false → trả vềfalse. - Thừa đóng ngoặc
)abc→ gặp)đầu tiên, stack rỗng → trả vềfalsengay.
6. Pitfall tổng hợp
Pitfall 1 — Đừng dùng java.util.Stack
java.util.Stack có trong JDK từ Java 1.0 (1996) và là lựa chọn sai cho mọi code mới. Ba lý do cụ thể:
- Synchronized methods: Mọi method kế thừa từ
Vectorđều synchronized — lock overhead không cần thiết trong single-threaded context.ArrayDequekhông synchronized, nhanh hơn đáng kể trong hot path. - Kế thừa
VectorAPI:StackextendsVector, lộ raaddElement(),set(i, x),get(i)— cho phép random access phá vỡ invariant LIFO. Code nhìn như stack nhưng thực ra dùng như list. - Recommendation chính thức: Javadoc của
java.util.Stackghi rõ: "A more complete and consistent set of LIFO stack operations is provided by theDequeinterface and its implementations, which should be used in preference to this class."
-- SAI: java.util.Stack -- synchronized, kế thừa Vector
bad <- new Stack()
bad.push(1)
bad.push(2)
bad.addElement(3) -- Vector method -- phá invariant LIFO
bad.set(0, 99) -- random access qua Vector -- phá invariant stack
-- ĐÚNG: Deque với ArrayDeque backing
stack <- new ArrayDeque() -- khai báo qua Deque interface
stack.push(1)
stack.push(2)
stack.pop() -- O(1), không synchronized overhead
Pitfall 2 — Deque.peek() trả về đầu nào?
Deque là double-ended queue — có hai đầu. Khi dùng ArrayDeque làm stack, convention là push/pop/peek thao tác ở head (đầu trái). Nhưng Deque kế thừa từ Queue, và peek() của Queue trả về head — nhìn đúng, nhưng dễ nhầm:
stack <- new ArrayDeque()
stack.push(1) -- addFirst(1) -- head = 1
stack.push(2) -- addFirst(2) -- head = 2
stack.push(3) -- addFirst(3) -- head = 3
-- Dùng làm stack: 3 là top
stack.peek() -- 3 -- đúng, Queue.peek() = head = top
stack.peekFirst() -- 3 -- explicit, rõ ràng hơn
stack.peekLast() -- 1 -- đây là BOTTOM của stack, KHÔNG phải top!
Rule: khi dùng Deque làm stack, dùng push/pop/peek hoặc tương đương explicit addFirst/removeFirst/peekFirst. Tuyệt đối không dùng peekLast/pollLast vì sẽ thao tác từ phía bottom.
Pitfall 3 — Stack overflow JVM call stack khác data stack
StackOverflowError trong JVM xảy ra trên call stack của thread, không phải ArrayDeque data structure. Hai thứ hoàn toàn khác nhau:
-- JVM call stack: giới hạn bởi -Xss (512KB-1MB mỗi thread)
-- Đệ quy sâu 10k-20k frame là StackOverflowError
recursive(n):
recursive(n - 1) -- mỗi call push 1 frame lên thread stack
-- Data stack trên heap: giới hạn bởi heap size (thường vài GB)
-- Xử lý 1 triệu phần tử không vấn đề
dataStack <- new ArrayDeque()
for i <- 0 đến 999999:
dataStack.push(i) -- alloc trên heap, không tiêu thụ thread stack
Khi thuật toán DFS đệ quy gặp cây sâu, giải pháp đúng là chuyển sang explicit Deque trên heap — heap lớn hơn thread stack hàng nghìn lần. Bài 02 Module 1 đã phân tích chi tiết cơ chế call stack và lý do JVM không có TCO. Cross-link: Thuật toán Cốt lõi — Module 3 (Đồ thị) sẽ dùng pattern explicit stack cho DFS tránh StackOverflowError trên graph sâu.
7. Applied to tech
JVM call stack: Mỗi method call push một stack frame lên thread stack, mỗi return pop frame đó. Frame chứa local variables, parameters, return address. StackOverflowError xảy ra khi recursion quá sâu và frame vượt giới hạn -Xss. Bài 02 Module 1 phân tích đầy đủ cơ chế này.
Browser back và forward: Browser dùng hai stack: back stack và forward stack. Click link mới → push URL hiện tại lên back stack. Bấm Back → pop từ back stack, push URL vừa rời lên forward stack. Bấm Forward → pop từ forward stack. Nếu click link mới từ giữa lịch sử → forward stack bị xóa sạch.
Text editor undo/redo: Mỗi thao tác edit push một command object lên undo stack. Ctrl+Z pop từ undo stack và revert, đồng thời push lên redo stack. Ctrl+Y pop từ redo stack. Thao tác mới bất kỳ xóa redo stack. Vim dùng một stack với branching tree — phức tạp hơn nhưng cùng nguyên lý cơ bản.
Compiler expression evaluation: Thuật toán Shunting-yard của Dijkstra dùng hai stack (operator stack và operand stack) để chuyển infix expression thành Reverse Polish Notation (RPN). Ví dụ (2 + 3) * 4 → 2 3 + 4 *. Sau khi có RPN, evaluate bằng một stack: gặp số thì push, gặp operator thì pop hai operand, tính và push kết quả. Thuật toán Cốt lõi — Module 2 (Sắp xếp) sẽ phân tích đầy đủ.
DFS implementation: DFS đệ quy dùng JVM call stack — nguy hiểm với graph sâu tùy ý. DFS lặp dùng explicit Deque trên heap — an toàn hơn vì heap thường lớn hơn thread stack hàng nghìn lần. Thuật toán Cốt lõi — Module 3 (Đồ thị) sẽ implement DFS cả hai cách và so sánh.
8. ArrayDeque source — Cơ chế grow
java.util.ArrayDeque là implementation được khuyến nghị cho cả stack lẫn queue trong production Java. Backing là một circular array — không phải array thông thường mà là array với head và tail index có thể wrap around.
-- push(x) = addFirst(x), pop() = removeFirst()
-- Dùng vì Deque là 2-end, nhưng convention stack dùng head là top
stack <- ArrayDeque rỗng
stack.push("a") -- addFirst("a")
stack.push("b") -- addFirst("b")
stack.push("c") -- addFirst("c")
stack.pop() -- "c" -- removeFirst()
stack.pop() -- "b"
Capacity luôn là power-of-2 (8, 16, 32...). Khi backing array đầy, grow() trong OpenJDK 21 tăng gấp đôi với capacity nhỏ hơn 64, tăng 50% khi lớn hơn — cùng triết lý amortization với ArrayList.grow() ở bài 07 Module 1.
Nếu biết trước số phần tử tối đa, truyền capacity vào constructor khi khởi tạo. Điều này tránh grow event làm copy toàn bộ backing array, giảm GC pressure trong hot path.
9. Deep Dive
Source code và spec:
- OpenJDK 21 — ArrayDeque.java — Đọc
addFirst()/removeFirst(), circular index arithmetic, vàgrow(). So sánh vớiArrayList.grow()để thấy điểm khác biệt khi backing là circular. - Effective Java Item 27 (Eliminate unchecked warnings) — liên quan đến
@SuppressWarnings("unchecked")trong generic array cast khi tự implement stack. - Effective Java Item 64 (Refer to objects by their interfaces) — lý do khai báo
Deque<T> stack = new ArrayDeque<>()thay vìArrayDeque<T> stack. - Knuth, The Art of Computer Programming Vol. 1, §2.2 — định nghĩa gốc của stack và queue, phân tích sequential allocation vs linked allocation.
Cross-link:
- Bài 02 Module 1 — Recursion và call stack: cơ chế JVM thread stack,
StackOverflowError, tail recursion. - Bài 07 Module 1 —
ArrayList.grow(): amortized analysis grow strategy — cùng nguyên lý vớiArrayDeque.grow(). - Bài 02 Module 2 — Linked list: singly linked list làm nền tảng cho linked stack.
10. Tóm tắt
- Stack là LIFO: Last In, First Out — chỉ thao tác ở một đầu.
push/pop/peekđềuO(1). - Dùng
Deque<T> stack = new ArrayDeque<>()trong production — không dùngjava.util.Stack(synchronized, kế thừaVector, API không sạch). ArrayDequedùng circular array — cache-friendly, không có pointer chasing.push = addFirst,pop = removeFirst.peek()củaDequetrả về head — đúng khi dùng làm stack. Tránh nhầm vớipeekLast()là bottom của stack.- JVM call stack khác data stack:
StackOverflowErrorxảy ra trên thread stack (giới hạnXss), không liên quan đếnArrayDequetrên heap. - Balanced bracket check là bài kinh điển: push khi gặp mở ngoặc, pop và kiểm tra khớp khi gặp đóng ngoặc, trả về
isEmpty()cuối vòng lặp. - DFS đệ quy dùng JVM call stack; DFS lặp dùng explicit
Dequetrên heap — an toàn hơn với input sâu tùy ý. - Two-stack pattern (browser back/forward, undo/redo) là ứng dụng thực tế quan trọng — một stack lưu lịch sử, một stack lưu trạng thái tương lai.
11. Tự kiểm tra
Q1Vì sao java.util.Stack không nên dùng dù có sẵn trong JDK từ 1996?▸
Ba lý do cụ thể: (1) Synchronized overhead — mọi method kế thừa từ Vector đều synchronized, tốn lock overhead không cần thiết trong single-threaded context. (2) API sai invariant — Stack extends Vector, lộ ra addElement(), set(i, x), get(i) cho phép random access — phá vỡ tính chất LIFO về mặt conceptual. (3) Javadoc chính thức khuyến nghị ngược lại — tài liệu JDK ghi rõ nên dùng Deque và implementation của nó thay thế.
Cách đúng: Deque<T> stack = new ArrayDeque<>(). Dùng push/pop/peek từ Deque interface — không synchronized, không lộ Vector API.
Q2Deque.peek() trả về head hay tail? Khi dùng ArrayDeque làm stack, dễ nhầm ở đâu?▸
Deque.peek() trả về head (đầu trái) — tương đương peekFirst(). Khi dùng ArrayDeque làm stack với convention push = addFirst, head chính là top — nên peek() trả về đúng top.
Điểm nhầm lẫn phổ biến: peekLast() trả về tail, tức là bottom của stack — phần tử được push đầu tiên, không phải top. Nếu viết nhầm stack.peekLast() thay vì stack.peek(), code vẫn compile nhưng logic sai hoàn toàn. Rule an toàn: chỉ dùng push/pop/peek (hoặc addFirst/removeFirst/peekFirst) khi dùng Deque làm stack.
Q3Viết pseudocode dùng stack để reverse một chuỗi. Big-O time và space là bao nhiêu?▸
Push từng ký tự vào stack, sau đó pop ra lần lượt để xây chuỗi ngược:
reverse(s):
S <- Stack rỗng
for each ký tự c trong s:
S.push(c)
result <- ""
while not S.isEmpty():
result <- result + S.pop()
return resultTime complexity: O(n) — duyệt chuỗi một lần để push, một lần để pop.
Space complexity: O(n) — stack chứa tối đa n ký tự. Cách đơn giản hơn trong production Java: new StringBuilder(s).reverse().toString() — O(n) time và space tương tự nhưng ngắn gọn hơn.
Q4JVM call stack và data stack ArrayDeque khác nhau về cơ chế như thế nào? Khi nào StackOverflowError xảy ra?▸
JVM call stack là vùng memory riêng của mỗi thread, kích thước cố định theo tham số -Xss (mặc định 512KB–1MB trên HotSpot). Mỗi method call push một stack frame lên đó; return pop frame. StackOverflowError xảy ra khi tổng depth × frame_size vượt -Xss — thường ở khoảng 10.000–20.000 frame.
Data stack như ArrayDeque là object trên heap — vùng memory lớn hơn nhiều (thường vài GB). Push một triệu phần tử vào ArrayDeque không ảnh hưởng đến thread stack chút nào. Hai thứ hoàn toàn độc lập về mặt memory. Khi recursion sâu gặp nguy cơ StackOverflowError, giải pháp là chuyển sang explicit Deque trên heap — tận dụng heap lớn thay vì thread stack nhỏ.
Q5Browser back và forward dùng 2 stack. Cho operation: visit A, visit B, visit C, Back, visit D — state của 2 stack lúc cuối là gì?▸
Trace từng bước:
- visit A: back=
[], ở A, forward=[] - visit B: back=
[A], ở B, forward=[] - visit C: back=
[A, B], ở C, forward=[] - Back: pop B từ back, push C lên forward → back=
[A], ở B, forward=[C] - visit D: push B lên back, xóa forward stack → back=
[A, B], ở D, forward=[]
Kết quả cuối: back stack = [A, B], đang ở D, forward stack = [] (rỗng). Forward stack bị xóa vì khi navigate đến trang mới từ giữa lịch sử, không còn "tương lai" để forward đến. Bấm Back từ D sẽ về B, bấm tiếp về A.
Q6Cho expression (2 + 3) * (4 - 1), mô tả cách dùng stack để đánh giá Reverse Polish Notation.▸
Bước 1 — chuyển infix sang RPN (Shunting-yard): (2 + 3) * (4 - 1) thành 2 3 + 4 1 - *.
Bước 2 — evaluate RPN bằng một stack: duyệt từ trái sang phải:
2: push → stack=[2]3: push → stack=[2, 3]+: pop 3 và 2, tính2 + 3 = 5, push → stack=[5]4: push → stack=[5, 4]1: push → stack=[5, 4, 1]-: pop 1 và 4, tính4 - 1 = 3, push → stack=[5, 3]*: pop 3 và 5, tính5 * 3 = 15, push → stack=[15]
Kết quả: pop phần tử cuối cùng = 15. Lưu ý thứ tự pop quan trọng với phép trừ/chia: pop hai lần, operand thứ hai pop trước là right operand.
Bài tiếp theo: Queue & Deque
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên