LeetCode 刷題日記EP.20

EP.20 — Heap:
從 Top K Frequent 往底層理解

#347 Top K Frequent Elements
從 Counter.most_common、串列生成式、heapq.nlargest,到 binary heap 長什麼樣子

Joseph Chen

2026
15 min read
1 題深挖 + 延伸

我第一次真正碰到 Heap,不是從資料結構課本開始,而是從 #347 Top K Frequent Elements 開始。 題目問的是:給一串數字,找出出現頻率最高的前 k 個元素。

一開始我其實沒有想到 Heap。最直覺的做法是:先用 Counter 統計每個數字出現幾次, 再用 most_common(k) 直接拿前 k 高頻的元素。 這個解法很好懂,也很適合當作這篇文章的起點。

但如果只停在 most_common(),會知道「怎麼用」,卻不一定知道「底層為什麼可以做到」。 所以這篇會照著我的學習路線:先從能過題目的寫法開始,再往下看 heapq.nlargest(), 最後自己用 heappushheappop 實作一次。

這篇文章的學習順序

1

先解題

Counter(nums).most_common(k)

先拿到能過題目的版本,建立 Top K Frequent 的問題感。

2

整理答案

[num for num, freq in ...]

用串列生成式把 tuple 清成 LeetCode 需要的答案格式。

3

看往底層

heapq.nlargest(k, ...)

不用 most_common 時,改用 heap 思維取前 k 大。

4

手刻 nlargest

heappush / heappop

理解 heap 如何只保留最有價值的 k 個元素。

5

看結構

binary heap

把 list index 和完全二元樹對起來,理解 heapq 為什麼能 O(log k)。

我覺得這個順序更適合初學者

因為它不是一開始就丟出抽象的 Heap 定義,而是先從你真的遇到的題目和 Python 函式開始。 讀者會先知道「這題到底要解什麼」,再逐步理解「為什麼 Top K 問題會需要 Heap」。


Step 1 — 用 Counter 統計頻率

先把「每個數字出現幾次」算出來

counter_basic.py
from collections import Counter

nums = [1, 1, 1, 2, 2, 3]

# Counter 是 collections 模組提供的計數工具
# 它會回傳一個類似 dict 的物件:
# key   = 元素本身
# value = 這個元素出現幾次
count = Counter(nums)

print(count)
# Counter({1: 3, 2: 2, 3: 1})

Counter 可以把 iterable 裡的元素拿來計數。 在這題裡,key 是數字本身,value 是出現次數。 所以 Counter([1,1,1,2,2,3]) 會得到1:3, 2:2, 3:1

用法意思
Counter(nums)統計 nums 裡每個元素出現幾次
count[1]查詢 1 出現幾次,這裡會得到 3
count.items()取得所有 (num, freq),例如 (1, 3)、(2, 2)

Step 2 — most_common(k) 直接拿前 k 高頻

這是最直覺,也最適合先理解題目的寫法

top_k_most_common.py
from collections import Counter

def topKFrequent(nums: list[int], k: int) -> list[int]:
    # Step 1:先統計每個數字出現幾次
    # nums = [1,1,1,2,2,3]
    # count = Counter({1: 3, 2: 2, 3: 1})
    count = Counter(nums)

    # Step 2:most_common(k) 會依照「出現次數」由高到低排序
    # 並回傳前 k 組 (num, freq)
    # k = 2 時,top_k = [(1, 3), (2, 2)]
    top_k = count.most_common(k)

    # Step 3:LeetCode 只要回傳 num,不需要 freq
    # 所以用串列生成式把 [(1,3), (2,2)] 轉成 [1,2]
    return [num for num, freq in top_k]

# nums = [1,1,1,2,2,3], k = 2
# count.most_common(2) -> [(1, 3), (2, 2)]
# return [1, 2]
Time: O(n log n) 或依實作優化Space: O(n)

most_common(k) 會回傳一個 list, 裡面每個元素都是 (元素, 次數) 的 tuple, 並且已經按照次數由高到低排列。

拆開看

Counter(nums)Counter({1: 3, 2: 2, 3: 1})
count.most_common(2)[(1, 3), (2, 2)]
[num for num, freq in top_k][1, 2]

Step 3 — 串列生成式怎麼理解?

