Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions weeks/week-18/solutions/1114405019/AI_LOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# AI_LOG

## 我問 AI 什麼

請 AI 針對「二分搜尋效能比較」(K = 100 + 學號末兩碼 19 = 119)設計測試與實作:要求先寫測試(紅燈)、我確認後才寫實作,並要求 AI 在動手前先提出函式簽名、陣列產生策略(如何控制 K 在不在陣列中是可控的)、以及邊界測試規劃。

## AI 給了什麼

AI 提出 `linear_search` / `binary_search` 回傳 `(idx, cmp)`、用獨立的 `generate_sorted_array(m, target, present, seed)` 控制 K 是否在陣列中、以及 12 個涵蓋邊界值(單元素、第一/最後元素、間隙誤判、cmp 上限、linear/binary 交叉驗證)的測試案例。雷達圖第一版用中文標籤,執行時 matplotlib 出現 `Glyph ... missing from font(s) DejaVu Sans` 警告(中文字會變成方框)。

## 我改了什麼

1. 對 AI 提出的兩個開放決策做了選擇(而不是讓 AI 自己猜):main() 的輸入方式採「優先讀 stdin,沒輸入才自動生成」,雷達圖的 small_n/large_n 採 200 / 200,000——後者特地選擇遠超過題目要求的 10^5 門檻,確保能看出 binary 在大資料量下的優勢。
2. 確認測試案例前,檢查了 12 個測試是否真的涵蓋題目要求的所有 edge case(K 存在/不存在、單元素、邊界元素、off-by-one、cmp 上限、交叉驗證),確認無誤後才放行讓 AI 寫實作。
3. 發現雷達圖出現中文缺字警告後,要求把圖上文字(軸標籤、標題)改成英文,避免輸出圖片裡出現方框亂碼;中文解讀則保留在 README.md 文字說明中,不受字型限制影響。
86 changes: 86 additions & 0 deletions weeks/week-18/solutions/1114405019/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# 第四題:二分搜尋效能(學號 1114405019)

K = 100 + 19 = **119**(學號末兩碼 19)

## 檔案

- `search_perf-easy.py`:**AI 教的簡單版本**,只留 `linear_search` / `binary_search` 兩個核心函式,附詳細中文註解,給 CPE 練習時手打背誦用
- `search_perf.py`:**手打的完整版本**,加上 `generate_sorted_array` / `time_searches` / `collect_radar_metrics` / `main`,串接輸入輸出、timeit、畫圖
- `plot.py`:雷達圖(`matplotlib.use("Agg")`,無視窗環境可執行)
- `test_search_perf.py`:pytest 單元測試(只測搜尋正確性,不測 timeit / 畫圖)
- `assets/radar.png`:雷達圖輸出
- `test_log.txt`:測試執行紀錄(針對手打版本 `search_perf.py` 跑 `test_search_perf.py` 的結果)
- `requirements.txt`:依賴套件(`matplotlib`、`pytest`)
- `AI_LOG.md`:AI 使用紀錄

## 輸入/輸出

- 優先從 stdin 讀「第1行 m;第2行 m 個升冪整數」。
- 沒有 stdin 輸入時,自動用 `generate_sorted_array(m=200_000, target=119, present=True)` 產生陣列。
- 輸出:`FOUND idx cmp=次數` 或 `NOT FOUND cmp=次數`,接著印兩種搜尋的 timeit 結果與較快者。

## 陣列產生規則(如何控制 K 在不在)

`generate_sorted_array(m, target, present, seed)`:在排除 `target` 的整數範圍內取樣 `m`(或 `m-1`)個唯一整數、排序,
若 `present=True` 才用 `bisect.insort` 把 `target` 插入。因此:

- `present=True` → `target` **保證**恰好出現一次 → 結果必為 `FOUND`。
- `present=False` → `target` **保證**不出現 → 結果必為 `NOT FOUND`。

`main()` 自動生成時固定用 `present=True`,所以執行 `python search_perf.py`(無 stdin)一定會印出 `FOUND`;
`NOT FOUND` 與其他邊界情況(單元素陣列、K 為第一個/最後一個元素、off-by-one 邊界)改用單元測試裡手刻的固定陣列驗證,
因為這些情況需要精確控制元素位置,用隨機產生器反而不可控。

## 雷達圖:維度與正規化

選了 4 個維度(`plot.py` 的 `DIMENSIONS`):

