快速選擇 (Quickselect)
快速選擇:懶惰的藝術
演算法背後的故事:偵探與名次
想像妳是一名在一場 50,000 人馬拉松比賽現場的偵探。妳並不關心所有人的最終排名,妳只想找到那個恰好第 100 名衝過終點線的人。
妳可以等待官方把 50,000 人的成績全部排好序,但這可能需要幾個小時。
相反,妳隨機挑選了一名選手——我們叫他 基準點 (Pivot)。妳問所有人:「妳是在這個人之前還是之後衝過終點線的?」 突然間,妳發現這名基準點選手恰好是第 500 名。 這是個絕佳的消息!妳瞬間知道,那個第 100 名肯定在「更快」的那一組人裡。現在,妳可以徹底無視掉那個「更慢」組裡的 49,500 人了。
這就是 快速選擇 (Quickselect),Tony Hoare 快速排序的一個變種。它是尋找特定排名最聰明的方法,因為它拒絕做任何非絕對必要的工作。
為什麼需要它?
快速選擇是尋找 統計數據 和 TopK 項的核心引擎。
- 中位數: 在不排序整個群體的情況下,找到數據集的中間值(例如家庭收入中位數)。
- 排行榜: 「告訴我得分第 10 高的玩家是誰。」
- 性能: 排序全部數據需要 ,而快速選擇在平均情況下僅需 的線性時間。
演算法是如何「思考」的
這個演算法像是一個目標極其明確的旅行者,它從不回頭。
分區 (Divide): 就像快速排序一樣,它挑選一個基準點,並將列表分為「較小」和「較大」兩部分。
現實檢查: 它觀察「較小」組的規模。
- 第 K 個元素在這一組嗎?太好了,扔掉大的那一半。
- 基準點本身就是第 K 個元素嗎?恭喜,妳找到了!
- 第 K 個元素在「較大」組嗎?扔掉小的那一半,繼續。
單行道: 這就是「懶惰」的部分。快速排序會遞迴處理兩邊。而快速選擇只遞迴處理其中一邊。它本質上是在一個已分區陣列上的二分搜尋。
工程決策: 的幻影
快速選擇在平均情況下極快,但它也有一個「最壞情況」的噩夢。
- 陷阱: 如果妳每次都選到最差的基準點(例如在找最大值時選到了最小值),速度會降到 。
- 防護: 在生產環境(如 C++ 的
std::nth_element)中,工程師會使用 Introselect 演算法——它以快速選擇開始,如果檢測到分區效率太低,則自動切換到堆積排序。
實作參考 (Python)
import random
def quickselect(arr, k):
"""
尋找第 k 小的元素(索引從 0 開始)。
"""
if len(arr) == 1:
return arr[0]
# 1. 選拔:隨機挑選基準點,避免 O(N^2) 陷阱
pivot = random.choice(arr)
# 2. 分區
lows = [x for x in arr if x < pivot]
highs = [x for x in arr if x > pivot]
pivots = [x for x in arr if x == pivot]
# 3. 決策
if k < len(lows):
# 目標在小值組
return quickselect(lows, k)
elif k < len(lows) + len(pivots):
# 基準點本身就是我們要找的人!
return pivots[0]
else:
# 目標在大值組
# 調整 k 的值,因為我們跳過了較小的組
return quickselect(highs, k - len(lows) - len(pivots))
# 使用範例
# data = [3, 2, 1, 5, 4]
# print(quickselect(data, 2)) # 尋找第 3 小(索引為 2) -> 輸出: 3小結
快速選擇教會我們:為了找到一個答案,妳不需要整理整個世界。透過激進地忽略那些無關的數據,我們可以在一個充滿對數複雜度的世界裡實現線性級的速度。它提醒我們,在工程藝術中,知道該忽略什麼與知道該修復什麼同樣重要。
