CHUYÊN ĐỀ COMPETITIVE PROGRAMMING — TUẦN 6

Stack & Queue,
Monotonic Stack

Hai cấu trúc dữ liệu cơ bản với ứng dụng nâng cao. Monotonic Stack là pattern then chốt giải bài "next greater/smaller" trong O(n).

9 worked examples · 3 knowledge checks · 12 bài về nhà

Phần 1. Nền tảng Stack & Queue

Hai cấu trúc, hai triết lý: LIFO vs FIFO
1.1

1.1 Stack vs Queue — Hai triết lý

STACK (LIFO) D (top) C B A (bottom) ↑ push/pop Last In, First Out QUEUE (FIFO) D C B A enqueue ↓ ↑ dequeue First In, First Out

Khi nào dùng cái nào?

1.2

1.2 Stack trong Python — list

Python dùng list làm stack qua appendpop:

stack = []
stack.append(x)        # push      O(1) amortised
stack.pop()            # pop top   O(1)
stack[-1]              # peek      O(1)
len(stack) == 0        # check empty

Bẫy phổ biến

stack[0]               # SAI — đây là bottom, không phải top
stack.pop(0)           # SAI — pop ở đầu là O(n), không phải O(1)
Quy tắc:
Khi dùng list làm stack, luôn thao tác ở cuối list (append, pop, [-1]). Nếu cần thao tác cả hai đầu, dùng collections.deque để giữ O(1).
1.3

1.3 Queue trong Python — collections.deque

list.pop(0) là O(n) → không phù hợp cho queue. Dùng deque:

from collections import deque
q = deque()
q.append(x)            # enqueue   O(1)
q.popleft()            # dequeue   O(1)
q[0]                   # front     O(1)

# deque cũng hỗ trợ thao tác hai đầu:
q.appendleft(x)        # O(1)
q.pop()                # O(1) — pop ở phải

Tại sao deque mạnh?

Deque cài đặt bằng doubly linked list của block. Mọi thao tác ở hai đầu là O(1). Đây cũng là cấu trúc dùng cho Monotonic Deque (Phần 4).

Khi nào dùng list, khi nào dùng deque?
list: stack thuần (chỉ thêm/bớt ở cuối). deque: queue, hai đầu, sliding window. Khi nghi ngờ → deque. Deque không có nhược điểm gì so với list khi chỉ dùng làm stack.
1.4

1.4 Bảng tổng hợp Big-O

Thao táclist (stack)deque (queue/two-end)
append (cuối)O(1) amortisedO(1)
pop (cuối)O(1)O(1)
appendleft (đầu)O(1)
popleft (đầu)O(n) — list.pop(0)O(1)
Truy cập index giữaO(1)O(n)
Bộ nhớ tương đốiThấpCao hơn ~30%

Đa số bài thuật toán không quan tâm "truy cập index giữa" — nên deque ổn cho cả stack và queue. Tuy nhiên, vì list nhẹ hơn, đa số code Python idiom dùng list cho stack thuần và deque chỉ khi cần thao tác hai đầu.

Phần 2. Stack — Ứng dụng cơ bản

Matching cặp, design data structure, evaluate biểu thức
2.1

2.1 Pattern: Matching Pairs với Stack

Mô hình tổng quát:
Khi bài có cấu trúc lồng nhau (ngoặc, HTML tags, biểu thức), stack là lựa chọn tự nhiên. Push khi gặp "mở", pop khi gặp "đóng" và kiểm tra khớp.

Ba câu hỏi cần trả lời

  1. Cái gì coi là "mở" → push.
  2. Cái gì coi là "đóng" → pop và kiểm tra.
  3. Trạng thái cuối hợp lệ là gì? (thường: stack rỗng).

Pattern này áp dụng cho: parentheses, brackets, HTML tags, function call simulation, undo/redo, expression evaluation.

2.2

2.2 Worked Example 1 — Valid Parentheses

