Bài 3.15. Làm việc với set trong Python

Chào mừng bạn đến với thế giới của set trong Python! Nếu bạn đã quen thuộc với list và tuple, thì set sẽ mang đến một góc nhìn mới về cách lưu trữ và xử lý tập hợp dữ liệu. Set là một cấu trúc dữ liệu độc đáo, mạnh mẽ và cực kỳ hữu ích trong nhiều tình huống lập trình. Hãy cùng FullhouseDev khám phá nhé!

Set là gì?

Trong Python, set là một kiểu dữ liệu tập hợp (collection) không có thứ tự (unordered) và không chứa các phần tử trùng lặp (unique elements). Hãy nghĩ về nó như một túi đồ mà bạn chỉ quan tâm đến việc món đồ đó trong túi hay không, chứ không quan tâm nó nằm ở vị trí nào và mỗi món đồ chỉ có duy nhất một cái.

Đặc điểm chính của Set:

  1. Không có thứ tự (Unordered): Các phần tử trong set không được lưu trữ theo một thứ tự cụ thể nào. Điều này có nghĩa là bạn không thể truy cập phần tử bằng chỉ số (index) như trong list hay tuple. Thứ tự các phần tử khi bạn in ra hoặc duyệt qua có thể thay đổi.
  2. Phần tử là duy nhất (Unique Elements): Set tự động loại bỏ các phần tử trùng lặp. Nếu bạn cố gắng thêm một phần tử đã tồn tại vào set, set đó sẽ không thay đổi.
  3. Khả biến (Mutable): Bạn có thể thêm hoặc xóa các phần tử khỏi set sau khi nó được tạo (trừ frozenset, chúng ta sẽ nói sau).
  4. Phần tử phải là bất biến (Immutable): Các phần tử bên trong một set phải là các kiểu dữ liệu bất biến như số (int, float), chuỗi (string), boolean (True, False), hoặc tuple. Bạn không thể chứa list hoặc dictionary (là các kiểu khả biến) bên trong một set.

Cách tạo Set

Có hai cách chính để tạo một set trong Python:

1. Sử dụng dấu ngoặc nhọn {}

Bạn có thể tạo set bằng cách đặt các phần tử, phân tách bởi dấu phẩy, bên trong cặp dấu ngoặc nhọn {}.

# Tạo set với các phần tử số
number_set = {1, 2, 3, 4, 5}
print(number_set)
print(type(number_set))

# Tạo set với các kiểu dữ liệu khác nhau
mixed_set = {1.0, "Hello", (1, 2, 3), True}
print(mixed_set)

# Set tự động loại bỏ phần tử trùng lặp
duplicate_set = {1, 2, 2, 3, 3, 3, 4, 4, 4, 4}
print(duplicate_set) # Kết quả chỉ chứa các phần tử duy nhất

Giải thích code:

  • number_setmixed_set được tạo trực tiếp bằng {}.
  • duplicate_set minh họa tính năng tự động loại bỏ trùng lặp. Mặc dù nhập vào nhiều số 2, 3, 4, nhưng set cuối cùng chỉ chứa mỗi số một lần.

Lưu ý quan trọng: Để tạo một set rỗng, bạn phải sử dụng hàm set(), không phải {}. Lý do là {} được Python dùng để tạo một dictionary rỗng.

# Sai cách tạo set rỗng (sẽ tạo ra dictionary)
empty_dict = {}
print(type(empty_dict)) # Output: <class 'dict'>

# Đúng cách tạo set rỗng
empty_set = set()
print(empty_set)      # Output: set()
print(type(empty_set)) # Output: <class 'set'>
2. Sử dụng hàm set()

Hàm set() có thể được sử dụng để tạo set từ một đối tượng có thể duyệt qua (iterable) như list, tuple, string, hoặc range.

# Tạo set từ list (loại bỏ trùng lặp)
my_list = ['apple', 'banana', 'cherry', 'banana']
fruit_set = set(my_list)
print(fruit_set) # Output: {'cherry', 'banana', 'apple'} (thứ tự có thể khác)

# Tạo set từ tuple
my_tuple = (1, 8, 3, 5, 8)
tuple_set = set(my_tuple)
print(tuple_set) # Output: {1, 3, 5, 8}

# Tạo set từ string (mỗi ký tự là một phần tử)
my_string = "FullhouseDev"
char_set = set(my_string)
print(char_set) # Output: {'e', 'l', 's', 'o', 'v', 'F', 'D', 'u', 'h'} (thứ tự có thể khác)