1. **small-n speed**:m=200 時的 timeit 耗時
2. **large-n speed**:m=200,000 時的 timeit 耗時
3. **fewer comparisons**:在 large-n 規模下的 cmp 次數
4. **no presort needed**:binary 需要事先排序好的陣列才能用,linear 不需要——這是類別變數(0/1),不是連續量

選這 4 個的原因:前 3 個是這題明確要求量測的指標(小 n 速度、大 n 速度、cmp),第 4 個補上一個 binary 的隱藏成本——
如果原始資料沒排序,要先付出排序成本,這在前 3 個維度裡看不出來,但是 binary search 真實使用情境裡很關鍵的權衡。

正規化方式(讓「越外凸代表越好」):

- 連續且越小越好的維度(時間、cmp):`score = min(linear, binary) / 該方法的值`。贏家分數恰好是 1,
輸家是兩者的比例(沒有直接除以「全部方法的最大值」,因為時間/次數的量級差很多——例如 binary 的 cmp
可能是個位數、linear 是六位數——直接除最大值會讓贏家的分數被輸家的尺度拉到幾乎貼著 0,反而看不出差異;
用「最小值 / 自己」可以保證贏家永遠頂滿,輸家的分數則反映「贏家比我快幾倍」這個有意義的比例)。
- `needs_presort`(已經是 0/1):直接 `score = 1 - value`,因為「不需要排序」才是優點,分數方向要跟其他維度一致(外凸=好)。

## 解讀(2-3 句)

雷達圖中 binary 在 **large-n speed**、**small-n speed**、**fewer comparisons** 三個維度都明顯外凸,
顯示資料量越大時 binary search 的優勢越明顯(這也對應到 O(log n) vs O(n) 的理論複雜度)。
但 linear 在 **no presort needed** 維度滿分、binary 是 0 分——binary search 要求資料先排序好,
若資料本身是無序的且只搜尋一次,排序成本可能蓋過 binary 省下來的搜尋時間,所以沒有絕對贏家,
要看「資料量大小」與「資料是否已經排序/會被重複查詢多次」來決定用哪種演算法。

## 測試

```
pytest test_search_perf.py -v
```

12 個測試,涵蓋:找到時 idx 正確、NOT FOUND 邊界(落在元素間隙、小於最小值、大於最大值)、
單元素陣列(FOUND / NOT FOUND)、K 為第一個/最後一個元素、cmp 次數理論上限、
linear 與 binary 在多組 target 上的 FOUND/NOT FOUND 與 idx 交叉一致性、
以及 `generate_sorted_array` 對 K 在不在陣列裡的保證。全數通過,紀錄於 `test_log.txt`。

## 執行

```
python search_perf.py
```

範例輸出(陣列由程式隨機生成,idx/cmp/timeit 數字每次執行會不同):

```
FOUND 100323 cmp=18
linear : 0.0426 s
binary : 0.0000 s
=> binary faster
```
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
63 changes: 63 additions & 0 deletions weeks/week-18/solutions/1114405019/plot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""
畫 linear vs binary 的多維權衡雷達圖。
無視窗環境(CI / 沒有 DISPLAY)需要在 import pyplot 之前指定 Agg backend。
正規化與維度選擇的理由寫在 README.md。
"""

import math
import os

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt

DIMENSIONS = ["small_n_time", "large_n_time", "cmp", "needs_presort"]
LABELS = ["small-n speed", "large-n speed", "fewer comparisons", "no presort needed"]


def _normalize(metrics):
"""
把每個維度轉成「越大越好、範圍落在 (0, 1]」的分數:
- 連續且越小越好的維度(時間、cmp):score = min_value / value,
贏家恰好等於 1,輸家是 min/value 的比例(避免兩個方法數值差太多時,
輸家被壓成肉眼看不出來的 0)。
- 已經是 0/1 類別變數的維度(needs_presort):直接 score = 1 - value,
因為「不需要先排序」才是優點。
"""
scores = {method: [] for method in metrics}
for dim in DIMENSIONS:
values = {method: metrics[method][dim] for method in metrics}
if dim == "needs_presort":
for method in metrics:
scores[method].append(1 - values[method])
continue
min_v = min(v for v in values.values() if v > 0) if any(values.values()) else 1
for method in metrics:
v = values[method]
scores[method].append(min_v / v if v > 0 else 1.0)
return scores


def plot_radar(metrics, output_path="assets/radar.png"):
"""metrics: collect_radar_metrics() 回傳的 dict('linear' / 'binary' 兩個 method)。"""
scores = _normalize(metrics)
n = len(DIMENSIONS)
angles = [i / n * 2 * math.pi for i in range(n)]
angles += angles[:1]

fig, ax = plt.subplots(figsize=(6, 6), subplot_kw={"projection": "polar"})
for method, color in (("linear", "tab:blue"), ("binary", "tab:orange")):
values = scores[method] + scores[method][:1]
ax.plot(angles, values, label=method, color=color)
ax.fill(angles, values, alpha=0.15, color=color)

ax.set_xticks(angles[:-1])
ax.set_xticklabels(LABELS)
ax.set_ylim(0, 1)
ax.set_title("Linear vs Binary Search Trade-offs")
ax.legend(loc="upper right", bbox_to_anchor=(1.2, 1.1))

os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
fig.savefig(output_path, bbox_inches="tight")
plt.close(fig)
2 changes: 2 additions & 0 deletions weeks/week-18/solutions/1114405019/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
matplotlib>=3.10
pytest>=8.0
44 changes: 44 additions & 0 deletions weeks/week-18/solutions/1114405019/search_perf-easy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""
簡化/好記版本:只留下 CPE 當場手打時真正需要記住的兩個函式。
不含 timeit、雷達圖、檔案輸入這些「裝飾」,方便練習時專心背邏輯。
"""


