algorithm prune and search binary search quickselect median of medians bin packing

[Algorithm] 淘汰與搜尋法

演算法概念

每次比較或測試,都讓一整塊搜尋空間被證明「不可能含答案」,就整塊丟掉、只在剩下的地方繼續找。丟掉的不再回頭——這就是淘汰與搜尋。能用這個招式的關鍵,是你要有一個便宜(O(1)O(1)O(n)O(n))的測試,一次就能把問題規模砍成 1/c1/c。砍得越狠、每輪越便宜,整體就越快。

一次比較能產生幾種互斥結果,就決定了底數 cc:是非題切兩半、三值天平切三份。選擇不是隨便訂的,是被工具解析度逼出來的。

各適用情境

偽幣問題

演算法策略

一個無砝碼天平、nn 枚硬幣裡藏一枚已知較輕的偽幣。把硬幣分成三等份——左盤、右盤、未上秤。秤一次看三個結果:左輕、右輕、平衡,偽幣就在對應那一組。對那組再做同樣的事,直到剩一枚。

為什麼分三組不分兩組?因為天平一次吐三個結果,天生就切三份;硬要切兩份是浪費資訊量。

複雜度

每輪秤一次(O(1)O(1)),規模砍成三分之一。總共 T(n)=T(n/3)+O(1)=O(log3n)T(n) = T(n/3) + O(1) = O(\log_3 n)。如果工具只能回答是/否,那就變 O(log2n)O(\log_2 n)

範例

題目:9 枚硬幣編號 0–8,偽幣是 5,用最少次數找出來。

圖解

步驟

回合左盤右盤未上秤結果剩下候選
1{0,1,2}{3,4,5}{6,7,8}右盤輕{3,4,5}
2{3}{4}{5}平衡(左右都真){5}

兩次秤量命中。

Python 實作
def find_fake_light(coins):
    if len(coins) == 1:
        return coins[0]
    k = len(coins) // 3
    left, right, rest = coins[:k], coins[k:2*k], coins[2*k:]
    ls, rs = sum(left), sum(right)
    if len(left) == len(right):
        if ls < rs:
            return find_fake_light(left)
        if ls > rs:
            return find_fake_light(right)
        return find_fake_light(rest)
    return find_fake_light(left + right + rest)
例題:方向未知的偽幣(4 枚未知 + 參考真幣,兩次秤量)

兩組各 4 枚硬幣:

  • S1={A,B,C,D}S_1 = \{A, B, C, D\}:恰有一枚偽幣,不知道偏輕或偏重
  • S2={T1,T2,T3,T4}S_2 = \{T_1, T_2, T_3, T_4\}:全是真幣,可作為參考

用天平兩次找出偽幣並判斷輕重。

資訊量檢查:4×2=84 \times 2 = 8 種情況,32=93^2 = 9 種結果路徑,898 \leq 9 足夠。

第一次秤量 W1W_1{A,B,C}\{A, B, C\}{T1,T2,T3}\{T_1, T_2, T_3\}

W1W_1 結果推論偽幣候選方向
平衡A,B,CA, B, C 都是真幣{D}\{D\}未知
左重A,B,CA, B, C 中有一枚重偽幣{A,B,C}\{A, B, C\}
左輕A,B,CA, B, C 中有一枚輕偽幣{A,B,C}\{A, B, C\}

第二次秤量 W2W_2:依 W1W_1 結果決定

  • W1W_1 平衡 → W2W_2DDT1T_1。左重 → DD 重;左輕 → DD 輕。
  • W1W_1 左重 → W2W_2AABB。左重 → AA 重;右重 → BB 重;平衡 → CC 重。
  • W1W_1 左輕 → W2W_2AABB。左重 → BB 輕;左輕 → AA 輕;平衡 → CC 輕。

正確性驗證(8 種情況全覆蓋):