# Tạo set từ range
range_set = set(range(5))
print(range_set) # Output: {0, 1, 2, 3, 4}

Giải thích code:

  • Hàm set() nhận đầu vào là một iterable.
  • Khi tạo set từ list hoặc tuple, các phần tử trùng lặp sẽ bị loại bỏ.
  • Khi tạo set từ string, mỗi ký tự trong chuỗi trở thành một phần tử duy nhất trong set.

Các thao tác cơ bản với Set

Set cung cấp nhiều phương thức hữu ích để thao tác với các phần tử.

1. Thêm phần tử
  • add(element): Thêm một phần tử duy nhất vào set. Nếu phần tử đã tồn tại, set không thay đổi.
  • update(iterable): Thêm tất cả các phần tử từ một iterable (như list, tuple, set khác) vào set hiện tại. Các phần tử trùng lặp sẽ tự động bị bỏ qua.
colors = {"red", "green"}
print("Ban đầu:", colors)

# Thêm một phần tử
colors.add("blue")
print("Sau khi add('blue'):", colors)

# Thêm phần tử đã tồn tại -> không thay đổi
colors.add("red")
print("Sau khi add('red'):", colors)

# Thêm nhiều phần tử từ list
colors.update(["yellow", "orange", "green"])
print("Sau khi update(['yellow', 'orange', 'green']):", colors)

# Thêm nhiều phần tử từ set khác
more_colors = {"purple", "black"}
colors.update(more_colors)
print("Sau khi update({'purple', 'black'}):", colors)

Giải thích code:

  • add() dùng để thêm một phần tử.
  • update() dùng để thêm nhiều phần tử từ một nguồn khác (list, set...). Nó tương đương với việc gọi add() cho từng phần tử trong nguồn đó.
2. Xóa phần tử

Có ba cách chính để xóa phần tử khỏi set:

  • remove(element): Xóa phần tử được chỉ định. Nếu phần tử không tồn tại trong set, phương thức này sẽ gây ra lỗi KeyError.
  • discard(element): Xóa phần tử được chỉ định. Nếu phần tử không tồn tại, phương thức này không làm gì cả và không gây lỗi. Đây là lựa chọn an toàn hơn nếu bạn không chắc chắn phần tử có tồn tại hay không.
  • pop(): Xóa và trả về một phần tử bất kỳ từ set. Vì set không có thứ tự, bạn không thể biết trước phần tử nào sẽ bị xóa. Nếu set rỗng, pop() sẽ gây ra lỗi KeyError.
  • clear(): Xóa tất cả các phần tử khỏi set, làm cho set trở thành rỗng.
gadgets = {"phone", "laptop", "tablet", "watch", "earbuds"}
print("Set ban đầu:", gadgets)

# Xóa 'tablet' bằng remove()
gadgets.remove("tablet")
print("Sau remove('tablet'):", gadgets)

# Thử xóa phần tử không tồn tại bằng remove() -> Gây lỗi KeyError
# gadgets.remove("headset") # Bỏ comment dòng này sẽ thấy lỗi

# Xóa 'watch' bằng discard()
gadgets.discard("watch")
print("Sau discard('watch'):", gadgets)

# Thử xóa phần tử không tồn tại bằng discard() -> Không lỗi
gadgets.discard("keyboard")
print("Sau discard('keyboard') (không có gì xảy ra):", gadgets)

# Xóa và lấy một phần tử bất kỳ bằng pop()
removed_item = gadgets.pop()
print(f"Phần tử bị pop(): {removed_item}")
print("Set sau khi pop():", gadgets)

# Xóa hết phần tử bằng clear()
gadgets.clear()
print("Sau khi clear():", gadgets)

Giải thích code:

  • remove() hiệu quả nhưng cần cẩn thận vì có thể gây lỗi.
  • discard() an toàn hơn khi bạn không chắc phần tử có tồn tại.
  • pop() hữu ích khi bạn muốn lấy và xóa một phần tử bất kỳ mà không cần quan tâm cụ thể là phần tử nào.
  • clear() dùng để làm rỗng set hoàn toàn.
3. Kiểm tra sự tồn tại và kích thước
  • element in my_set: Trả về True nếu element có trong my_set, ngược lại False.
  • len(my_set): Trả về số lượng phần tử (duy nhất) trong set.
permissions = {"read", "write", "execute"}

# Kiểm tra sự tồn tại
print("'read' in permissions:", "read" in permissions)      # Output: True
print("'admin' in permissions:", "admin" in permissions)    # Output: False
print("'write' not in permissions:", "write" not in permissions) # Output: False

