演算法基礎:空間複雜度完全指南

深入理解程式的記憶體使用與優化策略

什麼是空間複雜度?

空間複雜度(Space Complexity)描述了演算法執行時所需的記憶體空間如何隨著輸入規模增長。它回答了一個關鍵問題:「當資料量變大時,程式需要多少記憶體?」

為什麼空間複雜度很重要?

# 情境:處理一個 10GB 的檔案

# 方法 1:全部載入記憶體(可能造成記憶體不足)
def process_file_v1(filename):
    with open(filename, 'r') as f:
        content = f.read()  # 需要 10GB 記憶體!
        return content.upper()

# 方法 2:逐行處理(記憶體使用量固定)
def process_file_v2(filename):
    result = []
    with open(filename, 'r') as f:
        for line in f:  # 一次只載入一行
            result.append(line.upper())
    return ''.join(result)

# 方法 3:串流處理(最省記憶體)
def process_file_v3(filename, output_filename):
    with open(filename, 'r') as f_in, open(output_filename, 'w') as f_out:
        for line in f_in:
            f_out.write(line.upper())

空間複雜度的組成

1. 輸入空間

演算法輸入資料佔用的空間(通常不計入額外空間)。

2. 輔助空間(Auxiliary Space)

演算法執行過程中額外使用的空間。

def example_spaces(arr):
    n = len(arr)                    # 輸入空間:O(n)
    
    # 輔助空間
    temp_list = [0] * n            # O(n) 額外空間
    counter = 0                     # O(1) 額外空間
    result_dict = {}               # O(k) 額外空間,k 是不同元素數量
    
    return result_dict

常見的空間複雜度

O(1) - 常數空間

無論輸入多大,使用的額外空間都固定。

def find_max_in_place(arr):
    """原地找最大值 - O(1) 空間"""
    if not arr:
        return None
    
    max_val = arr[0]  # 只用一個變數
    for num in arr:
        if num > max_val:
            max_val = num
    
    return max_val

def swap_elements(arr, i, j):
    """原地交換 - O(1) 空間"""
    arr[i], arr[j] = arr[j], arr[i]
    return arr

def reverse_array_in_place(arr):
    """原地反轉陣列 - O(1) 空間"""
    left, right = 0, len(arr) - 1
    
    while left < right:
        arr[left], arr[right] = arr[right], arr[left]
        left += 1
        right -= 1
    
    return arr

O(n) - 線性空間

空間使用與輸入成正比。

def create_copy(arr):
    """建立副本 - O(n) 空間"""
    return arr.copy()  # 需要 n 個元素的空間

def array_to_dict(arr):
    """陣列轉字典 - O(n) 空間"""
    result = {}
    for i, val in enumerate(arr):
        result[i] = val
    return result

def get_unique_elements(arr):
    """取得唯一元素 - O(n) 空間(最壞情況)"""
    return list(set(arr))  # set 需要額外空間

def build_frequency_map(arr):
    """建立頻率表 - O(k) 空間,k <= n"""
    freq = {}
    for num in arr:
        freq[num] = freq.get(num, 0) + 1
    return freq

O(n²) - 平方空間

通常出現在二維資料結構。

def create_matrix(n):
    """建立 n×n 矩陣 - O(n²) 空間"""
    matrix = []
    for i in range(n):
        row = []
        for j in range(n):
            row.append(0)
        matrix.append(row)
    return matrix

def adjacency_matrix(edges, n):
    """建立鄰接矩陣 - O(n²) 空間"""
    matrix = [[False] * n for _ in range(n)]
    
    for u, v in edges:
        matrix[u][v] = True
        matrix[v][u] = True  # 無向圖
    
    return matrix

def all_pairs_distances(points):
    """計算所有點對距離 - O(n²) 空間"""
    n = len(points)
    distances = [[0] * n for _ in range(n)]
    
    for i in range(n):
        for j in range(n):
            if i != j:
                distances[i][j] = calculate_distance(points[i], points[j])
    
    return distances

遞迴與空間複雜度

理解調用堆疊

def factorial_recursive(n):
    """
    遞迴計算階乘
    空間複雜度:O(n) - 調用堆疊深度
    """
    if n <= 1:
        return 1
    return n * factorial_recursive(n - 1)

# 調用堆疊視覺化:
# factorial_recursive(5)
# ├─ 5 * factorial_recursive(4)
# │  ├─ 4 * factorial_recursive(3)
# │  │  ├─ 3 * factorial_recursive(2)
# │  │  │  ├─ 2 * factorial_recursive(1)
# │  │  │  │  └─ return 1
# │  │  │  └─ return 2
# │  │  └─ return 6
# │  └─ return 24
# └─ return 120

尾遞迴優化(Python 不支援,但概念重要)