偽幣方向W1W_1W2W_2結果判定
A左重A vs B 左重A 重 ✓
A左輕A vs B 左輕A 輕 ✓
B左重A vs B 右重B 重 ✓
B左輕A vs B 左重B 輕 ✓
C左重A vs B 平衡C 重 ✓
C左輕A vs B 平衡C 輕 ✓
D平衡D vs T 左重D 重 ✓
D平衡D vs T 左輕D 輕 ✓

關鍵技巧:用參考真幣壓住不確定性。第一次秤把「偽幣身分」與「偏向」耦合進同一個結果——平衡代表偽幣是 DD(方向待查)、不平衡代表方向已定、只剩 3 枚嫌疑犯。第二次秤量剛好各處理 3 種結果,資訊量完美耗盡。

若沒有參考真幣,4 枚未知方向的硬幣無法用兩次秤量同時找出偽幣與方向——任何第一次秤量都會把 8 種情況壓成 3 種結果,必有一支裝超過 3 種,第二次切不開。原始 12 硬幣問題(無參考真幣)需要 3 次秤量。

例題:一般公式

kk 次秤量可解的硬幣數:

  • 已知方向:3k3^k
  • 未知方向(無參考真幣):3k32\frac{3^k - 3}{2}
  • 未知方向(有參考真幣):3k12\frac{3^k - 1}{2}

常考:k=3k = 3、未知方向、無參考真幣 → 2732=12\frac{27 - 3}{2} = 12 枚。


晶片測試

演算法策略

nn 個晶片,好晶片嚴格多於一半。兩個晶片互測時:兩好互報「好」、一好一壞時好的誠實、兩壞隨便回。我們兩兩配對,若兩邊都說對方好就保留其中一個(另一個丟掉);只要有人說對方壞就兩個都丟。一輪下來,剩下的集合裡好晶片還是嚴格多數——遞迴做就行。

關鍵觀察:都說對方好的兩片必為同類(同好或同壞),不可能一好一壞,因為好的會揭發壞的。

複雜度

每輪配對掃一遍要 O(n)O(n),規模減半:T(n)=T(n/2)+O(n)=O(n)T(n) = T(n/2) + O(n) = O(n)。線性次測試就能找到一片保證的好晶片。

範例

題目n=8n = 8 晶片,其中 5 個好、3 個壞,找出一片好的。

步驟(條列比表格清楚):

  1. 兩兩配對:(1,2)(3,4)(5,6)(7,8)。
  2. 測試結果假設:
    • (1,2) 互報好 → 保留 1,丟 2
    • (3,4) 互報好 → 保留 3,丟 4
    • (5,6) 有人說對方壞 → 全丟
    • (7,8) 互報好 → 保留 7,丟 8
  3. 剩下 {1,3,7}\{1, 3, 7\},好晶片仍嚴格多於一半。
  4. 繼續配對 (1,3)、7 落單 → 測試、再篩選,直到剩一片。
Python 實作
def chip_test(chips, is_good):
    while len(chips) > 1:
        kept = []
        i = 0
        while i + 1 < len(chips):
            a, b = chips[i], chips[i + 1]
            a_says = is_good(a, b)
            b_says = is_good(b, a)
            if a_says and b_says:
                kept.append(a)
            i += 2
        if len(chips) % 2 == 1:
            kept.append(chips[-1])
        chips = kept
    return chips[0]

二元搜尋

演算法策略

陣列已排序,中間那個值跟目標比一下:相等就中獎;目標較大就丟掉中點以左;較小就丟掉中點以右。每次砍半。

複雜度

每輪一次比較、規模減半:T(n)=T(n/2)+O(1)=O(logn)T(n) = T(n/2) + O(1) = O(\log n)

範例

題目:陣列 [3,7,12,18,25,31,42,56,64,78,85,91][3, 7, 12, 18, 25, 31, 42, 56, 64, 78, 85, 91],找 x=56x = 56

圖解

步驟