把「迴圈產生新 list」寫成一行

most_common(k) 回傳的是[(num, freq), ...], 但 LeetCode 要的答案通常只需要數字本身,所以要把每個 tuple 裡的 num 抽出來。

list_comprehension.py
top_k = [(1, 3), (2, 2)]

# 寫法一:一般 for loop
result = []
for num, freq in top_k:
    # num  是數字本身,例如 1
    # freq 是出現次數,例如 3
    # 題目只要數字,所以只 append num
    result.append(num)

# 寫法二:串列生成式
# 格式:[要放進新 list 的值 for 每一筆資料 in 原本的資料]
result = [num for num, freq in top_k]

# result = [1, 2]

串列生成式的閱讀方式

[num for num, freq in top_k] 可以讀成: 「對 top_k 裡的每一組 (num, freq),我只把 num 放進新的 list」。 前面的 num 是你要收集的結果, 後面的 for num, freq in top_k 是資料從哪裡來。


Step 4 — 不用 most_common,改用 heapq.nlargest

這一步開始進入 Heap 思維

先把 heapq.nlargest() 當成一句白話來看: 「請你從一堆候選資料裡,依照我指定的比較規則,找出最大的 k 個」。 在 #347 裡,候選資料是「不重複的數字」,比較規則是「出現頻率」。

top_k_nlargest_expanded.py
from collections import Counter
import heapq

def topKFrequent(nums: list[int], k: int) -> list[int]:
    # Step 1:統計頻率
    # count = Counter({1: 3, 2: 2, 3: 1})
    count = Counter(nums)

    # Step 2:取得所有不重複的數字
    # candidates = dict_keys([1, 2, 3])
    candidates = count.keys()

    # Step 3:定義比較規則
    # nlargest 要找「最大」,但這題不是比數字大小
    # 而是比每個數字出現的頻率
    def get_frequency(num: int) -> int:
        return count[num]

    # Step 4:從 candidates 裡找出 get_frequency 最大的 k 個
    result = heapq.nlargest(
        k,                 # 要取前 k 大
        candidates,        # 候選資料:1, 2, 3
        key=get_frequency, # 比較規則:看 count[num]
    )

    return result

# get_frequency(1) -> 3
# get_frequency(2) -> 2
# get_frequency(3) -> 1
# heapq.nlargest(2, candidates, key=get_frequency) -> [1, 2]
top_k_nlargest_short.py
from collections import Counter
import heapq

def topKFrequent(nums: list[int], k: int) -> list[int]:
    count = Counter(nums)

    # lambda num: count[num]
    # 等同於:
    #
    # def get_frequency(num):
    #     return count[num]
    #
    # 這裡用 lambda,是因為比較規則只有一行
    return heapq.nlargest(
        k,
        count.keys(),
        key=lambda num: count[num],
    )
Time: O(n log k)Space: O(n)

這段程式的重點是 keyheapq.nlargest(k, iterable, key=...) 不是直接比較元素本身, 而是先把元素丟進 key 函式,拿回來的值才是排序和比較依據。

展開版函式

normal_function.py
def get_frequency(num):
    return count[num]

適合初學時閱讀,因為函式有名字,也可以在裡面放多行邏輯。

lambda 縮寫版

lambda_function.py
lambda num: count[num]

lambda 是匿名函式。 冒號左邊是參數,右邊是回傳值;適合邏輯很短時使用。

片段意思
k要取前幾大的元素
count.keys()候選元素,也就是所有不重複的數字
lambda匿名函式,用來寫很短的比較規則或轉換規則
key=lambda num: count[num]比較時看的是 num 的出現頻率,不是 num 的大小

Step 5 — 自己實作 heapq.nlargest

用 heappush / heappop 維護最頂端的 k 個值

要理解 nlargest,核心就是一句話:用一個大小最多為 k 的 min-heap,保存目前看過的前 k 大元素。因為 heap 頂端永遠是這 k 個元素裡最小的,所以一旦 heap 超過 k,就把最小的 pop 掉。

manual_nlargest.py
from collections import Counter
import heapq

