Chương 15 - Heap
Heap (Priority Queue) trả lời nhanh câu hỏi “phần tử lớn/nhỏ nhất bây giờ là gì?”. Hai thao tác cốt lõi
pushvàpopđềuO(log n). Heap giải gọn các bài: top-k, median trên data stream, merge k lists, scheduling. Pattern phổ biến nhất: heap kích thước k để duy trì k phần tử tốt nhất.
Mục tiêu chương
Sau chương này, bạn sẽ:
- Pattern heap size k → top k phần tử
O(n log k). - Pattern 2 heap (low max-heap + high min-heap) cho median.
- K-way merge với heap chứa head mỗi list.
- Biết max-heap trong Python = push
-x(vìheapqlà min-heap).
Khi nào dùng pattern này?
- Cần lấy top k liên tục từ stream (Top K, Kth Largest).
- Cần lấy min/max động khi insert/delete (Median, Sliding Window Max - nhưng cái sau dùng monotonic deque, Chương 18).
- K-way merge (gộp
klist đã sort). - Greedy với priority (Task Scheduler, Reorganize String, Meeting Rooms).
Python heapq chỉ có min-heap. Muốn max-heap → push -x.
Template code
import heapq
# 1) Min-heap cơ bản
heap: list[int] = []
heapq.heappush(heap, x)
top = heap[0] # peek (không pop)
val = heapq.heappop(heap)
# 2) Max-heap bằng cách negate
heapq.heappush(heap, -x)
val = -heapq.heappop(heap)
# 3) K phần tử nhỏ nhất / lớn nhất từ list
smallest_k = heapq.nsmallest(k, arr) # O(n log k)
largest_k = heapq.nlargest(k, arr)
# 4) Heapify O(n) - nhanh hơn n lần push
heapq.heapify(arr)
# 5) Heap với key tuỳ ý - push tuple (priority, payload)
heapq.heappush(heap, (priority, payload))
Bài tự luyện cuối chương
- LC 378 - Kth Smallest Element in a Sorted Matrix
- LC 451 - Sort Characters By Frequency
- LC 502 - IPO
- LC 767 - Reorganize String
- LC 1046 - Last Stone Weight
- LC 1167 - Minimum Cost to Connect Sticks
15.1 Kth Largest Element in an Array (LC 215)
Đề bài
Cho mảng nums và số k. Trả về phần tử lớn thứ k (1-indexed, theo thứ tự giảm dần). Có duplicate.
Ví dụ
Input: nums = [3, 2, 1, 5, 6, 4], k = 2 → 5
Input: nums = [3, 2, 3, 1, 2, 4, 5, 5, 6], k = 4 → 4
Ràng buộc
- 1 <= k <= len(nums) <= 10^5
- -10^4 <= nums[i] <= 10^4
Clarifying questions
- k > len(nums)? → Theo đề: 1 ≤ k ≤ len.
- Duplicate? → Có (Kth largest tính cả duplicate).
Hướng tiếp cận
Cách 1 - Sort, O(n log n). sorted(nums)[-k]. Đơn giản nhất.
Cách 2 - Min-heap kích thước k, O(n log k).
Duyệt mảng, đẩy vào heap. Khi heap > k → pop. Cuối cùng heap[0] là Kth largest.
Cách 3 - Quickselect, O(n) trung bình.
Variant của quicksort: partition quanh pivot, recurse chỉ vào nửa chứa Kth.
Code Python 3
import heapq
from typing import List
class Solution:
"""Cách 2 - heap kích thước k."""
def findKthLargest(self, nums: List[int], k: int) -> int:
heap: list[int] = []
for x in nums:
heapq.heappush(heap, x)
if len(heap) > k:
heapq.heappop(heap)
return heap[0]
class SolutionQS:
"""Cách 3 - quickselect, O(n) trung bình."""
def findKthLargest(self, nums: List[int], k: int) -> int:
# Kth largest = (n-k)-th smallest (0-indexed).
target = len(nums) - k
def partition(lo: int, hi: int) -> int:
pivot = nums[hi]
store = lo
for i in range(lo, hi):
if nums[i] < pivot:
nums[store], nums[i] = nums[i], nums[store]
store += 1
nums[store], nums[hi] = nums[hi], nums[store]
return store
lo, hi = 0, len(nums) - 1
while True:
p = partition(lo, hi)
if p == target:
return nums[p]
elif p < target:
lo = p + 1
else:
hi = p - 1
Phân tích độ phức tạp
| Cách | Time | Space |
|---|---|---|
| Sort | O(n log n) | O(1) |
| Heap size k | O(n log k) | O(k) |
| Quickselect | O(n) avg, O(n²) worst | O(1) |
Bình luận
- Trong phỏng vấn: trình bày heap size
ktrước (gọn và hiệu quả khiknhỏ), sau đó nhắc đến quickselect nếu interviewer hỏi “có cáchO(n)không?”. - Quickselect worst case xảy ra khi pivot luôn xấu - có thể tránh bằng random pivot.
Bài tự luyện liên quan
- LC 347 - Top K Frequent Elements (Chương 6).
- LC 973 - K Closest Points to Origin (bài 15.4).
- LC 703 - Kth Largest Element in a Stream.
15.2 Top K Frequent Words (LC 692)
Đề bài
Cho mảng words và số k. Trả về k từ thường gặp nhất. Nếu hoà tần suất, từ nào lexicographically smaller ưu tiên.
Ví dụ
Input: words = ["i", "love", "leetcode", "i", "love", "coding"], k = 2
Output: ["i", "love"]
Giải thích: "i" và "love" cùng tần suất 2. "i" < "love" trong từ điển.
Input: k = 4
Output: ["the", "is", "sunny", "day"] (tie → sort lex)
Ràng buộc
- 1 <= len(words) <= 500
- 1 <= len(words[i]) <= 10
Clarifying questions
- Tie freq + lex bằng nhau? → Lex thì luôn phân biệt (string distinct).
Hướng tiếp cận
Mấu chốt: comparator tie-break - tần suất cao hơn ưu tiên, nếu hoà thì từ lex nhỏ hơn ưu tiên.
Cách 1 - Sort, O(n log n). sorted(cnt.keys(), key=lambda w: (-cnt[w], w))[:k].
Cách 2 - Min-heap kích thước k, O(n log k).
Heap chứa tuple (freq, word). Vì heap min, ta muốn: - Tần suất thấp ở top (pop trước) → push thẳng freq. - Hoà → từ lex lớn hơn ở top (pop trước) → push word.
Trick: với từng tuple, ta dùng (-freq, word) rồi sort… không, dùng custom wrapper cũng được. Cách 1 đơn giản hơn nhiều.
Code Python 3
import heapq
from collections import Counter
from typing import List
class Solution:
"""Cách 1 - sort. Sạch nhất cho bài này."""
def topKFrequent(self, words: List[str], k: int) -> List[str]:
cnt = Counter(words)
return sorted(cnt.keys(), key=lambda w: (-cnt[w], w))[:k]
class SolutionHeap:
"""Cách 2 - min-heap kích thước k."""
def topKFrequent(self, words: List[str], k: int) -> List[str]:
cnt = Counter(words)
# Trong min-heap, muốn pop ra cái "thua kém" trước.
# "Thua kém" = freq thấp hơn HOẶC (freq bằng + word lex lớn hơn).
# Dùng tuple (freq, -word_chars) thì không tiện vì word là string.
# Cách: dùng wrapper class.
class Wrap:
__slots__ = ("freq", "word")
def __init__(self, f, w): self.freq, self.word = f, w
def __lt__(self, other):
if self.freq != other.freq:
return self.freq < other.freq # min-heap → freq nhỏ ở top
return self.word > other.word # tie → word lex LỚN ở top
def __eq__(self, other):
return (self.freq, self.word) == (other.freq, other.word)
heap: list[Wrap] = []
for w, f in cnt.items():
heapq.heappush(heap, Wrap(f, w))
if len(heap) > k:
heapq.heappop(heap)
# Pop ra → ngược lại để có thứ tự đúng.
return [heapq.heappop(heap).word for _ in range(k)][::-1]
Phân tích độ phức tạp
- Sort:
O(n log n). Heap:O(n log k).
Bình luận
- Tại sao sort thắng heap ở bài này về sự rõ ràng? Comparator tie-break trên string khó express ngắn gọn trong heap (vì heapq dùng
__lt__mặc định, không cho key=). - Pattern wrapper với
__lt__tuỳ biến rất hữu ích - dùng được cho mọi trường hợp comparator phức tạp trên heap.
Bài tự luyện liên quan
- LC 347 - Top K Frequent Elements.
- LC 451 - Sort Characters By Frequency.
15.3 Find Median from Data Stream (LC 295)
Đề bài
Thiết kế class: - addNum(num): thêm num vào data stream. - findMedian(): trả về median hiện tại.
Median của n số: phần tử giữa (sorted), hoặc trung bình 2 phần tử giữa nếu n chẵn.
Ví dụ
Input (LC-style operation arrays):
ops = ["MedianFinder", "addNum", "addNum", "findMedian", "addNum", "findMedian"]
args = [[], [1], [2], [], [3], []]
Output: [null, null, null, 1.5, null, 2.0]
Giải thích:
MedianFinder() → khởi tạo, null
addNum(1) → null; stream = [1]
addNum(2) → null; stream = [1, 2]
findMedian() → 1.5 (mean của 2 phần tử giữa)
addNum(3) → null; stream = [1, 2, 3]
findMedian() → 2.0 (phần tử giữa của 3 số sorted)
Ràng buộc
- -10^5 <= num <= 10^5
- Tối đa 5·10^4 op
Clarifying questions
- Số phần tử lẻ? → Median = phần tử giữa. Chẵn → average.
Hướng tiếp cận
Trick 2 heap: - low = max-heap chứa nửa nhỏ (dưới median). - high = min-heap chứa nửa lớn (trên median).
Invariant: - Mọi phần tử trong low ≤ mọi phần tử trong high. - len(low) == len(high) hoặc len(low) == len(high) + 1.
addNum: 1. Push vào low (max-heap → push -num). 2. Đẩy top của low sang high (rebalance). 3. Nếu len(high) > len(low) → đẩy top của high về lại low.
findMedian: - len(low) > len(high) → low[0] (negated). - Bằng nhau → trung bình của 2 top.
Hình minh hoạ với stream 1, 2, 3:
addNum(1):
push low=-1. low=[-1]
move -low[0]=1 to high. low=[], high=[1]
len(high) > len(low) → move 1 back to low. low=[-1], high=[]
median: low[0]=-(-1)=1.0
addNum(2):
push -2 to low. low=[-1, -2] → heap-balanced [-2, -1] (max-heap → 2 on top)
Hmm cần check: max-heap đơn giản, top = max = 2.
move 2 to high. low=[-1], high=[2]
len(high) <= len(low) OK.
median: (low[0] + high[0]) / 2 = (1 + 2) / 2 = 1.5 ✓
addNum(3):
push -3 to low. After push: low contains -1 and -3.
Top of max-heap = 1 (smallest negated → 1).
move 1 to high. low=[-3] (i.e., values {3}), high=[1, 2]
Hmm, now high has 2 items, low has 1.
Wait that's wrong invariant. Let me redo.
Thực ra trick đúng:
addNum(num):
if not low or num <= -low[0]:
heappush(low, -num)
else:
heappush(high, num)
# rebalance: |len(low) - len(high)| <= 1, low >= high in size
if len(low) > len(high) + 1:
heappush(high, -heappop(low))
elif len(high) > len(low):
heappush(low, -heappop(high))
Code Python 3
import heapq
class MedianFinder:
def __init__(self):
self.low: list[int] = [] # max-heap (negated)
self.high: list[int] = [] # min-heap
def addNum(self, num: int) -> None:
if not self.low or num <= -self.low[0]:
heapq.heappush(self.low, -num)
else:
heapq.heappush(self.high, num)
# Rebalance.
if len(self.low) > len(self.high) + 1:
heapq.heappush(self.high, -heapq.heappop(self.low))
elif len(self.high) > len(self.low):
heapq.heappush(self.low, -heapq.heappop(self.high))
def findMedian(self) -> float:
if len(self.low) > len(self.high):
return float(-self.low[0])
return (-self.low[0] + self.high[0]) / 2
Phân tích độ phức tạp
addNum:O(log n).findMedian:O(1).- Bộ nhớ:
O(n).
Bình luận
- Tại sao 2 heap? Median chia mảng làm 2 - 1 heap mỗi nửa cho
O(1)access vào “ranh giới” giữa 2 nửa. - Pattern 2 heap xuất hiện ở: LC 480 (Sliding Window Median), Kth Largest Stream với khoảng động.
Bài tự luyện liên quan
- LC 480 - Sliding Window Median.
- LC 1825 - Finding MK Average.
- LC 703 - Kth Largest Element in a Stream.
15.4 K Closest Points to Origin (LC 973)
Đề bài
Cho mảng points, mỗi điểm [x, y]. Trả về k điểm gần origin nhất (Euclidean distance).
Ví dụ
Input: points = [[1,3], [-2,2]], k = 1
Output: [[-2, 2]]
Giải thích: dist²(1,3) = 10, dist²(-2,2) = 8.
Ràng buộc
- 1 <= k <= len(points) <= 10^4
- -10^4 <= x, y <= 10^4
Clarifying questions
- Points trùng (x,y)? → Có thể; cùng distance, lấy bất kỳ.
Hướng tiếp cận
Cách heap size k - O(n log k). Max-heap chứa k điểm gần nhất; khi vượt k, pop điểm xa nhất.
Lưu ý: dùng
dist²thay vìsqrt(dist)- tránh float, giữ thứ tự không đổi.
Code Python 3
import heapq
from typing import List
class Solution:
def kClosest(self, points: List[List[int]], k: int) -> List[List[int]]:
heap: list[tuple[int, list[int]]] = [] # (-dist², point) - max-heap
for p in points:
d2 = p[0] ** 2 + p[1] ** 2
heapq.heappush(heap, (-d2, p))
if len(heap) > k:
heapq.heappop(heap)
return [p for _, p in heap]
Phân tích độ phức tạp
- Thời gian:
O(n log k). Bộ nhớ:O(k).
Bình luận
- Quickselect cũng làm được
O(n)trung bình (như bài 15.1), bằng cách partition theodist². - Bẫy float: so sánh
sqrt(d²)thừa tính toán và mất chính xác - luôn so bằngd².
Bài tự luyện liên quan
- LC 215 - Kth Largest Element (bài 15.1).
- LC 658 - Find K Closest Elements (đã sort).
15.5 Merge k Sorted Lists (LC 23)
Đề bài
Input: lists: List[Optional[ListNode]] - mảng k head của k singly linked list đã sort tăng dần. Gộp tất cả thành 1 linked list sort và trả về head mới.
Ví dụ
Input: lists = [1→4→5, 1→3→4, 2→6]
Output: 1→1→2→3→4→4→5→6
Ràng buộc
- 0 <= k <= 10^4
- 0 <= total nodes <= 10^4
Clarifying questions
- Một số list rỗng? → Skip khi push.
- Tất cả null? → Trả None.
Hướng tiếp cận
Cách 1 - Min-heap k-way merge, O(N log k).
Heap chứa (val, idx, node) của head mỗi list. Pop cái nhỏ nhất, push hàng tiếp theo từ cùng list. Lặp đến hết.
idxlà tiebreaker - Python không so sánhListNodeđược khi val bằng.
Cách 2 - Merge từng cặp (Divide & Conquer), O(N log k).
Mergesort kiểu tournament: ghép cặp lists rồi merge, lặp lại. Cùng độ phức tạp.
Code Python 3
import heapq
from typing import List, Optional
class ListNode:
def __init__(self, val=0, next=None):
self.val, self.next = val, next
class Solution:
"""Cách 1 - min-heap k-way merge."""
def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
heap: list[tuple[int, int, ListNode]] = []
for i, head in enumerate(lists):
if head:
heapq.heappush(heap, (head.val, i, head))
dummy = ListNode()
tail = dummy
while heap:
val, i, node = heapq.heappop(heap)
tail.next = node
tail = node
if node.next:
heapq.heappush(heap, (node.next.val, i, node.next))
return dummy.next
Phân tích độ phức tạp
- Thời gian:
O(N log k)vớiN= tổng số node. - Bộ nhớ:
O(k)cho heap.
Bình luận
- Cách divide & conquer lý tưởng khi
krất nhỏ và muốn tránh dependency trên heap. - Bẫy: quên
idxtiebreaker →TypeError: '<' not supported between instances of 'ListNode' and 'ListNode'khi val trùng.
Bài tự luyện liên quan
- LC 21 - Merge Two Sorted Lists.
- LC 264 - Ugly Number II.
- LC 632 - Smallest Range Covering Elements from K Lists.
15.6 Task Scheduler (LC 621)
Đề bài
Cho mảng tasks (chữ cái A-Z) và số n. Mỗi đơn vị thời gian thực hiện 1 task hoặc idle. 2 task giống nhau phải cách nhau ít nhất n đơn vị. Trả về số đơn vị thời gian tối thiểu để hoàn thành tất cả.
Ví dụ
Input: tasks = ["A","A","A","B","B","B"], n = 2
Output: 8
Lịch: A → B → idle → A → B → idle → A → B
Ràng buộc
- 1 <= len(tasks) <= 10^4
- tasks[i] ∈ ‘A’-‘Z’
- 0 <= n <= 100
Clarifying questions
- n = 0? → Vẫn dùng được; LC tasks luôn có ít nhất 1.
- n rất lớn (n > len(tasks))? → Vẫn return được kết quả
= len(tasks).
Hướng tiếp cận
Cách 1 - Greedy + max-heap, O(N log 26).
Mỗi tick: - Lấy task có freq cao nhất (heap top). Decrement freq, đẩy vào “cooldown queue” (end_time, freq).
- Khi
cooldown_queue.front().end_time <= now→ pop và đẩy lại vào heap.
Cách 2 - Công thức trực tiếp, O(N).
Cho f_max = freq cao nhất, count_max = số task có freq này. Đáp án = max(n_total, (f_max - 1) * (n + 1) + count_max).
Trực giác: Task có freq cao nhất tạo ra f_max - 1 khoảng có độ dài n+1, cộng count_max cuối ở “row cuối”.
Code Python 3
from collections import Counter
from typing import List
class Solution:
"""Cách 2 - công thức O(N)."""
def leastInterval(self, tasks: List[str], n: int) -> int:
cnt = Counter(tasks)
f_max = max(cnt.values())
count_max = sum(1 for v in cnt.values() if v == f_max)
slots = (f_max - 1) * (n + 1) + count_max
return max(slots, len(tasks))
import heapq
from collections import deque
class SolutionHeap:
"""Cách 1 - heap + cooldown queue."""
def leastInterval(self, tasks: List[str], n: int) -> int:
heap = [-c for c in Counter(tasks).values()] # max-heap (negated)
heapq.heapify(heap)
cooldown: deque[tuple[int, int]] = deque() # (available_time, neg_count)
time = 0
while heap or cooldown:
time += 1
if heap:
neg = heapq.heappop(heap) + 1 # giảm freq 1 đơn vị
if neg < 0:
cooldown.append((time + n, neg))
if cooldown and cooldown[0][0] == time:
_, neg = cooldown.popleft()
heapq.heappush(heap, neg)
return time
Phân tích độ phức tạp
- Công thức:
O(N). Heap:O(N log 26).
Bình luận
- Tại sao công thức work? Sắp task theo lưới (f_max - 1) hàng × (n + 1) cột, ô cuối có thể có nhiều task share freq cao nhất.
- Pattern heap + cooldown sẽ tái xuất ở các bài “Reorganize String” (LC 767), “Rearrange String K Distance Apart” (LC 358).
Bài tự luyện liên quan
- LC 767 - Reorganize String.
- LC 358 - Rearrange String K Distance Apart.
- LC 1834 - Single-Threaded CPU.
Tóm tắt chương & Quyết định
Heap vs Sort vs Quickselect
| Yêu cầu | Heap O(n log k) | Sort O(n log n) | Quickselect O(n) avg |
|---|---|---|---|
Top-k khi n lớn, k nhỏ | ✅ Tốt nhất | OK | ✅ Khi không cần thứ tự |
| Cần k phần tử đã sắp | ✅ | ✅ | Cần sort thêm sau |
| Streaming (data đến từng phần) | ✅ | ❌ | ❌ |
| Đảm bảo worst-case | ✅ | ✅ | ❌ (worst O(n²)) |
| Interview preference | Mặc định, dễ giải thích | Khi n nhỏ | Khi push “linear time” |
Median Finder invariant
max_heap (lo) min_heap (hi)
…,3,5,7,8 ← ← 9,10,12,…
top = 8 top = 9
|len(lo) - len(hi)| ≤ 1, mọi phần tử lo ≤ mọi phần tử hi.- Add: push tạm sang heap kia rồi balance.
- Median = top heap lớn hơn, hoặc trung bình 2 top khi bằng size.
Task Scheduler 2 cách
- Heap simulation: push count vào max-heap; mỗi cycle
n+1lấy ra nhiều nhấtn+1task khác nhau. - Counting formula:
max((max_count - 1) * (n + 1) + ties, total_tasks). Đẹp & nhanh hơn nhưng cần chứng minh.
Top K Frequent Words tie-break
Sort theo (-freq, word): tần số giảm dần, từ điển tăng dần.