連続メモリと配列インデックスによるO(1)アクセス
このトピックを終えると
配列がインデックスアクセスにおいてO(1)になる理由を数学的に説明できるようになり、連続メモリの利点と欠点を理解し、配列と連結リストのパフォーマンスの違いを把握できるようになります。
配列がなぜ高速なのか
numbers = [10, 20, 30, 40, 50]print(numbers[3]) # 40 — 瞬時にアクセス!要素数が5個でも500万個でも、インデックスによるアクセスにかかる時間は常に同じです。これがO(1) — 定数時間アクセスです。
なぜそうなるのでしょうか? 配列はメモリに連続的に格納されるからです。
連続メモリ — 配列の秘密
メモリアドレス: 1000 1004 1008 1012 1016
値: [10] [20] [30] [40] [50]
インデックス: 0 1 2 3 4配列の各要素は、メモリ内で隙間なく隣り合って格納されます。各整数が4バイトの場合:
numbers[0]→ アドレス 1000numbers[1]→ アドレス 1004numbers[2]→ アドレス 1008numbers[3]→ アドレス 1012
インデックスの公式
要素のアドレス = 開始アドレス + (インデックス × 要素のサイズ)
numbers[3] = 1000 + (3 × 4) = 1012この計算は、加算1回、乗算1回だけです。配列のサイズが5個でも5億個でも同じです。したがって、O(1)です。
連結リストとの比較
連結リストは、各ノードが次のノードのアドレスを格納します。メモリ上に分散して存在します。
メモリ: [10|→1500] ... [20|→2300] ... [30|→1800] ... [40|→2100]
3番目の要素を見つけるには:
0番ノード(1000) → ポインタを辿る
1番ノード(1500) → ポインタを辿る
2番ノード(2300) → ポインタを辿る
3番ノード(1800) ← ここ!3番目の要素を見つけるには、0番から順番に辿る必要があります。n番目の要素を見つけるにはn回移動する必要があります → O(n)。
| 演算 | 配列 | 連結リスト |
|---|---|---|
| インデックスアクセス | O(1) | O(n) |
| 先頭への挿入 | O(n) — 全体移動 | O(1) |
| 中間への挿入 | O(n) — 後ろを移動 | O(1) — ポインタのみ変更 |
| 末尾への追加 | O(1) (空きがあれば) | O(1) (tailポインタがあれば) |
| 検索 (ソートされていない) | O(n) | O(n) |
Pythonリストの正体
Pythonのlistは、Cの配列とは異なります。ポインタの配列です。
Python list: [ptr0][ptr1][ptr2][ptr3]
↓ ↓ ↓ ↓
[10] ["hi"] [3.14] [[1,2]]ポインタ(アドレス)はサイズが一定(64ビットシステムでは8バイト)であるため、ポインタ配列自体は連続メモリ上に存在します。インデックスの公式は依然として適用され、O(1)です。
# Python list — 様々な型を格納可能(ポインタ配列のため)mixed = [42, "hello", 3.14, [1, 2, 3]]print(mixed[2]) # 3.14 — O(1)ただし、実際のデータはメモリのあちこちに分散しているため、C配列よりもキャッシュ効率は低くなります。
NumPy配列 — 真の連続メモリ
import numpy as np
# NumPyはC配列のようにデータを連続して格納arr = np.array([1, 2, 3, 4, 5], dtype=np.int32)# メモリ: [00000001 00000002 00000003 00000004 00000005]# 4バイトずつ隙間なく連続NumPyがPythonリストよりも数十倍高速である理由の一つが、この連続メモリレイアウトです。
キャッシュフレンドリー
CPUはメモリからデータを読み込むとき、要求されたアドレスの周辺にあるデータも一緒に**キャッシュライン(通常64バイト)**にロードします。
配列の連続アクセス:
arr[0]へのアクセス → キャッシュにarr[0]〜arr[15]をロード
arr[1]へのアクセス → キャッシュヒット!(すでにロード済み)
arr[2]へのアクセス → キャッシュヒット!
...
連結リストへのアクセス:
node0へのアクセス → キャッシュに周辺をロード
node1へのアクセス → 別の住所! キャッシュミス → メモリから再ロード
node2へのアクセス → また別の住所! キャッシュミス
...配列は連続しているため、キャッシュヒット率が高く、連結リストは分散しているため、キャッシュミスが頻繁に発生します。最新のCPUでは、キャッシュミスはキャッシュヒットよりも100倍以上遅くなります。
配列の限界 — 挿入と削除
配列の中間に挿入(インデックス2に25を挿入):
Before: [10][20][30][40][50]
↓
Step 1: [10][20][ ][30][40][50] ← 30,40,50を1つずつ後ろに移動
Step 2: [10][20][25][30][40][50] ← 空いた場所に25を挿入後ろの要素をすべて移動する必要があるため、O(n)です。削除も同様 — 空いた場所を埋めるために前に移動します。
2次元配列のメモリレイアウト
Row-major (C, Python, NumPyのデフォルト):
[[1, 2, 3], メモリ: [1][2][3][4][5][6]
[4, 5, 6]] → 行順で格納
Column-major (Fortran, MATLAB):
[[1, 2, 3], メモリ: [1][4][2][5][3][6]
[4, 5, 6]] → 列順で格納import numpy as np
arr = np.array([[1, 2, 3], [4, 5, 6]])
# C order (row-major, デフォルト)print(arr.flags['C_CONTIGUOUS']) # True
# Fortran orderarr_f = np.asfortranarray(arr)print(arr_f.flags['F_CONTIGUOUS']) # True行方向の走査が多い場合はRow-majorが、列方向の走査が多い場合はColumn-majorがキャッシュ効率的です。NumPyはデフォルトでC order(Row-major)を使用します。これが、行方向の演算(axis=1)が列方向の演算(axis=0)よりもしばしば高速である理由です — メモリへのアクセスパターンがキャッシュに有利だからです。
動的配列 — サイズが変化する配列
配列はサイズが固定です。Pythonリストは動的配列であり、いっぱいになると、より大きな配列を割り当ててコピーします。
import sys
items = []prev_size = 0for i in range(20): items.append(i) size = sys.getsizeof(items) if size != prev_size: print(f"len={len(items):2d}, capacity changed: {prev_size} → {size} bytes") prev_size = size
# len= 1, capacity changed: 56 → 88 bytes# len= 5, capacity changed: 88 → 120 bytes# len= 9, capacity changed: 120 → 184 bytes# len=17, capacity changed: 184 → 248 bytesPythonは通常、現在のサイズの1.125倍 + 定数だけ余分なスペースを確保します。毎回1つずつ増やすと、毎回全体をコピーする必要が生じますが(O(n))、倍数で増やせば**分割償却O(1)**になります。
重要なまとめ
| 概念 | まとめ |
|---|---|
| 連続メモリ | 要素が隙間なく隣り合って格納される |
| O(1)インデックスアクセス | アドレス = 開始 + (インデックス × サイズ)。配列のサイズに関係なく |
| キャッシュフレンドリー | 連続メモリ → CPUのキャッシュヒット率が高い → 高速 |
| Pythonリスト | ポインタの配列。インデックスアクセスはO(1)だがキャッシュ効率は低い |
| NumPy | データを連続して格納。C配列と似たパフォーマンス |
| 動的配列 | いっぱいになると、倍数で拡張。分割償却O(1)のappend |
配列がO(1)であるのは、「連続メモリ + 1回の乗算」という単純な数学に基づいています。この原理を理解すると、なぜNumPyがPythonリストよりも高速なのか、なぜデータベースがインデックスを使用するのか、なぜキャッシュの最適化が重要なのかが自然に理解できます。
実践的なまとめ:インデックスアクセスが多い場合は配列、挿入/削除が多い場合は連結リスト。Pythonリストはほとんどの場合に適していますが、数値計算ではNumPyを使用すると数十倍高速になります — 真の連続メモリだからです。