リストから重複を消したい。そう思って検索したら、set という見慣れない単語が出てきて戸惑った経験はありませんか。
リストと辞書はなんとなくわかる。でも集合と言われると、数学の授業を思い出して身構えてしまう。そんな方は多いはずです。
ところが集合は、Pythonのデータ構造のなかでいちばん覚えることが少ないものです。役割がはっきりしているぶん、迷う場面もほとんどありません。
この記事では、集合の性質と使いどころを、手元で動かせるコードと一緒に整理していきます。
集合は、重複を持たない入れ物¶
まず性質から押さえましょう。集合には2つの大きな特徴があります。
同じ値を2つ以上持てないこと。そして、並び順を持たないことです。
言葉より動かしたほうが早いので、次のコードを写してみてください。
tags = ["python", "flask", "python", "sql", "flask"]
unique_tags = set(tags)
print(unique_tags)
# {'sql', 'flask', 'python'} ← 重複が消えている
# 波かっこで直接書くこともできる
languages = {"python", "go", "rust"}
print(type(languages))
# <class 'set'>
set() にリストを渡しただけで、重複が消えました。自分で判定のコードを書く必要はありません。
出力の順番が元のリストと違う点にも注目してください。集合は順番を管理しないため、書いた順に並ぶ保証がないのです。
リストやタプルとの違いを表にまとめておきます。使い分けに迷ったら、ここに戻ってきてください。
| list | tuple | set | |
|---|---|---|---|
| 重複 | 持てる | 持てる | 持てない |
| 並び順 | 保たれる | 保たれる | 保たれない |
| あとから変更 | できる | できない | できる |
| 番号での取り出し | x[0] でできる |
x[0] でできる |
できない |
| 存在チェックの速さ | 遅い | 遅い | 速い |
リストとタプルの違いそのものが曖昧な方は、先にこちらを読んでおくと整理しやすくなります。【関連記事】Pythonのリスト(list)とタプル(tuple)、どっちを使う? それぞれの違いを徹底解説
空の集合を作るときだけ、書き方に注意する¶
集合でいちばん有名な落とし穴を先に潰しておきます。空の集合は波かっこでは作れません。
波かっこだけを書くと、空の辞書になってしまうからです。歴史的に辞書のほうが先にこの記号を使っていた、という事情によるものです。
a = {}
print(type(a)) # <class 'dict'> ← 辞書になる
b = set()
print(type(b)) # <class 'set'> ← これが空の集合
つまり空の集合がほしいときは、必ず set() と書きます。中身があるときだけ波かっこが使える、と覚えておけば十分です。
重複の削除が、いちばん多い使い道¶
実務で集合を使う理由の大半はこれです。アンケートの回答、ログに出てきたユーザーID、CSVの部署名。重複を落としたい場面は毎日のように出てきます。
書き方はとても短くなります。set() で包んで、必要ならリストに戻すだけです。
visitors = ["sato", "endo", "sato", "kato", "endo", "sato"]
unique = list(set(visitors))
print(len(visitors), "件 →", len(unique), "件")
# 6 件 → 3 件
ループと if を書いていた処理が、1行に収まりました。読み手にとっても意図が伝わりやすくなります。
順番を保ったまま重複を消したいとき¶
ただし、この書き方には弱点があります。集合を通した時点で、元の並び順が失われることです。
表示する順番に意味があるなら、別の手を使いましょう。辞書のキーが重複を許さず、かつ挿入順を保つ性質を利用します。
visitors = ["sato", "endo", "sato", "kato", "endo", "sato"]
ordered = list(dict.fromkeys(visitors))
print(ordered)
# ['sato', 'endo', 'kato'] ← 最初に出てきた順のまま
dict.fromkeys は、渡した要素をキーにした辞書を作る関数です。キーは重複できないので、結果として重複が落ちます。
私は10年ほどエンジニアとして開発に関わってきましたが、この違いで一度ひやりとしたことがあります。画面に出す候補リストを set で重複除去したところ、デプロイのたびに並び順が変わってしまい、表示が安定しないという問い合わせを受けました。
順番が意味を持つデータかどうか。集合を使う前に、いつもここを確認するようにしています。
存在チェックが速いのは、探し方が違うから¶
集合のもうひとつの強みが、この値は入っているかという判定の速さです。
リストに対する in は、先頭から順に1つずつ比べていきます。目的の値が末尾にあれば、全部を見終わるまで答えが出ません。
いっぽう集合は、値そのものから保管場所を計算します。だから最初から、その場所だけを見に行けるのです。
手元で差を測ってみましょう。次のコードは20万件のデータに対して、末尾に近い値を200回探しています。
import timeit
numbers_list = list(range(200_000))
numbers_set = set(numbers_list)
t_list = timeit.timeit(lambda: 199_999 in numbers_list, number=200)
t_set = timeit.timeit(lambda: 199_999 in numbers_set, number=200)
print(f"list: {t_list:.4f} 秒")
print(f"set : {t_set:.6f} 秒")
私の環境では、リストが約0.37秒、集合が約0.00002秒でした。桁が4つ以上違います。
数値は環境によって変わりますが、差の大きさは変わりません。件数が増えるほど、この差は開いていきます。
計算量で見ると差がはっきりする¶
なぜここまで違うのか。計算量という考え方で見ると、理由がすっきり説明できます。
Python公式Wikiの一覧表によれば、リストの in は平均でO(n)、集合の in は平均でO(1)です。nは要素の数を表します。
O(n)は要素が10倍になれば時間も10倍になるという意味。O(1)は要素がいくら増えても時間がほぼ変わらないという意味です。
この記号の読み方に自信がない方は、こちらで整理しておくと処理時間の見積もりができるようになります。【関連記事】「実行時間が終わらない…」を卒業する!あなたのコードを100倍速くする計算量の考え方
実務での判断はとても単純です。ループの中で in を何度も使うなら、その相手は集合にしておく。それだけで速度の問題はたいてい消えます。
集合どうしの計算で、差分や共通部分がすぐ出る¶
ここからが集合の本領です。2つの集合を演算子でつなぐだけで、共通部分や差分が取り出せます。
権限のチェックやデータの突き合わせで、そのまま使える機能です。
required = {"python", "sql", "flask"}
mine = {"python", "git"}
print(required & mine) # {'python'} 共通しているもの
print(required - mine) # {'flask', 'sql'} 足りないもの
print(required | mine) # 4つ全部 どちらかにあるもの
print(required ^ mine) # 片方にしかないもの
注目してほしいのは2行目です。必要なスキルから持っているスキルを引くだけで、不足分の一覧ができました。
同じことをリストでやろうとすると、二重ループと if が必要になります。集合ならこの1行で済みます。
演算子と意味の対応を表にしておきます。読み方さえわかれば、あとは組み合わせるだけです。
| 書き方 | メソッドでの書き方 | 意味 |
|---|---|---|
a \| b |
a.union(b) |
どちらかに入っているもの |
a & b |
a.intersection(b) |
両方に入っているもの |
a - b |
a.difference(b) |
aにだけ入っているもの |
a ^ b |
a.symmetric_difference(b) |
片方にだけ入っているもの |
a <= b |
a.issubset(b) |
aがbにすべて含まれるか |
メソッド形式には利点があります。演算子は両側とも集合でなければ動きませんが、メソッドならリストを直接渡せます。
集合に入れられるもの、入れられないもの¶
万能に見える集合ですが、入れられる値には制限があります。値から保管場所を計算する仕組み上、途中で中身が変わる値は扱えません。
具体的には、リストや辞書をそのまま集合に入れることはできません。試すとエラーになります。
try:
{[1, 2], [3, 4]}
except TypeError as e:
print(e)
# unhashable type: 'list'
# タプルは中身を変えられないので入れられる
print({(1, 2), (3, 4)})
# {(1, 2), (3, 4)}
unhashable という単語は、保管場所を計算できないという意味です。エラーメッセージにこれが出たら、変更できる型を入れようとしたと考えてください。
frozensetは、変更できない集合¶
では集合の中に集合を入れたいときはどうするか。そこで登場するのが frozenset です。
frozenset は、作ったあと中身を変えられない集合です。変わらないと保証されているので、辞書のキーにも集合の要素にもできます。
teams = {frozenset({"sato", "endo"}), frozenset({"kato"})}
print(len(teams)) # 2
memo = {frozenset({1, 2}): "ペアA"}
print(memo[frozenset({2, 1})]) # ペアA ← 順番が違っても同じ扱い
最後の行が面白いところです。集合は順番を持たないので、書いた順が違っても同じキーとして扱われます。
実務でつまずきやすいポイント¶
最後に、現場で見かける失敗を共有します。どれも一度知っておけば避けられるものです。
まず、要素を番号で取り出せません。my_set[0] と書くとエラーになります。
順番のある形で扱いたいなら、sorted(my_set) でリストに変換してから使ってください。並べ替えも同時に済むので一石二鳥です。
次に、削除のメソッドが2種類ある点です。remove は存在しない値を消そうとするとKeyErrorになり、discard は何も起きません。
存在するか確信が持てないときは discard を選ぶと、余計な例外処理を書かずに済みます。
そして、TrueとFalseの扱いには注意が必要です。Pythonの内部ではTrueが1、Falseが0と等しいため、集合の中で同一視されます。
print({1, True, 1.0}) # {1} すべて同じ値として1つにまとめられる
print({0, False}) # {0}
数値と真偽値が混ざるデータを重複除去するときは、ここで件数が合わなくなることがあります。私は集計値のずれを追いかけて、この挙動にたどり着いたことがありました。
つまずきやすい点をまとめておきます。
| つまずきポイント | 起きること | 対処 |
|---|---|---|
s = {} で空集合を作る |
辞書になってしまう | set() と書く |
my_set[0] で取り出す |
TypeErrorで止まる | sorted() などでリストにする |
| リストを要素に入れる | unhashable でエラー | タプルか frozenset にする |
remove で存在しない値を消す |
KeyErrorで止まる | discard を使う |
| 表示順が毎回変わる | 集合が順番を持たない | dict.fromkeys か sorted を使う |
今日から使える形にまとめておく¶
ここまでの内容を、実際に書くときの判断だけに絞って整理します。
重複を消したいなら set() で包む。並び順が大事なときだけ dict.fromkeys を選ぶ、という切り分けで十分です。
ループの中で in を使うなら、相手を集合にしておく。存在チェックの速さは、集合を使う最大の理由です。
2つのデータを比べたいなら、差集合や積集合を思い出してください。二重ループを書く前に、集合にできないか考えるだけでコードがぐっと短くなります。
なお、何が何回出てきたかを知りたい場面では集合は力不足です。回数を数えるならcollectionsのCounterが向いています。【関連記事】Pythonのcollectionsとは?Counterやdefaultdictで毎日のコードが短くなる使い方を解説
集合にも内包表記があり、{x * x for x in range(5)} のように書けます。内包表記の書き方から確認したい方はこちらが参考になります。【関連記事】Pythonのリスト内包表記を使いこなせ!3行のループを1行にまとめる書き方
まずは手元のリストを set() で包むところから試してみてください。重複が消える手軽さを体感できれば、あとは自然と使う場面が見えてきます。
ここまでお読みいただきありがとうございました。