National Taiwan University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try a Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

2

Local AI

--

Uedu Code

--

台灣大學 AQI 70 24°C PM2.5 17
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 04

線性搜尋與二分搜尋

一次排除一半:lo、hi、mid 怎麼移動,邊界為什麼容易寫錯
Interaction實作 25 minute(s)

Learning Objectives

  • 寫出線性搜尋,說明最壞情況要比較 n 次
  • 說明二分搜尋的前提是資料已經排序,並追蹤 lo、hi、mid 在每一輪怎麼移動
  • 說明二分搜尋每一輪排除一半,所以最多只要比較約 log₂n 次
  • 分辨「找到某個值」和「找第一個大於等於某值的位置」(lower_bound),正確寫出邊界條件
  • 使用 Python、C++、Java 內建的二分搜尋工具

在一排資料裡找東西

「學號 1123 在不在名單上?」「價格 300 元的商品排在第幾個?」這類問題叫做搜尋(search):給一組資料和一個目標值,找出目標在哪裡,找不到就回報找不到。

最直接的做法是從頭開始一個一個看,這叫線性搜尋(linear search):

  1. 從第 0 個元素開始
  2. 和目標相等就回傳目前的位置
  3. 否則看下一個;全部看完都沒有,就回傳 -1

這一課的每個範例都有 Python、C++、Java 三種寫法,可以用動畫上方的按鈕切換;選擇會記在瀏覽器裡,整頁一起換。 三種寫法的輸出完全相同,差別只在語法。

線性搜尋:一個一個比

上方的格子是陣列 a,▲ 標出迴圈變數 i 指到的位置。找 61 時比到第 5 個才找到;找 50 時把 7 個元素全部比完才確定不在裡面。

線性搜尋要比幾次

比較的次數取決於目標在哪裡:

情況 比較次數
最好:目標就在第一個 1
最壞:目標在最後一個,或根本不在陣列裡 n
平均(目標在每個位置的機會相同) 約 n / 2

分析演算法時通常看最壞情況,因為它保證「再慢也不會超過這樣」。線性搜尋的最壞情況是 n 次,資料量變成 10 倍,比較次數也變成 10 倍,用第 02 課的寫法就是 O(n)。

線性搜尋的好處是不挑資料:沒有排序、甚至是一邊讀一邊找都可以。但如果資料已經排好順序,就有快得多的方法。

二分搜尋:每次排除一半

翻字典找「演算法」這個詞,不會從第一頁開始翻。你會先翻到中間,看那一頁的字比目標前面還是後面,再決定往哪一半找。這就是二分搜尋(binary search)的想法。

前提:陣列必須已經由小到大排好。做法是用兩個索引 lo、hi 圍出「目標如果存在,一定在這個範圍裡」:

  1. 一開始 lo = 0、hi = n - 1,範圍是整個陣列
  2. 取中間 mid = (lo + hi) / 2(整數除法)
  3. a[mid] 剛好等於目標:找到了
  4. a[mid] 比目標小:目標只可能在右半邊,lo = mid + 1
  5. a[mid] 比目標大:目標只可能在左半邊,hi = mid - 1
  6. 只要 lo <= hi 範圍裡就還有元素,回到第 2 步;lo > hi 表示範圍空了,目標不在陣列裡

第 4、5 步寫成 mid + 1、mid - 1 而不是 mid,因為 a[mid] 已經比過、確定不是目標了,下一輪不需要再把它算進範圍。

二分搜尋:看 lo、mid、hi 怎麼移動

灰掉的格子已經不在搜尋範圍裡。找 63 時,第一輪看正中間的 34,比 63 小,左半邊整段被排除;第三輪就找到了。找 30 時,範圍縮到 lo > hi(範圍空了),回傳 -1。12 個元素最多只要比 4 次。

為什麼這麼快:對半再對半

每比一次,範圍就剩下一半(甚至更少)。n 個元素要對半切幾次才會只剩 1 個?

元素個數 n 最多比較次數
10 4
1,000 10
1,000,000 20
1,000,000,000 30

規律是:n 每變成 2 倍,次數只多 1。這個次數大約是 log₂n(2 要乘幾次才會超過 n),所以二分搜尋是 O(log n)。 十億筆資料,線性搜尋最壞要比十億次,二分搜尋只要約 30 次。

下面的程式模擬「猜數字」的最壞情況:每次猜正中間,而且每次都猜得太小,數一數總共猜了幾次。

數一數要猜幾次

看上方 lo 和 hi 的距離,每一輪大約縮成一半。n 從 1,000 變成 1,000,000(變成 1000 倍),次數只從 10 變成 20。

