Big O 與複雜度分析 — 時間複雜度、空間複雜度完整教學 | 資料結構與演算法

2026/06/14
Big O 與複雜度分析 — 時間複雜度、空間複雜度完整教學 | 資料結構與演算法

Big O 表示法 是每位工程師在學習 資料結構與演算法(DSA) 時必須掌握的第一塊基石。它讓你在不執行程式的情況下,就能預測一段程式碼面對百萬筆資料時的效能表現——是 時間複雜度 的精確語言,也是判斷 演算法優劣 的共同標準。

前言

想像你在一間熱門餐廳等位。當餐廳只有 10 桌客人時,服務生找到你的訂位只需幾秒;當客人增加到 1,000 桌,若服務生每次都從第一桌開始逐一確認,你等待的時間就會線性增長——這就是 O(n) 的直覺。

Big O 表示法(Big O Notation) 正是描述這種「隨著輸入規模 n 增長,演算法所需資源如何變化」的數學語言。它不是精確計算執行了幾毫秒,而是描述增長趨勢——當 n 變得很大時,哪個因素主導了整體效能。

本篇是「資料結構與演算法」系列的第 001 篇,學完你將能夠:

  • 理解 Big O、Big Omega、Big Theta 三種漸進符號
  • 辨認並分析 O(1)、O(log n)、O(n)、O(n log n)、O(n²)、O(2ⁿ) 等常見複雜度
  • 閱讀程式碼並推導其時間複雜度與空間複雜度
  • 避開複雜度分析的常見陷阱
  • 透過 TypeScript 與 C++ 實作驗證理論

核心概念

Big O 的數學定義

Big O 描述演算法在最壞情況(Worst Case) 下的上界行為。數學上的嚴格定義是:

T(n) = O(f(n))  ⟺  ∃ c > 0, n₀ > 0  使得  ∀n ≥ n₀: T(n) ≤ c·f(n)

白話翻譯:「存在某個夠大的 n 之後,演算法的實際執行成本永遠不超過 c·f(n) 的常數倍」。

工程師說的「這個演算法是 O(n)」,意思是:不管輸入多大,它的執行時間增長速率最多是線性的。

三種漸進符號

符號名稱意義工程直覺
O(f(n))Big O(上界)最差情況不超過 c·f(n)「最慢不超過這個」
Ω(f(n))Big Omega(下界)最佳情況至少需要 c·f(n)「最快不低於這個」
Θ(f(n))Big Theta(緊界)同時是上界與下界「剛好就是這個量級」

實務上工程師說「O(n)」幾乎總是指 Big O(最壞上界),而非 Big Theta(緊界)。

常見複雜度等級

從最優到最差排列:

O(1) < O(log n) < O(√n) < O(n) < O(n log n) < O(n²) < O(n³) < O(2ⁿ) < O(n!)

ASCII 增長曲線示意圖:

運算次數
^
|                                            2ⁿ ↗
|                                        ↗
|                                    ↗
|                                ↗      n²
|                           ↗  ↗
|                       ↗  /
|                   ↗  /     n log n
|              ↗  /
|         ↗  /
|    ↗  /         n
|  ↗ /
|↗/        log n
|/
|_________________________ O(1) ─────────
+─────────────────────────────────────── n
  1   10   100   1,000   10,000   100,000

真實數字比較(n = 1,000,000)

複雜度運算次數(約)10⁹ ops/s 的 CPU 耗時
O(1)1< 1 奈秒
O(log n)2020 奈秒
O(√n)1,0001 微秒
O(n)1,000,0001 毫秒
O(n log n)20,000,00020 毫秒
O(n²)10¹²~17 分鐘
O(2ⁿ)10³⁰⁰⁰⁰⁰超過宇宙年齡

n 是百萬規模時,O(n²) 和 O(n log n) 的差距是17 分鐘 vs 20 毫秒。選對演算法,就是這麼關鍵。

最佳、最差、平均情況

Quick Sort(快速排序) 為例,說明同一演算法在不同輸入下的行為差異:

最佳情況:每次 pivot 都是中位數
  → T(n) = 2T(n/2) + O(n) → O(n log n)

