Python 3 — Cấu trúc dữ liệu: list, tuple, dict, set
Python 3 — Cấu trúc dữ liệu: list, tuple, dict, set
Nếu biến là những chiếc hộp chứa một giá trị, thì cấu trúc dữ liệu là những chiếc tủ chứa nhiều giá trị một cách có tổ chức. Python cho bạn bốn cấu trúc dữ liệu tích hợp sẵn (built-in) mà bạn sẽ dùng đi dùng lại suốt sự nghiệp: list, tuple, dict, và set.
Hiểu rõ bốn cấu trúc này — chúng khác nhau ở đâu, khi nào dùng cái nào, và những cái bẫy ẩn giấu — là ranh giới giữa một người "viết được code chạy" và một người "viết được code đúng và nhanh". Bài này đi từ con số không lên tới những khái niệm mà cả lập trình viên kinh nghiệm vẫn hay nhầm.
1. List — danh sách có thứ tự, thay đổi được
list là cấu trúc dữ liệu được dùng nhiều nhất. Nó là một dãy các phần tử có thứ tự, có thể chứa bất kỳ kiểu nào, và thay đổi được (mutable).
fruits = ["táo", "cam", "chuối"]
mixed = [1, "hai", 3.0, True, [9, 9]] # trộn nhiều kiểu, kể cả list lồng nhau
empty = [] # list rỗng
also_empty = list() # cách khác
Truy cập và chỉ số
Python đánh chỉ số từ 0. Chỉ số âm đếm ngược từ cuối.
fruits = ["táo", "cam", "chuối", "xoài"]
print(fruits[0]) # táo (phần tử đầu)
print(fruits[-1]) # xoài (phần tử cuối)
print(fruits[-2]) # chuối
Slicing (cắt lát)
Slicing là một trong những tính năng "đáng tiền" nhất của Python. Cú pháp: list[start:stop:step]. Lưu ý start được lấy, còn stop không được lấy.
nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(nums[2:5]) # [2, 3, 4] từ chỉ số 2 đến trước 5
print(nums[:3]) # [0, 1, 2] từ đầu đến trước 3
print(nums[7:]) # [7, 8, 9] từ 7 đến hết
print(nums[::2]) # [0, 2, 4, 6, 8] cách 1 phần tử
print(nums[::-1]) # [9, 8, ..., 0] đảo ngược cả list
print(nums[-3:]) # [7, 8, 9] ba phần tử cuối
Slicing luôn tạo ra một list mới, không động đến list gốc.
Các thao tác thường gặp
fruits = ["táo", "cam"]
fruits.append("chuối") # thêm 1 phần tử vào cuối -> ["táo", "cam", "chuối"]
fruits.insert(1, "xoài") # chèn vào vị trí 1 -> ["táo", "xoài", "cam", "chuối"]
fruits.extend(["nho", "lê"]) # nối thêm nhiều phần tử
last = fruits.pop() # lấy ra và xóa phần tử cuối ("lê")
fruits.remove("cam") # xóa phần tử có giá trị "cam"
print(len(fruits)) # số phần tử
print("táo" in fruits) # kiểm tra tồn tại -> True
fruits.reverse() # đảo ngược tại chỗ
Phân biệt rõ: append thêm một đối tượng (kể cả khi đó là list), còn extend "mở" iterable ra và thêm từng phần tử.
a = [1, 2]
a.append([3, 4]) # [1, 2, [3, 4]] -> độ dài 3
b = [1, 2]
b.extend([3, 4]) # [1, 2, 3, 4] -> độ dài 4
Sắp xếp: sort() vs sorted()
nums = [3, 1, 4, 1, 5, 9, 2]
nums.sort() # sắp xếp TẠI CHỖ, trả về None, đổi nums luôn
print(nums) # [1, 1, 2, 3, 4, 5, 9]
original = [3, 1, 4, 1, 5]
new = sorted(original) # trả về list MỚI, không đổi original
print(original) # [3, 1, 4, 1, 5] (giữ nguyên)
# Sắp xếp giảm dần và theo khóa tùy chỉnh
words = ["banana", "kiwi", "apple"]
words.sort(key=len) # theo độ dài: ['kiwi', 'apple', 'banana']
words.sort(key=len, reverse=True) # dài -> ngắn
Mẹo nhớ: sort() là động từ tác động lên chính nó (trả None), sorted() là hàm tạo ra cái mới.
Copy nông (shallow) vs copy sâu (deep) — cái bẫy lớn
Đây là chỗ nhiều người mất hàng giờ debug. Gán b = a không tạo bản sao — b chỉ là một cái tên khác trỏ vào cùng một list.
a = [1, 2, 3]
b = a # KHÔNG phải bản sao!
b.append(4)
print(a) # [1, 2, 3, 4] -> a cũng bị đổi!
Để có bản sao thật, dùng .copy(), list(a), hay slicing a[:]. Nhưng đó vẫn chỉ là copy nông: các phần tử lồng nhau vẫn dùng chung.
import copy
original = [[1, 2], [3, 4]]
shallow = original.copy() # hoặc list(original) hoặc original[:]
shallow[0].append(99)
print(original) # [[1, 2, 99], [3, 4]] -> list con vẫn chung!
deep = copy.deepcopy(original) # sao chép đệ quy toàn bộ
deep[0].append(77)
print(original) # không đổi -> deepcopy mới thực sự tách rời
Quy tắc: nếu list chứa các object thay đổi được (list con, dict...), và bạn cần tách rời hoàn toàn, hãy dùng copy.deepcopy.
List comprehension
Cách "Pythonic" để tạo list từ một vòng lặp:
squares = [x * x for x in range(6)] # [0, 1, 4, 9, 16, 25]
evens = [x for x in range(20) if x % 2 == 0] # có điều kiện lọc
matrix = [[r * c for c in range(3)] for r in range(3)] # lồng nhau
2. Tuple — danh sách bất biến
tuple giống list nhưng không thể thay đổi (immutable) sau khi tạo. Dùng dấu ngoặc tròn (hoặc không cần ngoặc).
point = (3, 4)
rgb = 255, 128, 0 # ngoặc tròn là tùy chọn
single = (42,) # CHÚ Ý: tuple một phần tử PHẢI có dấu phẩy
not_a_tuple = (42) # cái này chỉ là số 42 trong ngoặc
empty = ()
Vì bất biến, tuple không có .append(), .sort(), không gán lại được phần tử:
point = (3, 4)
# point[0] = 9 -> TypeError: 'tuple' object does not support item assignment
Unpacking (giải nén)
Đây là lý do tuple cực kỳ tiện:
point = (3, 4)
x, y = point # x=3, y=4
# Hoán đổi biến không cần biến tạm
a, b = 1, 2
a, b = b, a # a=2, b=1
# Giải nén với dấu sao (gom phần còn lại)
first, *rest = [1, 2, 3, 4] # first=1, rest=[2, 3, 4]
*init, last = [1, 2, 3, 4] # init=[1, 2, 3], last=4
Hàm trả về nhiều giá trị thực chất là trả về một tuple:
def min_max(numbers):
return min(numbers), max(numbers) # trả về tuple
low, high = min_max([5, 2, 9, 1]) # low=1, high=9
Khi nào dùng tuple thay vì list? Khi dữ liệu cố định, không nên thay đổi: tọa độ, ngày-tháng-năm, một bản ghi (record). Tuple còn có thể làm khóa của dict (list thì không, vì list mutable).
3. Dict — ánh xạ khóa-giá trị
dict lưu các cặp khóa → giá trị (key-value). Đây là cấu trúc mạnh nhất và nhanh nhất khi cần tra cứu theo tên/khóa.
person = {
"name": "An",
"age": 25,
"city": "Hà Nội",
}
print(person["name"]) # An
person["age"] = 26 # cập nhật
person["email"] = "[email protected]" # thêm khóa mới
del person["city"] # xóa khóa
Khóa phải là kiểu bất biến (string, số, tuple). Giá trị thì kiểu nào cũng được.
get() và setdefault() — tránh KeyError
Truy cập khóa không tồn tại bằng [] sẽ ném KeyError. get() cho phép giá trị mặc định an toàn:
person = {"name": "An"}
print(person["phone"]) # KeyError!
print(person.get("phone")) # None (an toàn)
print(person.get("phone", "chưa có")) # 'chưa có'
setdefault() trả về giá trị nếu khóa tồn tại, ngược lại tạo khóa với mặc định rồi trả về — rất tiện để gom nhóm:
words = ["táo", "cam", "táo", "lê", "cam", "táo"]
count = {}
for w in words:
count[w] = count.get(w, 0) + 1 # đếm tần suất
print(count) # {'táo': 3, 'cam': 2, 'lê': 1}
# Gom phần tử vào danh sách theo nhóm
groups = {}
for name in ["An", "Bình", "Cường", "Anh"]:
groups.setdefault(name[0], []).append(name)
print(groups) # {'A': ['An', 'Anh'], 'B': ['Bình'], 'C': ['Cường']}
Duyệt dict
person = {"name": "An", "age": 25}
for key in person: # mặc định duyệt theo KHÓA
print(key)
for value in person.values(): # chỉ giá trị
print(value)
for key, value in person.items(): # cả cặp -> dùng phổ biến nhất
print(f"{key} = {value}")
Từ Python 3.7, dict giữ thứ tự chèn (insertion order). Đây là hành vi được bảo đảm, không còn là chi tiết cài đặt.
Dict comprehension
squares = {x: x * x for x in range(5)} # {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
prices = {"táo": 20, "cam": 30, "lê": 50}
expensive = {k: v for k, v in prices.items() if v > 25} # {'cam': 30, 'lê': 50}
# Đảo ngược khóa <-> giá trị
inverted = {v: k for k, v in prices.items()}
4. Set — tập hợp các phần tử duy nhất
set là một tập hợp không có thứ tự, các phần tử không trùng lặp. Dùng để loại trùng và làm các phép toán tập hợp.
s = {1, 2, 3, 2, 1} # {1, 2, 3} -> tự loại trùng
empty = set() # CHÚ Ý: {} là DICT rỗng, không phải set rỗng!
nums = [1, 1, 2, 3, 3, 3]
unique = set(nums) # {1, 2, 3} -> cách loại trùng nhanh nhất
Phép toán tập hợp
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a | b) # union (hợp): {1, 2, 3, 4, 5, 6}
print(a & b) # intersection (giao): {3, 4}
print(a - b) # difference (hiệu): {1, 2}
print(a ^ b) # symmetric diff: {1, 2, 5, 6} (có ở một bên, không cả hai)
# Cũng có dạng phương thức:
a.union(b); a.intersection(b); a.difference(b)
a.add(7) # thêm
a.discard(99) # xóa (không lỗi nếu không có)
print(3 in a) # kiểm tra tồn tại -> RẤT nhanh
Set tuyệt vời cho câu hỏi "phần tử này có trong tập không?" và "hai tập có gì chung?". Phần tử của set cũng phải bất biến (giống khóa dict).
Mutable vs Immutable — hệ quả sâu
Đây là khái niệm nền tảng ảnh hưởng đến mọi thứ bên trên.
- Mutable (thay đổi được):
list,dict,set. - Immutable (bất biến):
int,float,str,bool,tuple,frozenset.
Aliasing — nhiều tên, một object
a = [1, 2, 3]
b = a # b và a là CÙNG MỘT object
b.append(4)
print(a) # [1, 2, 3, 4] -> đổi qua b cũng thấy ở a
print(a is b) # True -> kiểm tra "cùng object" bằng `is`
Với object bất biến, vấn đề này không xảy ra, vì mọi "thay đổi" thực chất tạo ra object mới:
x = 5
y = x
y += 1 # tạo số mới, gán lại cho y
print(x) # 5 -> x không đổi
Cái bẫy "default argument" kinh điển
Giá trị mặc định của tham số được tính một lần duy nhất khi định nghĩa hàm, không phải mỗi lần gọi. Nếu mặc định là một list (mutable), nó được dùng chung giữa các lần gọi:
def add_item(item, basket=[]): # SAI: list mặc định bị chia sẻ
basket.append(item)
return basket
print(add_item("táo")) # ['táo']
print(add_item("cam")) # ['táo', 'cam'] -> ?! giỏ cũ chưa hết
Cách đúng dùng None làm sentinel:
def add_item(item, basket=None):
if basket is None:
basket = [] # tạo list mới mỗi lần gọi
basket.append(item)
return basket
Độ phức tạp thời gian — tại sao dict nhanh hơn list
Tốc độ tra cứu khác nhau cực lớn giữa các cấu trúc. Đây là lý do quan trọng nhất khi chọn cấu trúc dữ liệu cho dữ liệu lớn.
# Tìm trong list: phải duyệt từng phần tử -> O(n)
big_list = list(range(1_000_000))
print(999_999 in big_list) # phải quét gần như toàn bộ -> chậm
# Tìm trong set/dict: dùng hashing -> O(1), gần như tức thì
big_set = set(range(1_000_000))
print(999_999 in big_set) # nhanh bất kể kích thước
| Thao tác | list | dict | set |
|---|---|---|---|
Tra cứu theo khóa/giá trị (x in ...) | O(n) | O(1) | O(1) |
Truy cập theo chỉ số [i] | O(1) | — | — |
Thêm vào cuối (append/add) | O(1)* | O(1) | O(1) |
| Chèn/xóa ở đầu/giữa | O(n) | — | — |
(*) trung bình. Bài học: nếu code của bạn lặp đi lặp lại kiểm tra if x in collection trên dữ liệu lớn, hãy đổi collection từ list sang set/dict.
Bảng so sánh bốn cấu trúc
| Đặc điểm | list [] | tuple () | dict {k:v} | set {} |
|---|---|---|---|---|
| Có thứ tự | Có | Có | Có (3.7+) | Không |
| Thay đổi được | Có | Không | Có | Có |
| Cho phép trùng | Có | Có | Khóa không, giá trị có | Không |
| Truy cập bằng | chỉ số | chỉ số | khóa | (chỉ kiểm tra tồn tại) |
| Tra cứu nhanh | O(n) | O(n) | O(1) | O(1) |
| Làm khóa dict được | Không | Có | Không | Không |
| Dùng khi | dãy có thứ tự, hay đổi | bản ghi cố định | tra cứu theo tên | loại trùng, phép tập hợp |
Sơ đồ: chọn cấu trúc nào?
Khi nào chọn cấu trúc nào — tóm lược thực tế
- list: khi cần một dãy có thứ tự mà bạn sẽ thêm/bớt/sắp xếp — ví dụ danh sách sản phẩm, hàng đợi xử lý, kết quả vòng lặp.
- tuple: khi dữ liệu là một bản ghi cố định không nên đổi — tọa độ
(x, y), một dòng dữ liệu, giá trị trả về nhiều phần. Bonus: làm khóa dict được. - dict: khi cần ánh xạ tên → dữ liệu và tra cứu nhanh — hồ sơ người dùng, cấu hình, đếm tần suất, cache.
- set: khi quan tâm "có hay không" và "loại trùng" — danh sách email duy nhất, kiểm tra thành viên, so sánh hai tập.
Tóm tắt
- list có thứ tự, thay đổi được; thành thạo slicing, phân biệt
sort()(tại chỗ) vssorted()(tạo mới). - tuple bất biến, mạnh nhờ unpacking và khả năng làm khóa dict.
- dict ánh xạ khóa-giá trị, dùng
get/setdefaultđể an toàn, tra cứu O(1). - set loại trùng và làm phép toán union/intersection/difference cực nhanh.
- Phân biệt mutable vs immutable giúp tránh hai bẫy: aliasing (
b = a) và default argument dùngNonelàm sentinel. - Chọn cấu trúc theo độ phức tạp: tra cứu lớn thì dùng set/dict (O(1)), tránh
intrên list (O(n)).
Tự kiểm tra
- Khác biệt giữa
list.sort()vàsorted(list)là gì? Cái nào trả vềNone? - Cho
a = [1, 2, 3]; b = a; b.append(4). Giá trị củaabây giờ là gì và tại sao? - Khi nào
copy.deepcopycho kết quả khác với.copy()(copy nông)? - Tại sao hàm có tham số mặc định
def f(x=[])lại nguy hiểm, và cách sửa đúng? - Viết một biểu thức tính giao của hai set
avàb, rồi viết phiên bản dùng phương thức. - Vì sao
999_999 in big_setnhanh hơn nhiều so với999_999 in big_listkhi có một triệu phần tử?
Đọc tiếp
Bài viết liên quan
Định nghĩa hàm, tham số, *args/**kwargs, lambda, module/package, pip và virtualenv.
Lớp, kế thừa, đa hình, dunder methods, dataclass, type hints và nguyên tắc viết code sạch.
Exception handling, context manager (with), đọc/ghi file, JSON/CSV và logging đúng cách.
Kết nối CSDL với SQLAlchemy, gọi REST API với requests/httpx, và lập trình bất đồng bộ asyncio.