想一想:二分搜尋的前提與次數

1.

對陣列 [30, 10, 50, 20, 40] 用二分搜尋找 20,會怎樣?

2.

一個已排序的陣列有 4,000 個元素,二分搜尋最多要比較幾次?

3.

把二分搜尋的迴圈條件 while lo <= hi 改成 while lo < hi(其他不變),會發生什麼事?

第 1 次作答

找「第一個大於等於」的位置

二分搜尋找到的是「某一個」等於目標的位置。陣列裡有重複值時,例如 [10, 20, 20, 20, 30, 40] 找 20,回傳的可能是 1、2、3 任何一個,取決於 mid 先落在哪裡。

實際應用常問的是另一個問題:第一個 ≥ 目標的位置在哪裡? 這叫 lower_bound,它有幾個好用的性質:

  • 目標存在時,回傳的是第一次出現的位置
  • 目標不存在時,回傳的是「把目標插進去、陣列仍然有序」的位置
  • 所有元素都比目標小時,回傳 n(陣列的長度)

寫法改用半開區間 [lo, hi):含 lo、不含 hi。hi 從 n 開始(n 本身也是可能的答案),迴圈條件是 lo < hi:

  • a[mid] < target:mid 和它左邊都不是答案,lo = mid + 1
  • 否則 a[mid] >= target:mid 可能就是答案,所以 hi = mid(不是 mid - 1)

迴圈結束時 lo == hi,這就是答案。

lower_bound:重複值與不存在的值

找 20 時,即使 a[mid] 已經等於 20,hi 還是會繼續往左縮,因為左邊可能還有 20;最後停在第一個 20,索引 1。找 25 得到 4(25 應該插在 20 和 30 之間);找 99 得到 6,也就是陣列長度。hi 等於 6 時已經超出最後一格,所以標在格子下方的文字裡。

各語言內建的二分搜尋

實際寫程式時,通常直接用語言內建的工具,不必自己寫:

語言 寫法 回傳
Python bisect.bisect_left(a, x) 第一個 ≥ x 的位置(就是 lower_bound)
Python bisect.bisect_right(a, x) 第一個 > x 的位置
C++ lower_bound(a.begin(), a.end(), x) 指向第一個 ≥ x 的迭代器,減掉 a.begin() 得到索引
C++ binary_search(a.begin(), a.end(), x) 只回傳 true/false
Java Arrays.binarySearch(a, x) 找到時回傳某一個相等元素的索引;找不到時回傳負數 -(插入位置) - 1

注意 Java 的 Arrays.binarySearch 遇到重複值時,不保證回傳第一個;需要第一次出現的位置,要自己寫 lower_bound。

自己寫二分搜尋最常出錯的地方是邊界:用閉區間 [lo, hi] 還是半開區間 [lo, hi)、迴圈條件用 <= 還是 <、更新時用 mid 還是 mid ± 1。 選定一種區間,三個地方就要一致。

另外,C++ 與 Java 的 int 有上限(約 21 億)。陣列非常大時,lo + hi 可能超過上限而溢位變成負數,所以範例寫成 lo + (hi - lo) / 2,結果相同但不會溢位。Python 的整數沒有上限,不會有這個問題。

想一想:lower_bound 的結果

1.

a = [2, 4, 4, 4, 7, 9],lower_bound(a, 4) 和 lower_bound(a, 5) 分別是多少?

2.

已排序的陣列裡,怎麼用 lower_bound 算出「值 x 出現了幾次」?

第 1 次作答

實作:一個分數出現幾次

scores 是由小到大排好的成績。請完成 lower_bound(第一個 ≥ target 的位置),再用它算出 80 分、85 分、100 分各有幾個人。預期輸出在右邊;可以切換成你熟悉的語言。

你的輸出
預期輸出

重點整理

  • 線性搜尋一個一個比,不需要排序,最壞要比 n 次,O(n)
  • 二分搜尋要求資料已經排序,每一輪排除一半,最多約 log₂n 次,O(log n)
  • 閉區間 [lo, hi] 配 while lo <= hi、lo = mid + 1、hi = mid - 1;半開區間 [lo, hi) 配 while lo < hi、hi = mid
  • lower_bound 找第一個 ≥ 目標的位置:目標存在時是第一次出現的位置,不存在時是應該插入的位置
  • C++、Java 用 lo + (hi - lo) / 2 避免 lo + hi 溢位

下一課:遞迴。二分搜尋也可以用遞迴寫,到時候會看到「把問題切成一半」的另一種寫法。

這一課完成了!
Next lesson:遞迴思考
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。