# key=lambda x: x 代表:
# 如果呼叫者沒有指定比較規則,就直接用 item 本身來比較
def my_nlargest(k, iterable, key=lambda x: x):
    heap = []  # 這會是一個 min-heap

    for item in iterable:
        # score 是這個 item 的比較分數
        # 在 #347 裡,item 是 num,score 是 count[num]
        score = key(item)

        # heap 裡放 (score, item)
        # Python 會先比較 tuple 的第一個值 score
        heapq.heappush(heap, (score, item))

        # heap 只保留 k 個元素
        # 如果超過 k,就把目前 score 最小的踢掉
        if len(heap) > k:
            heapq.heappop(heap)

    # heap 裡剩下的就是 score 最大的 k 個 item
    # 但 heap 裡存的是 (score, item),所以要把 item 抽出來
    return [item for score, item in heap]

def topKFrequent(nums: list[int], k: int) -> list[int]:
    count = Counter(nums)
    return my_nlargest(k, count.keys(), key=lambda num: count[num])

這裡 heap 裡放的是 (score, item)。 對 #347 來說,score 就是出現頻率,item 就是數字本身。 Python tuple 比較會先比第一個值,所以 heap 會自動用頻率維持大小順序。

nums=[1,1,1,2,2,3], k=2 的 heap 維護過程

push (3, 1)[(3,1)]

1 出現 3 次,heap 還沒滿,直接放入。

push (2, 2)[(2,2), (3,1)]

2 出現 2 次,heap 大小剛好到 k=2。

push (1, 3)[(1,3), (3,1), (2,2)]

3 出現 1 次,先 push 進來檢查。

pop[(2,2), (3,1)]

heap 超過 k,pop 掉頻率最小的 (1,3)。

result[(2,2), (3,1)]

heap 裡剩下的就是頻率最高的 2 個元素:2 和 1。

Step 6 — Binary Heap 長什麼樣子?

heapq 表面上是 list,邏輯上是一棵完全二元樹

Python 的 heapq 不會真的建立 TreeNode。 它底層就是一個 list,但用 index 關係把 list 當成 binary tree 來看。

(2,2)
(3,1)
(4,4)
heap = [(2, 2), (3, 1), (4, 4)]

parent

(i - 1) // 2

left child

2 * i + 1

right child

2 * i + 2

Min-heap 的規則是:每個 parent 都要小於或等於自己的 children。 所以 list 的第 0 個位置永遠是整個 heap 裡最小的元素。 當我們維護「前 k 大」時,這個最小值就是目前 Top K 裡最弱的那個。

heap_push_pop_intuition.py
# heappush:先放到最後,再往上調整
heapq.heappush(heap, (1, 3))

# heappop:拿走 heap[0],把最後一個補到 root,再往下調整
smallest = heapq.heappop(heap)

# 所以每次調整只會走樹高
# 完全二元樹高度約為 log k
# 因此 heappush / heappop 都是 O(log k)

延伸:如果不是查最大,而是查最小呢?

Top K 類型可以分成 Top K Largest 和 Top K Smallest

#347 原題是「找出現頻率最高的前 k 個元素」。如果題目反過來問: 「找出現頻率最低的前 k 個元素」,思路其實一樣,只是把nlargest 換成nsmallest

largest_vs_smallest.py
import heapq

nums = [5, 1, 9, 3, 7]

# 查前 k 大
largest = heapq.nlargest(2, nums)
# [9, 7]

# 查前 k 小
smallest = heapq.nsmallest(2, nums)
# [1, 3]
bottom_k_frequent_complete.py
from collections import Counter
import heapq

def bottomKFrequent(nums: list[int], k: int) -> list[int]:
    # Step 1:統計每個數字出現幾次
    # nums = [1,1,1,2,2,3,4]
    # count = Counter({1: 3, 2: 2, 3: 1, 4: 1})
    count = Counter(nums)

    # Step 2:候選資料是所有不重複的數字
    # candidates = dict_keys([1, 2, 3, 4])
    candidates = count.keys()

    # Step 3:比較規則是「出現頻率」
    # num=1 -> count[1] = 3
    # num=3 -> count[3] = 1
    def get_frequency(num: int) -> int:
        return count[num]

    # Step 4:找出頻率最小的 k 個元素
    # nsmallest 會依照 key(num) 的結果由小到大取前 k 個
    return heapq.nsmallest(
        k,
        candidates,
        key=get_frequency,
    )