def linear_search(arr, target):
"""
線性搜尋:從第一個元素開始,一個一個比對到底。

口訣:「從頭找到尾,找到就回頭」
- 不要求 arr 已排序,所以隨機亂序的陣列也能用。
- cmp 用來計算總共比了幾次,每比一次 +1。
"""
cmp = 0
for i in range(len(arr)):
cmp += 1
if arr[i] == target:
return i, cmp # 找到了,立刻回頭,不要多比
return None, cmp # 比到最後都沒有,才算 NOT FOUND


def binary_search(arr, target):
"""
二分搜尋:每次都看中間那個,比較完直接砍掉一半。

口訣:「先看中間,比大砍右,比小砍左」
- 前提:arr 一定要先排好(升冪),否則砍半的邏輯會錯。
- lo / hi 是目前還沒被排除的範圍兩端(含 lo 和 hi 本身)。
- 迴圈條件用 lo <= hi(不是 <),因為當 lo == hi 時,
代表還剩 1 個元素沒檢查,這就是最容易漏掉邊界的地方。
"""
cmp = 0
lo, hi = 0, len(arr) - 1
while lo <= hi:
mid = (lo + hi) // 2 # 中間位置,整數除法直接捨去小數
cmp += 1
if arr[mid] == target:
return mid, cmp # 中間就是答案,回頭
elif arr[mid] < target:
lo = mid + 1 # target 比中間大 -> 答案在右半邊,砍掉左邊(含 mid)
else:
hi = mid - 1 # target 比中間小 -> 答案在左半邊,砍掉右邊(含 mid)
return None, cmp # lo > hi 代表範圍已經砍到空,確定找不到
147 changes: 147 additions & 0 deletions weeks/week-18/solutions/1114405019/search_perf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""
第四題:二分搜尋效能比較。

