ハッシュ関数の内部動作と衝突処理
Pythonがオブジェクトをハッシュする仕組み、オープンアドレス法とチェイン法による衝突解決、平均O(1)がO(n)まで悪化する理由を理解します。
「ハッシュ関数の内部動作と衝突処理」はCoddyKit上の無料Coding Interview Prepレッスンです。 これはレッスン1/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはCoding Interview Prep学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 Coding Interview Prepコースには全4レッスンが含まれています。
ハッシュマップとは
ハッシュマップ(Python では辞書)は、任意のキーを内部配列の整数インデックスに変換するハッシュ関数を使って、キーと値を対応付けます。理想的なハッシュ関数はキーを配列全体に均等に分散させるため、検索、挿入、削除を平均 O(1) で実行できます。内部配列はハッシュテーブルまたはバケット配列と呼ばれます。
Python の dict は高度に最適化されたハッシュマップです。その内部構造を理解すると、最悪時の挙動を考慮し、適切なキーを選べるようになります。
# Python dict is a hash map
hm = {}
hm['alice'] = 95
hm['bob'] = 87
hm['carol'] = 91
print(hm['alice']) # O(1) lookup: 95
print('bob' in hm) # O(1) membership: True
del hm['bob'] # O(1) deletion
print(hm) # {'alice': 95, 'carol': 91}ハッシュ関数と __hash__ メソッド
Python は __hash__(key) を呼び出してキーから整数を計算し、その整数をテーブルサイズで割った余りを使ってバケットのインデックスを決めます。int、str、tuple などの組み込み型には、高速な組み込みハッシュ実装があります。list と dict はハッシュ化できません(可変であり、変更すると保存済みのハッシュが無効になるためです)。
優れたハッシュ関数には、キーを均等に分散させること、決定的であること、計算が高速であることが求められます。Python の文字列ハッシュは実行ごとにランダム化されます(セキュリティ機能です)。テストで再現性が必要な場合は、PYTHONHASHSEED=0 を使って無効にしてください。
# Built-in hash in Python
print(hash(42)) # integer hashes to itself (CPython)
print(hash('hello')) # string hash (randomised per run)
print(hash((1, 2, 3))) # tuple hash: depends on contents
# Unhashable types
try:
hash([1, 2, 3]) # lists are mutable -> not hashable
except TypeError as e:
print('Error:', e)
# Custom class: define __hash__ and __eq__
class Point:
def __init__(self, x, y): self.x = x; self.y = y
def __hash__(self): return hash((self.x, self.y))
def __eq__(self, other): return self.x == other.x and self.y == other.y
points = {Point(1, 2): 'A', Point(3, 4): 'B'}
print(points[Point(1, 2)]) # 'A'衝突:2つのキーが同じバケットにハッシュされる場合
衝突とは、異なる2つのキーが同じバケットインデックスを生成することです。衝突は避けられません(鳩の巣原理によるものです。キーは無限にありますが、バケット数は有限です)。標準的な解決方法にはチェイン法とオープンアドレス法があります。Python は擬似ランダムプロービングを使うオープンアドレス法の一種を採用しています。
チェイン法では各バケットに連結リスト(または動的配列)を格納し、そのバケットで衝突したすべてのキーを1つのチェーンにまとめます。オープンアドレス法では、プローブ系列に従って次の空きバケットを探します。
# Simplified chaining hash map
class ChainingHashMap:
def __init__(self, capacity=8):
self.capacity = capacity
self.buckets = [[] for _ in range(capacity)]
def _idx(self, key):
return hash(key) % self.capacity
def put(self, key, val):
bucket = self.buckets[self._idx(key)]
for i, (k, v) in enumerate(bucket):
if k == key:
bucket[i] = (key, val)
return
bucket.append((key, val))
def get(self, key):
for k, v in self.buckets[self._idx(key)]:
if k == key:
return v
return None
hm = ChainingHashMap()
hm.put('a', 1); hm.put('b', 2)
print(hm.get('a')) # 1
print(hm.get('c')) # Noneオープンアドレス法:線形探索
線形探索では、インデックス i で衝突が起きた場合、空きスロットが見つかるまで i+1、i+2、… の順にマップを調べます(末尾に達したら先頭に戻ります)。検索でも同じ系列を調べてキーを見つける必要があります。削除時にスロットを空にするとプローブの連鎖が途切れるため、空にする代わりに「tombstone」マーカーを付けます。
主な欠点はクラスタリングです。埋まったスロットのクラスタができると、その領域への挿入がクラスタをさらに拡大させ、性能が O(n) に近づいていきます。
class LinearProbingHashMap:
DELETED = object() # tombstone sentinel
def __init__(self, capacity=8):
self.capacity = capacity
self.keys = [None] * capacity
self.vals = [None] * capacity
self.size = 0
def _probe(self, key):
idx = hash(key) % self.capacity
while self.keys[idx] is not None and self.keys[idx] != key:
idx = (idx + 1) % self.capacity
return idx
def put(self, key, val):
idx = self._probe(key)
if self.keys[idx] is None:
self.size += 1
self.keys[idx] = key
self.vals[idx] = val
def get(self, key):
idx = self._probe(key)
if self.keys[idx] == key:
return self.vals[idx]
return None
hm = LinearProbingHashMap()
hm.put('x', 10); hm.put('y', 20)
print(hm.get('x')) # 10負荷率とサイズ変更
負荷率とは、格納されているエントリ数と全容量の比率です。α = n/m で表します。α が増加すると衝突の確率が上がり、性能が低下します。Python の dict は、負荷率が約 2/3 を超えると容量を2倍にしてサイズを変更します。サイズ変更では、既存のすべてのエントリを新しい大きなテーブルに再ハッシュします。これは O(n) の処理ですが、頻繁には発生しないため、挿入の償却コストは O(1) に保たれます。
import sys
d = {}
prev_size = sys.getsizeof(d)
for i in range(30):
d[i] = i
new_size = sys.getsizeof(d)
if new_size != prev_size:
print(f'Resized at n={i+1}: {prev_size} -> {new_size} bytes')
prev_size = new_size平均 O(1) と最悪 O(n)
優れたハッシュ関数を使うと衝突はまれになり、期待されるチェーンの長さは n に関係なく一定です。したがって、平均計算量では検索、挿入、削除をO(1) で実行できます。ただし、すべてのキーが同じバケットに対応するような意図的に悪意のある入力など、最悪ケースでは、すべての操作が O(n) まで悪化します。Python のランダム化されたハッシュシードはこの攻撃を緩和しますが、理論上の最悪ケースをなくすわけではありません。
面接での分析では、「衝突が原因で、平均 O(1)、最悪 O(n) です」と答えてください。
# Python randomised hash seed prevents worst-case hash-flooding
import os
print('PYTHONHASHSEED:', os.environ.get('PYTHONHASHSEED', 'random'))
# By default Python randomises the hash of strings each run
# This prevents an attacker from crafting keys that all collide
# To reproduce results in testing: PYTHONHASHSEED=0 python script.pyPython の dict と defaultdict と Counter
Python には、知っておくべきハッシュマップの派生型が3つあります。dict は汎用的なマップで、存在しないキーにアクセスすると KeyError が発生します。defaultdict(factory) は存在しないキーにアクセスするとデフォルト値を返します(リストの収集やカウントに便利です)。Counter はハッシュ化可能なオブジェクトのカウントに特化したサブクラスで、カウンター同士の算術演算にも対応しています。
from collections import defaultdict, Counter
# defaultdict for grouping
groups = defaultdict(list)
for word in ['apple', 'ant', 'banana', 'bee', 'avocado']:
groups[word[0]].append(word)
print(dict(groups))
# {'a': ['apple','ant','avocado'], 'b': ['banana','bee']}
# Counter for frequency
c = Counter('abracadabra')
print(c.most_common(3)) # [('a',5),('b',2),('r',2)]
print(c['a'] - Counter('aa')['a']) # counter subtractionハッシュマップとハッシュセット
ハッシュセットはキーだけを格納し、値との対応付けは行いません。要素の存在確認、挿入、削除を O(1) で実行できます。Python の set はハッシュセットです。関連データを保存せず、「この要素は存在するか?」だけを確認したい場合はセットを使います。キーにカウントや結果などの値を対応付ける必要がある場合は dict を使います。
# set for membership testing
visited = set()
for node in [1, 3, 5, 3, 7, 1]:
if node not in visited:
print('New node:', node)
visited.add(node)
# Set operations: union, intersection, difference
A = {1, 2, 3, 4}
B = {3, 4, 5, 6}
print('Union:', A | B) # {1,2,3,4,5,6}
print('Intersection:', A & B) # {3,4}
print('Difference:', A - B) # {1,2}ハッシュマップをゼロから実装する(面接向け)
面接では、基本的なハッシュマップの実装を求められることがあります。主な構成要素は、固定サイズのバケット配列(16または1024を使います)、チェイン法のために各バケットへ格納する (key, value) ペアのリスト、ハッシュ関数(Python 組み込みの hash % capacity を使います)、そして負荷率が 0.7 を超えたときのサイズ変更です。サイズ変更と負荷率について自発的に言及できると、深い知識があることを示せます。
class HashMap:
def __init__(self, capacity=16):
self.capacity = capacity
self.size = 0
self.buckets = [[] for _ in range(capacity)]
def _hash(self, key):
return hash(key) % self.capacity
def put(self, key, val):
b = self.buckets[self._hash(key)]
for i, (k, v) in enumerate(b):
if k == key:
b[i] = (key, val)
return
b.append((key, val))
self.size += 1
if self.size / self.capacity > 0.7:
self._resize()
def get(self, key, default=None):
for k, v in self.buckets[self._hash(key)]:
if k == key:
return v
return default
def _resize(self):
old = self.buckets
self.capacity *= 2
self.buckets = [[] for _ in range(self.capacity)]
self.size = 0
for bucket in old:
for k, v in bucket:
self.put(k, v)
hm = HashMap()
for i in range(20):
hm.put(i, i * 2)
print(hm.get(10)) # 20
print(hm.capacity) # should have resizedハッシュマップが機能しない場合:ハッシュ化できないキー
ハッシュ化可能なオブジェクトだけが辞書のキーになれます。Python では、__hash__ メソッドと __eq__ メソッドを持ち、そのハッシュ値が存続期間中に変化しないオブジェクトはハッシュ化可能です。リスト、セット、dict は可変であるため、ハッシュ化できません。キーとして使う場合、タプルと frozenset はリストやセットの代わりになるハッシュ化可能な選択肢です。
面接でよくある落とし穴は、アナグラムをグループ化するときに、辞書のキーとしてソート済みのリストではなくソート済みのタプルを使うことです。
from collections import defaultdict
def groupAnagrams(strs):
groups = defaultdict(list)
for s in strs:
key = tuple(sorted(s)) # tuple is hashable; list is not
groups[key].append(s)
return list(groups.values())
print(groupAnagrams(['eat','tea','tan','ate','nat','bat']))
# [['eat','tea','ate'], ['tan','nat'], ['bat']]まとめ:ハッシュマップの計算量
ハッシュマップは、挿入、削除、検索を平均 O(1) で実行でき、多くの最適な面接解答の基盤になります。重要な前提は、優れたハッシュ関数がキーを均等に分散させること、負荷率が一定範囲に保たれること(サイズ変更によって維持されます)、そしてキーオブジェクトが不変かつハッシュ化可能であることです。これらの前提が成り立つと、ハッシュマップによって O(n) の線形探索を O(1) の検索に置き換えられ、two-sum のような問題を O(n²) ではなく O(n) で解けるようになります。
理解度チェック
このレッスンで学んだ Data Structures & Algorithms — Coding Interview Prep の概念について、理解度を確認しましょう。
レッスンのまとめ
このレッスンでは、ハッシュマップはハッシュ関数を使ってキーをバケットインデックスに対応付け、操作を平均 O(1) で実行できること、衝突はチェイン法(バケットごとの連結リスト)またはオープンアドレス法(次の空きスロットを探索)で解決すること、そして不変でハッシュ化可能なオブジェクトだけが辞書のキーになり、シーケンスをキーにする必要がある場合はリストではなくタプルを使うことを学びました。次は、two-sum とそのさまざまな面接向け応用問題を解きます。
よくある質問
「ハッシュ関数の内部動作と衝突処理」レッスンは無料ですか?
はい。「ハッシュ関数の内部動作と衝突処理」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、Coding Interview Prepコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 Coding Interview Prepコースには全4レッスンが含まれています。
「ハッシュ関数の内部動作と衝突処理」で何を学びますか?
Pythonがオブジェクトをハッシュする仕組み、オープンアドレス法とチェイン法による衝突解決、平均O(1)がO(n)まで悪化する理由を理解します。 ブラウザで直接実行するハンズオンコードでCoding Interview Prepを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。
Coding Interview Prepを始めるのに経験は必要ですか?
事前経験は必要ありません。CoddyKitのCoding Interview Prepは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン1/4です。
「ハッシュ関数の内部動作と衝突処理」レッスンにはどのくらい時間がかかりますか?
ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。
このCoding Interview Prepレッスンでコードを書いて実行できますか?
はい。すべてのCoding Interview Prepレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。
このコースのすべてのレッスン
- ハッシュ関数の内部動作と衝突処理
- Two-Sumとその多様な派生問題
- 頻度カウントとグループ化
- 最長連続列とLRUキャッシュ