平均情況:pivot 隨機選取(期望值分析)
  → 期望 O(n log n),實務上常數因子比 Merge Sort 小

最差情況:輸入已排序 + 每次選最後元素為 pivot
  → T(n) = T(n-1) + O(n) → O(n²)
  → 解法:隨機選取 pivot 或 Median-of-3 策略

JavaScript / TypeScript 實作

O(1) — 常數時間

無論輸入多大,執行時間固定不變。

// O(1):陣列隨機存取
// 透過記憶體位移直接計算位址,與陣列大小無關
function getElement<T>(arr: T[], index: number): T | undefined {
  return arr[index]; // 固定 1 次操作
}

// O(1):Hash Map 查詢
function hasKey(map: Map<string, number>, key: string): boolean {
  return map.has(key); // 雜湊計算後直接定位,O(1) 平均
}

const arr = [10, 20, 30, 40, 50];
console.log(getElement(arr, 2)); // 輸出:30(無論 arr 有 5 個還是 500 萬個元素)

O(log n) — 對數時間

每次操作將問題規模減半(或縮小固定倍數)。

// O(log n):二分搜尋
// 前提:輸入陣列必須已排序
function binarySearch(sortedArr: number[], target: number): number {
  let left = 0;
  let right = sortedArr.length - 1;

  while (left <= right) {
    // 使用位移運算避免大數整數溢位
    const mid = left + ((right - left) >> 1);

    if (sortedArr[mid] === target) return mid;       // 找到
    if (sortedArr[mid] < target) left = mid + 1;    // 目標在右半部
    else right = mid - 1;                            // 目標在左半部
  }

  return -1; // 未找到
}

// 每次迴圈將搜尋範圍減半 → log₂(1,000,000) ≈ 20 次即可找到
const sorted = Array.from({ length: 1_000_000 }, (_, i) => i * 2);
console.log(binarySearch(sorted, 999_998)); // 輸出:499999

O(n) — 線性時間

需要訪問每個元素至少一次。

// O(n):線性搜尋
function linearSearch<T>(arr: T[], target: T): number {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i] === target) return i; // 最壞情況:掃描整個陣列
  }
  return -1;
}

// O(n):陣列加總
function sumArray(arr: number[]): number {
  // reduce 內部對每個元素訪問一次,共 n 次
  return arr.reduce((acc, val) => acc + val, 0);
}

// O(n):找最大值
function findMax(arr: number[]): number {
  let max = arr[0];
  for (const val of arr) {
    if (val > max) max = val; // n 次比較
  }
  return max;
}

const data = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5];
console.log(sumArray(data));   // 輸出:44
console.log(findMax(data));    // 輸出:9

O(n log n) — 線性對數時間

最常見於高效排序演算法。

// O(n log n):合併排序(Merge Sort)
// 遞迴樹有 log n 層,每層合併成本 O(n) → 總計 O(n log n)
function mergeSort(arr: number[]): number[] {
  if (arr.length <= 1) return arr; // 基礎情況

  const mid = Math.floor(arr.length / 2);
  const left = mergeSort(arr.slice(0, mid));  // T(n/2):排序左半部
  const right = mergeSort(arr.slice(mid));     // T(n/2):排序右半部
  return merge(left, right);                   // O(n):合併兩個已排序陣列
}

function merge(left: number[], right: number[]): number[] {
  const result: number[] = [];
  let i = 0, j = 0;

  // 雙指標合併,每次取較小值
  while (i < left.length && j < right.length) {
    if (left[i] <= right[j]) {
      result.push(left[i++]);
    } else {
      result.push(right[j++]);
    }
  }

  // 接上剩餘元素
  return result.concat(left.slice(i)).concat(right.slice(j));
}

const unsorted = [38, 27, 43, 3, 9, 82, 10];
console.log(mergeSort(unsorted)); // 輸出:[3, 9, 10, 27, 38, 43, 82]

O(n²) — 二次方時間

典型於巢狀迴圈,n 增大 10 倍時,執行時間增大 100 倍。