學號末兩碼 = 19,搜尋目標 K = 100 + 19 = 119。
"""

import bisect
import math
import random
import sys
import timeit

K = 119


def linear_search(arr, target):
"""從頭逐一比較,找到回傳 (idx, cmp);找不到回傳 (None, cmp)。"""
cmp = 0
for i, v in enumerate(arr):
cmp += 1
if v == target:
return i, cmp
return None, cmp


def binary_search(arr, target):
"""
前提:arr 已升冪排序。
每次迴圈只做一次 == / < / > 的「決定方向」比較,cmp 計次以此為準,
這樣才能跟 ceil(log2(m)) + 1 的理論上限對齊。
"""
cmp = 0
lo, hi = 0, len(arr) - 1
while lo <= hi:
mid = (lo + hi) // 2
cmp += 1
if arr[mid] == target:
return mid, cmp
elif arr[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return None, cmp


def generate_sorted_array(m, target, present, seed=None):
"""
產生長度為 m 的升冪唯一整數陣列。
present=True:target 保證恰好出現一次。
present=False:target 保證不出現。
做法:在排除 target 的範圍內取樣 m(或 m-1)個唯一值,
present=True 時再用 bisect.insort 把 target 插入正確位置。
"""
rng = random.Random(seed)
span = max(m * 5, 1000)
pool_low, pool_high = target - span, target + span
candidates = [v for v in range(pool_low, pool_high) if v != target]

count = m - 1 if present else m
sample = rng.sample(candidates, count)
sample.sort()

if present:
bisect.insort(sample, target)
return sample


def time_searches(arr, target, number=5):
"""用 timeit 量測 linear_search 與 binary_search 各跑 number 次的總秒數。"""
linear_time = timeit.timeit(lambda: linear_search(arr, target), number=number)
binary_time = timeit.timeit(lambda: binary_search(arr, target), number=number)
return {"linear": linear_time, "binary": binary_time}


def collect_radar_metrics(small_n, large_n, target, seed=None):
"""
為雷達圖準備原始數據(未正規化):
- small_n / large_n 兩種規模下,linear 與 binary 各自的耗時
- 在 large_n 規模下兩者的比較次數 cmp
- 是否需要先排序(categorical:linear=0 不需要,binary=1 需要)
"""
small_arr = generate_sorted_array(small_n, target, present=True, seed=seed)
large_arr = generate_sorted_array(large_n, target, present=True, seed=seed)

small_time = time_searches(small_arr, target)
large_time = time_searches(large_arr, target)

_, linear_cmp = linear_search(large_arr, target)
_, binary_cmp = binary_search(large_arr, target)

return {
"linear": {
"small_n_time": small_time["linear"],
"large_n_time": large_time["linear"],
"cmp": linear_cmp,
"needs_presort": 0,
},
"binary": {
"small_n_time": small_time["binary"],
"large_n_time": large_time["binary"],
"cmp": binary_cmp,
"needs_presort": 1,
},
}


def _read_array_from_stdin():
"""嘗試從 stdin 讀取「第1行 m;第2行 m 個升冪整數」,讀不到就回傳 None。"""
data = sys.stdin.read().strip()
if not data:
return None
lines = data.splitlines()
if len(lines) < 2:
return None
m = int(lines[0])
arr = [int(x) for x in lines[1].split()]
if len(arr) != m:
raise ValueError(f"輸入的整數個數 ({len(arr)}) 與宣告的 m ({m}) 不一致")
return arr


def main():
arr = _read_array_from_stdin()
if arr is None:
# 沒有 stdin 輸入:自動生成一個 >= 10^5 的大陣列,並保證 K 在裡面。
arr = generate_sorted_array(m=200_000, target=K, present=True, seed=0)

idx, cmp = binary_search(arr, K)
if idx is not None:
print(f"FOUND {idx} cmp={cmp}")
else:
print(f"NOT FOUND cmp={cmp}")

times = time_searches(arr, K)
print(f"linear : {times['linear']:.4f} s")
print(f"binary : {times['binary']:.4f} s")
faster = "binary" if times["binary"] < times["linear"] else "linear"
print(f"=> {faster} faster")

from plot import plot_radar

metrics = collect_radar_metrics(small_n=200, large_n=200_000, target=K, seed=0)
plot_radar(metrics, "assets/radar.png")


if __name__ == "__main__":
main()
21 changes: 21 additions & 0 deletions weeks/week-18/solutions/1114405019/test_log.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
============================= test session starts =============================
platform win32 -- Python 3.11.9, pytest-9.0.2, pluggy-1.6.0 -- C:\Users\yiteng\AppData\Local\Programs\Python\Python311\python.exe
cachedir: .pytest_cache
rootdir: C:\Users\yiteng\Downloads\0622-4\2026-python\weeks\week-18\solutions\1114405019
plugins: anyio-4.13.0
collecting ... collected 12 items

test_search_perf.py::test_binary_found_idx_matches_value PASSED [ 8%]
test_search_perf.py::test_binary_not_found_in_gap_between_elements PASSED [ 16%]
test_search_perf.py::test_binary_not_found_below_minimum PASSED [ 25%]
test_search_perf.py::test_binary_not_found_above_maximum PASSED [ 33%]
test_search_perf.py::test_single_element_found PASSED [ 41%]
test_search_perf.py::test_single_element_not_found PASSED [ 50%]
test_search_perf.py::test_first_element_boundary PASSED [ 58%]
test_search_perf.py::test_last_element_boundary PASSED [ 66%]
test_search_perf.py::test_binary_cmp_upper_bound PASSED [ 75%]
test_search_perf.py::test_linear_binary_consistency_found_and_not_found PASSED [ 83%]
test_search_perf.py::test_generate_sorted_array_present_guarantees_target PASSED [ 91%]
test_search_perf.py::test_generate_sorted_array_absent_guarantees_no_target PASSED [100%]

============================= 12 passed in 0.02s ==============================
Loading