# Lấy kích thước set
print("Số quyền:", len(permissions)) # Output: 3

Giải thích code:

  • Toán tử in là cách rất hiệu quả để kiểm tra xem một phần tử có thuộc set hay không. Đây là một trong những ưu điểm lớn của set so với list (việc kiểm tra trong list thường chậm hơn nhiều khi list lớn).
  • len() cho biết số lượng phần tử duy nhất hiện có.

Các phép toán tập hợp (Set Operations)

Đây là nơi sức mạnh thực sự của set tỏa sáng! Python hỗ trợ đầy đủ các phép toán tập hợp quen thuộc trong toán học. Giả sử chúng ta có hai set:

set_a = {1, 2, 3, 4, 5}
set_b = {4, 5, 6, 7, 8}
print(f"Set A: {set_a}")
print(f"Set B: {set_b}")
1. Hợp (Union)

Tập hợp chứa tất cả các phần tử từ cả hai set (loại bỏ trùng lặp).

  • Toán tử: |
  • Phương thức: union()
# Sử dụng toán tử |
union_set_op = set_a | set_b
print(f"A | B: {union_set_op}") # Output: {1, 2, 3, 4, 5, 6, 7, 8}

# Sử dụng phương thức union()
union_set_method = set_a.union(set_b)
print(f"A.union(B): {union_set_method}") # Output: {1, 2, 3, 4, 5, 6, 7, 8}

# union() có thể nhận nhiều iterable khác
union_complex = set_a.union(set_b, [8, 9, 10])
print(f"A.union(B, [8, 9, 10]): {union_complex}") # Output: {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

Giải thích code:

  • Cả |.union() đều trả về một set mới chứa tất cả các phần tử duy nhất từ các set tham gia. Set gốc không bị thay đổi.
  • union() linh hoạt hơn vì có thể nhận nhiều đối số iterable.
2. Giao (Intersection)

Tập hợp chứa các phần tử chung của cả hai set.

  • Toán tử: &
  • Phương thức: intersection()
# Sử dụng toán tử &
intersect_set_op = set_a & set_b
print(f"A & B: {intersect_set_op}") # Output: {4, 5}

# Sử dụng phương thức intersection()
intersect_set_method = set_a.intersection(set_b)
print(f"A.intersection(B): {intersect_set_method}") # Output: {4, 5}

# intersection() cũng có thể nhận nhiều iterable
set_c = {5, 6, 10}
intersect_complex = set_a.intersection(set_b, set_c)
print(f"A.intersection(B, C={{5, 6, 10}}): {intersect_complex}") # Output: {5}

Giải thích code:

  • Cả &.intersection() đều trả về một set mới chỉ chứa các phần tử xuất hiện trong tất cả các set tham gia. Set gốc không bị thay đổi.
3. Hiệu (Difference)

Tập hợp chứa các phần tử có trong set đầu tiên nhưng không có trong set thứ hai.

  • Toán tử: -
  • Phương thức: difference()
# Sử dụng toán tử -
diff_set_op_ab = set_a - set_b
print(f"A - B: {diff_set_op_ab}") # Output: {1, 2, 3} (Phần tử chỉ có trong A)

diff_set_op_ba = set_b - set_a
print(f"B - A: {diff_set_op_ba}") # Output: {6, 7, 8} (Phần tử chỉ có trong B)

# Sử dụng phương thức difference()
diff_set_method = set_a.difference(set_b)
print(f"A.difference(B): {diff_set_method}") # Output: {1, 2, 3}

# difference() có thể nhận nhiều iterable
diff_complex = set_a.difference(set_b, {3, 10})
print(f"A.difference(B, {{3, 10}}): {diff_complex}") # Output: {1, 2} (Loại bỏ cả phần tử của B và {3, 10})

Giải thích code:

  • Phép hiệu không có tính giao hoán, nghĩa là A - B khác B - A.
  • Cả -.difference() đều trả về một set mới. Set gốc không bị thay đổi.
4. Hiệu đối xứng (Symmetric Difference)

Tập hợp chứa các phần tử chỉ có trong một trong hai set (không chứa phần tử chung).

  • Toán tử: ^
  • Phương thức: symmetric_difference()
# Sử dụng toán tử ^
sym_diff_op = set_a ^ set_b
print(f"A ^ B: {sym_diff_op}") # Output: {1, 2, 3, 6, 7, 8} (Phần tử chỉ ở A hoặc chỉ ở B)