// O(n²):氣泡排序(Bubble Sort)
function bubbleSort(arr: number[]): number[] {
  const result = [...arr];
  const n = result.length;

  for (let i = 0; i < n - 1; i++) {           // 外層:n 輪
    for (let j = 0; j < n - i - 1; j++) {     // 內層:每輪最多 n 次比較
      if (result[j] > result[j + 1]) {
        // 交換相鄰元素
        [result[j], result[j + 1]] = [result[j + 1], result[j]];
      }
    }
  }

  return result;
  // 總比較次數 = n(n-1)/2 ≈ n²/2 = O(n²)
}

// O(n²):判斷陣列中是否有重複元素(暴力法)
function hasDuplicateBrute(arr: number[]): boolean {
  for (let i = 0; i < arr.length; i++) {
    for (let j = i + 1; j < arr.length; j++) {
      if (arr[i] === arr[j]) return true; // 找到重複
    }
  }
  return false;
}

// 更好的解法:O(n) 使用 Set
function hasDuplicateFast(arr: number[]): boolean {
  const seen = new Set<number>();
  for (const val of arr) {
    if (seen.has(val)) return true;
    seen.add(val);
  }
  return false;
}

const testArr = [64, 34, 25, 12, 22, 11, 90];
console.log(bubbleSort(testArr));          // 輸出:[11, 12, 22, 25, 34, 64, 90]
console.log(hasDuplicateFast([1, 2, 3, 2])); // 輸出:true

O(2ⁿ) — 指數時間

每增加一個輸入,運算量翻倍。常見於遞迴未加記憶化(Memoization)的情況。

// O(2ⁿ):費波那契數列(樸素遞迴)
// 遞迴樹每層節點數加倍 → 總節點數 ≈ 2ⁿ
function fibNaive(n: number): number {
  if (n <= 1) return n;
  return fibNaive(n - 1) + fibNaive(n - 2); // 兩個遞迴呼叫
}

// O(n):加入記憶化,空間換時間
function fibMemo(n: number, memo: Map<number, number> = new Map()): number {
  if (n <= 1) return n;
  if (memo.has(n)) return memo.get(n)!; // 已計算過,直接回傳

  const result = fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
  memo.set(n, result); // 儲存結果供後續使用
  return result;
}

console.log(fibNaive(10));  // 輸出:55(但 fibNaive(50) 需要等很久)
console.log(fibMemo(50));   // 輸出:12586269025(瞬間完成)

C++ 對照實作

相同邏輯的 C++ 版本,標注與 TypeScript 的關鍵差異。

// complexity_demo.cpp
// 編譯:g++ -O2 -std=c++20 complexity_demo.cpp -o complexity_demo
#include <algorithm>
#include <chrono>
#include <iostream>
#include <numeric>
#include <vector>

// 計時輔助(對應 TypeScript 的 performance.now())
template <typename Fn>
double measureMs(Fn&& fn) {
  auto start = std::chrono::high_resolution_clock::now();
  fn();
  auto end = std::chrono::high_resolution_clock::now();
  return std::chrono::duration<double, std::milli>(end - start).count();
}

// ── O(1):常數時間 ──────────────────────────────────────────
// C++ vector 和 TS Array 一樣,隨機存取為 O(1)
int getElement(const std::vector<int>& v, std::size_t idx) {
  return v[idx]; // 直接記憶體位移,與 n 無關
}

// ── O(log n):二分搜尋 ──────────────────────────────────────
// C++ 提供 STL std::lower_bound,內部即為二分搜尋
int binarySearch(const std::vector<int>& v, int target) {
  // 差異:C++ 慣用迭代器(iterator),TS 直接操作索引
  auto it = std::lower_bound(v.begin(), v.end(), target);
  if (it != v.end() && *it == target) {
    return static_cast<int>(std::distance(v.begin(), it));
  }
  return -1;
}

// ── O(n):線性加總 ──────────────────────────────────────────
// std::accumulate 對應 TS 的 Array.reduce
long long sumArray(const std::vector<int>& v) {
  return std::accumulate(v.begin(), v.end(), 0LL);
}

// ── O(n log n):排序 ─────────────────────────────────────────
// std::sort 使用 introsort(平均 O(n log n)),對應 TS 的 Array.sort
std::vector<int> sortArray(std::vector<int> v) {
  std::sort(v.begin(), v.end()); // 傳值,不修改原始陣列
  return v;
}

