Python 容器全攻略:list/dict/set 怎麼選
從一份社團報名名單出發,動手摸熟串列、元組、字典、集合四種容器,並理解它們底層的速度差異與選用時機。
從一份社團報名名單開始
假設你是某個社團的幹部,手上有一份報名名單,今天要做三件事:把名單照報名順序印出來、查某位同學的聯絡方式、還要算出「同時報名 A 活動跟 B 活動」的人有哪些。這三件事,剛好對應到 Python 裡三種最常用的容器(container):串列(list)、字典(dict)、集合(set)。
很多初學者寫程式時,不管什麼資料都塞進 list,結果查一筆資料要從頭找到尾,去重複還得自己寫一堆 if。其實 Python 早就準備好趁手的工具,重點是「選對容器」。這篇文章我們就一邊動手寫,一邊把 list、tuple、dict、set 四種容器摸熟,最後再聊聊它們底層到底快在哪裡。
打開你的 Python 直譯器(終端機輸入 python3)或任何編輯器,跟著一起敲,學起來最快。

串列(list):有順序、可變動的萬用容器
串列是最常用的容器,用中括號 [] 建立,裡面的元素有順序、可以重複、而且可以隨時修改。
members = ["小芸", "阿哲", "怡君"]
print(members) # 輸出:['小芸', '阿哲', '怡君']
print(len(members)) # 輸出:3
print(members[0]) # 輸出:小芸(索引從 0 開始)
print(members[-1]) # 輸出:怡君(負索引從尾巴數)
增、刪、改、查
新增元素用 append()(加在最後)或 insert()(插在指定位置):
members.append("大文") # 加在最後
members.insert(1, "佳玲") # 插在索引 1 的位置
print(members)
# 輸出:['小芸', '佳玲', '阿哲', '怡君', '大文']
刪除可以用 remove()(依「值」刪)、pop()(依「索引」刪並回傳該值):
members.remove("阿哲") # 刪掉值為 "阿哲" 的元素
last = members.pop() # 拿掉最後一個並回傳
print(last) # 輸出:大文
print(members) # 輸出:['小芸', '佳玲', '怡君']
修改就直接用索引賦值:
members[0] = "曉芸"
print(members) # 輸出:['曉芸', '佳玲', '怡君']
查詢成員是否存在、在哪個位置:
print("佳玲" in members) # 輸出:True
print(members.index("佳玲")) # 輸出:1
切片(slicing):一次取一段
切片是 Python 很優雅的設計,語法是 串列[起點:終點:間隔],包含起點、不包含終點。
nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(nums[2:5]) # 輸出:[2, 3, 4]
print(nums[:3]) # 輸出:[0, 1, 2](起點省略 = 從頭)
print(nums[7:]) # 輸出:[7, 8, 9](終點省略 = 到尾)
print(nums[::2]) # 輸出:[0, 2, 4, 6, 8](每 2 個取 1 個)
print(nums[::-1]) # 輸出:[9, 8, 7, 6, 5, 4, 3, 2, 1, 0](反轉)
切片會產生一個新的串列,不會動到原本的資料,這在處理資料時很安全。
元組(tuple):建立後就不能改
元組(tuple)長得很像串列,但用小括號 () 建立,而且一旦建立就不能修改(稱為不可變,immutable)。
point = (120.5, 24.3) # 一個經緯度座標
print(point[0]) # 輸出:120.5
print(point[1]) # 輸出:24.3
# point[0] = 100 # 這行會報錯!TypeError: 'tuple' object does not support item assignment
為什麼需要一個「不能改」的容器?因為有些資料本來就不該被改:座標、RGB 顏色值、一筆固定的設定。把它做成 tuple,等於昭告「這組資料是一體的、別動它」,也能避免不小心改錯。
tuple 還有一個常見用法:多重指派與函式回傳多個值。
def min_max(data):
return min(data), max(data) # 其實回傳的是一個 tuple
low, high = min_max([3, 7, 1, 9, 4])
print(low, high) # 輸出:1 9
小提醒:建立只有一個元素的 tuple 時,逗號不能省,否則括號只是普通括號。
a = (5) # 這是整數 5,不是 tuple
b = (5,) # 這才是只有一個元素的 tuple
print(type(a)) # 輸出:<class 'int'>
print(type(b)) # 輸出:<class 'tuple'>
字典(dict):用「鍵」找「值」
當你想用「名字查電話」「學號查成績」這種對應關係時,字典(dict)就是首選。它儲存的是一組組「鍵值對(key-value pair)」,用大括號 {} 建立。
contacts = {
"小芸": "0912-345-678",
"阿哲": "0922-111-222",
"怡君": "0933-444-555",
}
print(contacts["小芸"]) # 輸出:0912-345-678
注意這裡的差別:list 是用「位置(索引 0、1、2)」找東西,dict 是用「鍵(名字)」找東西。當資料有自然的標籤時,dict 讀起來直覺多了。
新增、修改、刪除
contacts["大文"] = "0944-777-888" # 鍵不存在 → 新增
contacts["小芸"] = "0900-000-000" # 鍵已存在 → 修改
del contacts["怡君"] # 刪除
print(contacts)
# 輸出:{'小芸': '0900-000-000', '阿哲': '0922-111-222', '大文': '0944-777-888'}
安全查詢:用 get() 避免當機
直接用 字典[鍵] 查一個不存在的鍵會直接報錯(KeyError)。比較安全的做法是用 get(),找不到時回傳 None(或你指定的預設值),而不會讓程式爆掉。
print(contacts.get("不存在的人")) # 輸出:None
print(contacts.get("不存在的人", "查無此人")) # 輸出:查無此人
走訪(iteration)字典
走訪字典時,請善用 .items(),可以同時拿到鍵和值,這是最 Pythonic 的寫法:
for name, phone in contacts.items():
print(f"{name} 的電話是 {phone}")
# 輸出:
# 小芸 的電話是 0900-000-000
# 阿哲 的電話是 0922-111-222
# 大文 的電話是 0944-777-888
只想要鍵就用 .keys(),只想要值就用 .values()。另外,要判斷某個鍵在不在,用 in 即可:
print("阿哲" in contacts) # 輸出:True(in 檢查的是「鍵」,不是值)
集合(set):自動去重與集合運算
集合(set)也用大括號 {} 建立,但它裡面沒有重複元素、沒有順序。它最擅長兩件事:去除重複、做集合運算。
一行去重複
raw = [1, 2, 2, 3, 3, 3, 4]
unique = set(raw)
print(unique) # 輸出:{1, 2, 3, 4}
print(list(unique)) # 轉回 list:[1, 2, 3, 4]
把一個 list 丟進 set(),重複的就自動消失了,比自己寫迴圈判斷簡潔太多。
集合運算:交集、聯集、差集
回到開頭的任務:找出「同時報名 A 跟 B」的人。集合運算就是為這種問題設計的。
activity_a = {"小芸", "阿哲", "怡君", "大文"}
activity_b = {"怡君", "大文", "佳玲"}
print(activity_a & activity_b) # 交集(兩邊都有):{'怡君', '大文'}
print(activity_a | activity_b) # 聯集(任一邊有):{'小芸', '阿哲', '怡君', '大文', '佳玲'}
print(activity_a - activity_b) # 差集(只在 A):{'小芸', '阿哲'}
print(activity_a ^ activity_b) # 對稱差(只在其中一邊):{'小芸', '阿哲', '佳玲'}
由於 set 沒有順序,上面印出來的元素排列可能跟你看到的不同,這是正常的。
要檢查某個元素在不在 set 裡,一樣用 in,而且這個操作非常快(後面深入段會解釋為什麼)。
何時用哪一個?
四種容器各有定位,選擇時可以這樣判斷:
- 需要有順序、會增刪改的清單 → 用 list(如報名順序、待辦清單)。
- 一組固定不該變動的資料 → 用 tuple(如座標、RGB、回傳多值)。
- 有「鍵 → 值」對應關係 → 用 dict(如名字查電話、學號查成績)。
- 只在乎「有沒有、要不要去重、做集合運算」 → 用 set(如去重、找交集)。
一個關鍵的差異:判斷某元素是否存在時,in 在 list 上是逐一比對(慢),在 set 與 dict 上卻幾乎是瞬間完成。所以如果你的主要操作是「反覆檢查某筆資料在不在一個大集合裡」,請把它放進 set 或 dict,而不是 list。
動手寫一段:統計文章字詞出現次數
把學到的東西組合起來,寫一個小程式:統計一段文字裡每個字出現幾次,並找出最常出現的字。這裡會同時用到 dict 與 set 的概念。
text = "蘋果 香蕉 蘋果 橘子 香蕉 蘋果 葡萄 橘子 蘋果"
words = text.split() # 用空白切成 list
# 用 dict 統計次數
counts = {}
for word in words:
counts[word] = counts.get(word, 0) + 1 # get 的預設值技巧
print("出現過的字種類:", set(words))
print("各字出現次數:", counts)
# 找出出現最多次的字
top_word = max(counts, key=counts.get)
print(f"最常出現的是「{top_word}」,共 {counts[top_word]} 次")
# 輸出:
# 出現過的字種類: {'橘子', '葡萄', '香蕉', '蘋果'}
# 各字出現次數: {'蘋果': 4, '香蕉': 2, '橘子': 2, '葡萄': 1}
# 最常出現的是「蘋果」,共 4 次
這段程式裡,counts.get(word, 0) + 1 是非常經典的計數技巧:第一次遇到某個字時 get 回傳預設值 0,加 1 變成 1;之後每遇到一次就在原本基礎上加 1。set 則幫我們一眼看出「總共有幾種字」。
(補充:標準函式庫 collections.Counter 可以一行完成計數,Counter(words),等你熟悉手寫版本後,再去欣賞它的優雅。)
常見錯誤
- 誤以為切片包含終點:
nums[2:5]取的是索引 2、3、4,不含 5。記住口訣「含頭不含尾」。 - 在迴圈走訪 list 的同時刪除元素:邊走邊刪會讓索引錯亂、跳過元素。正確做法是用列表生成式建立新清單,例如
result = [x for x in data if x != 0]。 - 用
字典[鍵]查可能不存在的鍵:會直接拋出KeyError讓程式中斷。不確定鍵在不在時,改用dict.get(鍵, 預設值)。 - 一個元素的 tuple 忘了加逗號:
(5)是整數,(5,)才是 tuple。這個雷踩過一次就會記得。 - 指望 set 有順序:set 是無序的,不要假設印出來的順序、也不要用索引
s[0]去取 set 的元素(會報錯)。需要順序就用 list。
深入探討(研究所視角)
前面我們說 set 與 dict 的查找「非常快」,現在來看看快在哪裡。
dict 與 set 的雜湊(hash)實作
dict 與 set 底層都是雜湊表(hash table)。當你存入一個鍵時,Python 會先對它呼叫 hash() 函式,把鍵轉成一個整數雜湊值,再依這個值算出該放進底層陣列的哪一格(slot)。查找時同樣先算 hash、直接跳到對應的格子,所以平均時間複雜度是 $O(1)$——不管容器裡有 10 筆還是一千萬筆資料,查一筆的時間幾乎不變。
big = set(range(10_000_000)) # 一千萬個元素
print(9_999_999 in big) # 幾乎瞬間回傳 True
big_list = list(range(10_000_000))
print(9_999_999 in big_list) # 同樣是 True,但要從頭掃到尾,明顯慢得多
相對地,list 的 in 是線性搜尋,最壞情況要比對到最後一個元素,複雜度是 $O(n)$。這就是為什麼「反覆做存在性檢查」時要選 set 或 dict。
這也解釋了一個限制:只有「可雜湊(hashable)」的物件才能當 dict 的鍵或放進 set。不可變物件(int、str、tuple)都可雜湊;而 list 因為內容會變、雜湊值不穩定,所以不能當鍵或 set 元素:
ok = {("台北", "信義區"): 1} # tuple 可當鍵,沒問題
# bad = {["台北", "信義區"]: 1} # TypeError: unhashable type: 'list'
需要補充的是,$O(1)$ 是平均情況。當不同鍵算出相同 slot(雜湊碰撞,hash collision)時,需要額外處理(CPython 採開放定址法 open addressing 探測下一格),最壞情況會退化成 $O(n)$。但在實務上,Python 會在裝填率過高時自動擴充底層陣列,碰撞機率被壓得很低,所以平均 $O(1)$ 是可靠的假設。
list 與 deque 的複雜度差異
list 在 Python 裡是用動態陣列(dynamic array)實作的,元素連續存放在記憶體中。這帶來兩個特性:
- 用索引存取
lst[i]、在尾端新增append()或刪除pop():平均 $O(1)$。 - 在開頭插入或刪除
insert(0, x)、pop(0):$O(n)$。因為陣列開頭一動,後面所有元素都得整體往後或往前搬移。
如果你的演算法需要頻繁在「兩端」進出資料(典型如佇列 queue、廣度優先搜尋 BFS),用 list 的 pop(0) 會很慢。這時應改用 collections.deque(double-ended queue,雙端佇列),它底層是雙向鏈結結構,兩端的新增與刪除都是 $O(1)$。
from collections import deque
dq = deque([1, 2, 3])
dq.appendleft(0) # 左端加入,O(1)
dq.append(4) # 右端加入,O(1)
print(dq) # 輸出:deque([0, 1, 2, 3, 4])
print(dq.popleft()) # 左端取出,O(1),輸出:0
print(dq.pop()) # 右端取出,O(1),輸出:4
代價是 deque 的隨機索引存取 dq[i] 是 $O(n)$(要從一端走過去),所以它不適合「常常用索引跳著取」的場景。這正體現了資料結構的核心思想:沒有萬用的最佳解,只有針對特定操作模式的取捨。當你清楚自己最頻繁的操作是什麼(尾端增刪?兩端進出?存在性檢查?鍵值對應?),自然就知道該選 list、deque、set 還是 dict。
把這套「依操作模式選容器」的思維練熟,你寫出來的程式不只跑得對,還會跑得又快又乾淨。現在,回去把開頭那份社團名單的三個任務各寫一遍吧。