# Sử dụng phương thức symmetric_difference()
sym_diff_method = set_a.symmetric_difference(set_b)
print(f"A.symmetric_difference(B): {sym_diff_method}") # Output: {1, 2, 3, 6, 7, 8}

Giải thích code:

  • Hiệu đối xứng là tập hợp các phần tử thuộc hợp của hai set trừ đi phần giao của chúng ((A | B) - (A & B)).
  • Cả ^.symmetric_difference() đều trả về một set mới. Set gốc không bị thay đổi.
5. Các phương thức cập nhật tại chỗ

Ngoài các phép toán trả về set mới, có các phương thức tương ứng để cập nhật trực tiếp set gọi chúng:

  • update(other) hoặc |=: A.update(B) tương đương A = A | B
  • intersection_update(other) hoặc &=: A.intersection_update(B) tương đương A = A & B
  • difference_update(other) hoặc -=: A.difference_update(B) tương đương A = A - B
  • symmetric_difference_update(other) hoặc ^=: A.symmetric_difference_update(B) tương đương A = A ^ B
set_x = {1, 2, 3}
set_y = {3, 4, 5}
print(f"Set X ban đầu: {set_x}")

set_x.intersection_update(set_y) # Cập nhật set_x tại chỗ
print(f"Set X sau intersection_update(Y): {set_x}") # Output: {3}

set_x = {1, 2, 3} # Reset set_x
set_x |= set_y    # Cập nhật set_x tại chỗ bằng phép hợp
print(f"Set X sau |= Y: {set_x}") # Output: {1, 2, 3, 4, 5}

Giải thích code:

  • Các phương thức _update và các toán tử gán (|=, &=, -=, ^=) thay đổi trực tiếp set ban đầu, thay vì tạo ra một set mới. Điều này có thể hiệu quả hơn về bộ nhớ nếu bạn không cần giữ lại set gốc.

Kiểm tra quan hệ tập hợp con / tập hợp cha

  • issubset(other) hoặc <=: Kiểm tra xem tất cả các phần tử của set hiện tại có nằm trong other không. A <= B nghĩa là A là tập con của B.
  • issuperset(other) hoặc >=: Kiểm tra xem set hiện tại có chứa tất cả các phần tử của other không. A >= B nghĩa là A là tập cha của B.
  • isdisjoint(other): Kiểm tra xem hai set có phần tử chung nào không. Nếu không có phần tử chung (giao rỗng), trả về True.
set_p = {1, 2, 3}
set_q = {1, 2, 3, 4, 5}
set_r = {4, 5, 6}
set_s = {1, 2}

print(f"P = {set_p}, Q = {set_q}, R = {set_r}, S = {set_s}")

# Subset checks
print(f"P <= Q (P is subset of Q?): {set_p <= set_q}")             # Output: True
print(f"P.issubset(Q): {set_p.issubset(set_q)}")                 # Output: True
print(f"P <= R (P is subset of R?): {set_p <= set_r}")             # Output: False
print(f"S < P (S is proper subset of P?): {set_s < set_p}")       # Output: True (S là con thực sự, không bằng P)

# Superset checks
print(f"Q >= P (Q is superset of P?): {set_q >= set_p}")             # Output: True
print(f"Q.issuperset(P): {set_q.issuperset(set_p)}")               # Output: True
print(f"P >= Q (P is superset of Q?): {set_p >= set_q}")             # Output: False
print(f"Q > S (Q is proper superset of S?): {set_q > set_s}")     # Output: True (Q là cha thực sự, không bằng S)

# Disjoint check
print(f"P.isdisjoint(R): {set_p.isdisjoint(set_r)}") # Output: True (P và R không có phần tử chung)
print(f"P.isdisjoint(Q): {set_p.isdisjoint(set_q)}") # Output: False (P và Q có phần tử chung 1, 2, 3)

Giải thích code:

  • Các toán tử <, >, <=, >= cung cấp cách viết tắt tiện lợi cho việc kiểm tra tập con/cha. Dấu <> kiểm tra tập con/cha thực sự (proper subset/superset), nghĩa là hai set không được bằng nhau.
  • isdisjoint() rất hữu ích để nhanh chóng xác định xem hai nhóm có bất kỳ sự chồng chéo nào không.

Duyệt qua các phần tử trong Set

Bạn có thể dễ dàng duyệt qua các phần tử của set bằng vòng lặp for. Tuy nhiên, hãy nhớ rằng thứ tự duyệt không được đảm bảo.

