LeetCodePython 復盤

Python 算法學習復盤
投票、鏈表與 Top-K 的深度解析

從真實的學習對話整理而來——Boyer-Moore 投票演算法、
鏈表虛擬頭節點、Heap vs 桶排序,以及那些讓我踩坑的思維誤區

J

Joseph Chen

2026·Python · LeetCode·5 min read·1.2k views
Boyer-MooreLinked ListHeap / Bucket

01

學習背景與痛點

這篇復盤來自我在學習 LeetCode 過程中與 AI 的對話紀錄——七段對話,橫跨陣列、鏈表、哈希表三大主題, 涵蓋了 #2、#26、#49、#169、#229、#242、#347 共七道題目。 回頭看這些對話,我的問題集中在三個層面:

🧠

演算法原理理解

  • Boyer-Moore 投票邏輯「為什麼這樣就能選出多數元素?」
  • Top-K 問題為什麼 Heap 比排序好?
🐛

程式碼邏輯 Bug

  • Counter 迭代不等於按頻率排序
  • range(nums) 忘記 len()
  • defaultdict 用錯 key 名稱
🔀

解法選擇迷茫

  • 哈希表 vs 排序,什麼時候用哪個?
  • dummy_head 是必要的嗎?
  • 原地修改和建新陣列有什麼差別?

本次復盤涵蓋的題目

#2Add Two NumbersLinked List
#26Remove DuplicatesTwo Pointer
#49Group AnagramsHash Table
#169Majority ElementBoyer-Moore
#229Majority Element IIBoyer-Moore
#242Valid AnagramHash Table
#347Top K FrequentHeap / Bucket

02

核心知識點拆解

2-1 Boyer-Moore 投票演算法

Boyer-Moore Voting Algorithm 是解決「多數元素」問題的最優解,能在 O(n) 時間、O(1) 空間內找出答案。 它的核心思想可以用一個比喻來理解:

⚔️ 戰爭消消樂比喻

把陣列看成戰場,每個數字是一個士兵。同一方的士兵互相支援(count +1)不同方的士兵互相抵消(count -1)。 當一個陣營的士兵全數陣亡(count = 0),換一個新的候選人繼續。 由於多數元素的數量嚴格超過一半,不管怎麼抵消,最後存活的候選人一定是它。

#169 Majority Element — Boyer-Moore
class Solution:
    def majorityElement(self, nums: list[int]) -> int:
        candidate = None  # 當前候選人
        count = 0         # 候選人的「存活票數」

        for num in nums:
            if count == 0:
                # 上一個候選人全數陣亡,換人
                candidate = num
            # 同陣營 +1,不同陣營 -1
            count += (1 if num == candidate else -1)

        # 嚴格多數(> n/2)保證最後存活的就是答案
        return candidate
⚠️
適用前提:Boyer-Moore 只在「多數元素嚴格超過 n/2 次」時保證正確。 如果題目改成「找出現最多次的元素(相對多數)」,就必須改用 HashMap 計數,不能用 Boyer-Moore。

延伸:#229 Majority Element II(找超過 n/3 的元素)

超過 n/3 的元素最多只有 2 個,所以同時維護兩個候選人即可。 這是 Boyer-Moore 的推廣形式——超過 n/k 的元素最多有 k-1 個,就維護 k-1 個候選人。

#229 Majority Element II — 雙候選人 Boyer-Moore
class Solution:
    def majorityElement(self, nums: list[int]) -> list[int]:
        # 初始化兩個候選人和各自的票數
        c1, c2, cnt1, cnt2 = None, None, 0, 0

        for num in nums:
            if num == c1:
                cnt1 += 1
            elif num == c2:
                cnt2 += 1
            elif cnt1 == 0:
                # c1 陣亡,換 c1
                c1, cnt1 = num, 1
            elif cnt2 == 0:
                # c2 陣亡,換 c2
                c2, cnt2 = num, 1
            else:
                # 兩個陣營都有士兵,遇到第三方就一起抵消
                cnt1 -= 1
                cnt2 -= 1

        # ⚠️ 第二次遍歷驗證(候選人不一定真的超過 n/3)
        result = []
        for c in [c1, c2]:
            if c is not None and nums.count(c) > len(nums) // 3:
                result.append(c)

        return result
💡
第二次遍歷是必要的。第一輪只是「篩選出最有可能的候選人」,他們不一定真的超過 n/3。 第二輪再確認每個候選人的實際出現次數,才能保證答案正確。