// ── O(n²):氣泡排序 ──────────────────────────────────────────
std::vector<int> bubbleSort(std::vector<int> v) {
  int n = static_cast<int>(v.size());
  for (int i = 0; i < n - 1; ++i) {
    for (int j = 0; j < n - i - 1; ++j) {
      if (v[j] > v[j + 1]) std::swap(v[j], v[j + 1]); // C++ std::swap
    }
  }
  return v;
}

int main() {
  const std::vector<std::size_t> sizes = {1'000, 10'000, 100'000};

  for (auto n : sizes) {
    std::vector<int> sorted(n);
    std::iota(sorted.begin(), sorted.end(), 0); // 填入 0, 1, 2, ..., n-1

    std::vector<int> unsorted(n);
    for (auto& x : unsorted) x = rand() % static_cast<int>(n);

    std::cout << "\n=== n = " << n << " ===\n";

    auto t1 = measureMs([&]{ getElement(sorted, n / 2); });
    std::cout << "O(1)       getElement:   " << t1 << "ms\n";

    auto t2 = measureMs([&]{ binarySearch(sorted, static_cast<int>(n) - 1); });
    std::cout << "O(log n)   binarySearch: " << t2 << "ms\n";

    auto t3 = measureMs([&]{ sumArray(sorted); });
    std::cout << "O(n)       sumArray:     " << t3 << "ms\n";

    auto t4 = measureMs([&]{ sortArray(unsorted); });
    std::cout << "O(n log n) std::sort:    " << t4 << "ms\n";

    if (n <= 10'000) {
      auto t5 = measureMs([&]{ bubbleSort(unsorted); });
      std::cout << "O(n²)      bubbleSort:   " << t5 << "ms\n";
    }
  }
  return 0;
}

/*
預期輸出(n = 10,000):
O(1)       getElement:   0.0001ms
O(log n)   binarySearch: 0.0003ms
O(n)       sumArray:     0.0500ms
O(n log n) std::sort:    0.5000ms
O(n²)      bubbleSort:   200.000ms  ← n 增加 10x 時間增加 100x
*/

TypeScript vs C++ 關鍵差異對照:

概念TypeScriptC++
計時performance.now()std::chrono::high_resolution_clock
排序Array.sort()std::sort()
二分搜尋手寫或 lodashstd::lower_bound()
加總Array.reduce()std::accumulate()
交換解構賦值 [a, b] = [b, a]std::swap(a, b)
型別安全執行期(TypeScript 編譯期)嚴格編譯期

複雜度分析

時間複雜度總覽

演算法 / 操作最佳平均最差說明
陣列隨機存取O(1)O(1)O(1)記憶體位移直接計算
雜湊表查詢O(1)O(1)O(n)最差為全部碰撞
二分搜尋O(1)O(log n)O(log n)每次減半
線性搜尋O(1)O(n)O(n)最壞掃描整個陣列
合併排序O(n log n)O(n log n)O(n log n)穩定,不受輸入影響
快速排序O(n log n)O(n log n)O(n²)最差需隨機 pivot 避免
氣泡排序O(n)O(n²)O(n²)已排序輸入最佳
費波那契(樸素)O(2ⁿ)O(2ⁿ)O(2ⁿ)指數爆炸
費波那契(記憶化)O(n)O(n)O(n)空間換時間

空間複雜度總覽

空間複雜度衡量演算法使用的額外記憶體(不含輸入本身):

演算法 / 操作空間複雜度說明
原地排序(Heap Sort)O(1)僅需固定數量的額外變數
迭代二分搜尋O(1)只有幾個指標變數
遞迴二分搜尋O(log n)呼叫堆疊深度 = log n
合併排序O(n)合併時需要暫存陣列
BFSO(n)Queue 最多儲存整層節點
DFS(遞迴)O(h)h = 樹高,最差 O(n)
費波那契(記憶化)O(n)儲存 n 個子問題的結果

空間換時間(Space-Time Tradeoff) 是工程上的核心取捨:

  • 快取(Memoization):O(n) 空間換取 O(n) 時間
  • 雜湊表(Hash Table):O(n) 空間換取 O(1) 平均查詢

變體與延伸

攤銷分析(Amortized Analysis)

攤銷分析用於分析偶發性昂貴操作的平均成本。最典型案例是動態陣列的 push 操作。

動態陣列(JavaScript Array、C++ std::vector)在容量不足時會觸發擴容(resize),複製所有元素,單次成本 O(n)。但採用「容量加倍」策略時,攤銷後每次 push 的平均成本仍是 O(1)。

// 模擬動態陣列擴容,觀察攤銷 O(1) 的運作方式
class DynamicArray<T> {
  private data: T[];
  private _size: number = 0;
  private _capacity: number = 1;

  constructor() {
    this.data = new Array(this._capacity);
  }

  push(item: T): void {
    if (this._size === this._capacity) {
      this._resize(); // O(n),但很少觸發
    }
    this.data[this._size++] = item; // O(1)
  }

  private _resize(): void {
    this._capacity *= 2; // 容量加倍策略
    const newData = new Array(this._capacity);
    for (let i = 0; i < this._size; i++) {
      newData[i] = this.data[i]; // 複製舊資料 O(n)
    }
    this.data = newData;
    console.log(`  [resize] capacity → ${this._capacity}`);
  }

  get size(): number { return this._size; }
  get capacity(): number { return this._capacity; }
}

// 追蹤擴容時機:push 第 2、3、5、9、17 個時觸發
const dynArr = new DynamicArray<number>();
for (let i = 1; i <= 20; i++) {
  dynArr.push(i);
}
// n 次 push 的總複製成本 = 1 + 2 + 4 + ... + n/2 < 2n = O(n)
// 平均每次 push 的攤銷成本 = O(n) / n = O(1)

記帳法直覺:每次 O(1) 的插入預付 3 枚「信用幣」——1 枚用於當次插入,2 枚存入帳戶備用於未來擴容時的搬移成本。帳戶永遠不會透支,所以攤銷 O(1) 成立。

其他攤銷分析案例:

操作單次最差攤銷平均說明
Array.push(加倍策略)O(n)O(1)擴容成本平攤
Stack pop(Two-Stack Queue)O(n)O(1)搬移成本平攤
Union-Find(路徑壓縮)O(log n)O(α(n)) ≈ O(1)Inverse Ackermann 函數

Big Omega 與 Big Theta

  • Big Omega Ω(f(n)):最佳情況的下界。例如任何基於比較的排序演算法,其 Ω(n log n) 的下界由決策樹(Decision Tree)模型嚴格證明——無論演算法多聰明,比較次數都不能低於 n log n。
  • Big Theta Θ(f(n)):同時是上界與下界,描述「緊界」。例如合併排序的時間複雜度是 Θ(n log n),無論輸入如何,都精確地在 n log n 的量級。

Master Theorem(主定理)

用於快速求解分治遞迴 T(n) = a·T(n/b) + f(n) 的複雜度:

演算法遞迴關係a, b結論
二分搜尋T(n) = T(n/2) + O(1)a=1, b=2O(log n)
合併排序T(n) = 2T(n/2) + O(n)a=2, b=2O(n log n)
Strassen 矩陣乘法T(n) = 7T(n/2) + O(n²)a=7, b=2O(n^2.81)

常見面試考點

複雜度推導練習

面試中常見「分析這段程式碼的時間複雜度」,以下是高頻題型:

題型 1:迴圈每次除以 2 → O(log n)

function mystery1(n: number): number {
  let count = 0;
  for (let i = n; i > 0; i = Math.floor(i / 2)) {
    count++;
  }
  return count;
  // 分析:i 每次除以 2,需要 log₂(n) 次後降為 0
  // 答案:O(log n)
}

題型 2:巢狀迴圈找重複 → O(n²) 可優化為 O(n)

// 暴力法 O(n²)
function hasDuplicateBrute(arr: number[]): boolean {
  for (let i = 0; i < arr.length; i++) {
    for (let j = i + 1; j < arr.length; j++) {
      if (arr[i] === arr[j]) return true;
    }
  }
  return false;
}

// 優化:Set 查詢 O(1),整體 O(n)
function hasDuplicateOptimal(arr: number[]): boolean {
  const seen = new Set<number>();
  for (const val of arr) {
    if (seen.has(val)) return true;
    seen.add(val);
  }
  return false; // 空間複雜度:O(n)
}

題型 3:遞迴費波那契 → O(2ⁿ) 可優化為 O(n)

// 樸素遞迴 O(2ⁿ):每個節點產生兩個子問題
function fibBad(n: number): number {
  if (n <= 1) return n;
  return fibBad(n - 1) + fibBad(n - 2);
}

// 記憶化 O(n):每個子問題只計算一次
function fibGood(n: number, memo = new Map<number, number>()): number {
  if (n <= 1) return n;
  if (memo.has(n)) return memo.get(n)!;
  const result = fibGood(n - 1, memo) + fibGood(n - 2, memo);
  return memo.set(n, result), result;
}

三大常見陷阱

陷阱 1:兩個獨立迴圈相加而非相乘

// ❌ 誤以為兩個 for 迴圈 = O(n²)
function twoLoopsAdd(arr: number[]): void {
  for (const x of arr) console.log(x);  // O(n)
  for (const x of arr) console.log(x);  // O(n)
  // 總計:O(n) + O(n) = O(2n) = O(n),兩個獨立迴圈相加!
}

// ✓ 只有巢狀迴圈才相乘
function twoLoopsMultiply(arr: number[]): void {
  for (const x of arr) {
    for (const y of arr) { // 巢狀 → O(n) × O(n) = O(n²)
      console.log(x, y);
    }
  }
}

陷阱 2:忽略內建函式的複雜度

// ❌ 誤以為這是 O(n),實際是 O(n²)
function removeDuplicatesBad(arr: number[]): number[] {
  const result: number[] = [];
  for (const x of arr) {
    if (!result.includes(x)) { // Array.includes 是 O(n)!
      result.push(x);
    }
  }
  return result; // O(n) × O(n) = O(n²)
}

// ✓ 正確:使用 Set,O(1) 查詢
function removeDuplicatesGood(arr: number[]): number[] {
  return [...new Set(arr)]; // O(n) 時間,O(n) 空間
}

陷阱 3:不指明最差/平均情況

  • 錯誤說法:「Quick Sort 是 O(n log n)」
  • 正確說法:「Quick Sort 平均 O(n log n),最差 O(n²)(已排序輸入 + 差勁 pivot)」

LeetCode 練習推薦

題號題目難度複雜度目標提示
704Binary SearchEasyO(log n)二分搜尋的標準實作,先從這題入手
217Contains DuplicateEasyO(n)用 Set 將 O(n²) 暴力法優化到 O(n)
1Two SumEasyO(n)Hash Map 讓「找補數」從 O(n²) 降到 O(n)
912Sort an ArrayMediumO(n log n)實作 Merge Sort 或 Heap Sort
347Top K Frequent ElementsMediumO(n log n)嘗試 Bucket Sort 達到 O(n)

總結

本篇從數學定義出發,完整介紹了 Big O 複雜度分析的核心知識:

  • 漸進符號:Big O(上界)、Big Omega(下界)、Big Theta(緊界),工程實務中主要使用 Big O
  • 常見複雜度:O(1) → O(log n) → O(n) → O(n log n) → O(n²) → O(2ⁿ),理解差距的量級意義
  • 分析方法:單層迴圈 O(n)、巢狀迴圈 O(n²)、每次減半 O(log n)、遞迴樹計算 O(2ⁿ)
  • 攤銷分析:動態陣列 push 的攤銷 O(1),理解「偶發昂貴操作的平均成本」
  • 空間複雜度:原地 O(1)、呼叫堆疊 O(log n)、輔助陣列 O(n)
  • 常見陷阱:相加 vs 相乘、內建函式複雜度、最差/平均情況的區別

掌握複雜度分析,是你閱讀後續所有 DSA 文章的基礎語言。接下來的 第 002 篇「陣列與字串」,將運用今天學到的分析工具,深入探討連續記憶體結構的操作複雜度,以及雙指標、滑動視窗等 O(n) 的高效技巧。

如果你對任何複雜度的推導有疑問,歡迎在留言區提問,也可以到 Contact 頁面 與我交流!

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。