Python の set vs NumPy の集合演算(np.unique, np.intersect1d)

Python の組み込み set と NumPy の集合演算関数は、どちらも集合的な操作を提供するが、設計思想とパフォーマンス特性が大きく異なる。

基本的なアプローチの違い

Python の set はハッシュテーブルを使い、NumPy はソートベースのアルゴリズムを使う。

Python set

ハッシュテーブル。O(1) の検索。任意のハッシュ可能オブジェクト対応。

NumPy 集合演算

ソートベース。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 setNumPy
重複排除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 では直接できない操作で、後続の処理で元データを参照したい場合に便利だ。

使い分けの指針

Python set を選ぶ場面

文字列やオブジェクトを扱う。頻繁に存在確認を行う。動的に要素を追加・削除する。集合のサイズが小〜中規模。

NumPy を選ぶ場面

大規模な数値配列を扱う。結果がソートされていてほしい。インデックスも必要。他の 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 の配列操作を組み合わせている。