2-2 鏈表虛擬頭節點(dummy_head)模式

在學習 #2 Add Two Numbers 時,我一開始想不通為什麼要額外建一個 dummy 節點—— 直接用 head 不行嗎?

為什麼要用 dummy_head?

❌ 無 dummy

沒有 dummy_head:第一個節點需要特別處理

✅ 有 dummy

有 dummy_head:所有節點都用同一套邏輯 current.next = ListNode(val)

❌ 無 dummy

程式碼需要 if head is None 分支,容易出 bug

✅ 有 dummy

不需要任何特殊判斷,程式碼更簡潔一致

#2 Add Two Numbers — dummy_head 模式
class Solution:
    def addTwoNumbers(self, l1, l2):
        # 建立虛擬頭節點:不存資料,只是為了讓後續邏輯統一
        dummy_head = ListNode(0)
        current = dummy_head  # current 指向當前要接新節點的位置
        carry = 0             # 進位值

        while l1 or l2 or carry:
            # 任一鏈表已走完,用 0 補位
            val1 = l1.val if l1 else 0
            val2 = l2.val if l2 else 0

            total = val1 + val2 + carry
            carry = total // 10   # 新的進位
            digit = total % 10    # 當前位的數值

            # 直接接到 current.next,不需要判斷是否為第一個節點
            current.next = ListNode(digit)
            current = current.next

            # 兩個指針各自前進(有值就前進,None 就停著)
            if l1: l1 = l1.next
            if l2: l2 = l2.next

        # dummy_head 本身不是答案,從它的 next 才是真正的結果鏈表
        return dummy_head.next
dummy_head 是鏈表題的萬用模板:凡是要建新鏈表或修改鏈表結構的題目, 第一步先建 dummy = ListNode(0)current = dummy,最後 return dummy.next。 這個模式能消除大量邊界判斷。

2-3 哈希表應用:計數、分組、查找

#242 Valid Anagram 和 #49 Group Anagrams 都是哈希表的應用,但我在實作時遇到了兩個 Python 語法細節。

Python dict 計數的標準寫法

dict-counting-patterns.py
# ❌ 初學時的寫法:需要先判斷 key 存不存在
count = {}
for char in s:
    if char in count:
        count[char] += 1
    else:
        count[char] = 0  # ← 還有 bug,應該是 1

# ✅ 方法 1:dict.get(key, default) 預防 KeyError
count = {}
for char in s:
    count[char] = count.get(char, 0) + 1

# ✅ 方法 2:defaultdict(int) 預設值為 0
from collections import defaultdict
count = defaultdict(int)
for char in s:
    count[char] += 1

# ✅ 方法 3:Counter(最 Pythonic)
from collections import Counter
count = Counter(s)
# Counter('abc') → Counter({'a': 1, 'b': 1, 'c': 1})
💡
defaultdict vs dictdefaultdict(list) 代表 「幫我建一個 dict,當 key 不存在時自動建一個空 list 作為預設值」。 這在分組問題(Group Anagrams)裡特別方便,不需要先 if key not in group: group[key] = []

03

經典例題與程式碼復盤

#169 Majority Element

Easy

給定一個陣列,保證有一個元素出現次數超過 ⌊n/2⌋,找出它。 從最暴力的解法一路演進到 O(1) 空間的 Boyer-Moore。

解法時間複雜度空間複雜度備註
暴力排序取中O(n log n)O(log n)排序後中間必是多數元素
HashMap 計數O(n)O(n)直觀,適合先理解問題
Boyer-Moore 投票O(n)O(1)最優,需理解原理

解法 1:排序(暴力但直觀)

