Bài 3 · Vận dụng · 18 phút
Ngăn xếp & hàng đợi
Biên soạn bởi Nguyễn Anh Tuấn
Ngăn xếp (stack) vào sau ra trước (LIFO) và hàng đợi (queue) vào trước ra trước (FIFO): dùng cho undo, call stack, duyệt BFS, hàng chờ.
Bài trước ta có hai viên gạch lưu dữ liệu: mảng & danh sách liên kết. Giờ ta cố tình giới hạn cách thêm/bớt, và bất ngờ thu được hai công cụ cực kỳ hữu dụng.
Hình dung một chồng đĩa: bỏ đĩa mới lên trên, và khi lấy cũng lấy từ trên xuống. Cái đặt sau cùng lại được lấy đầu tiên - đó là ngăn xếp. Ngược lại, một hàng mua vé: ai đến trước được phục vụ trước, ai đến sau xếp xuống cuối - đó là hàng đợi.
- ▸Ngăn xếp (stack): vào sau ra trước - LIFO (Last In, First Out).
- ▸Hàng đợi (queue): vào trước ra trước - FIFO (First In, First Out).
- ▸Cả hai chỉ là mảng / danh sách bị giới hạn cách thêm-bớt, nên thao tác rất nhanh.
Ngăn xếp có ba thao tác, tất cả ở đỉnh (top): push (đẩy một phần tử lên đỉnh), pop (lấy phần tử ở đỉnh ra), và peek (nhìn đỉnh mà không lấy). Vì chỉ đụng tới một đầu, cả ba đều là O(1). Một mảng là quá đủ để làm ngăn xếp:
ngan-xep.ts · mảng JS làm ngăn xếp Undo
const undo: number[] = [];
undo.push(1); undo.push(2); undo.push(3); // lam 3 viec
console.log(undo.pop()); // hoan tac viec MOI nhat -> 3
console.log(undo.pop()); // -> 2
console.log(undo); // con lai -> [ 1 ] Kết quả khi chạy
3 2 [ 1 ]
- ▸Thao tác: push (thêm đỉnh), pop (lấy đỉnh), peek (xem đỉnh) - đều O(1).
- ▸Ứng dụng: nút Undo, nút Back trình duyệt, và call stack của chương trình.
- ▸Lỗi “stack overflow” = đệ quy quá sâu, đẩy quá sức chứa của call stack.
Hàng đợi thêm vào cuối (enqueue) và lấy ra từ đầu (dequeue). Đúng như xếp hàng: người mới đứng xuống cuối, người đầu hàng được phục vụ rồi rời đi.
Trung thực
- ▸Thao tác: enqueue (thêm cuối), dequeue (lấy đầu), peek (xem đầu).
- ▸Cài đúng (danh sách có tail, hoặc mảng vòng) thì mọi thao tác là O(1).
- ▸Ứng dụng: hàng chờ in, lập lịch tác vụ, và duyệt theo chiều rộng (BFS) trên đồ thị.
Bấm Thêm để đẩy cùng một số vào cả ngăn xếp lẫn hàng đợi, rồi Lấy ra và để ý: hai cấu trúc trả về hai số khác nhau dù nhận đúng cùng một dãy đầu vào.
Ngăn xếp - vào/ra cùng một đầu (LIFO)
Hàng đợi - vào một đầu, ra đầu kia (FIFO)
(ra)
(vào)
Bấm Thêm để đẩy cùng một số vào cả hai, rồi Lấy ra để thấy: ngăn xếp trả về số mới nhất, hàng đợi trả về số cũ nhất. Mọi thao tác thêm/lấy đều là O(1).
- ▸Cùng một chuỗi “thêm”, ngăn xếp và hàng đợi cho thứ tự lấy ra NGƯỢC nhau.
- ▸Ngăn xếp trả về số mới nhất; hàng đợi trả về số cũ nhất.
- ▸Chọn cấu trúc nào là chọn THỨ TỰ xử lý bạn muốn.
Ngoặc trong code lồng theo kiểu “mở sau phải đóng trước” - đúng tinh thần LIFO, nên ngăn xếp là công cụ hoàn hảo. Quét chuỗi: gặp ngoặc mở thì push; gặp ngoặc đóng thì pop ra cái mở gần nhất để so khớp. Hết chuỗi mà ngăn xếp rỗng nghĩa là mọi ngoặc đã khớp:
ngoac-can.ts · trình soạn thảo dùng cách này để báo lỗi cú pháp
function ngoacCan(s: string): boolean {
const stack: string[] = [];
const cap: Record<string, string> = { ')': '(', ']': '[', '}': '{' };
for (const ch of s) {
if (ch === '(' || ch === '[' || ch === '{') stack.push(ch);
else if (ch in cap) {
if (stack.pop() !== cap[ch]) return false; // dong khong khop mo gan nhat
}
}
return stack.length === 0; // con ngoac mo chua dong -> chua can
}
console.log(ngoacCan('([]{})')); // can
console.log(ngoacCan('([)]')); // long sai thu tu
console.log(ngoacCan('((')); // con ngoac mo Kết quả khi chạy
true false false
Mẹo
Ngăn xếp và hàng đợi không lưu thêm gì mới so với mảng/danh sách - chúng chỉ áp một kỷ luật về thứ tự (LIFO hay FIFO), và chính kỷ luật đó làm chúng hữu dụng. Hai cấu trúc này sẽ trở lại khi ta duyệt cây và đồ thị: ngăn xếp cho duyệt theo chiều sâu (DFS), hàng đợi cho duyệt theo chiều rộng (BFS).
Bước tiếp theo
Câu hỏi thường gặp
Khác ở chỗ LẤY RA. Ngăn xếp (stack) lấy phần tử MỚI nhất - vào sau ra trước (LIFO); hàng đợi (queue) lấy phần tử CŨ nhất - vào trước ra trước (FIFO). Việc thêm vào thì cả hai đều ở một đầu.
Cả hai đều được. Ngăn xếp hợp với mảng (push/pop ở cuối mảng đều O(1)). Hàng đợi cài bằng mảng dễ dính bẫy: nếu dequeue bằng cách xoá phần tử đầu mảng thì tốn O(n) (dời cả dãy, đúng như bài Mảng đã chỉ). Muốn O(1) thì dùng danh sách liên kết có con trỏ tail, hoặc mảng vòng (circular buffer).
call stack (ngăn xếp lời gọi) là chính một ngăn xếp mà chương trình dùng để nhớ các hàm đang gọi nhau: hàm gọi hàm thì đẩy thêm một “khung” lên đỉnh, hàm xong thì lấy ra. Lỗi stack overflow là khi đệ quy quá sâu, đẩy nhiều hơn sức chứa của ngăn xếp này.
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Ngăn xếp (stack) lấy ra phần tử nào trước tiên?
- 1
Hai câu định nghĩa
Viết một câu cho ngăn xếp và một câu cho hàng đợi, mỗi câu nêu rõ thứ tự lấy ra (LIFO/FIFO) kèm một ví dụ đời thường.
Hoàn thành khi: Ngăn xếp = LIFO (vd chồng đĩa); hàng đợi = FIFO (vd xếp hàng mua vé). Hai ví dụ khác nhau và đúng tinh thần.
- 2
Đoán thứ tự lấy ra
Thực hiện: thêm 5, thêm 8, thêm 2, lấy ra, thêm 9, lấy ra. Viết dãy giá trị lấy ra cho NGĂN XẾP và cho HÀNG ĐỢI.
Hoàn thành khi: Ngăn xếp lấy ra:
2, rồi9. Hàng đợi lấy ra:5, rồi8. - 3
Ngoặc cân không?
Dùng quy tắc ngăn xếp, kiểm tra bằng tay 3 chuỗi:
()[],([)],((). Chuỗi nào cân?Hoàn thành khi: Chỉ
()[]cân.([)]sai thứ tự lồng;(()còn một ngoặc mở chưa đóng. - 4
Bẫy hàng đợi bằng mảng
Giải thích vì sao cài hàng đợi bằng mảng JS rồi dùng
array.shift()để lấy ra lại có thể tốnO(n).Hoàn thành khi: Nêu được:
shift()xoá phần tử đầu nên mọi phần tử còn lại phải dịch sang trái một ô - tỉ lệ với n, đúng như chèn/xoá đầu mảng ở bài trước. - 5
Chọn công cụ
Với mỗi việc, chọn ngăn xếp hay hàng đợi: (1) nút Undo; (2) xử lý yêu cầu in theo thứ tự gửi; (3) nút Back của trình duyệt.
Hoàn thành khi: (1) Ngăn xếp; (2) Hàng đợi; (3) Ngăn xếp.
- 6
Tự code ngăn xếp
Viết một lớp/đối tượng
Stackbằng TypeScript vớipush,pop,peek,isEmptydùng một mảng bên trong.Hoàn thành khi:
pushthêm cuối mảng,poplấy cuối,peekđọc cuối mà không xoá,isEmptykiểm tra độ dài 0; tất cảO(1).