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

BST & Heap

Heap (priority queue) giải hiệu quả các bài Top-K, K-way Merge và Median Stream. Kết hợp với BST operations từ Tuần 8 — bộ công cụ hoàn chỉnh cho cấu trúc dữ liệu nâng cao.

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

Phần 1. Nền tảng Heap

Cấu trúc dữ liệu cho phép truy cập "phần tử cực đoan" trong O(1)
1.1

1.1 Heap là gì?

Định nghĩa:
Heap là cây nhị phân hoàn chỉnh thoả tính chất heap property:
Min-heap 1 3 5 7 9 Max-heap 9 7 5 3 1

Heap được lưu dưới dạng mảng: cha tại i, con trái tại 2i+1, con phải tại 2i+2. Mảng nhỏ gọn → ít overhead so với cây thực sự.

1.2

1.2 heapq trong Python — Mẹo cho Max-heap

Module heapq chỉ cài min-heap. Để có max-heap, dùng mẹo nhân âm:

import heapq

# Min-heap
h = []
heapq.heappush(h, 3)
heapq.heappush(h, 1)
heapq.heappush(h, 5)
heapq.heappop(h)        # 1 (nhỏ nhất)
h[0]                    # 3 (peek không pop)

# Max-heap idiom — nhân -1
h = []
heapq.heappush(h, -3)
heapq.heappush(h, -1)
heapq.heappush(h, -5)
-heapq.heappop(h)       # 5 (lớn nhất, đảo dấu khi đọc)

heapify từ list có sẵn

arr = [9, 5, 6, 2, 3]
heapq.heapify(arr)      # In-place, O(n)
arr[0]                  # 2 — phần tử nhỏ nhất sau heapify
Lưu ý heapify là O(n), không phải O(n log n):
Tận dụng "bottom-up sift down". Đây là chi tiết quan trọng — heap n phần tử dựng được trong O(n), nhanh hơn n lần push (O(n log n)). Khi cần dựng heap từ list, luôn dùng heapify.
1.3

1.3 Operations và complexity

OperationComplexityMô tả
heappush(h, x)O(log n)Thêm x, duy trì heap property
heappop(h)O(log n)Lấy & xoá phần tử cực đoan (root)
h[0]O(1)Peek root, không xoá
heapify(arr)O(n)Convert list thành heap in-place
heappushpop(h, x)O(log n)Push x rồi pop — gọn hơn 2 thao tác
nlargest(k, iter)O(n log k)k phần tử lớn nhất
nsmallest(k, iter)O(n log k)k phần tử nhỏ nhất

Heap với tuple — sort theo key custom

heapq.heappush(h, (priority, item))   # heap sort theo priority
heapq.heappush(h, (priority, idx, item))  # tiebreaker = idx khi priority bằng
Mẹo tiebreaker:
Khi priority có thể bằng và item không sortable (như TreeNode, ListNode), thêm idx để tránh "< not supported between TreeNode" error. Đây là pattern bắt buộc cho Merge K Sorted Lists.
1.4

1.4 Khi nào dùng Heap?

Tín hiệu trong đềPattern
"Top K", "K largest/smallest"Heap size K (min-heap cho top-K largest)
"K closest", "K most frequent"Heap size K
"Merge K sorted X"K-way Merge với min-heap
"Find median in stream"Two heaps (max + min)
"Schedule with priority"Priority queue
"Dijkstra's shortest path" (Tuần 10)Min-heap
"Lúc nào cũng cần biết min/max"Heap (ưu tiên) hoặc deque monotonic

Heap vs Sort — khi nào ưu tiên cái nào?

Sort O(n log n) toàn bộ; Heap O(n log K) chỉ cho K phần tử quan tâm — lợi rõ rệt khi K << n.

Phần 2. Top-K Patterns

Kỹ thuật cốt lõi: heap kích thước K để duy trì top-K trong O(n log K)
2.1

2.1 Pattern Top-K với Heap size K

Quy tắc đảo ngược:
Để tìm K lớn nhất, dùng min-heap size K. Để tìm K nhỏ nhất, dùng max-heap size K.