solution1-sort.py
def majorityElement(nums):
    # 排序後,多數元素超過一半,所以中間位置一定是它
    nums.sort()
    return nums[len(nums) // 2]

# 時間:O(n log n)  空間:O(log n) (Python timsort 的遞迴棧)
# 優點:三行搞定,面試前三分鐘想不到其他解法時的保底

解法 2:HashMap 計數

solution2-hashmap.py
def majorityElement(nums):
    count = {}
    majority_count = len(nums) // 2

    for num in nums:
        count[num] = count.get(num, 0) + 1
        # 邊統計邊檢查,找到就直接回傳
        if count[num] > majority_count:
            return num

    # 題目保證一定有答案,不會走到這裡
    return -1

# 時間:O(n)  空間:O(n)
# 優點:通用,不依賴「絕對多數」的前提

解法 3:Boyer-Moore(最優)

solution3-boyer-moore.py
def majorityElement(nums):
    candidate = None
    count = 0

    for num in nums:
        if count == 0:
            # 舊候選人全軍覆沒,換人
            candidate = num
        count += (1 if num == candidate else -1)

    # 「絕對多數」保證:不管如何抵消,最後活下來的就是答案
    return candidate

# 時間:O(n)  空間:O(1)
# 限制:只有嚴格超過 n/2 才保證正確

#347 Top K Frequent Elements

Medium

給定一個陣列和整數 k,回傳出現頻率最高的 k 個元素。 Follow-up 要求:時間複雜度必須優於 O(n log n)。 這道題完美展示了演算法的三個層次。

解法時間複雜度空間複雜度備註
全部排序O(n log n)O(n)不符合 Follow-up 要求
Min-Heap(堆)O(n log k)O(n + k)k << n 時顯著優於排序
桶排序(Bucket Sort)O(n)O(n)最優,利用頻率範圍固定

解法 1:全部排序(不符合 Follow-up)

solution1-sort.py
from collections import Counter

def topKFrequent(nums, k):
    count = Counter(nums)
    # most_common(k) 內部用排序,O(n log n)
    return [num for num, _ in count.most_common(k)]

解法 2:Min-Heap(符合 Follow-up)

solution2-heap.py
from collections import Counter
import heapq

def topKFrequent(nums, k):
    count = Counter(nums)
    # 最簡寫法:heapq.nlargest 內部用 min-heap
    # 時間:O(n log k)  — 只維護大小為 k 的堆,而非 n
    return heapq.nlargest(k, count.keys(), key=lambda x: count[x])

# 手動 min-heap 的邏輯(理解原理用):
# heap = []
# for num, freq in count.items():
#     if len(heap) < k:
#         heapq.heappush(heap, (freq, num))
#     elif freq > heap[0][0]:          # 比堆頂(最小頻率)大才換
#         heapq.heapreplace(heap, (freq, num))
# return [num for _, num in heap]

解法 3:桶排序(O(n),最優)

solution3-bucket-sort.py
from collections import Counter

def topKFrequent(nums, k):
    count = Counter(nums)

    # 建立桶:index 代表頻率,最大頻率不超過 len(nums)
    # bucket[i] 裡存所有頻率為 i 的元素
    bucket = [[] for _ in range(len(nums) + 1)]
    for num, freq in count.items():
        bucket[freq].append(num)

    # 從高頻率往低頻率收集,收集到 k 個就停
    result = []
    for i in range(len(bucket) - 1, -1, -1):
        result.extend(bucket[i])
        if len(result) >= k:
            return result[:k]  # 截取前 k 個

    return result

# 時間:O(n)  空間:O(n)
# 關鍵洞察:頻率的範圍是固定的 [1, n],所以可以用桶排序
為什麼桶排序能做到 O(n)?
一般排序無法突破 O(n log n) 是因為「元素大小的範圍不確定」。 但這題的「頻率」範圍是固定的——最大只可能是 n(全部相同)。 範圍固定就能開一個大小為 n+1 的桶陣列,直接映射,不需要比較排序。

04

易錯點與避坑指南

Bug #1

Counter 迭代 ≠ 按頻率排序

我在做 #347 時,以為直接迭代 Counter 就能按頻率從高到低取,結果取到的是插入順序,不是頻率順序。

❌ 誤以為前 k 個就是最高頻
from collections import Counter

def topKFrequent(nums, k):
    count = Counter(nums)
    result = []
    for num in count:        # ← 迭代順序是插入順序,不是頻率
        if k > 0:
            result.append(num)
            k -= 1
    return result            # ❌ 答案錯誤
✅ 必須明確指定排序方式
from collections import Counter

def topKFrequent(nums, k):
    count = Counter(nums)
    # most_common(k) 才是按頻率由高到低
    return [num for num, _ in count.most_common(k)]

Counter 繼承自 dict,Python 3.7+ dict 的迭代順序是插入順序。如果需要按頻率排序,必須用 .most_common(k) 或搭配 heapq.nlargest。

Bug #2

range(nums) 忘記 len()

❌ 對陣列呼叫 range()
# 在 #26 Remove Duplicates 中犯的錯
def removeDuplicates(nums):
    slow = 0
    for fast in range(1, nums):  # ← TypeError: 'list' object cannot be
        ...                       #   interpreted as an integer
✅ range(len(nums))
def removeDuplicates(nums):
    slow = 0
    for fast in range(1, len(nums)):  # ← len() 才能取得陣列長度
        if nums[fast] != nums[slow]:
            slow += 1
            nums[slow] = nums[fast]
    return slow + 1

range() 需要整數,不能傳入 list 本身。這個錯誤在 Python 新手中非常常見,好在 Python 會立刻拋出 TypeError 而不是悄悄給錯誤答案。

Bug #3

defaultdict 用了錯誤的 key

在 #49 Group Anagrams 中,我計算了 key 之後,grouping 時卻把原始字串 i 當成了 key, 導致每個字串都自成一組。

❌ 把 word 本身當 key
from collections import defaultdict

def groupAnagrams(strs):
    group = defaultdict(list)
    for i in strs:
        key = ''.join(sorted(i))
        group[i].append(i)   # ❌ 用 i 當 key,每個字串都進自己的桶
    return list(group.values())
✅ 用計算出的 key 分組
from collections import defaultdict

def groupAnagrams(strs):
    group = defaultdict(list)
    for word in strs:
        key = ''.join(sorted(word))
        group[key].append(word)  # ✅ 用排序後的 key 當作分組依據
    return list(group.values())

計算 key 之後忘記用 key 分組——這種「算了但沒用到」的 bug 在 review 時很難發現,因為程式碼邏輯看起來是完整的。

Bug #4

Two Pointer 的指針更新順序

在 #26 中,我把 slow 的更新順序搞反了——應該「先移動 slow、再賦值」, 否則會覆蓋掉 slow 指向的有效元素。

❌ 先賦值再移動(覆蓋有效元素)
for fast in range(1, len(nums)):
    if nums[fast] != nums[slow]:
        nums[slow] = nums[fast]  # ❌ 先蓋掉 slow 的值
        slow += 1                # 再移動已經太晚了
✅ 先移動再賦值
for fast in range(1, len(nums)):
    if nums[fast] != nums[slow]:
        slow += 1                # ✅ 先移動到下一個空位
        nums[slow] = nums[fast]  # 再填入新的不重複值

Two Pointer 的 slow 指針語義是「下一個有效值要放的位置」。賦值之前要先讓 slow 前進一格,讓出空間。

Bug #5

Boyer-Moore 用在「相對多數」問題上

Boyer-Moore 有一個不能突破的前提:目標元素必須嚴格超過 n/2(或 n/k)。 如果題目只要求「找出現最多次的元素」,Boyer-Moore 可能給出錯誤答案。

boyer-moore-limitation.py
nums = [1, 1, 2, 2, 3]  # 1 和 2 各出現 2 次,沒有超過 n/2 的元素

# Boyer-Moore 的結果:
# i=0: candidate=1, count=1
# i=1: candidate=1, count=2
# i=2: candidate=1, count=1
# i=3: candidate=1, count=0
# i=4: candidate=3, count=1  ← 換成了 3!

# 回傳 3,但 3 只出現 1 次,明顯是錯的
# ✅ 這種情況必須用 Counter + max()
⚠️
面試時如果題目沒有明確說「保證存在超過 n/2 的元素」,用 Boyer-Moore 之前要先說明前提, 否則可能在 edge case 上出錯。

05

總結與延伸

這次學習的三個核心收穫

⚔️

Boyer-Moore 投票演算法

「消消樂戰爭」的比喻讓我真正理解它為什麼正確,而不只是背下程式碼。關鍵是記住它的限制:只適用於嚴格絕對多數。

🎭

dummy_head 是鏈表題的萬用模板

不只 #2,凡是需要建構或修改鏈表的題目,dummy_head + current + dummy.next 這三行幾乎是固定開頭。消除邊界條件的代價只是一個額外節點。

🪣

桶排序突破 O(n log n) 的思路

當排序對象的範圍固定時,可以用空間換時間,用下標直接映射代替比較排序。#347 的桶是「頻率」,未來遇到類似的固定範圍問題可以套用。

推薦的延伸練習題

#75Two Pointer

Sort Colors

三指針 Boyer-Moore 排序思想的直接應用,進一步鞏固多指針操作。

#215Heap / QuickSelect

Kth Largest Element in an Array

同樣是 Top-K 問題,加入了 QuickSelect 解法,是 Heap 方法的進化版。

#21Linked List

Merge Two Sorted Lists

dummy_head 模式的最基本練習,把兩條鏈表合成一條。

#229Boyer-Moore

Majority Element II

雙候選人的 Boyer-Moore,加上第二輪驗證,是 #169 的絕佳延伸。