def factorial_tail_recursive(n, accumulator=1):
    """
    尾遞迴版本(理論上可優化為 O(1) 空間)
    但 Python 不進行尾遞迴優化
    """
    if n <= 1:
        return accumulator
    return factorial_tail_recursive(n - 1, n * accumulator)

def factorial_iterative(n):
    """
    迭代版本 - O(1) 空間
    """
    result = 1
    for i in range(1, n + 1):
        result *= i
    return result

樹遞迴的空間分析

def fibonacci_recursive(n):
    """
    樹狀遞迴
    時間:O(2ⁿ)
    空間:O(n) - 最大調用深度
    """
    if n <= 1:
        return n
    return fibonacci_recursive(n - 1) + fibonacci_recursive(n - 2)

def fibonacci_memoized(n, memo=None):
    """
    記憶化遞迴
    時間:O(n)
    空間:O(n) - 調用堆疊 + 記憶化表
    """
    if memo is None:
        memo = {}
    
    if n in memo:
        return memo[n]
    
    if n <= 1:
        return n
    
    memo[n] = fibonacci_memoized(n - 1, memo) + fibonacci_memoized(n - 2, memo)
    return memo[n]

空間優化技巧

技巧 1:滾動陣列

# 原始版本:O(n) 空間
def fibonacci_dp_v1(n):
    if n <= 1:
        return n
    
    dp = [0] * (n + 1)
    dp[0], dp[1] = 0, 1
    
    for i in range(2, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]
    
    return dp[n]

# 優化版本:O(1) 空間
def fibonacci_dp_v2(n):
    if n <= 1:
        return n
    
    prev2, prev1 = 0, 1
    
    for i in range(2, n + 1):
        current = prev1 + prev2
        prev2 = prev1
        prev1 = current
    
    return prev1

技巧 2:位元操作節省空間

# 使用布林陣列:每個元素 1 byte
def sieve_normal(n):
    is_prime = [True] * (n + 1)  # O(n) bytes
    # ... 篩法邏輯
    return is_prime

