Python の set vs NumPy の集合演算(np.unique, np.intersect1d)
Python の組み込み set と NumPy の集合演算関数は、どちらも集合的な操作を提供するが、設計思想とパフォーマンス特性が大きく異なる。
基本的なアプローチの違い
Python の set はハッシュテーブルを使い、NumPy はソートベースのアルゴリズムを使う。
ハッシュテーブル。O(1) の検索。任意のハッシュ可能オブジェクト対応。
ソートベース。O(n log n) の前処理。数値配列に特化。ベクトル化による高速処理。
この違いが、それぞれの得意分野を決定している。
重複排除の比較
まず基本的な重複排除を比較する。
import numpy as np
# Python set
data = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5]
unique_set = set(data)
print(unique_set) # {1, 2, 3, 4, 5, 6, 9}
# NumPy
arr = np.array(data)
unique_np = np.unique(arr)
print(unique_np) # [1 2 3 4 5 6 9](ソート済み)
np.unique() は結果がソートされる点が異なる。これはソートベースのアルゴリズムを使っているためだ。
積集合の比較
2 つの配列の共通要素を求める。
import numpy as np
a = [1, 2, 3, 4, 5]
b = [4, 5, 6, 7, 8]
# Python set
result_set = set(a) & set(b)
print(result_set) # {4, 5}
# NumPy
result_np = np.intersect1d(a, b)
print(result_np) # [4 5]
NumPy の np.intersect1d() は内部で両方の配列をソートし、マージソートのような走査で共通要素を見つける。
和集合と差集合
NumPy にも対応する関数がある。
import numpy as np
a = np.array([1, 2, 3, 4])
b = np.array([3, 4, 5, 6])
# 和集合
print(np.union1d(a, b)) # [1 2 3 4 5 6]
# 差集合
print(np.setdiff1d(a, b)) # [1 2]
# 対称差
print(np.setxor1d(a, b)) # [1 2 5 6]
Python の set 演算 |, -, ^ に対応している。
パフォーマンス比較
小規模データでは set が有利だが、大規模な数値配列では NumPy が逆転する。
import numpy as np
import time
# 100万要素のテスト
n = 1000000
list_a = list(range(n))
list_b = list(range(n // 2, n + n // 2))
arr_a = np.array(list_a)
arr_b = np.array(list_b)
# Python set
start = time.perf_counter()
result = set(list_a) & set(list_b)
print(f"set: {time.perf_counter() - start:.4f}s")
# NumPy
start = time.perf_counter()
result = np.intersect1d(arr_a, arr_b)
print(f"numpy: {time.perf_counter() - start:.4f}s")
NumPy は C 言語で実装されており、SIMD 命令を活用できるため、純粋な計算では高速だ。ただし、Python リストから NumPy 配列への変換コストを含めると、set のほうが速いこともある。
計算量の違い
| 操作 | Python set | NumPy |
|---|---|---|
| 重複排除 | O(n) | O(n log n) |
| 積集合 | O(min(m, n)) | O(m log m + n log n) |
| 存在確認 | O(1) | O(log n) または O(n) |
set はハッシュテーブルの O(1) 検索を活かせる。NumPy はソート後のバイナリサーチか線形走査になる。
メモリ効率
NumPy 配列は連続したメモリに格納されるため、数値データではメモリ効率がよい。
import sys
import numpy as np
n = 10000
py_set = set(range(n))
np_arr = np.unique(np.arange(n))
print(f"set: {sys.getsizeof(py_set):,} bytes")
print(f"numpy: {np_arr.nbytes:,} bytes")
整数の場合、NumPy の int64 配列は要素あたり 8 バイトで済む。Python の set は各要素が Python オブジェクトとして格納されるため、オーバーヘッドが大きい。
np.isin による存在確認
NumPy で「配列の各要素が別の集合に含まれるか」を調べるには np.isin() を使う。
import numpy as np
data = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
targets = np.array([2, 4, 6, 8])
mask = np.isin(data, targets)
print(mask) # [False True False True False True False True False False]
print(data[mask]) # [2 4 6 8]
これは set の in 演算を配列全体に適用するようなもので、ベクトル化されているため高速だ。
assume_unique オプション
入力が既にユニークであることがわかっている場合、assume_unique=True で高速化できる。
import numpy as np
a = np.array([1, 2, 3, 4, 5]) # すでにユニーク
b = np.array([3, 4, 5, 6, 7]) # すでにユニーク
# 通常(ユニーク化処理が入る)
result1 = np.intersect1d(a, b)
# assume_unique=True(ユニーク化をスキップ)
result2 = np.intersect1d(a, b, assume_unique=True)
大規模データでは数十パーセントの高速化になることがある。
return_indices オプション
NumPy 特有の機能として、元の配列でのインデックスを返すオプションがある。
import numpy as np
a = np.array([10, 20, 30, 40, 50])
b = np.array([30, 50, 70])
common, idx_a, idx_b = np.intersect1d(a, b, return_indices=True)
print(common) # [30 50]
print(idx_a) # [2 4](a での位置)
print(idx_b) # [0 1](b での位置)
これは set では直接できない操作で、後続の処理で元データを参照したい場合に便利だ。
使い分けの指針
文字列やオブジェクトを扱う。頻繁に存在確認を行う。動的に要素を追加・削除する。集合のサイズが小〜中規模。
大規模な数値配列を扱う。結果がソートされていてほしい。インデックスも必要。他の NumPy 処理と組み合わせる。
両者を組み合わせることもできる。例えば、フィルタリング条件を set で持ち、NumPy 配列に適用する場合だ。
import numpy as np
valid_ids = {100, 200, 300, 400, 500} # set で管理
data = np.array([100, 150, 200, 250, 300, 350])
# set を使ったフィルタリング
mask = np.array([x in valid_ids for x in data])
filtered = data[mask]
print(filtered) # [100 200 300]
この例では set の O(1) 検索と NumPy の配列操作を組み合わせている。