Lý do: heap size K luôn giữ K phần tử "ứng viên". Khi gặp x mới: "Tốt hơn" = lớn hơn (cho top-K largest) hoặc nhỏ hơn (cho top-K smallest).

Template chuẩn

def top_k_largest(nums, k):
    h = []
    for x in nums:
        heapq.heappush(h, x)
        if len(h) > k:
            heapq.heappop(h)             # bỏ phần tử nhỏ nhất ngoài top-K
    return h                              # K phần tử lớn nhất, không sort

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

2.2

2.2 Worked Example 1 — Kth Largest Element in an Array

Đề (LeetCode #215): Tìm phần tử lớn thứ k trong mảng (không khác nhau).

Approach 1 — Sort

def findKthLargest(nums, k):
    return sorted(nums)[-k]
# Time O(n log n)

Approach 2 — Min-heap size K (chuẩn)

def findKthLargest(nums, k):
    h = []
    for x in nums:
        heapq.heappush(h, x)
        if len(h) > k:
            heapq.heappop(h)
    return h[0]                       # nhỏ nhất trong top-K = phần tử thứ k lớn nhất
# Time O(n log K)

Approach 3 — Quick Select (Tuần 8 review)

# Trung bình O(n), worst case O(n²) — partition-based

So sánh: Sort đơn giản nhất, Heap tốt nhất cho stream, Quick Select nhanh nhất trung bình.

2.3

2.3 Kth Largest — Trace tay (Heap approach)

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

Bướcxheap sau pushlen > k?heap sau pop
13[3]1 ≤ 2[3]
22[2, 3]2 ≤ 2[2, 3]
31[1, 3, 2]3 > 2 → pop 1[2, 3]
45[2, 3, 5]3 > 2 → pop 2[3, 5]
56[3, 5, 6]3 > 2 → pop 3[5, 6]
64[4, 6, 5]3 > 2 → pop 4[5, 6]

Trả h[0] = 5 ✓.

Tinh thần:
Heap luôn giữ k phần tử "ứng viên top-K". Phần tử nhỏ nhất trong các ứng viên (= root) là phần tử thứ k lớn nhất nếu mảng đã duyệt hết.
2.4

2.4 Worked Example 2 — Top K Frequent Elements (Heap revisit)

Đề (LeetCode #347): Top K phần tử xuất hiện nhiều nhất.

Tuần 3 đã làm với Bucket Sort O(n). Giờ thêm cách Heap O(n log K):

from collections import Counter
import heapq

def topKFrequent(nums, k):
    cnt = Counter(nums)
    return heapq.nlargest(k, cnt.keys(), key=cnt.get)

Tự cài thay vì nlargest

def topKFrequent(nums, k):
    cnt = Counter(nums)
    h = []
    for num, freq in cnt.items():
        heapq.heappush(h, (freq, num))      # min-heap theo freq
        if len(h) > k:
            heapq.heappop(h)
    return [num for freq, num in h]

So sánh 3 cách

2.5

2.5 Worked Example 3 — K Closest Points to Origin

Đề (LeetCode #973): Mảng các điểm points[i] = [x, y]. Trả K điểm gần gốc toạ độ nhất.

Quan sát

"K nhỏ nhất" theo khoảng cách → dùng max-heap size K. Khi heap quá K, pop phần tử xa nhất.

def kClosest(points, k):
    h = []
    for x, y in points:
        d = x*x + y*y                     # bình phương khoảng cách (đủ để so sánh)
        heapq.heappush(h, (-d, x, y))     # nhân -1 để max-heap
        if len(h) > k:
            heapq.heappop(h)
    return [[x, y] for _, x, y in h]

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

Vì sao bình phương khoảng cách, không sqrt?
Để so sánh thứ tự, không cần giá trị thực. sqrt chậm và có sai số floating-point. Đây là tối ưu nhỏ nhưng quan trọng — luôn áp dụng khi có thể.
2.6

2.6 Worked Example 4 — Last Stone Weight

Đề (LeetCode #1046): Mảng stones. Mỗi vòng, chọn 2 viên nặng nhất: nếu bằng → cả hai biến mất; nếu khác → viên mới = hiệu. Trả về viên cuối còn lại (0 nếu hết).

Pattern: max-heap với simulation

def lastStoneWeight(stones):
    h = [-s for s in stones]              # max-heap idiom
    heapq.heapify(h)                      # O(n)
    while len(h) > 1:
        a = -heapq.heappop(h)             # nặng nhất
        b = -heapq.heappop(h)             # nặng thứ hai
        if a != b:
            heapq.heappush(h, -(a - b))
    return -h[0] if h else 0

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

Idiom max-heap qua nhân -1:
Thấy đoạn code dùng -x push vào và -heapq.heappop(h) đọc ra → đây là max-heap. Pattern xuất hiện ở nhiều bài: Reorganize String, Task Scheduler, Maximum Performance of a Team.
2.Q

Knowledge Check — Top-K Patterns

Q1: Để tìm K phần tử LỚN NHẤT, dùng heap loại nào?
"Quy tắc đảo ngược": min-heap size K cho top-K largest. Min-heap có root = phần tử nhỏ nhất trong K ứng viên — đó là ngưỡng để loại các giá trị nhỏ hơn. Dùng max-heap size K thì root = lớn nhất, không phải ngưỡng cần.
Q2: heapify(arr) có complexity gì?
Tận dụng "bottom-up sift down". Phân tích chi tiết: tổng số bước sift down qua mọi tầng tỷ lệ với n (chứ không phải n × log n). Đây là chi tiết quan trọng — luôn dùng heapify thay vì n lần push.
Q3: Heap O(n log K) tốt hơn Sort O(n log n) khi nào?
Khi K = n, heap O(n log n) tương đương sort. Lợi rõ khi K nhỏ (K = 10, n = 10⁶). Cũng lợi với stream — heap cập nhật từng phần tử mới mà không cần biết toàn bộ trước.

Phần 3. K-way Merge

Merge K nguồn đã sort thành 1 dãy sort — pattern then chốt cho data pipeline
3.1

3.1 Pattern K-way Merge

Mô hình:
Cho K mảng/list đã sort, tổng N phần tử. Merge thành 1 sequence sort.

Tinh thần

Tại mỗi bước, lấy nhỏ nhất trong K đầu hiện tại, đẩy vào kết quả, đẩy đầu mới của nguồn đó vào heap.

Khi nào dùng?

3.2

3.2 Worked Example 5 — Merge K Sorted Lists (Hard)

Đề (LeetCode #23, Hard): Cho k linked list đã sort. Merge thành 1 list sort.

import heapq
def mergeKLists(lists):
    h = []
    # Push (val, idx, node) — idx làm tiebreaker để tránh "< not supported"
    for i, head in enumerate(lists):
        if head:
            heapq.heappush(h, (head.val, i, head))

    dummy = ListNode(0)
    tail = dummy
    while h:
        val, i, node = heapq.heappop(h)
        tail.next = node
        tail = tail.next
        if node.next:
            heapq.heappush(h, (node.next.val, i, node.next))
    return dummy.next

Time O(N log K) với N = tổng node, K = số list.

Phân tích

Heap tối đa K phần tử. Mỗi node được push 1 lần và pop 1 lần → tổng N × O(log K).

3.3

3.3 Merge K Sorted Lists — Trace

3 list: [1→4→5], [1→3→4], [2→6].

BướcHeapPop (val, idx)Push tiếpOutput
0[(1,0,...), (1,1,...), (2,2,...)]
1(1, 0)(4, 0, ...)1
2(1, 1)(3, 1, ...)1, 1
3(2, 2)(6, 2, ...)1, 1, 2
4(3, 1)(4, 1, ...)1, 1, 2, 3
5(4, 0)(5, 0, ...)1, 1, 2, 3, 4
6(4, 1)1, 1, 2, 3, 4, 4
7(5, 0)1, 1, 2, 3, 4, 4, 5
8(6, 2)1, 1, 2, 3, 4, 4, 5, 6

Hết heap → trả output. Heap không vượt 3 phần tử.

Vai trò của idx (tiebreaker):
Khi val bằng nhau (1 ở list 0 và list 1), Python tries so sánh node — không có __lt__ → lỗi. idx duy nhất đảm bảo so sánh không bao giờ chạm tới node.
3.4

3.4 Worked Example 6 — Find K Smallest Pairs

Đề (LeetCode #373): Hai mảng sort nums1, nums2, số k. Trả K cặp (u, v) có tổng nhỏ nhất.

Quan sát

Tổng số cặp = n × m, có thể rất lớn. Brute force: tạo mọi cặp + sort → O(nm log(nm)). Cần tốt hơn.

Min-heap với "expand" thông minh

def kSmallestPairs(nums1, nums2, k):
    if not nums1 or not nums2: return []
    h = []
    # Khởi tạo: ghép nums1[i] với nums2[0], i ∈ [0, min(k, len(nums1)))
    for i in range(min(k, len(nums1))):
        heapq.heappush(h, (nums1[i] + nums2[0], i, 0))

    res = []
    while h and len(res) < k:
        s, i, j = heapq.heappop(h)
        res.append([nums1[i], nums2[j]])
        if j + 1 < len(nums2):
            heapq.heappush(h, (nums1[i] + nums2[j+1], i, j+1))
    return res

Time O(K log K), Space O(K).

Tinh thần "expand frontier":
Heap chỉ chứa "biên giới ứng viên" — mỗi lần pop một cặp (i, j), thêm cặp (i, j+1) vào. Pattern xuất hiện ở nhiều bài "tìm K nhỏ nhất từ ma trận sort" như Kth Smallest in Sorted Matrix.
3.Q

Knowledge Check — K-way Merge

Q1: Trong Merge K Sorted Lists, vì sao heap chỉ chứa tối đa K phần tử?
Logic: pop một node → push node kế tiếp của CHÍNH list đó → kích thước heap không đổi (hoặc giảm khi list cạn). Vì mỗi list chỉ có 1 đại diện trong heap → tối đa K đại diện.
Q2: Tại sao cần idx tiebreaker trong heap chứa ListNode?
Tuple được so sánh từng phần tử. Khi (val, node) và (val', node') có val = val', Python so sánh node với node' → không có operator → lỗi. Chèn idx đảm bảo so sánh dừng tại idx (luôn khác nhau).

Phần 4. Two Heaps Pattern

Max-heap + Min-heap cân bằng — pattern cho Median Stream và bài "boss" của tuần
4.1

4.1 Pattern Two Heaps

Ý tưởng:
Chia tập số thành hai nửa: Duy trì kích thước cân bằng: |lo| = |hi| hoặc |lo| = |hi| + 1.

Tính chất

Pattern này áp dụng cho mọi bài "duy trì median động khi dữ liệu đến từng bước".

4.2

4.2 Worked Example 7 — Find Median from Data Stream (Hard)

Đề (LeetCode #295, Hard): Cài data structure hỗ trợ:

import heapq
class MedianFinder:
    def __init__(self):
        self.lo = []                            # max-heap (nhân -1)
        self.hi = []                            # min-heap

    def addNum(self, num):
        # Bước 1: đẩy vào lo, sau đó lấy top lo đẩy sang hi
        heapq.heappush(self.lo, -num)
        heapq.heappush(self.hi, -heapq.heappop(self.lo))
        # Bước 2: nếu hi lớn hơn → cân bằng lại
        if len(self.hi) > len(self.lo):
            heapq.heappush(self.lo, -heapq.heappop(self.hi))

    def findMedian(self):
        if len(self.lo) > len(self.hi):
            return -self.lo[0]
        return (-self.lo[0] + self.hi[0]) / 2

addNum O(log n), findMedian O(1).

4.3

4.3 Median Stream — Trace tay

Stream: 1, 2, 3, 4, 5

AddBước 1: lo→hi qua đỉnhCân bằnglo (max-heap)hi (min-heap)Median
1push 1 vào lo, top lo (1) → hihi > lo → trả 1 về lo[1][]1
2push 2 vào lo (top=2), 2 → hicân bằng (1=1)[1][2](1+2)/2 = 1.5
3push 3 vào lo (top=3), 3 → hihi > lo → 2 về lo[2, 1][3]2
4push 4 vào lo (top=4), 4 → hicân bằng (2=2)[2, 1][3, 4](2+3)/2 = 2.5
5push 5 vào lo (top=5), 5 → hihi > lo → 3 về lo[3, 1, 2][4, 5]3
Vì sao bước 1 luôn "vòng qua hi"?
Để đảm bảo invariant: mọi phần tử trong lo ≤ mọi phần tử trong hi. Nếu push thẳng vào lo (không vòng qua), num có thể lớn hơn min của hi → vi phạm thứ tự.

Phần 5. BST Operations

Insert, Delete, và bài Kth Smallest tận dụng inorder traversal
5.1

5.1 BST Operations — Tóm tắt

OperationAverageWorst
SearchO(log n)O(n)
InsertO(log n)O(n)
DeleteO(log n)O(n)
Inorder traversalO(n)O(n)

Worst case = O(n) khi cây bị skewed (mỗi node chỉ có 1 con). Balanced BST (AVL, Red-Black) đảm bảo O(log n) — nhưng LeetCode ít khi yêu cầu cài.

Search và Insert — đơn giản

def search(root, target):
    if not root or root.val == target: return root
    if target < root.val:
        return search(root.left, target)
    return search(root.right, target)

Đệ quy theo nhánh duy nhất → O(h) với h = chiều cao cây.

5.2

5.2 Worked Example 8 — Kth Smallest in BST

Đề (LeetCode #230): Trả phần tử nhỏ thứ k trong BST.

Quan sát then chốt

Inorder của BST cho dãy sort tăng dần (Tuần 8). Phần tử thứ k của inorder = đáp án.

Approach 1 — Inorder đầy đủ

def kthSmallest(root, k):
    res = []
    def inorder(node):
        if not node: return
        inorder(node.left)
        res.append(node.val)
        inorder(node.right)
    inorder(root)
    return res[k - 1]
# Time O(n), Space O(n) — không tận dụng "dừng sớm"

Approach 2 — Inorder iterative với early stop

def kthSmallest(root, k):
    stack = []
    cur = root
    while cur or stack:
        while cur:
            stack.append(cur)
            cur = cur.left
        cur = stack.pop()
        k -= 1
        if k == 0: return cur.val
        cur = cur.right
# Time O(h + k), Space O(h)
5.3

5.3 Worked Example 9 — Insert into BST

Đề (LeetCode #701): Thêm node giá trị val vào BST. Trả root cây mới.

def insertIntoBST(root, val):
    if not root: return TreeNode(val)            # đến vị trí trống → tạo node
    if val < root.val:
        root.left = insertIntoBST(root.left, val)
    else:
        root.right = insertIntoBST(root.right, val)
    return root

Time O(h), Space O(h).

Phân tích leap of faith

Tin rằng insertIntoBST(root.left, val) trả về cây con trái với val đã chèn. Gán lại root.left = ... để cây cha "biết" thay đổi. Đây là pattern "trả về node đã sửa, gán lại" — xuất hiện ở Insert/Delete BST.

Vì sao gán root.left = insertIntoBST(...) chứ không gọi không gán?
Khi cây con trái rỗng (None), insertIntoBST tạo node mới và trả về. Cha phải gán node này vào root.left. Không gán → node mới bị bỏ rơi.
5.4

5.4 Worked Example 10 — Delete from BST

Đề (LeetCode #450): Xoá node có key trong BST. Trả root cây mới.

Ba case quan trọng khi xoá

  1. Node là leaf: xoá thẳng (return None lên).
  2. Node có 1 con: thay node bằng con đó.
  3. Node có 2 con: thay bằng inorder successor (nhỏ nhất bên phải) hoặc inorder predecessor (lớn nhất bên trái), rồi đệ quy xoá successor đó.
def deleteNode(root, key):
    if not root: return None
    if key < root.val:
        root.left = deleteNode(root.left, key)
    elif key > root.val:
        root.right = deleteNode(root.right, key)
    else:                                          # node cần xoá
        if not root.left: return root.right        # case 1+2
        if not root.right: return root.left        # case 1+2
        # Case 3: tìm successor
        succ = root.right
        while succ.left: succ = succ.left
        root.val = succ.val                        # copy giá trị
        root.right = deleteNode(root.right, succ.val)   # xoá successor
    return root

Time O(h), Space O(h).

5.Q

Knowledge Check — Two Heaps & BST

Q1: Trong Median Stream, vì sao addNum luôn "vòng qua" heap còn lại?
Push thẳng num vào lo có thể vi phạm thứ tự (num lớn hơn min của hi). Push num → lo, top lo → hi đảm bảo phần tử "vào đúng vị trí" trong thứ tự tổng. Sau đó cân bằng kích thước.
Q2: Trong Delete BST, case "node có 2 con" cần làm gì?
Inorder successor = phần tử nhỏ nhất lớn hơn node hiện tại. Thay vào sẽ giữ tính chất BST. Sau đó successor cần xoá ở vị trí cũ — nhưng successor luôn có 0 hoặc 1 con (case dễ), nên đệ quy gọn.
Q3: Kth Smallest in BST cách nào tốt nhất khi k << n?
Inorder đầy đủ luôn O(n) dù k nhỏ. Iterative với stack cho phép dừng ngay khi đến phần tử k → O(h + k). Khi cây balanced và k = 1, chỉ tốn O(log n).
6.0

6.0 Decision Tree — Khi nào dùng Heap?

Tín hiệu trong đềPattern
"Top K largest" với n lớn / streamMin-heap size K
"Top K smallest"Max-heap size K (idiom -x)
"K most frequent"Counter + Heap (hoặc Bucket Sort O(n))
"K closest points"Max-heap size K theo khoảng cách bình phương
"Merge K sorted lists/arrays"K-way Merge với min-heap chứa "đầu hiện tại"
"K smallest pairs/sums"Min-heap với "expand frontier"
"Median in stream"Two heaps (max + min) cân bằng
"Schedule with priority"Priority queue
"Find Kth in BST"Inorder iterative với early stop
"Kth largest in array (one-shot)"Quick Select O(n) trung bình hoặc Heap O(n log K)

Quy tắc chọn nhanh

Tự hỏi: "Mỗi bước có cần biết phần tử cực đoan của tập đã thấy không?" Có → Heap.

6.1

Tổng kết Tuần 9

Đã học

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

  1. LeetCode #215 — Kth Largest Element in an Array (3 approach)
  2. LeetCode #347 — Top K Frequent Elements (heap approach)
  3. LeetCode #973 — K Closest Points to Origin
  4. LeetCode #1046 — Last Stone Weight
  5. LeetCode #23 — Merge K Sorted Lists (Hard)
  6. LeetCode #373 — Find K Pairs with Smallest Sums
  7. LeetCode #295 — Find Median from Data Stream (Hard, boss)
  8. LeetCode #621 — Task Scheduler (max-heap simulation)
  9. LeetCode #230 — Kth Smallest Element in a BST
  10. LeetCode #701 — Insert into a Binary Search Tree
  11. LeetCode #450 — Delete Node in a BST
  12. LeetCode #703 — Kth Largest Element in a Stream (online)

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

Graph — BFS, DFS, Connected Components, Topological Sort

Cấu trúc dữ liệu cuối cùng cần làm chủ. Học cách biểu diễn graph (adjacency list, matrix), duyệt graph qua BFS (queue đã học) và DFS (recursion đã học), tìm thành phần liên thông qua Union-Find, và Topological Sort cho graph có hướng không chu kỳ. Bài "boss": Course Schedule, Word Ladder, Number of Islands. Min-heap (Tuần 9) sẽ tái xuất hiện ở Dijkstra's Shortest Path.

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

Mục lục

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