Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

容器:list/dict/set

Python 容器全攻略:list/dict/set 怎麼選

從一份社團報名名單出發,動手摸熟串列、元組、字典、集合四種容器,並理解它們底層的速度差異與選用時機。

從一份社團報名名單開始

假設你是某個社團的幹部,手上有一份報名名單,今天要做三件事:把名單照報名順序印出來、查某位同學的聯絡方式、還要算出「同時報名 A 活動跟 B 活動」的人有哪些。這三件事,剛好對應到 Python 裡三種最常用的容器(container):串列(list)、字典(dict)、集合(set)。

很多初學者寫程式時,不管什麼資料都塞進 list,結果查一筆資料要從頭找到尾,去重複還得自己寫一堆 if。其實 Python 早就準備好趁手的工具,重點是「選對容器」。這篇文章我們就一邊動手寫,一邊把 list、tuple、dict、set 四種容器摸熟,最後再聊聊它們底層到底快在哪裡。

打開你的 Python 直譯器(終端機輸入 python3)或任何編輯器,跟著一起敲,學起來最快。

容器:list/dict/set概念示意圖

串列(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。

把這套「依操作模式選容器」的思維練熟,你寫出來的程式不只跑得對,還會跑得又快又乾淨。現在,回去把開頭那份社團名單的三個任務各寫一遍吧。

AI 共讀助教正在陪你讀:Python 容器全攻略:list/dict/set 怎麼選
嗨!我是這篇文章的共讀助教,只根據〈Python 容器全攻略:list/dict/set 怎麼選〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。