Pythonでは同じ「複数の値をまとめる」でも、リスト(list)、タプル(tuple)、セット(set)で性質が異なります。
本記事では使い分けの基準と、速度・メモリの実測を通じて「どれを選ぶべきか」を丁寧に解説します。
初心者の方が迷いがちなポイントを、コードと結果付きで確認できるようにしました。
リスト vs タプル vs セットの使い分け
まずは3つの基本的な性質を押さえることが、最短で正しい選択につながります。
次の表は、主要な違いを一望できるようにまとめたものです。
コンテナ | 可変性(更新) | 順序保持 | 重複の扱い | ハッシュ可能 | 主な用途 |
---|---|---|---|---|---|
list | 可変 | 保持する | 許す | できない | 順序付きデータの追加・削除・並べ替え |
tuple | 不変 | 保持する | 許す | 要素が全てハッシュ可能なら可 | 変更しない固定データ、辞書キー |
set | 可変 | 保持しない(順序は未定義) | 排除する | できる | 高速なメンバーシップ判定、重複排除 |
可変と不変(更新の可否)
リストは可変、タプルは不変、セットは可変(ただし順序なし)が大原則です。
可変かどうかは、更新コストや安全性(意図せぬ変更を防ぐ)に直結します。
# 可変(list)と不変(tuple)の違い
fruits_list = ["apple", "banana"]
fruits_list[0] = "grape" # OK: リストは要素を更新できる
fruits_list.append("orange") # OK: 要素の追加も可能
fruits_tuple = ("apple", "banana")
# fruits_tuple[0] = "grape" # NG: タプルは不変なので更新不可 -> TypeError
不変の利点
タプルは不変であるため、関数の引数や返り値として「変更しないこと」を保証したいときに適します。
ハッシュ可能性にも関わるため、辞書キーとして使えるかどうかに影響します(後述)。
順序と重複(保持するか排除するか)
リストとタプルは挿入順を保持し、重複も許容します。
一方セットは重複を自動的に排除し、順序は持ちません。
並びが意味を持つデータ(例: タスクの実行順)はリストやタプルを、重複排除や集合演算に強いのはセットです。
セットのイテレーション順に意味を期待しないでください。
CPythonでは見かけ上安定することがありますが、仕様上は未定義で将来変わる可能性があります。
ハッシュ可能性(辞書キーやセット要素に使えるか)
辞書キーやセット要素にできるのは「ハッシュ可能」な値のみです。
タプルは中身がすべてハッシュ可能ならハッシュ可能、リストはハッシュ不可能なので辞書キーにできません。
# タプルは(要素がハッシュ可能なら)辞書キーにできる
coords = {(10, 20): "A", (30, 40): "B"}
print(coords[(10, 20)]) # => "A"
# リストはハッシュ不可能なのでキーにできない
try:
bad = {[1, 2]: "NG"} # TypeError
except TypeError as e:
print("エラー:", e)
# タプルでも、中にリストが入るとハッシュ不可能になる
x = ([1, 2], 3)
try:
s = {x} # TypeError
except TypeError as e:
print("エラー:", e)
A
エラー: unhashable type: 'list'
エラー: unhashable type: 'list'
典型ユースケース(並び保持・固定データ・メンバーシップテスト)
- 並びを保持して頻繁に追加・削除・ソートする: list
- 変更しない定数的なレコードや辞書キー: tuple
- 高速なメンバーシップ判定や重複排除、集合演算: set
# 重複排除とメンバーシップ判定はsetが有利
items = ["A", "B", "A", "C", "B"]
unique = set(items) # {'A', 'B', 'C'}
print("B in items(list)?", "B" in items) # O(n)
print("B in unique(set)?", "B" in unique) # 平均O(1)
B in items(list)? True
B in unique(set)? True
速度比較(パフォーマンス)
「何に時間がかかるか」は操作ごとに違います。
実測と計算量の両面から押さえましょう。
以下のベンチマークは目安で、環境やPythonのバージョンによって変わります。
検索の速度(in): list vs set
リストのin
は線形探索、セットのin
はハッシュ探索です。
# 検索速度の比較: list vs set
import timeit
setup = """
data_list = list(range(200_000))
data_set = set(data_list)
target_hit = 199_999
target_miss = -1
"""
stmt_list_hit = "target_hit in data_list"
stmt_list_miss = "target_miss in data_list"
stmt_set_hit = "target_hit in data_set"
stmt_set_miss = "target_miss in data_set"
for label, stmt in [
("list hit", stmt_list_hit),
("list miss", stmt_list_miss),
("set hit", stmt_set_hit),
("set miss", stmt_set_miss),
]:
t = timeit.timeit(stmt, setup=setup, number=5)
print(f"{label:10s}: {t:.6f}s (5回合計)")
list hit : 0.140000s (5回合計)
list miss: 0.275000s (5回合計)
set hit : 0.000030s (5回合計)
set miss: 0.000030s (5回合計)
外れ検索(miss)ほど差が開きます。
メンバーシップ判定を多用するならセットが圧倒的に速いです。
追加と削除: list.append/pop vs set.add/remove
- リストの
append
と末尾pop
は平均O(1)。途中位置のinsert/remove
はO(n)。 - セットの
add
やdiscard/remove
は平均O(1)ですが、順序は管理しません。
# 追加・削除のコスト比較
import timeit
setup = """
N = 200_000
lst = []
st = set()
"""
# append vs add
t_list_append = timeit.timeit("for i in range(N): lst.append(i)", setup=setup, number=1)
t_set_add = timeit.timeit("for i in range(N): st.add(i)", setup=setup, number=1)
print(f"list.append: {t_list_append:.4f}s, set.add: {t_set_add:.4f}s")
# pop vs remove
setup2 = """
N = 200_000
lst = list(range(N))
st = set(range(N))
"""
t_list_pop = timeit.timeit("while lst: lst.pop()", setup=setup2, number=1)
t_set_remove = timeit.timeit("for i in range(N): st.remove(i)", setup=setup2, number=1) # 存在前提
print(f"list.pop (末尾): {t_list_pop:.4f}s, set.remove: {t_set_remove:.4f}s")
list.append: 0.0110s, set.add: 0.0250s
list.pop (末尾): 0.0100s, set.remove: 0.0280s
単純な末尾操作はリストが速いことが多いです。
なおset.remove
は存在しない要素でKeyError、discard
はエラーにならず安全です。
連結と拡張: list.extend vs tuple結合 vs setの和集合
- リストは
extend
が効率的。+
は新リストを作るためメモリ追加負荷。 - タプルの結合は常に新規タプルを作成。
- セットは
|
やunion
、破壊的なupdate
を使います。
# 連結・拡張の違い
a_list = [1, 2]
b_list = [3, 4]
a_list.extend(b_list) # a_listが[1,2,3,4]に拡張(破壊的)
a_tuple = (1, 2)
b_tuple = (3, 4)
c_tuple = a_tuple + b_tuple # 新しいタプル(元は不変)
a_set = {1, 2}
b_set = {2, 3}
u1 = a_set | b_set # 新しい集合{1,2,3}
a_set.update(b_set) # a_setが{1,2,3}に(破壊的)
print(a_list, c_tuple, u1, a_set)
[1, 2, 3, 4] (1, 2, 3, 4) {1, 2, 3} {1, 2, 3}
イテレーション速度: list/tuple/set
一般にタプルの反復が最速、リストが次点、セットは順序がない分やや遅い傾向があります。
# イテレーション速度の目安
import timeit
setup = "lst = list(range(1_000_000)); tpl = tuple(lst); st = set(lst)"
for label, stmt in [
("list", "for x in lst: pass"),
("tuple", "for x in tpl: pass"),
("set", "for x in st: pass"),
]:
t = timeit.timeit(stmt, setup=setup, number=1)
print(f"{label:5s}: {t:.4f}s")
list : 0.0450s
tuple: 0.0370s
set : 0.0600s
ソートと順序操作: listのみ、setは変換して対応
ソートはリストの領分です。
セットは順序を持たないため、並べ替えたい場合は一旦リストに変換します。
nums_set = {5, 1, 3}
# nums_set.sort() # NG: setにsortはない
sorted_list = sorted(nums_set) # 新しいリスト [1, 3, 5]
nums_list = [5, 1, 3]
nums_list.sort(reverse=True) # 就地で降順 [5, 3, 1]
print(sorted_list, nums_list)
[1, 3, 5] [5, 3, 1]
計算量の目安(O記法)
操作 | list | tuple | set |
---|---|---|---|
末尾追加 | 平均O(1) | 不可 | 平均O(1) |
末尾削除 | 平均O(1) | 不可 | 平均O(1)相当(要素指定はO(1)平均) |
任意位置挿入/削除 | O(n) | 不可 | 位置の概念なし |
in(メンバーシップ) | O(n) | O(n) | 平均O(1) |
連結/拡張 | O(n+m) | O(n+m)(新規作成) | O(n+m) |
ソート | O(n log n) | 不可 | 不可(変換して対応) |
setのO(1)は平均的な期待値で、最悪ケースでは劣化する可能性があります(ハッシュ衝突など)。
メモリ使用量の違い
メモリはtupleが最小、listは余裕(オーバーアロケーション)込み、setはハッシュ構造で大きめです。
傾向を実測してみます。
オーバーヘッド: tupleが最小、listは余裕確保、setはハッシュ構造
import sys
empty_sizes = {
"list": sys.getsizeof([]),
"tuple": sys.getsizeof(()),
"set": sys.getsizeof(set()),
}
print(empty_sizes)
# 代表サイズ
lst = list(range(10_000))
tpl = tuple(lst)
st = set(lst)
print(sys.getsizeof(lst), sys.getsizeof(tpl), sys.getsizeof(st))
{'list': 56, 'tuple': 40, 'set': 216}
91112 80056 524296
- 空のコンテナでも差があります。
- 要素数が増えるとtupleが最もコンパクト、setは大きなテーブルを持つため最大になりやすいです。
- listは将来の追加に備えて余分に領域を確保する設計です。
小規模データで軽量化: tupleを優先
データを更新しない前提ならtupleへの切り替えでメモリを削減できます。
数万件規模でも差が大きく、かつイテレーションもわずかに高速です。
重複排除のコスト: setはメモリ多めだが高速
高速なin
と集合演算の代償がメモリです。
重複排除が必要ならsetは非常に有効ですが、メモリ制約下では注意が必要です。
ミックス戦略: setで判定、listで順序保持
実務では「判定はset、保持はlist」が有効です。
例えば重複を除きつつ挿入順を維持するには次のようにします。
def unique_preserve_order(seq):
# 見た要素をsetで記録し、結果はリストで並びを保持
seen = set()
out = []
for x in seq:
if x not in seen:
seen.add(x)
out.append(x)
return out
data = ["A", "B", "A", "C", "B"]
print(unique_preserve_order(data)) # ['A', 'B', 'C']
['A', 'B', 'C']
Python 3.7+ならdict.fromkeys(seq)
でも順序維持の重複排除が可能です。
data = ["A", "B", "A", "C", "B"]
print(list(dict.fromkeys(data))) # ['A', 'B', 'C']
['A', 'B', 'C']
実践ガイド(初心者の選び方)
「順序」「更新」「重複」の3軸で決めると迷いにくいです。
最後に現場で役立つ実践的な指針をまとめます。
判断フロー: 順序・更新・重複の要否で選ぶ
- 並びが必要か → はいならリスト/タプル、いいえならセット
- 更新するか → はいならリスト、いいえならタプル
- 重複を許すか → 許さないならセット、許すなら用途に応じてリスト/タプル
よくあるパターン: 検索中心はset、固定データはtuple、順序保持はlist
- IDの存在チェックやブラックリスト判定はset
- 設定の定数値や座標など変更不要な組はtuple
- 表示順・処理順を扱い、並べ替えも行うデータはlist
アンチパターン: listで頻繁なin、setに順序を期待しない
- 大量データで
if x in my_list
を繰り返すのは遅いです。setに置き換えるだけで桁違いに速くなります。 - setの順序は未定義です。安定順を要求される表示やファイル出力に直接使わないでください。
相互変換のコツ: list↔tuple↔set
# list <-> tuple
lst = [1, 2, 3]
tpl = tuple(lst)
lst2 = list(tpl)
# list/set: 重複排除や集合演算の後に並びが必要ならlistへ
st = set(lst) # {1, 2, 3}
lst_unique = list(st) # 並びは保証されない。必要ならsorted(st)やdict.fromkeysを使う
# 代表的な集合演算
a = {1, 2, 3}
b = {3, 4}
print(a | b) # 和集合 {1, 2, 3, 4}
print(a & b) # 積集合 {3}
print(a - b) # 差集合 {1, 2}
print(a ^ b) # 対称差 {1, 2, 4}
{1, 2, 3, 4}
{3}
{1, 2}
{1, 2, 4}
ベンチマークの取り方(timeitのポイント)
- 少なくとも数回以上繰り返して合計時間を比較します(
number
やrepeat
を活用)。 - 準備コードは
setup=
に分離し、測定対象だけを最小化します。 - ウォームアップやガベージコレクションの影響に注意し、
timeit
に任せるのが簡単です。 - 絶対値ではなく相対差で見ると判断を誤りにくいです。
# timeitの典型パターン
import timeit
setup = "data = list(range(100_000)); s = set(data); target = -1"
stmt1 = "target in data"
stmt2 = "target in s"
t1 = min(timeit.repeat(stmt1, setup=setup, repeat=5, number=3))
t2 = min(timeit.repeat(stmt2, setup=setup, repeat=5, number=3))
print(f"list in: {t1:.6f}s (best of 5)")
print(f"set in: {t2:.6f}s (best of 5)")
list in: 0.090000s (best of 5)
set in: 0.000050s (best of 5)
まとめ
更新する・並びが要る・重複をどう扱うかの3点で、list/tuple/setは自然に使い分けられます。
速度面ではメンバーシップ判定はsetが圧勝、反復はtuple ≥ listの傾向、末尾操作はlistが得意です。
メモリはtupleが最小、setは大きめという前提を覚えておくと、設計と運用の判断が速くなります。
実務では「判定はset、保持はlist、変更しないならtuple」を基本に、必要に応じて相互変換やベンチマークで裏取りを行うと堅実です。