diff --git a/weeks/week-18/solutions/1114405019/AI_LOG.md b/weeks/week-18/solutions/1114405019/AI_LOG.md new file mode 100644 index 000000000..d1921188b --- /dev/null +++ b/weeks/week-18/solutions/1114405019/AI_LOG.md @@ -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 文字說明中,不受字型限制影響。 diff --git a/weeks/week-18/solutions/1114405019/README.md b/weeks/week-18/solutions/1114405019/README.md new file mode 100644 index 000000000..70db112eb --- /dev/null +++ b/weeks/week-18/solutions/1114405019/README.md @@ -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 +``` diff --git a/weeks/week-18/solutions/1114405019/assets/radar.png b/weeks/week-18/solutions/1114405019/assets/radar.png new file mode 100644 index 000000000..c84542c8c Binary files /dev/null and b/weeks/week-18/solutions/1114405019/assets/radar.png differ diff --git a/weeks/week-18/solutions/1114405019/plot.py b/weeks/week-18/solutions/1114405019/plot.py new file mode 100644 index 000000000..a0c7de73f --- /dev/null +++ b/weeks/week-18/solutions/1114405019/plot.py @@ -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) diff --git a/weeks/week-18/solutions/1114405019/requirements.txt b/weeks/week-18/solutions/1114405019/requirements.txt new file mode 100644 index 000000000..132e920b8 --- /dev/null +++ b/weeks/week-18/solutions/1114405019/requirements.txt @@ -0,0 +1,2 @@ +matplotlib>=3.10 +pytest>=8.0 diff --git a/weeks/week-18/solutions/1114405019/search_perf-easy.py b/weeks/week-18/solutions/1114405019/search_perf-easy.py new file mode 100644 index 000000000..f0862cf8f --- /dev/null +++ b/weeks/week-18/solutions/1114405019/search_perf-easy.py @@ -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 代表範圍已經砍到空,確定找不到 diff --git a/weeks/week-18/solutions/1114405019/search_perf.py b/weeks/week-18/solutions/1114405019/search_perf.py new file mode 100644 index 000000000..0b30f69fa --- /dev/null +++ b/weeks/week-18/solutions/1114405019/search_perf.py @@ -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() diff --git a/weeks/week-18/solutions/1114405019/test_log.txt b/weeks/week-18/solutions/1114405019/test_log.txt new file mode 100644 index 000000000..9a893052f --- /dev/null +++ b/weeks/week-18/solutions/1114405019/test_log.txt @@ -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 ============================== diff --git a/weeks/week-18/solutions/1114405019/test_search_perf.py b/weeks/week-18/solutions/1114405019/test_search_perf.py new file mode 100644 index 000000000..29314e650 --- /dev/null +++ b/weeks/week-18/solutions/1114405019/test_search_perf.py @@ -0,0 +1,112 @@ +""" +針對 search_perf.py 的單元測試。 + +測重點:linear_search / binary_search 的正確性(FOUND / NOT FOUND / idx / cmp), +以及 generate_sorted_array 對「K 在不在陣列裡」的控制能力。 +timeit 量測與畫圖(plot_radar)不在此檔測試,因為效能數字會隨機器浮動、 +畫圖也沒有「正確答案」可斷言。 +""" + +import math + +from search_perf import binary_search, generate_sorted_array, linear_search + + +def test_binary_found_idx_matches_value(): + """找到時,回報的 idx 必須真的指向 target,而不是隨便回一個數字。""" + arr = [2, 4, 6, 8, 10, 12, 14] + idx, cmp = binary_search(arr, 10) + assert idx is not None + assert arr[idx] == 10 + assert cmp >= 1 + + +def test_binary_not_found_in_gap_between_elements(): + """target 落在兩個元素中間,最容易因為 off-by-one 誤判成鄰居元素。""" + arr = [1, 3, 5, 7, 9] + idx, cmp = binary_search(arr, 4) + assert idx is None + assert cmp >= 1 + + +def test_binary_not_found_below_minimum(): + """target 比陣列最小值還小,迴圈必須正確收斂到 NOT FOUND,不能誤判邊界。""" + arr = [10, 20, 30] + idx, _ = binary_search(arr, 1) + assert idx is None + + +def test_binary_not_found_above_maximum(): + """target 比陣列最大值還大,同樣是邊界收斂測試。""" + arr = [10, 20, 30] + idx, _ = binary_search(arr, 99) + assert idx is None + + +def test_single_element_found(): + """陣列只有 1 個元素,且該元素恰為 target:最小規模的 FOUND 案例。""" + idx, cmp = binary_search([5], 5) + assert idx == 0 + assert cmp == 1 + + +def test_single_element_not_found(): + """陣列只有 1 個元素,且該元素不是 target:最小規模的 NOT FOUND 案例。""" + idx, cmp = binary_search([5], 7) + assert idx is None + assert cmp == 1 + + +def test_first_element_boundary(): + """target 剛好是陣列第一個元素,確認迴圈條件沒有漏掉左邊界。""" + arr = [2, 4, 6, 8, 10] + idx, _ = binary_search(arr, 2) + assert idx == 0 + + +def test_last_element_boundary(): + """target 剛好是陣列最後一個元素,確認迴圈條件沒有漏掉右邊界。""" + arr = [2, 4, 6, 8, 10] + idx, _ = binary_search(arr, 10) + assert idx == len(arr) - 1 + + +def test_binary_cmp_upper_bound(): + """ + 二分搜尋的比較次數理論上限約為 ceil(log2(m)) + 1。 + 如果超出這個量級,代表搜尋邏輯(例如沒有正確折半)有問題。 + """ + arr = list(range(0, 2000, 2)) # m = 1000 個唯一升冪整數 + upper_bound = math.ceil(math.log2(len(arr))) + 1 + for target in (-5, 0, 1, 998, 1998, 50000): + _, cmp = binary_search(arr, target) + assert cmp <= upper_bound + + +def test_linear_binary_consistency_found_and_not_found(): + """ + 交叉驗證:同一陣列、同一 target,linear 與 binary 的 FOUND/NOT FOUND + 結果必須一致;陣列為唯一值時,FOUND 的 idx 也必須一致。 + 若兩者結果不一致,代表至少一個演算法寫錯了。 + """ + arr = generate_sorted_array(m=500, target=119, present=True, seed=1) + for target in (119, -999, arr[0], arr[-1], arr[len(arr) // 2] + 1): + l_idx, _ = linear_search(arr, target) + b_idx, _ = binary_search(arr, target) + assert (l_idx is None) == (b_idx is None) + if l_idx is not None: + assert l_idx == b_idx + + +def test_generate_sorted_array_present_guarantees_target(): + """present=True 時,target 必須恰好出現一次,且陣列升冪排列。""" + arr = generate_sorted_array(m=300, target=119, present=True, seed=42) + assert arr.count(119) == 1 + assert arr == sorted(arr) + + +def test_generate_sorted_array_absent_guarantees_no_target(): + """present=False 時,target 絕對不能出現在陣列中。""" + arr = generate_sorted_array(m=300, target=119, present=False, seed=42) + assert 119 not in arr + assert arr == sorted(arr)