my_set = {"apple", "banana", "cherry"}

print("Duyệt qua set:")
for fruit in my_set:
  print(fruit)
# Thứ tự in ra có thể là: banana, cherry, apple hoặc một thứ tự khác

# Bạn cũng có thể dùng list comprehension (nhưng thứ tự vẫn không đảm bảo)
squared_numbers = {x*x for x in {1, 2, 3, 4}}
print("Bình phương các số trong set:", squared_numbers) # Output: {16, 1, 4, 9} (thứ tự có thể khác)

frozenset - Phiên bản bất biến của Set

Python cung cấp một loại set đặc biệt gọi là frozenset. Như tên gọi, frozenset là một phiên bản bất biến (immutable) của set. Sau khi được tạo, bạn không thể thêm hoặc xóa phần tử khỏi frozenset.

# Tạo frozenset
frozen = frozenset([1, 2, 3, 2]) # Tự động loại bỏ trùng lặp
print(frozen)       # Output: frozenset({1, 2, 3})
print(type(frozen)) # Output: <class 'frozenset'>

# Thử thêm phần tử -> Gây lỗi AttributeError
# frozen.add(4) # Bỏ comment sẽ thấy lỗi 'frozenset' object has no attribute 'add'

# Thử xóa phần tử -> Gây lỗi AttributeError
# frozen.remove(1) # Bỏ comment sẽ thấy lỗi

Tại sao cần frozenset?

frozenset là bất biến, chúng có thể được hash. Điều này có nghĩa là:

  1. Bạn có thể sử dụng frozenset làm phần tử của một set khác.
  2. Bạn có thể sử dụng frozenset làm key trong dictionary.
set1 = {1, 2}
set2 = {3, 4}

# Không thể thêm set thường vào set khác vì set thường là mutable
# regular_set = {set1, set2} # Gây lỗi TypeError: unhashable type: 'set'

# Có thể thêm frozenset vào set khác
frozen1 = frozenset(set1)
frozen2 = frozenset(set2)
set_of_frozensets = {frozen1, frozen2}
print("Set chứa các frozenset:", set_of_frozensets)

# Có thể dùng frozenset làm key của dictionary
my_dict = {frozen1: "Đây là set 1", frozen2: "Đây là set 2"}
print("Dictionary với key là frozenset:", my_dict)
print(my_dict[frozenset({1, 2})]) # Truy cập bằng frozenset tương ứng

Giải thích code:

  • frozenset được tạo bằng hàm frozenset().
  • Nó không có các phương thức thay đổi như add, remove, update, pop, clear, v.v.
  • Tính bất biến làm cho frozenset "hashable", mở ra khả năng sử dụng nó trong các cấu trúc dữ liệu yêu cầu key hoặc phần tử phải hashable.

Khi nào nên sử dụng Set?

Set là một công cụ tuyệt vời cho các tác vụ cụ thể:

  1. Loại bỏ các phần tử trùng lặp khỏi một danh sách hoặc iterable: Đây là một trong những ứng dụng phổ biến và hiệu quả nhất. Chuyển đổi một list thành set rồi quay lại list là cách nhanh nhất để khử trùng lặp (nhưng sẽ mất thứ tự ban đầu).
    my_list = [1, 2, 5, 2, 3, 4, 5, 1, 4]
    unique_list = list(set(my_list))
    print(f"List gốc: {my_list}")
    print(f"List duy nhất: {unique_list}") # Thứ tự có thể thay đổi
    
  2. Kiểm tra sự tồn tại (Membership Testing): Kiểm tra xem một phần tử có nằm trong một tập hợp lớn hay không bằng toán tử in với set nhanh hơn đáng kể so với kiểm tra trong list, đặc biệt khi tập hợp lớn.
  3. Thực hiện các phép toán tập hợp: Khi bạn cần tìm hợp, giao, hiệu, hoặc các quan hệ tập hợp khác giữa các nhóm dữ liệu, set là lựa chọn tự nhiên và hiệu quả. Ví dụ: tìm các user chung giữa hai nhóm, tìm các mục chỉ có ở một nguồn dữ liệu,...

Set là một phần quan trọng và mạnh mẽ trong bộ công cụ cấu trúc dữ liệu của Python. Hiểu rõ cách hoạt động và các phương thức của nó sẽ giúp bạn viết code hiệu quả và rõ ràng hơn trong nhiều tình huống. Chúc bạn áp dụng thành công!

Comments

There are no comments at the moment.