# 使用位元陣列:每個元素 1 bit
def sieve_bitarray(n):
    # 每個整數可存 32 個布林值
    size = (n + 31) // 32
    is_prime = [0xFFFFFFFF] * size  # O(n/32) bytes
    
    def set_composite(num):
        is_prime[num // 32] &= ~(1 << (num % 32))
    
    def is_prime_number(num):
        return bool(is_prime[num // 32] & (1 << (num % 32)))
    
    # ... 篩法邏輯
    return is_prime

技巧 3:生成器節省記憶體

# 一次性建立所有結果:O(n) 空間
def get_squares_list(n):
    return [i ** 2 for i in range(n)]

# 使用生成器:O(1) 空間
def get_squares_generator(n):
    for i in range(n):
        yield i ** 2

# 使用範例
# 列表版本
squares_list = get_squares_list(1000000)  # 佔用大量記憶體
for square in squares_list:
    process(square)

# 生成器版本
for square in get_squares_generator(1000000):  # 幾乎不佔記憶體
    process(square)

技巧 4:原地演算法

# 非原地排序:O(n) 額外空間
def merge_sort(arr):
    if len(arr) <= 1:
        return arr
    
    mid = len(arr) // 2
    left = merge_sort(arr[:mid])  # 建立新陣列
    right = merge_sort(arr[mid:])
    
    return merge(left, right)

# 原地排序:O(1) 額外空間
def bubble_sort_in_place(arr):
    n = len(arr)
    for i in range(n):
        for j in range(0, n - i - 1):
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]
    return arr

# 原地分割(用於快速排序)
def partition_in_place(arr, low, high):
    pivot = arr[high]
    i = low - 1
    
    for j in range(low, high):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    
    arr[i + 1], arr[high] = arr[high], arr[i + 1]
    return i + 1

實戰分析:記憶體使用測量

import sys
import tracemalloc

def measure_memory(func, *args):
    """測量函數的記憶體使用"""
    tracemalloc.start()
    
    # 執行前的記憶體
    before = tracemalloc.get_traced_memory()[0]
    
    # 執行函數
    result = func(*args)
    
    # 執行後的記憶體
    after = tracemalloc.get_traced_memory()[0]
    tracemalloc.stop()
    
    # 計算差異
    memory_used = after - before
    return memory_used, result

# 測試不同資料結構的記憶體使用
def test_list(n):
    return [i for i in range(n)]

def test_set(n):
    return {i for i in range(n)}

def test_dict(n):
    return {i: i for i in range(n)}

# 測量 Python 物件大小
n = 1000
print(f"List 大小: {sys.getsizeof(test_list(n))} bytes")
print(f"Set 大小: {sys.getsizeof(test_set(n))} bytes")
print(f"Dict 大小: {sys.getsizeof(test_dict(n))} bytes")

# 深入分析記憶體使用
def analyze_memory_growth():
    sizes = [100, 1000, 10000, 100000]
    
    for size in sizes:
        # 測試列表
        mem_list, _ = measure_memory(test_list, size)
        
        # 測試字典
        mem_dict, _ = measure_memory(test_dict, size)
        
        print(f"n={size}:")
        print(f"  List: {mem_list:,} bytes")
        print(f"  Dict: {mem_dict:,} bytes")

常見的空間複雜度陷阱

陷阱 1:字串的不可變性

# 低效版本:O(n²) 空間
def build_string_inefficient(n):
    result = ""
    for i in range(n):
        result += str(i)  # 每次都建立新字串!
    return result

# 高效版本:O(n) 空間
def build_string_efficient(n):
    parts = []
    for i in range(n):
        parts.append(str(i))
    return "".join(parts)

陷阱 2:切片操作

# 切片會建立新陣列
def process_subarray_v1(arr, start, end):
    subarray = arr[start:end]  # O(end - start) 額外空間
    return sum(subarray)

# 避免切片,直接處理
def process_subarray_v2(arr, start, end):
    total = 0
    for i in range(start, end):  # O(1) 額外空間
        total += arr[i]
    return total

陷阱 3:隱藏的空間使用

# sorted() 建立新列表
def find_kth_largest_v1(arr, k):
    sorted_arr = sorted(arr, reverse=True)  # O(n) 額外空間
    return sorted_arr[k - 1]

# 使用堆,空間更優
import heapq
def find_kth_largest_v2(arr, k):
    heap = []
    for num in arr:
        heapq.heappush(heap, num)
        if len(heap) > k:
            heapq.heappop(heap)  # 維持 k 個元素,O(k) 空間
    return heap[0]

空間與時間的權衡

範例 1:兩數之和

# 方法 1:暴力法 - 時間 O(n²),空間 O(1)
def two_sum_brute_force(nums, target):
    n = len(nums)
    for i in range(n):
        for j in range(i + 1, n):
            if nums[i] + nums[j] == target:
                return [i, j]
    return []

# 方法 2:雜湊表 - 時間 O(n),空間 O(n)
def two_sum_hash(nums, target):
    seen = {}
    for i, num in enumerate(nums):
        complement = target - num
        if complement in seen:
            return [seen[complement], i]
        seen[num] = i
    return []

範例 2:檢查重複

# 方法 1:排序 - 時間 O(n log n),空間 O(1)(原地排序)
def has_duplicate_sort(nums):
    nums.sort()  # 原地排序
    for i in range(1, len(nums)):
        if nums[i] == nums[i - 1]:
            return True
    return False

# 方法 2:集合 - 時間 O(n),空間 O(n)
def has_duplicate_set(nums):
    return len(nums) != len(set(nums))

記憶體優化最佳實踐

1. 選擇合適的資料結構

# 根據需求選擇資料結構
# 需要快速查找?用 dict/set
# 需要保持順序?用 list
# 需要節省空間?考慮 array.array 或 numpy

import array

# Python list:每個整數約 28 bytes
py_list = [i for i in range(1000)]

# array.array:每個整數 4 bytes
arr = array.array('i', range(1000))

# 空間差異顯著!

2. 及時釋放記憶體

def process_large_data():
    # 處理大型資料
    huge_data = load_huge_file()
    result = process(huge_data)
    
    # 明確刪除不再需要的大型物件
    del huge_data
    
    # 如果需要,強制垃圾回收
    import gc
    gc.collect()
    
    return result

3. 使用記憶體映射檔案

import mmap

def process_huge_file(filename):
    """使用記憶體映射處理超大檔案"""
    with open(filename, 'r+b') as f:
        with mmap.mmap(f.fileno(), 0) as mmapped_file:
            # 可以像處理字串一樣處理檔案
            # 但不會全部載入記憶體
            for line in iter(mmapped_file.readline, b""):
                process_line(line)

總結

空間複雜度分析是寫出記憶體高效程式的關鍵。記住這些要點:

  1. 了解你的資料規模:1MB 還是 1GB?這決定了優化策略
  2. 權衡是必要的:時間換空間,或空間換時間
  3. 遞迴要小心:調用堆疊也佔記憶體
  4. 原地演算法很強大:但可能犧牲可讀性
  5. 測量實際使用:理論分析後要實測驗證

記憶體優化不只是技術問題,更是一種思維方式。在資源受限的環境中(如嵌入式系統或處理大資料),空間複雜度往往比時間複雜度更重要。

練習題

  1. 實現一個原地反轉鏈結串列的演算法
  2. 設計一個 O(1) 空間的找出陣列中缺失數字的方法
  3. 優化遞迴演算法,減少調用堆疊深度
  4. 比較不同資料結構在相同任務下的記憶體使用

延伸閱讀

0%