← Cấu trúc dữ liệu & Giải thuật

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

Cài hàng đợi bằng mảng dễ dính bẫy: nếu dequeue bằng array.shift() (xoá phần tử đầu), mọi phần tử còn lại phải dịch sang trái - thành O(n), đúng cái giá của “xoá đầu mảng” ở bài trước. Muốn dequeue O(1) thật sự, dùng danh sách liên kết có con trỏ tail, hoặc mảng vòng (circular buffer).
  • 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.

Sơ đồ ngăn xếp xếp chồng dọc với push/pop ở đỉnh, và hàng đợi nằm ngang với dequeue ở front, enqueue ở rear.
Ngăn xếp thêm/lấy ở cùng một đầu (LIFO); hàng đợi vào một đầu, ra đầu kia (FIFO).

Ngăn xếp - vào/ra cùng một đầu (LIFO)

↑ đỉnh (vào/ra ở đây)
3
2
1
đáy

Hàng đợi - vào một đầu, ra đầu kia (FIFO)

trước
(ra)
1
2
3
sau
(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

Đây chính là một phần của dự án cuối khoá: kiểm tra ngoặc cân bằng ngăn xếp. Cùng một ý tưởng giúp máy tính tính biểu thức, phân tích cú pháp HTML, và lần ngược call stack khi báo lỗi.

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

Tới giờ ta mới chỉ LƯU và LẤY phần tử. Bước kế là biến một đống lộn xộn thành có trật tự để tìm kiếm thật nhanh. Hẹn gặp ở bài Sắp xếp & tìm kiếm; còn ngăn xếp/hàng đợi sẽ tái xuất ở bài Đồ thị & duyệt (BFS/DFS).

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.

Không. peek chỉ NHÌN phần tử ở đỉnh ngăn xếp (hoặc đầu hàng đợi) mà không bỏ nó đi - khác với pop/dequeue là lấy ra hẳn. Cả peek cũng là O(1).

Vì ngoặc lồng nhau theo kiểu “mở sau thì phải đóng trước” - đúng tinh thần LIFO. Gặp ngoặc mở thì đẩy vào; gặp ngoặc đóng thì lấy ra cái mở GẦN nhất để so khớp. Hết chuỗi mà ngăn xếp rỗng là cân.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Ngăn xếp (stack) lấy ra phần tử nào trước tiên?

  1. 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. 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ồi 9. Hàng đợi lấy ra: 5, rồi 8.

  3. 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. 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ốn O(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. 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. 6

    Tự code ngăn xếp

    Viết một lớp/đối tượng Stack bằng TypeScript với push, pop, peek, isEmpty dùng một mảng bên trong.

    Hoàn thành khi: push thêm cuối mảng, pop lấy cuối, peek đọc cuối mà không xoá, isEmpty kiểm tra độ dài 0; tất cả O(1).