一覧へ

連続メモリと配列インデックス O(1)

配列がインデックスで即座にアクセスできるのはなぜか。連続メモリの原理とキャッシュの効率性を理解します。

中級
|
10
|
検証済み (2026-07)
連続メモリ配列インデックスO(1) アクセスメモリアドレスキャッシュ
進捗0/23 (0%)

連続メモリと配列インデックスによるO(1)アクセス

このトピックを終えると

配列がインデックスアクセスにおいてO(1)になる理由を数学的に説明できるようになり、連続メモリの利点と欠点を理解し、配列と連結リストのパフォーマンスの違いを把握できるようになります。


配列がなぜ高速なのか

python
numbers = [10, 20, 30, 40, 50]
print(numbers[3]) # 40 — 瞬時にアクセス!

要素数が5個でも500万個でも、インデックスによるアクセスにかかる時間は常に同じです。これがO(1) — 定数時間アクセスです。

なぜそうなるのでしょうか? 配列はメモリに連続的に格納されるからです。


連続メモリ — 配列の秘密

text
メモリアドレス:  1000  1004  1008  1012  1016
値:          [10]  [20]  [30]  [40]  [50]
インデックス:        0     1     2     3     4

配列の各要素は、メモリ内で隙間なく隣り合って格納されます。各整数が4バイトの場合:

  • numbers[0] → アドレス 1000
  • numbers[1] → アドレス 1004
  • numbers[2] → アドレス 1008
  • numbers[3] → アドレス 1012

インデックスの公式

text
要素のアドレス = 開始アドレス + (インデックス × 要素のサイズ)

numbers[3] = 1000 + (3 × 4) = 1012

この計算は、加算1回、乗算1回だけです。配列のサイズが5個でも5億個でも同じです。したがって、O(1)です。


連結リストとの比較

連結リストは、各ノードが次のノードのアドレスを格納します。メモリ上に分散して存在します。

text
メモリ:  [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の配列とは異なります。ポインタの配列です。

text
Python list:  [ptr0][ptr1][ptr2][ptr3]
                ↓     ↓     ↓     ↓
              [10]  ["hi"] [3.14] [[1,2]]

ポインタ(アドレス)はサイズが一定(64ビットシステムでは8バイト)であるため、ポインタ配列自体は連続メモリ上に存在します。インデックスの公式は依然として適用され、O(1)です。

python
# Python list — 様々な型を格納可能(ポインタ配列のため)
mixed = [42, "hello", 3.14, [1, 2, 3]]
print(mixed[2]) # 3.14 — O(1)

ただし、実際のデータはメモリのあちこちに分散しているため、C配列よりもキャッシュ効率は低くなります。

NumPy配列 — 真の連続メモリ

python
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バイト)**にロードします。

text
配列の連続アクセス:
  arr[0]へのアクセス → キャッシュにarr[0]〜arr[15]をロード
  arr[1]へのアクセス → キャッシュヒット!(すでにロード済み)
  arr[2]へのアクセス → キャッシュヒット!
  ...

連結リストへのアクセス:
  node0へのアクセス → キャッシュに周辺をロード
  node1へのアクセス → 別の住所! キャッシュミス → メモリから再ロード
  node2へのアクセス → また別の住所! キャッシュミス
  ...

配列は連続しているため、キャッシュヒット率が高く、連結リストは分散しているため、キャッシュミスが頻繁に発生します。最新のCPUでは、キャッシュミスはキャッシュヒットよりも100倍以上遅くなります。


配列の限界 — 挿入と削除

text
配列の中間に挿入(インデックス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次元配列のメモリレイアウト

text
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]]     → 列順で格納
python
import numpy as np
arr = np.array([[1, 2, 3], [4, 5, 6]])
# C order (row-major, デフォルト)
print(arr.flags['C_CONTIGUOUS']) # True
# Fortran order
arr_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リストは動的配列であり、いっぱいになると、より大きな配列を割り当ててコピーします。

python
import sys
items = []
prev_size = 0
for 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 bytes

Pythonは通常、現在のサイズの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を使用すると数十倍高速になります — 真の連続メモリだからです。

💬 質問・コメント

0件のコメント

ログインせずに投稿できます。ゲスト投稿は投稿者自身で編集・削除できません。

0/2000

読み込み中...