目次
「アルゴリズムを学ぶ前にデータ構造をマスターせよ」——これはコンピュータサイエンスの世界で広く信じられている教訓だ。データ構造は情報の整理方法であり、適切な選択が処理速度を 100 倍に変えることもある。本稿では主要なデータ構造を実装例とともに体系的に解説する。
データ構造が重要な理由
同じ「検索」処理でも、データ構造によって必要な時間が全く異なる。
配列(未ソート): 100 万要素 → 最大 100 万回比較(O(n))
ハッシュテーブル: 100 万要素 → 数回で検索(O(1))
二分探索木: 100 万要素 → 最大 20 回比較(O(log n))
データ構造の選択は「どの操作を最も頻繁に行うか」で決まる。
| 操作 | 最適なデータ構造 |
|---|---|
| インデックスアクセス | 配列 |
| 頻繁な挿入・削除 | 連結リスト |
| 検索 | ハッシュテーブル・平衡木 |
| 順序付き処理 | ヒープ・平衡木 |
| FIFO | キュー |
| LIFO | スタック |
1. 配列(Array)
1.1 基本特性
配列は連続したメモリ領域に要素を格納する最も基本的なデータ構造だ。
# Python のリスト(動的配列)
arr = [10, 20, 30, 40, 50]
# インデックスアクセス - O(1)
print(arr[2]) # 30
# 末尾への追加 - 平均 O(1)(amortized)
arr.append(60)
# 途中への挿入 - O(n)(要素をシフトする必要あり)
arr.insert(2, 25) # [10, 20, 25, 30, 40, 50, 60]
# 削除 - O(n)
arr.remove(30)
1.2 計算量の特性
| 操作 | 計算量 | 理由 |
|---|---|---|
| アクセス | O(1) | メモリ地址 = 基底 + インデックス×サイズ |
| 末尾追加 | O(1)* | 平均的に定数時間(再確保時は O(n)) |
| 途中挿入 | O(n) | 要素のシフトが必要 |
| 削除 | O(n) | 要素のシフトが必要 |
| 検索(未ソート) | O(n) | 全要素を見る必要がある |
* 平均的に定数時間(amortized O(1))
1.3 配列の利点と欠点
利点:
- インデックスによる高速アクセス
- メモリ効率が良い(オーバーヘッド小)
- CPU キャッシュに乗りやすい(局所性)
欠点:
- 途中の挿入・削除が遅い
- 容量変更にコスト(動的配列の場合)
2. 連結リスト(Linked List)
2.1 基本構造
連結リストはノードが次のノードへの参照を持つ構造だ。
class Node:
def __init__(self, data):
self.data = data
self.next = None
class LinkedList:
def __init__(self):
self.head = None
self.size = 0
def append(self, data):
"""末尾に追加 - O(n)"""
new_node = Node(data)
if not self.head:
self.head = new_node
else:
current = self.head
while current.next: # 末尾まで辿る必要あり
current = current.next
current.next = new_node
self.size += 1
def insert(self, index, data):
"""途中に挿入 - O(n)"""
new_node = Node(data)
if index == 0:
new_node.next = self.head
self.head = new_node
else:
current = self.head
for _ in range(index - 1):
if current is None:
raise IndexError("Index out of range")
current = current.next
new_node.next = current.next
current.next = new_node
self.size += 1
def delete(self, data):
"""値で削除 - O(n)"""
current = self.head
prev = None
while current:
if current.data == data:
if prev:
prev.next = current.next
else:
self.head = current.next
self.size -= 1
return True
prev = current
current = current.next
return False
def search(self, data):
"""線形探索 - O(n)"""
current = self.head
index = 0
while current:
if current.data == data:
return index
current = current.next
index += 1
return -1
2.2 計算量の特性
| 操作 | 計算量 | 理由 |
|---|---|---|
| 先頭への挿入 | O(1) | head を書き換えるだけ |
| 末尾への挿入 | O(n) | 末尾まで辿る必要がある |
| 途中への挿入 | O(n) | 位置まで辿る必要がある |
| 削除 | O(n) | 検索+リンクの張り替え |
| 検索 | O(n) | 順に辿る必要がある |
| アクセス | O(n) | 先頭から辿る必要がある |
2.3 連結リストの利点と欠点
利点:
- 先頭への挿入・削除が O(1)
- 容量を事前に決める必要がない
- メモリの断片化に強い
欠点:
- インデックスアクセスができない(O(n))
- 各ノードに参照用のメモリが必要(オーバーヘッド)
- CPU キャッシュに乗りづらい(非連続メモリ)
2.4 番兵付き連結リスト
class SentinelLinkedList:
"""番兵(ダミーノード)を使った実装"""
def __init__(self):
self.head = Node(None) # 番兵
self.head.next = self.head # 循環リスト
def append(self, data):
"""O(1) - 番兵があるので境界条件不要"""
new_node = Node(data)
new_node.next = self.head.next
self.head.next = new_node
番兵を使うと「リストが空の場合」の特別処理が不要になる。
3. スタック(Stack)
3.1 LIFO——後入れ先出し
スタックは最後に追加した要素が最初に取り出される(LIFO: Last-In, First-Out)構造だ。
class Stack:
def __init__(self):
self.items = []
def push(self, item):
"""積み込み - O(1)"""
self.items.append(item)
def pop(self):
"""取り出し - O(1)"""
if self.is_empty():
raise IndexError("Stack is empty")
return self.items.pop()
def peek(self):
"""頂上要素の確認 - O(1)"""
if self.is_empty():
raise IndexError("Stack is empty")
return self.items[-1]
def is_empty(self):
return len(self.items) == 0
def __len__(self):
return len(self.items)
# 使用例
stack = Stack()
stack.push(1)
stack.push(2)
stack.push(3)
print(stack.pop()) # 3
print(stack.pop()) # 2
3.2 実用例:括弧の整合性チェック
def is_valid_parentheses(s: str) -> bool:
"""括弧の整合性をスタックでチェック"""
stack = Stack()
pairs = {')': '(', '}': '{', ']': '['}
for char in s:
if char in '({[':
stack.push(char)
elif char in ')}]':
if stack.is_empty() or stack.pop() != pairs[char]:
return False
return stack.is_empty()
# テスト
print(is_valid_parentheses("()[]{}")) # True
print(is_valid_parentheses("(]")) # False
print(is_valid_parentheses("([)]")) # False
print(is_valid_parentheses("{[]}")) # True
3.3 実用例:再帰のスタック化
def factorial_recursive(n):
"""再帰版 - スタックオーバーフローのリスク"""
if n <= 1:
return 1
return n * factorial_recursive(n - 1)
def factorial_iterative(n):
"""スタックを使った反復版 - 安全"""
stack = Stack()
# 積む
while n > 1:
stack.push(n)
n -= 1
# 出し掛け算
result = 1
while not stack.is_empty():
result *= stack.pop()
return result
4. キュー(Queue)
4.1 FIFO——先入れ先出し
キューは最初に加えた要素が最初に取り出される(FIFO: First-In, First-Out)構造だ。
from collections import deque
class Queue:
def __init__(self):
self.items = deque()
def enqueue(self, item):
"""追加 - O(1)"""
self.items.append(item)
def dequeue(self):
"""取り出し - O(1)"""
if self.is_empty():
raise IndexError("Queue is empty")
return self.items.popleft()
def peek(self):
"""先頭要素の確認 - O(1)"""
if self.is_empty():
raise IndexError("Queue is empty")
return self.items[0]
def is_empty(self):
return len(self.items) == 0
def __len__(self):
return len(self.items)
# 使用例
queue = Queue()
queue.enqueue(1)
queue.enqueue(2)
queue.enqueue(3)
print(queue.dequeue()) # 1
print(queue.dequeue()) # 2
4.2 実用例:BFS(幅優先探索)
from collections import deque
def bfs(graph, start):
"""キューを使った幅優先探索"""
visited = set()
queue = deque([start])
visited.add(start)
order = []
while queue:
node = queue.popleft()
order.append(node)
for neighbor in graph.get(node, []):
if neighbor not in visited:
visited.add(neighbor)
queue.append(neighbor)
return order
# グラフ例
graph = {
'A': ['B', 'C'],
'B': ['A', 'D', 'E'],
'C': ['A', 'F'],
'D': ['B'],
'E': ['B', 'F'],
'F': ['C', 'E']
}
print(bfs(graph, 'A')) # ['A', 'B', 'C', 'D', 'E', 'F']
4.3 優先度付きキュー(ヒープ)
import heapq
# ヒープは最小要素が先頭に来る
heap = []
heapq.heappush(heap, (2, "タスク B"))
heapq.heappush(heap, (1, "タスク A")) # 優先度 1 が最高
heapq.heappush(heap, (3, "タスク C"))
print(heapq.heappop(heap)) # (1, "タスク A")
print(heapq.heappop(heap)) # (2, "タスク B")
5. ハッシュテーブル(Hash Table)
5.1 O(1) 検索の仕組み
ハッシュテーブルはハッシュ関数でキーをインデックスに変換し、高速な検索を実現する。
class HashTable:
def __init__(self, size=16):
self.size = size
self.table = [[] for _ in range(size)] # 衝突はチェイン法
def _hash(self, key):
"""ハッシュ関数"""
return hash(key) % self.size
def put(self, key, value):
"""挿入 - 平均 O(1)"""
index = self._hash(key)
bucket = self.table[index]
# 既存キーがあれば更新
for i, (k, v) in enumerate(bucket):
if k == key:
bucket[i] = (key, value)
return
# なければ追加
bucket.append((key, value))
# ロードファクターが閾値を超えたらリハッシュ
if self._load_factor() > 0.75:
self._resize()
def get(self, key):
"""検索 - 平均 O(1)"""
index = self._hash(key)
bucket = self.table[index]
for k, v in bucket:
if k == key:
return v
raise KeyError(f"Key not found: {key}")
def delete(self, key):
"""削除 - 平均 O(1)"""
index = self._hash(key)
bucket = self.table[index]
for i, (k, v) in enumerate(bucket):
if k == key:
del bucket[i]
return v
raise KeyError(f"Key not found: {key}")
def _load_factor(self):
"""ロードファクター = 要素数 / バケット数"""
n_elements = sum(len(bucket) for bucket in self.table)
return n_elements / self.size
def _resize(self):
"""リハッシュ - O(n)"""
old_table = self.table
self.size *= 2
self.table = [[] for _ in range(self.size)]
for bucket in old_table:
for key, value in bucket:
self.put(key, value)
5.2 計算量の特性
| 操作 | 平均 | 最悪 | 理由 |
|---|---|---|---|
| 挿入 | O(1) | O(n) | 衝突が全て同一バケットの場合 |
| 検索 | O(1) | O(n) | 同上 |
| 削除 | O(1) | O(n) | 同上 |
最悪ケースを避けるには:
- 良いハッシュ関数を使う(均一な分布)
- ロードファクターを管理(0.75 程度でリハッシュ)
- バケット数を十分に大きくする
5.3 実用例:単語の出現頻度
def count_words(text):
"""ハッシュテーブルで単語頻度をカウント"""
word_count = {} # Python の dict はハッシュテーブル実装
for word in text.lower().split():
word = word.strip(".,!?")
word_count[word] = word_count.get(word, 0) + 1
return word_count
text = "Hello world. Hello Python. World is great."
print(count_words(text))
# {'hello': 2, 'world': 2, 'python': 1, 'is': 1, 'great': 1}
6. 木(Tree)
6.1 基本用語
A ← 根(ルート)
/ \
B C ← B, C は A の子
/ \ \
D E F ← D, E, F は葉(リーフ)
深さ:根からの距離(A=0, B,C=1, D,E,F=2)
高さ:最長の葉までの距離(この木の高さ=2)
6.2 二分木(Binary Tree)
class TreeNode:
def __init__(self, value):
self.value = value
self.left = None
self.right = None
class BinaryTree:
def __init__(self):
self.root = None
def inorder(self, node, result=None):
"""中間順走査(左→根→右)"""
if result is None:
result = []
if node:
self.inorder(node.left, result)
result.append(node.value)
self.inorder(node.right, result)
return result
def preorder(self, node, result=None):
"""先行順走査(根→左→右)"""
if result is None:
result = []
if node:
result.append(node.value)
self.preorder(node.left, result)
self.preorder(node.right, result)
return result
def postorder(self, node, result=None):
"""後行順走査(左→右→根)"""
if result is None:
result = []
if node:
self.postorder(node.left, result)
self.postorder(node.right, result)
result.append(node.value)
return result
# 使用例
# 1
# / \
# 2 3
# / \
# 4 5
tree = BinaryTree()
tree.root = TreeNode(1)
tree.root.left = TreeNode(2)
tree.root.right = TreeNode(3)
tree.root.left.left = TreeNode(4)
tree.root.left.right = TreeNode(5)
print(tree.inorder(tree.root)) # [4, 2, 5, 1, 3]
print(tree.preorder(tree.root)) # [1, 2, 4, 5, 3]
print(tree.postorder(tree.root)) # [4, 5, 2, 3, 1]
6.3 二分探索木(BST)
class BST:
def __init__(self):
self.root = None
def insert(self, value):
"""挿入 - 平均 O(log n), 最悪 O(n)"""
def _insert(node, value):
if node is None:
return TreeNode(value)
if value < node.value:
node.left = _insert(node.left, value)
else:
node.right = _insert(node.right, value)
return node
self.root = _insert(self.root, value)
def search(self, value):
"""検索 - 平均 O(log n), 最悪 O(n)"""
def _search(node, value):
if node is None:
return False
if node.value == value:
return True
if value < node.value:
return _search(node.left, value)
return _search(node.right, value)
return _search(self.root, value)
def delete(self, value):
"""削除 - 平均 O(log n), 最悪 O(n)"""
def _delete(node, value):
if node is None:
return None
if value < node.value:
node.left = _delete(node.left, value)
elif value > node.value:
node.right = _delete(node.right, value)
else: # 削除対象発見
# 子が 0 個または 1 個
if node.left is None:
return node.right
if node.right is None:
return node.left
# 子が 2 個:右部分木の最小値で置き換え
min_node = self._find_min(node.right)
node.value = min_node.value
node.right = _delete(node.right, min_node.value)
return node
self.root = _delete(self.root, value)
def _find_min(self, node):
while node.left:
node = node.left
return node
6.4 平衡二分探索木
BST は偏ると O(n) になるため、自動的にバランスを取る構造が使われる。
| 木の種類 | 特徴 | 使用例 |
|---|---|---|
| AVL 木 | 厳密にバランス | 検索重視 |
| 赤黒木 | 緩いバランス | 挿入・削除も多い場合 |
| B 木 | 多分木 | データベース・ファイルシステム |
Python の dict、set、Java の TreeMap は赤黒木ベース。
7. ヒープ(Heap)
7.1 優先度付きキューの実装
ヒープは完全二分木で、親が常に子より優先度が高い(または低い)。
class MinHeap:
def __init__(self):
self.heap = []
def push(self, value):
"""挿入 - O(log n)"""
self.heap.append(value)
self._sift_up(len(self.heap) - 1)
def pop(self):
"""最小要素の取り出し - O(log n)"""
if not self.heap:
raise IndexError("Heap is empty")
min_val = self.heap[0]
last = self.heap.pop()
if self.heap:
self.heap[0] = last
self._sift_down(0)
return min_val
def _sift_up(self, index):
"""親へ上がる"""
while index > 0:
parent = (index - 1) // 2
if self.heap[index] < self.heap[parent]:
self.heap[index], self.heap[parent] = self.heap[parent], self.heap[index]
index = parent
else:
break
def _sift_down(self, index):
"""子へ下がる"""
n = len(self.heap)
while True:
smallest = index
left = 2 * index + 1
right = 2 * index + 2
if left < n and self.heap[left] < self.heap[smallest]:
smallest = left
if right < n and self.heap[right] < self.heap[smallest]:
smallest = right
if smallest != index:
self.heap[index], self.heap[smallest] = self.heap[smallest], self.heap[index]
index = smallest
else:
break
def __len__(self):
return len(self.heap)
7.2 実用例:ダイクストラ法
import heapq
from math import inf
def dijkstra(graph, start):
"""ヒープを使った最短経路探索 - O((V+E)log V)"""
dist = {node: inf for node in graph}
dist[start] = 0
pq = [(0, start)] # (距離,ノード)
while pq:
d, node = heapq.heappop(pq)
if d > dist[node]:
continue # 古いエントリ
for neighbor, weight in graph.get(node, {}).items():
new_dist = d + weight
if new_dist < dist[neighbor]:
dist[neighbor] = new_dist
heapq.heappush(pq, (new_dist, neighbor))
return dist
# グラフ例
graph = {
'A': {'B': 1, 'C': 4},
'B': {'A': 1, 'C': 2, 'D': 5},
'C': {'A': 4, 'B': 2, 'D': 1},
'D': {'B': 5, 'C': 1}
}
print(dijkstra(graph, 'A')) # {'A': 0, 'B': 1, 'C': 3, 'D': 4}
8. データ構造の選択ガイド
8.1 選択のフローチャート
何を最優先するか?
/ | \
高速検索 順序維持 高速挿入削除
| | |
ハッシュ 平衡木/ 連結リスト
テーブル ヒープ /スタック/キュー
8.2 実装別まとめ
| データ構造 | アクセス | 挿入 | 削除 | 検索 | 主な用途 |
|---|---|---|---|---|---|
| 配列 | O(1) | O(n) | O(n) | O(n) | 固定サイズデータ |
| 連結リスト | O(n) | O(1)* | O(1)* | O(n) | 頻繁な挿入削除 |
| スタック | - | O(1) | O(1) | - | 再帰・undo |
| キュー | - | O(1) | O(1) | - | タスクキュー・BFS |
| ハッシュテーブル | - | O(1) | O(1) | O(1) | 辞書・集合 |
| 二分探索木 | O(log n) | O(log n) | O(log n) | O(log n) | 順序付きデータ |
| ヒープ | O(1)** | O(log n) | O(log n) | O(n) | 優先度キュー |
* 先頭の場合のみ ** 最小要素のみ
8.3 Python での実装対応
| データ構造 | Python の実装 |
|---|---|
| 配列 | list |
| 連結リスト | 自作 or collections.deque |
| スタック | list(append() / pop()) |
| キュー | collections.deque |
| ハッシュテーブル | dict, set |
| 平衡木 | 内部実装(dict, set の背後) |
| ヒープ | heapq モジュール |
まとめ
データ構造の選択は以下の基準で行う:
- どの操作が最も頻繁か(アクセス・挿入・削除・検索)
- 順序は必要か(順序付き・順序なし)
- 重複は許容するか
- メモリ効率は重要か
適切なデータ構造を選べば、コードは簡潔になり、パフォーマンスは向上する。これらは全てのプログラミングの基礎となる知識だ。
免責事項 — 掲載情報は執筆時点のものです。料金・機能は変更される場合があります。最新情報は各公式サイトをご確認ください。