Đề (LeetCode #20): Cho string chỉ gồm ()[]{}. Trả True nếu mọi ngoặc khớp đúng.

def isValid(s):
    pair = {')': '(', ']': '[', '}': '{'}
    stack = []
    for ch in s:
        if ch in '([{':
            stack.append(ch)
        else:
            if not stack or stack[-1] != pair[ch]:
                return False
            stack.pop()
    return len(stack) == 0

Trace với "({[]})"

BướcchHành độngStack sau
1(push[(]
2{push[(, {]
3[push[(, {, []
4]top = [, khớp → pop[(, {]
5}top = {, khớp → pop[(]
6)top = (, khớp → pop[]

Stack rỗng → True. Time O(n), Space O(n).

2.3

2.3 Worked Example 2 — Min Stack (Design)

Đề (LeetCode #155): Thiết kế stack hỗ trợ thêm getMin() trong O(1).

Thách thức

Stack thường có push/pop/top O(1), nhưng tìm min là O(n). Làm sao biến min cũng thành O(1)?

Approach: Stack phụ lưu min

class MinStack:
    def __init__(self):
        self.stack = []
        self.min_stack = []           # đỉnh = min hiện tại

    def push(self, x):
        self.stack.append(x)
        m = min(x, self.min_stack[-1] if self.min_stack else x)
        self.min_stack.append(m)

    def pop(self):
        self.stack.pop()
        self.min_stack.pop()

    def top(self):
        return self.stack[-1]

    def getMin(self):
        return self.min_stack[-1]

Mọi thao tác O(1). Space O(n).

Bài học design:
Khi cần truy vấn O(1) một thuộc tính tổng hợp (min, max, sum) trên stack → giữ một stack phụ song song. Đây là trade-off space O(n) lấy time O(1) — tinh thần xuyên suốt khoá học.
2.4

2.4 Worked Example 3 — Evaluate Reverse Polish Notation

Đề (LeetCode #150): Tính giá trị biểu thức RPN.

Ví dụ: ["2","1","+","3","*"] = (2+1) * 3 = 9.

def evalRPN(tokens):
    stack = []
    ops = {'+', '-', '*', '/'}
    for t in tokens:
        if t in ops:
            b = stack.pop()
            a = stack.pop()
            if t == '+': stack.append(a + b)
            elif t == '-': stack.append(a - b)
            elif t == '*': stack.append(a * b)
            else: stack.append(int(a / b))     # Python truncate khác C++
        else:
            stack.append(int(t))
    return stack[0]

Quan sát

RPN là dạng "tự nhiên" cho stack: số → push, toán tử → pop 2 số, tính, push lại. Không có ngoặc, không có ưu tiên — đơn giản hoá hoàn toàn.

Time O(n), Space O(n).

Bẫy chia số nguyên:
Đề yêu cầu chia truncate về 0 (như C). Python // chia làm tròn xuống cho số âm (−7 // 2 = −4 thay vì −3). Dùng int(a / b) để truncate đúng.
2.Q

Knowledge Check — Stack Basics

Q1: Khi nào pattern matching pairs với stack phù hợp?
Stack tự nhiên hợp với cấu trúc lồng vì LIFO khớp với "đóng cái mở gần nhất". Ngoặc, HTML tags, function calls đều có cấu trúc này.
Q2: Min Stack giữ getMin() ở O(1) bằng kỹ thuật gì?
Stack phụ có cùng kích thước với stack chính. Khi push x, push min(x, current_min) vào stack phụ. Pop đồng thời. Truy vấn min = đỉnh stack phụ.

Phần 3. Monotonic Stack

Pattern then chốt cho "next greater/smaller" — biến O(n²) thành O(n)
3.1

3.1 Monotonic Stack là gì?

Định nghĩa:
Monotonic Stack là stack mà các phần tử bên trong luôn theo một thứ tự đơn điệu — tăng (monotonic increasing) hoặc giảm (monotonic decreasing) từ đáy lên đỉnh. Tính chất này được duy trì bằng cách: trước khi push x, pop hết các phần tử vi phạm thứ tự.

Hai biến thể

Biến thểQuy tắcDùng cho
Decreasing (đỉnh nhỏ nhất)Pop khi top < xNext Greater Element
Increasing (đỉnh lớn nhất)Pop khi top > xNext Smaller Element

Mỗi phần tử push 1 lần, pop tối đa 1 lần

→ Tổng số thao tác qua toàn thuật toán ≤ 2n → O(n) (phân tích amortised).

3.2

3.2 Tín hiệu nhận biết Monotonic Stack

Khi đọc đề, các "mùi" sau gợi ý mạnh:

Cụm từ trong đềPattern
"next greater element"Decreasing stack
"next smaller element"Increasing stack
"days until warmer temperature"Decreasing stack
"largest rectangle in histogram"Increasing stack (boundary trick)
"trapping rain water" (O(1) variant)Two pointer (hoặc Monotonic)
"sliding window max/min"Monotonic Deque (Phần 4)
Quy tắc:
Nếu bài có thể giải brute force O(n²) bằng cách "với mỗi i, tìm j > i thoả X" → 80% là Monotonic Stack. Bí quyết: tự hỏi "với mỗi phần tử, mình đang tìm phần tử lớn/nhỏ hơn gần nhất theo hướng nào?"
3.3

3.3 Worked Example 4 — Daily Temperatures

Đề (LeetCode #739): Mảng temps. Trả mảng res với res[i] = số ngày phải chờ đến ngày có nhiệt độ cao hơn. 0 nếu không có.

Ví dụ: [73,74,75,71,69,72,76,73][1,1,4,2,1,1,0,0].

Brute force

Với mỗi i, scan j > i tìm temp lớn hơn → O(n²). Với n = 10⁵ → TLE.

Monotonic Stack (Decreasing)

def dailyTemperatures(temps):
    res = [0] * len(temps)
    stack = []                          # lưu index
    for i, t in enumerate(temps):
        while stack and temps[stack[-1]] < t:
            j = stack.pop()
            res[j] = i - j              # tìm thấy "next greater"
        stack.append(i)
    return res

Time O(n), Space O(n).

3.4

3.4 Daily Temperatures — Trace tay

temps = [73, 74, 75, 71, 69, 72, 76, 73]

itStack trướcPop & gánStack sau pushres sau
073[][0][0,0,0,0,0,0,0,0]
174[0]pop 0, res[0]=1[1][1,0,0,0,0,0,0,0]
275[1]pop 1, res[1]=1[2][1,1,0,0,0,0,0,0]
371[2][2,3][1,1,0,0,0,0,0,0]
469[2,3][2,3,4][1,1,0,0,0,0,0,0]
572[2,3,4]pop 4 (res[4]=1), pop 3 (res[3]=2)[2,5][1,1,0,2,1,0,0,0]
676[2,5]pop 5 (res[5]=1), pop 2 (res[2]=4)[6][1,1,4,2,1,1,0,0]
773[6][6,7][1,1,4,2,1,1,0,0]

Index còn trên stack ([6,7]) là không có "next greater" → res = 0.

3.5

3.5 Vì sao Monotonic Stack là O(n)? Phân tích amortised

Lo lắng: vòng while bên trong for có thể tốn nhiều bước → tổng có thể O(n²)?

Lập luận

Mỗi index i:

Tổng số thao tác push + pop qua n vòng for ≤ 2n. Do đó tổng thời gian là O(n) bất kể vòng while có vẻ tốn nhiều.

$$ T(n) = O(\text{push}_\text{total}) + O(\text{pop}_\text{total}) \leq O(2n) = O(n) $$
Pattern phân tích này quen rồi:
Tuần 3 (Longest Consecutive Sequence), Tuần 4 (Sliding Window) đều dùng cùng tinh thần amortised. Đây là kỹ năng phải thuộc để giải thích phỏng vấn.
3.6

3.6 Worked Example 5 — Next Greater Element II (Circular)

Đề (LeetCode #503): Mảng circular (vòng tròn). Tìm next greater cho mỗi phần tử. Nếu không có, trả -1.

Ví dụ: [1,2,1][2,-1,2] (phần tử cuối quay lại từ đầu).

Mẹo "duyệt mảng 2 lần"

Để mô phỏng vòng tròn, duyệt index từ 0 đến 2n−1, dùng i % n để truy cập:

def nextGreaterElements(nums):
    n = len(nums)
    res = [-1] * n
    stack = []
    for i in range(2 * n):
        x = nums[i % n]
        while stack and nums[stack[-1]] < x:
            res[stack.pop()] = x
        if i < n:                          # chỉ push trong vòng đầu
            stack.append(i)
    return res

Time O(n), Space O(n).

Mẹo "duyệt 2 lần":
Áp dụng cho mọi bài circular array — duyệt 2n phần tử là đủ để phần tử cuối "thấy" được phần tử đầu. Mẹo này gặp lại ở Tuần 7 (Binary Search trên rotated array).
3.7

3.7 Worked Example 6 — Largest Rectangle in Histogram

Đề (LeetCode #84, Hard): Mảng heights các cột. Tìm hình chữ nhật diện tích lớn nhất chứa trong histogram.

Ví dụ: [2,1,5,6,2,3] → 10 (cột 5 và 6 cao 5 → 5×2 = 10).

Quan sát then chốt

Hình chữ nhật lớn nhất "đặt trên" cột i có:

"Cột thấp hơn gần nhất" = bài Next Smaller. Dùng Monotonic Increasing Stack.

def largestRectangleArea(heights):
    stack = []                              # index, monotonic increasing
    best = 0
    heights.append(0)                       # sentinel để đẩy hết stack ở cuối
    for i, h in enumerate(heights):
        while stack and heights[stack[-1]] > h:
            top = stack.pop()
            left = stack[-1] if stack else -1
            width = i - left - 1
            best = max(best, heights[top] * width)
        stack.append(i)
    return best
3.8

3.8 Largest Rectangle — Phân tích chi tiết

Vai trò sentinel heights.append(0)

Sau vòng for thường, stack có thể còn phần tử (các cột chưa "gặp cột thấp hơn"). Append 0 ở cuối buộc mọi cột pop ra để xử lý. Không có sentinel sẽ cần xử lý sau vòng for.

Vì sao width = i - left - 1?

Khi pop top:

Trace với [2,1,5,6,2,3,0] (đã append sentinel)

ihPop nào?widthareabest
020
11pop 0 (h=2)1−(−1)−1=12×1=22
252
362
42pop 3 (h=6); pop 2 (h=5)1; 26; 1010
5310
60pop tất cả......10
3.9

3.9 Worked Example 7 — Trapping Rain Water (Monotonic Stack)

Tuần 2 đã giải #42 bằng Prefix Max O(n) time, O(n) space. Giờ giải bằng Monotonic Stack — đây là approach phổ biến trong phỏng vấn.

Ý tưởng — "Vũng nước" giữa hai cột cao

Duy trì decreasing stack các index. Khi gặp cột cao hơn đỉnh stack, phần "vũng" được tính:

def trap(height):
    stack = []
    water = 0
    for i, h in enumerate(height):
        while stack and height[stack[-1]] < h:
            bottom = stack.pop()
            if not stack: break
            left = stack[-1]
            width = i - left - 1
            bounded_height = min(height[left], h) - height[bottom]
            water += width * bounded_height
        stack.append(i)
    return water

Time O(n), Space O(n) — cùng O(n) như Prefix Max nhưng tinh thần khác.

Ba cách giải #42:
Prefix Max (Tuần 2, intuitive), Monotonic Stack (tuần này, phỏng vấn ưa), và Two Pointer O(1) space (xem 3.10). Biết cả ba thể hiện chiều sâu kỹ thuật.
3.10

3.10 Trapping Rain Water — Two Pointer O(1) Space

Approach thứ ba, O(1) space, kết hợp tinh thần Container With Most Water (Tuần 2):

def trap(height):
    left, right = 0, len(height) - 1
    left_max = right_max = 0
    water = 0
    while left < right:
        if height[left] < height[right]:
            if height[left] >= left_max:
                left_max = height[left]
            else:
                water += left_max - height[left]
            left += 1
        else:
            if height[right] >= right_max:
                right_max = height[right]
            else:
                water += right_max - height[right]
            right -= 1
    return water

Trực giác

Tại mỗi bước, dịch pointer của bên thấp hơn — vì bên đó "biết" nước có thể đọng đến đâu (giới hạn bởi left_max hoặc right_max đã thấy). Bên cao hơn vẫn còn "chưa chắc" — không thể quyết định.

Cùng tinh thần "luôn dịch cột thấp hơn" như Container With Most Water.

3.Q

Knowledge Check — Monotonic Stack

Q1: Monotonic Stack giảm O(n²) → O(n) bằng cơ chế nào?
Đây là phân tích amortised. Vòng while có thể tốn nhiều bước trong một vòng for, nhưng tổng số bước qua tất cả các vòng for ≤ 2n. Cùng tinh thần với Sliding Window và Longest Consecutive Sequence.
Q2: Trong Largest Rectangle in Histogram, vai trò của heights.append(0)?
Sentinel = 0 chắc chắn nhỏ hơn mọi heights[i] hợp lệ → khi gặp nó, vòng while sẽ pop hết stack. Không có sentinel sẽ cần xử lý phần dư stack sau vòng for, code dài hơn.
Q3: Bài "circular array" giải bằng kỹ thuật nào trong Monotonic Stack?
Duyệt 2n bước với i % n đảm bảo phần tử cuối "thấy" được phần tử đầu. Chỉ push index trong vòng đầu (i < n) để tránh push trùng. Mẹo này gặp lại ở Binary Search trên rotated array (Tuần 7).

Phần 4. Monotonic Deque

Khi cần monotonic structure trên cửa sổ trượt — bài "boss" của tuần
4.1

4.1 Vì sao cần Deque, không phải Stack?

Monotonic Stack chỉ thao tác một đầu (top). Nhưng có bài cần thao tác cả hai đầu:

Đây là khi collections.deque phát huy: O(1) cả hai đầu.

Kết hợp Sliding Window (Tuần 4) + Monotonic — bài Sliding Window Maximum

Bài này là "boss" của tuần — kết hợp 2 tuần kỹ thuật:

4.2

4.2 Worked Example 8 — Sliding Window Maximum

Đề (LeetCode #239, Hard): Mảng nums, số k. Trả mảng max của mọi cửa sổ size k.

Constraints: 1 ≤ n ≤ 10⁵ → cần O(n).

Brute force

Với mỗi cửa sổ, tìm max → O(n × k). Với n,k = 10⁵ → TLE.

Monotonic Decreasing Deque

from collections import deque
def maxSlidingWindow(nums, k):
    dq = deque()                       # lưu index, decreasing theo nums[index]
    res = []
    for i, x in enumerate(nums):
        # 1. Bỏ index ngoài cửa sổ ở đầu trái
        while dq and dq[0] < i - k + 1:
            dq.popleft()
        # 2. Duy trì monotonic decreasing — pop các phần tử nhỏ hơn x ở phải
        while dq and nums[dq[-1]] < x:
            dq.pop()
        # 3. Thêm i ở phải
        dq.append(i)
        # 4. Đầu trái = max của cửa sổ hiện tại
        if i >= k - 1:
            res.append(nums[dq[0]])
    return res

Time O(n), Space O(k).

4.3

4.3 Sliding Window Maximum — Trace tay

nums = [1,3,-1,-3,5,3,6,7], k = 3. Kỳ vọng: [3,3,5,5,6,7].

ixBỏ ngoài cửa sổ?Pop phảidq saures
01[0]
13pop 0 (1<3)[1]
2-1[1,2]3
3-3[1,2,3]3
45popleft 1 (1<2)pop 3 (-3<5), pop 2 (-1<5)[4]5
53[4,5]5
66pop 5 (3<6), pop 4 (5<6)[6]6
77pop 6 (6<7)[7]7

Kết quả: [3,3,5,5,6,7].

Triết lý "mạnh hơn và mới hơn":
Khi push x mới, mọi phần tử nhỏ hơn x ở đuôi đều bị loại — vì với x mới hơn và lớn hơn, chúng không bao giờ là max nữa. Đây là tinh thần "loại đối thủ yếu hơn" giống greedy của Container With Most Water.

Phần 5. Queue Patterns & BFS Preparation

Cầu nối sang Tuần 10 — BFS sẽ dùng queue rất nhiều
5.1

5.1 Pattern Queue + BFS (Preview)

Mô hình BFS dùng Queue:
from collections import deque
q = deque([start])
visited = {start}
while q:
    node = q.popleft()
    for neighbor in neighbors(node):
        if neighbor not in visited:
            visited.add(neighbor)
            q.append(neighbor)

FIFO của queue đảm bảo BFS thăm nodes theo thứ tự khoảng cách từ start tăng dần. Đây là nền tảng của:

Tuần 10 sẽ đào sâu — tuần này chỉ giới thiệu để sinh viên không bỡ ngỡ với deque.

5.2

5.2 Worked Example 9 — Implement Queue using Stacks

Đề (LeetCode #232): Cài queue chỉ dùng 2 stack. push, pop, peek, empty. poppeek cần O(1) amortised.

class MyQueue:
    def __init__(self):
        self.in_stack = []
        self.out_stack = []

    def push(self, x):
        self.in_stack.append(x)

    def pop(self):
        self._move()
        return self.out_stack.pop()

    def peek(self):
        self._move()
        return self.out_stack[-1]

    def empty(self):
        return not self.in_stack and not self.out_stack

    def _move(self):
        if not self.out_stack:
            while self.in_stack:
                self.out_stack.append(self.in_stack.pop())

Phân tích amortised

Mỗi phần tử được "di chuyển" giữa hai stack tối đa 1 lần (in → out). Push O(1) thường; pop/peek O(1) amortised vì khi _move tốn O(n), nó chỉ xảy ra khi out_stack rỗng.

Bài học design:
Cùng tinh thần Min Stack — dùng cấu trúc phụ thay đổi cách dữ liệu được lưu để chuyển từ FIFO sang LIFO. Đây là pattern xuất hiện trong system design (event sourcing, lazy evaluation).
6.0

Tổng kết Tuần 6

Đã học

Bài về nhà (12 bài)

  1. LeetCode #20 — Valid Parentheses
  2. LeetCode #155 — Min Stack
  3. LeetCode #150 — Evaluate Reverse Polish Notation
  4. LeetCode #739 — Daily Temperatures
  5. LeetCode #496 — Next Greater Element I
  6. LeetCode #503 — Next Greater Element II (circular)
  7. LeetCode #84 — Largest Rectangle in Histogram (Hard)
  8. LeetCode #42 — Trapping Rain Water (cả 3 approach)
  9. LeetCode #239 — Sliding Window Maximum (Hard, boss)
  10. LeetCode #232 — Implement Queue using Stacks
  11. LeetCode #225 — Implement Stack using Queues (chiều ngược)
  12. LeetCode #901 — Online Stock Span (monotonic stack streaming)

Tuần 7 sẽ học gì?

Binary Search — Trên Array và Trên Answer Space

Pattern cổ điển nhưng cực mạnh — biến O(n) thành O(log n). Học template chuẩn để tránh off-by-one, Binary Search trên rotated array (mẹo duyệt 2 lần áp dụng tiếp), và đặc biệt là kỹ thuật Binary Search trên answer space để giải Capacity to Ship Packages, Koko Eating Bananas, Median of Two Sorted Arrays. Đây là kỹ thuật nâng cao nhất của tuần và xuất hiện thường xuyên trong phỏng vấn senior.

Hết Tuần 6 — Cảm ơn các em.

Mục lục

Nhấn T hoặc Esc để đóng