回合lohimida[mid]a[\text{mid}]動作
101153131<5631 < 56 → 淘汰左半,lo ← 6
261186464>5664 > 56 → 淘汰右半,hi ← 7
36764242<5642 < 56 → 淘汰左半,lo ← 7
477756命中,回傳索引 7
Python 實作
def binary_search(arr, target):
    lo, hi = 0, len(arr) - 1
    while lo <= hi:
        mid = (lo + hi) // 2
        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            lo = mid + 1
        else:
            hi = mid - 1
    return -1
例題:1-indexed、上取整、目標不存在

有些課本採 1-indexed 並用 middle=(left+right)/2\text{middle} = \lceil (\text{left} + \text{right}) / 2 \rceil 取中點(右偏中點)。邏輯完全一致,差別只在索引起點與偶數長度的中點位置。

陣列 a[1..18]=[1,2,4,6,8,10,12,13,16,18,20,28,30,35,36,41,44,49]a[1..18] = [1, 2, 4, 6, 8, 10, 12, 13, 16, 18, 20, 28, 30, 35, 36, 41, 44, 49],找 x=33x = 33

回合leftrightmiddlea[middle]a[\text{middle}]比較動作
1118101818<3318 < 33left ← 11
21118153636>3336 > 33right ← 14
31114133030<3330 < 33left ← 14
41414143535>3335 > 33right ← 13

第 4 回合後 left = 14 > right = 13,結束——x=33x = 33 不在陣列中。3333 夾在 a[13]=30a[13] = 30a[14]=35a[14] = 35 之間。


選出第 kk 小的元素

演算法策略

選一個 pivot 把陣列切成「比 pivot 小」「等於 pivot」「比 pivot 大」三堆。看 kk 落在哪一堆,就只遞迴那一堆,另外兩堆整個丟掉。兩種變體差在 pivot 怎麼挑:

  • BFPRT(Median of Medians):每 5 個一組取中位數,再對這些中位數遞迴求中位數當 pivot。稍慢但保證每輪砍掉至少 3n/103n/10,最差還是 O(n)O(n)
  • Randomized Select:隨手抽一個當 pivot。期望 O(n)O(n)、運氣差時會退化成 O(n2)O(n^2)(機率極低)。

複雜度

  • BFPRTT(n)=T(n/5)+T(7n/10)+O(n)=O(n)T(n) = T(n/5) + T(7n/10) + O(n) = O(n)
    • T(n/5)T(n/5):對 n/5n/5 個中位數遞迴求 MoM
    • T(7n/10)T(7n/10):每輪保證砍掉 3n/10\geq 3n/10,剩下 7n/10\leq 7n/10
    • O(n)O(n):分組、取中位數、分堆都是線性
  • Randomized Select:期望 O(n)O(n)、最差 O(n2)O(n^2)

為什麼每組 5 個?組大小 3 時 1/3+2/3=11/3 + 2/3 = 1 不收斂;組大小 5 是「最小可線性」的選擇;組大小 7 常數更大不划算。

範例

題目[17,3,25,8,41,12,36,5,29,14,22,7,33,19,46,2,38,11,27,6,43,15,31,9,21][17, 3, 25, 8, 41, 12, 36, 5, 29, 14, 22, 7, 33, 19, 46, 2, 38, 11, 27, 6, 43, 15, 31, 9, 21],用 BFPRT 找第 8 小。

圖解

步驟

第一輪:25 個元素,分 5 組取中位數:

原始排序後中位數
G117, 3, 25, 8, 413, 8, 17, 25, 4117
G212, 36, 5, 29, 145, 12, 14, 29, 3614
G322, 7, 33, 19, 467, 19, 22, 33, 4622
G42, 38, 11, 27, 62, 6, 11, 27, 3811
G543, 15, 31, 9, 219, 15, 21, 31, 4321

中位數集 {17,14,22,11,21}\{17, 14, 22, 11, 21\} 排序後 {11,14,17,21,22}\{11, 14, 17, 21, 22\},MoM = 17 → pivot。