# nums = [1,1,1,2,2,3,4], k = 2
# 頻率:1->3, 2->2, 3->1, 4->1
# bottomKFrequent(nums, 2) -> [3, 4]
bottom_k_frequent_short.py
from collections import Counter
import heapq

def bottomKFrequent(nums: list[int], k: int) -> list[int]:
    count = Counter(nums)

    # 縮寫版:
    # key=lambda num: count[num]
    # 代表每個 num 都用 count[num] 當作比較分數
    return heapq.nsmallest(
        k,
        count.keys(),
        key=lambda num: count[num],
    )
需求直覺寫法自己維護 heap 時
找前 k 大heapq.nlargest(k, nums)用大小 k 的 min-heap,超過 k 就 pop 最小
找前 k 小heapq.nsmallest(k, nums)可用 max-heap 概念,Python 常用負值模擬

heapq 常用函式速查

第一次學 Heap 時,先掌握這幾個函式就夠用

Python 的 heapq 預設是 min-heap, 也就是 heap[0] 永遠是目前最小的元素。 如果要模擬 max-heap,常見做法是把數字乘上 -1 存進去,取出時再乘回 -1。

heapq_common_functions.py
import heapq

# 1. heappush:放入元素,並維持 min-heap
heap = []
heapq.heappush(heap, 3)
heapq.heappush(heap, 1)
heapq.heappush(heap, 2)
# heap[0] 永遠是最小值 -> 1

# 2. heappop:取出並移除最小值
smallest = heapq.heappop(heap)
# smallest = 1

# 3. heapify:把普通 list 原地轉成 heap
nums = [5, 1, 9, 3]
heapq.heapify(nums)
# nums 現在符合 heap 規則,nums[0] 是最小值

# 4. heappushpop:先 push,再 pop 最小值
# 適合「加入新元素後,仍然只想保留較大的那些」的情境
removed = heapq.heappushpop(nums, 4)

# 5. heapreplace:先 pop 最小值,再 push 新元素
# 注意:即使新元素比較小,也一定會先移除原本的 heap[0]
removed = heapq.heapreplace(nums, 6)

# 6. nlargest / nsmallest:直接取前 k 大或前 k 小
top_2 = heapq.nlargest(2, nums)
bottom_2 = heapq.nsmallest(2, nums)
函式用途複雜度
heapq.heappush(heap, item)把 item 放進 heap,並自動維持 heap 規則。O(log n)
heapq.heappop(heap)取出並移除 heap 裡最小的元素,也就是 heap[0]。O(log n)
heapq.heapify(nums)把既有 list 原地轉成 heap,不會回傳新 list。O(n)
heapq.heappushpop(heap, item)先 push 再 pop,但比分兩行更有效率。O(log n)
heapq.heapreplace(heap, item)先 pop 再 push,適合 heap 已經不為空的情境。O(log n)
heapq.nlargest(k, iterable, key=...)取出依 key 比較後最大的 k 個元素。O(n log k)
heapq.nsmallest(k, iterable, key=...)取出依 key 比較後最小的 k 個元素。O(n log k)

heappushpop 和 heapreplace 差在哪?

heappushpop 是「先放新值,再把最小的拿掉」, 所以如果新值本身很小,它可能立刻被拿掉。heapreplace 則是「一定先拿掉原本的 heap[0],再放新值」, 適合你確定一定要替換堆頂的情境。

這篇學到什麼

1Counter 負責統計頻率,most_common(k) 可以直接取前 k 高頻元素。
2串列生成式可以把 [(num, freq), ...] 轉成 LeetCode 要的 [num, ...]。
3heapq.nlargest(k, iterable, key=...) 可以用指定的 key 取前 k 大;lambda 是短函式寫法。
4手刻 nlargest 的核心是大小為 k 的 min-heap,超過 k 就 pop 掉目前最小的。
5如果題目要找最低頻率前 k 個,改用 heapq.nsmallest(k, ..., key=...) 即可。
6heapq 底層是 list,但邏輯上是一棵完全二元樹,因此 heappush / heappop 是 O(log k)。

LeetCodeHeapPriority QueueTop KCounterheapqPythonEP.20