演算法圖鑑
Searching & Two Pointers · 01 / 05

Linear Search線性搜尋

一個一個看,無序資料唯一的選擇

用在:小資料、無序資料、只找一次

時間複雜度O(n)
空間複雜度O(1)
難度入門
前置知識Array

01為什麼需要它

設定檔裡找一個 key

程式啟動時讀一個幾十行的設定檔,要找某個欄位的值。要不要先建索引、排序、用雜湊表?

為什麼用它幾十筆資料從頭看到尾,只要微秒等級的時間。排序或建雜湊表本身就得把每一筆都處理一遍,只找一次的話,建置成本一定比直接掃一遍高。資料小又只找一次,一個一個看就是最快的方法。

日誌裡找第一筆錯誤

一份剛寫完的日誌檔,順序是時間,內容沒有任何索引。要找出第一次出現 ERROR 的那一行。

為什麼用它資料沒有依你要找的東西排序,也不會重複查很多次。這種情況沒有捷徑,順著掃是唯一的選擇,而且找到就能停。

「最近開啟的檔案」清單

手上有一份 5 個元素的「最近用過的檔案」清單,每次開檔都要查它在不在清單裡。要用雜湊表嗎?

為什麼用它元素很少時,線性掃描的常數比雜湊小:不用算 hash、記憶體連續、CPU 快取友善。5 個元素最多比 5 次,不值得為它另外維護一個雜湊表,還要保持兩者同步。

看到這些關鍵字就想到它:資料無序、資料很小、只查一次、找到就停、不值得先排序或建索引。

02核心概念

線性搜尋是最直接的搜尋:從第一個元素開始,一個一個和目標比,相等就回傳位置,掃完都沒有就回傳「不存在」。它不需要資料有任何性質,不需要排序、不需要額外空間,任何可以逐一走訪的東西(陣列、鏈結串列、檔案的每一行)都能用。

成本是 O(n) 時間、O(1) 額外空間:最好情況第一格就中,比 1 次;最壞情況比 n 次(目標在最後一格或不存在);目標存在且在每個位置的機率相同時,平均比 (n+1)/2 次,仍然是 O(n)。這個數字本身不是問題,問題是它會不會被重複很多次。查一次 O(n) 很便宜;查 m 次就是 O(mn),這時才需要先花 O(n log n) 排序換取每次 O(log n) 的二分搜尋,或花 O(n) 建雜湊表換取每次平均 O(1)。優化是用建置成本換查詢成本,只查一次的資料不值得。

另一個容易忽略的點是常數。線性掃描的迴圈極簡單,記憶體連續存取,CPU 分支預測和快取都很友善。元素在幾十個以內時,它常常比雜湊表快,因為省掉了算 hash 和隨機記憶體存取。所以「小就直接掃」是真實世界的做法,不是偷懶:例如 Rust 標準函式庫的 BTreeMap,每個節點最多放 11 個 key,在節點裡找 key 用的就是線性搜尋。

和相鄰工具的分界:資料已經有序(而且能隨機存取),直接二分搜尋,查一次也划算;資料無序但會重複查,先排序再二分,或建雜湊表;資料無序又只查一次,或資料小到建結構的成本都划不來,就用線性搜尋。另外,沒有任何結構可利用時,最壞情況每一格都得看過才能確定目標不在,所以 O(n) 已經是下限,不是寫得不好。學它的重點不是演算法本身,而是知道什麼時候不需要更好的演算法

03演算法步驟

  1. 1從索引 0 開始,i = 0
  2. 2只要 i < n,就比較 nums[i] 和目標。相等就回傳 i,這是唯一的成功出口。
  3. 3不相等就 i += 1,回到上一步。
  4. 4i 到達 n(包括陣列是空的、一開始 n = 0)表示全部看過都沒有,回傳 -1
  5. 5需要「所有符合的位置」時,不要提前回傳,把每個符合的 i 收進清單,掃完再回傳。

04互動示範

無序的 10 個數字裡找 46,再切換成找不存在的 40,看最壞情況比了幾次。下方表格列出不同 n 時,線性搜尋和二分搜尋最壞要比幾次,二分的前提是資料已經排好。

n = 10 · 無序
陣列(無序)
170
41
292
83
514
235
126
467
38
359
目標 46比較次數 0結果
最壞情況比較次數:線性 vs 二分(二分需要資料先排好)
n線性 n二分 ⌈log₂(n+1)⌉
10104
1,0001,00010
1,000,0001,000,00020
1,000,000,0001,000,000,00030

n = 10 時只差 6 次,為了省這幾次先排序(O(n log n))反而更慢。n = 10 億時線性最壞要比 10 億次,二分只要 30 次,所以同一份資料要查很多次時,先排序一次再二分才划算。

步驟 0/8要找 46。資料沒有排序也沒有索引,只能從最左邊開始一格一格比。

05程式碼

基本版、回傳所有符合位置的版本,以及每一輪省一次邊界檢查的哨兵法。三段都是 O(n) 時間,差在回傳什麼和迴圈裡做幾次比較。最後附上語言內建的線性搜尋:Python 的 inlist.index,C++ 的 std::find

# 線性搜尋:從頭掃到尾,找到就回傳索引,沒有就回傳 -1
def linear_search(nums, target):
    for i, x in enumerate(nums):
        if x == target:
            return i
    return -1


# 變形一:回傳所有符合條件的位置(條件用函式傳入)
# 條件是任意函式時,每個元素都得檢查一次,O(n) 已經是最好
def find_all(items, pred):
    return [i for i, x in enumerate(items) if pred(x)]


# 變形二:哨兵法。暫時把目標放在尾端,保證一定找得到,
# 迴圈裡每一輪就省掉一次 i < n 的邊界檢查
# 這是 C 這類語言的技巧;在 Python 裡通常比上面的 for 迴圈還慢
def sentinel_search(nums, target):
    n = len(nums)
    nums.append(target)                 # 哨兵(暫時改動 nums)
    i = 0
    while nums[i] != target:
        i += 1
    nums.pop()                          # 還原
    return i if i < n else -1           # 停在哨兵上 = 原本沒有


if __name__ == "__main__":
    data = [17, 4, 29, 8, 51, 23, 12, 46, 3, 35]
    print(linear_search(data, 46))                 # 7
    print(linear_search(data, 40))                 # -1
    print(find_all(data, lambda x: x % 2 == 0))    # [1, 3, 6, 7]
    print(sentinel_search(data, 46))               # 7
    print(sentinel_search(data, 40), len(data))    # -1 10(哨兵已移除)
    # 內建的 in 和 list.index 也是線性搜尋;index 找不到會丟 ValueError
    print(40 in data, data.index(46))              # False 7

06練習題

  • LeetCode 2057Smallest Index With Equal Value(找第一個,找不到回傳 -1)Easy
  • LeetCode 2108Find First Palindromic String in the Array(條件換成函式,找到就停)Easy
  • LeetCode 2942Find Words Containing Character(回傳所有符合的位置)Easy
  • LeetCode 1779Find Nearest Point That Has the Same X or Y CoordinateEasy
  • LeetCode 1848Minimum Distance to the Target Element(從 start 往兩邊找)Easy
  • LeetCode 1Two Sum(先對每個數線性搜尋另一半,再想為什麼要換雜湊表)Easy