以 pivot = 17 分堆:

  • LL(< 17):11 個({3,8,12,5,14,7,2,11,6,15,9}\{3, 8, 12, 5, 14, 7, 2, 11, 6, 15, 9\}
  • EE(= 17):1 個
  • GG(> 17):13 個

k=8L=11k = 8 \leq |L| = 11 → 到 LL 找第 8 小。

第二輪:LL 共 11 個

原始排序後中位數
G13, 8, 12, 5, 143, 5, 8, 12, 148
G27, 2, 11, 6, 152, 6, 7, 11, 157
G3999

MoM = 8 → pivot。

以 8 分堆:L={3,5,7,2,6}L' = \{3, 5, 7, 2, 6\}(5 個)、E={8}E' = \{8\}(1 個)、G={12,14,11,15,9}G' = \{12, 14, 11, 15, 9\}(5 個)。k=8>L+E=6k = 8 > |L'| + |E'| = 6 → 到 GG' 找第 86=28 - 6 = 2 小。

第三輪:GG' 共 5 個:排序後 {9,11,12,14,15}\{9, 11, 12, 14, 15\},MoM = 12 → pivot。

分堆:L={11,9}L''= \{11, 9\}(2 個)、E={12}E'' = \{12\}G={14,15}G'' = \{14, 15\}k=2L=2k = 2 \leq |L''| = 2 → 到 LL'' 找第 2 小。

第四輪:LL'' 共 2 個[11,9][11, 9] 排序後 [9,11][9, 11],第 2 小 = 11

驗證:原陣列排序後 2,3,5,6,7,8,9,11,12,14,2, 3, 5, 6, 7, 8, 9, \mathbf{11}, 12, 14, \ldots,第 8 位確實是 11 ✓

Python 實作(BFPRT + Randomized Select)
def median_of_medians(arr, k):
    if len(arr) <= 5:
        return sorted(arr)[k - 1]
    groups = [sorted(arr[i:i + 5]) for i in range(0, len(arr), 5)]
    medians = [g[len(g) // 2] for g in groups]
    pivot = median_of_medians(medians, len(medians) // 2 + 1)
    L = [x for x in arr if x < pivot]
    E = [x for x in arr if x == pivot]
    G = [x for x in arr if x > pivot]
    if k <= len(L):
        return median_of_medians(L, k)
    if k <= len(L) + len(E):
        return pivot
    return median_of_medians(G, k - len(L) - len(E))


import random

def randomized_select(arr, k):
    if len(arr) == 1:
        return arr[0]
    pivot = random.choice(arr)
    L = [x for x in arr if x < pivot]
    E = [x for x in arr if x == pivot]
    G = [x for x in arr if x > pivot]
    if k <= len(L):
        return randomized_select(L, k)
    elif k <= len(L) + len(E):
        return pivot
    return randomized_select(G, k - len(L) - len(E))
例題:27 個數字找第 16 小(pivot 剛好命中 EE

陣列 a[1..27]=[7,3,6,8,1,10,4,3,8,5,7,10,0,10,2,1,4,4,13,5,0,8,9,9,2,6,1]a[1..27] = [7, 3, 6, 8, 1, 10, 4, 3, 8, 5, 7, 10, 0, 10, 2, 1, 4, 4, 13, 5, 0, 8, 9, 9, 2, 6, 1]k=16k = 16

分 6 組(前 5 組 5 個、最後一組 2 個):

原始排序後中位數
G17, 3, 6, 8, 11, 3, 6, 7, 86
G210, 4, 3, 8, 53, 4, 5, 8, 105
G37, 10, 0, 10, 20, 2, 7, 10, 107
G41, 4, 4, 13, 51, 4, 4, 5, 134
G50, 8, 9, 9, 20, 2, 8, 9, 98
G66, 11, 66

中位數集 {6,5,7,4,8,6}\{6, 5, 7, 4, 8, 6\} 排序後 {4,5,6,6,7,8}\{4, 5, 6, 6, 7, 8\},MoM = 6 → pivot。

以 6 分堆:

內容大小
LL(< 6)3, 1, 4, 3, 5, 0, 2, 1, 4, 4, 5, 0, 2, 114
EE(= 6)6, 62
GG(> 6)7, 8, 10, 8, 7, 10, 10, 13, 8, 9, 911

L=14<1616=L+E|L| = 14 < 16 \leq 16 = |L| + |E| → 答案就是 pivot = 6,不用再遞迴。

常見錯誤:G4 的中位數誤寫成 5、G5 誤寫成 6。一定要先排序再取中間,不是「原始順序的第 3 個」。

例題:Randomized Select 同一組找第 8 小

假設 pivot 剛好依序抽到 21 → 9 → 12。

第一輪:pivot = 21。LL 共 13 個、EE 1 個、GG 11 個。k=813k = 8 \leq 13 → 進 LL

第二輪L=[17,3,8,12,5,14,7,19,2,11,6,15,9]L = [17, 3, 8, 12, 5, 14, 7, 19, 2, 11, 6, 15, 9],pivot = 9。L=[3,8,5,7,2,6]L' = [3, 8, 5, 7, 2, 6](6 個)、E=[9]E' = [9]G=[17,12,14,19,11,15]G' = [17, 12, 14, 19, 11, 15](6 個)。k=8>7k = 8 > 7 → 進 GG' 找第 87=18 - 7 = 1 小。

第三輪:pivot = 12。L=[11]L'' = [11]E=[12]E'' = [12]G=[17,14,19,15]G'' = [17, 14, 19, 15]k=11k = 1 \leq 1 → 進 LL''

第四輪L=[11]L'' = [11],直接回傳 11

與 BFPRT 答案一致。差別:BFPRT pivot 固定為 MoM,穩但慢;Randomized 隨手抽,快但最差 O(n2)O(n^2)


打包問題(Bin Packing)

演算法策略

nn 個物品、大小 siCs_i \leq C,箱容量 CC,目標用最少箱子裝完。這題是 NP-Hard,實務用近似演算法:

  • Next Fit (NF):只看最後一個箱子,塞不下就開新箱。
  • First Fit (FF):從第一個箱子掃,第一個塞得下的就放。
  • Best Fit (BF):選剩餘空間最少且還裝得下的箱子。
  • First Fit Decreasing (FFD):先把物品由大到小排序,再跑 FF。

直覺:先放大的物品、小的用來填縫,所以 FFD 最好。要精確最佳解時用 Branch and Bound——對決策樹上下界不可能比目前最佳解好的子樹整個剪掉,這就是淘汰思想。

複雜度

  • NF / FF / BF:O(nlogn)O(n \log n)(找可用箱子要 O(logn)O(\log n),總共 nn 個物品)
  • FFD:O(nlogn)O(n \log n)(排序主導)

近似比(OPT 為最佳解箱子數):

策略近似比
Next FitA2OPTA \leq 2 \cdot \text{OPT}
First FitA1.7OPT+2A \leq 1.7 \cdot \text{OPT} + 2
First Fit DecreasingA119OPT+69A \leq \tfrac{11}{9} \cdot \text{OPT} + \tfrac{6}{9}

WG(Wasted Gap)= 箱子關閉時的剩餘容量;WE(Wasted Element)= 被所有現有箱子拒絕而被迫開新箱的物品。WG 累積越少、WE 越少發生,近似比就越好。

範例

題目:物品 [0.5,0.7,0.5,0.2,0.4,0.2,0.3,0.1][0.5, 0.7, 0.5, 0.2, 0.4, 0.2, 0.3, 0.1],箱容量 C=1.0C = 1.0,跑 FFD。

步驟:排序後由大到小 [0.7,0.5,0.5,0.4,0.3,0.2,0.2,0.1][0.7, 0.5, 0.5, 0.4, 0.3, 0.2, 0.2, 0.1]

物品動作箱況(剩餘容量)WE
10.7開 Bin1Bin1: 0.3
20.5Bin1(0.3) 塞不下 → 開 Bin2Bin1: 0.3, Bin2: 0.5+1
30.5Bin1 不行、Bin2 剛好Bin1: 0.3, Bin2: 0.0
40.4Bin1 不行、Bin2 不行 → 開 Bin3Bin1: 0.3, Bin2: 0.0, Bin3: 0.6+1
50.3Bin1 剛好Bin1: 0.0, Bin2: 0.0, Bin3: 0.6
60.2Bin3 可塞Bin3: 0.4
70.2Bin3 可塞Bin3: 0.2
80.1Bin3 可塞Bin3: 0.1

最終:3 個箱子、總 WG = 0.1。物品總大小 2.9、下界 2.9/1=3\lceil 2.9/1 \rceil = 3 → FFD 打到最佳。

Python 實作(NF、FF、FFD)
def next_fit(items, C):
    bins = [0]
    for x in items:
        if bins[-1] + x > C:
            bins.append(x)
        else:
            bins[-1] += x
    return bins


def first_fit(items, C):
    bins = []
    for x in items:
        placed = False
        for i, used in enumerate(bins):
            if used + x <= C:
                bins[i] += x
                placed = True
                break
        if not placed:
            bins.append(x)
    return bins


def first_fit_decreasing(items, C):
    return first_fit(sorted(items, reverse=True), C)
例題:NF vs FF vs FFD 比較

物品 [0.5,0.7,0.5,0.2,0.4,0.2,0.3,0.1][0.5, 0.7, 0.5, 0.2, 0.4, 0.2, 0.3, 0.1]C=1.0C = 1.0

  • NF:0.5→Bin1(剩 0.5);0.7→Bin1 塞不下開 Bin2(剩 0.3);0.5→Bin2 塞不下開 Bin3(剩 0.5);… 最終約 4~5 箱,浪費最多。
  • FF:不排序、照原順序從頭找可用箱。大致 3~4 箱。
  • FFD:如上例,3 箱,最佳。

結論:FFD 在這組資料上是最佳,也符合近似比最緊的直覺。

常見考法

題型必備技能
偽幣(已知方向)三分法,T(n)=O(log3n)T(n) = O(\log_3 n)kk 次可解 3k3^k
偽幣(方向未知)3k32\frac{3^k - 3}{2}(無參考)、3k12\frac{3^k - 1}{2}(有參考);k=3k = 3、12 枚是經典
二元搜尋left / right / mid / a[mid] / 動作 表;注意 0-/1-indexed、\lfloor\rfloor vs \lceil\rceil、找不到時 left > right
BFPRT5 個一組求中位數 → MoM → 分堆 → 比較 kkL,L+E\lvert L\rvert, \lvert L\rvert+\lvert E\rvert 決定遞迴哪堆
Randomized Select期望 O(n)O(n)、最差 O(n2)O(n^2);跟 BFPRT 的差異要會比
Bin Packing背 FFD 近似比 119OPT+69\leq \tfrac{11}{9} \cdot \text{OPT} + \tfrac{6}{9};會手算 WG、WE、NF/FF/BF/FFD 比較
晶片測試兩兩互測、成對淘汰;T(n)=T(n/2)+O(n)=O(n)T(n) = T(n/2) + O(n) = O(n)

最常考:跑一輪 BFPRT 找第 kk。分組、每組中位數、MoM、L/E/G\lvert L\rvert/\lvert E\rvert/\lvert G\rvertkk 落在哪堆——每一步都要列出來,缺一扣分。

結論

淘汰與搜尋的共通核心:每輪淘汰一固定比例、被淘汰的不再回頭。能用的條件:

  1. 存在低成本的比較或測試,能判定「哪部分必不含答案」
  2. 每輪能淘汰固定比例(1/c1/c),整體複雜度 T(n)=T(n/c)+O(f(n))T(n) = T(n/c) + O(f(n))

Latest Updates

  • 2026.04.08 Content updated