Python 3 — Cấu trúc dữ liệu: list, tuple, dict, set

13 thg 7, 2026 3 lượt xem
#python
#cau-truc-du-lieu
#dict
#list

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()động từ tác động lên chính nó (trả None), sorted()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 saob 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áclistdictset
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ữaO(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ểmlist []tuple ()dict {k:v}set {}
Có thứ tựCó (3.7+)Không
Thay đổi đượcKhông
Cho phép trùngKhóa không, giá trị cóKhông
Truy cập bằngchỉ sốchỉ sốkhóa(chỉ kiểm tra tồn tại)
Tra cứu nhanhO(n)O(n)O(1)O(1)
Làm khóa dict đượcKhôngKhôngKhông
Dùng khidãy có thứ tự, hay đổibản ghi cố địnhtra cứu theo tênloạ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ỗ) vs sorted() (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ùng None là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 in trên list (O(n)).

Tự kiểm tra

  1. Khác biệt giữa list.sort()sorted(list) là gì? Cái nào trả về None?
  2. Cho a = [1, 2, 3]; b = a; b.append(4). Giá trị của a bây giờ là gì và tại sao?
  3. Khi nào copy.deepcopy cho kết quả khác với .copy() (copy nông)?
  4. 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?
  5. Viết một biểu thức tính giao của hai set ab, rồi viết phiên bản dùng phương thức.
  6. Vì sao 999_999 in big_set nhanh hơn nhiều so với 999_999 in big_list khi có một triệu phần tử?

Đọc tiếp

Python 4 — Hàm & Module

Bài viết liên quan

Định nghĩa hàm, tham số, *args/**kwargs, lambda, module/package, pip và virtualenv.

13 thg 7, 2026 4

Lớp, kế thừa, đa hình, dunder methods, dataclass, type hints và nguyên tắc viết code sạch.

13 thg 7, 2026 4

Exception handling, context manager (with), đọc/ghi file, JSON/CSV và logging đúng cách.

13 thg 7, 2026 4

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.

13 thg 7, 2026 4