2.10. Tập hợp (Set)

Một tập hợp là một bộ sưu tập các mục duy nhất không có thứ tự. Thêm một giá trị đã tồn tại không có hiệu lực; lặp qua sẽ trả ra mỗi giá trị đúng một lần. Tập hợp là công cụ phù hợp khi quan tâm đến tư cách thành viên và loại trùng lặp, còn thứ tự thì không.

2.10.1. Tạo một tập hợp

Sử dụng dấu ngoặc nhọn cho tập hợp không rỗng, hoặc set() cho tập hợp rỗng:

colours = {"red", "green", "blue"}
empty = set()

Dấu ngoặc nhọn trông giống literal dict; {} đứng một mình là một dict rỗng, không phải tập hợp rỗng -- đây là một trong những tai nạn lịch sử của Python. Dùng set() cho trường hợp rỗng.

set() cũng xây dựng một tập hợp từ bất kỳ iterable nào, đây là cách chuẩn để loại bỏ trùng lặp khỏi một chuỗi:

nums = [1, 2, 2, 3, 1, 4]
unique = set(nums)
print(unique)

Output:

{1, 2, 3, 4}

Thứ tự in ra có thể thay đổi -- tập hợp không đảm bảo lặp theo bất kỳ thứ tự cụ thể nào.

2.10.2. Set vs dict

Set và dict đều lưu trữ các mục duy nhất trong bảng băm. Điều khác biệt là mỗi mục mang theo thông tin gì:

  • Một dict lưu trữ các cặp khóa-giá trị. Tra cứu một khóa trả về giá trị của nó.

  • Một set lưu trữ chỉ các mục. Tra cứu một mục cho bạn biết liệu nó có ở đó không.

Lựa chọn giữa hai loại phụ thuộc vào việc giá trị đi kèm mỗi mục có ý nghĩa gì không:

  • Chọn set khi không có giá trị nào cần gắn kèm mỗi mục -- bạn chỉ quan tâm mục có tồn tại không, hoặc bạn đang kết hợp các nhóm mục duy nhất bằng hợp / giao.

  • Chọn dict khi mỗi mục được ghép với dữ liệu mà việc tra cứu cần lấy ra -- một bản đồ cấu hình, một bộ nhớ đệm, một bộ đếm có khóa theo tên.

Hai kiểu này chia sẻ nhiều cú pháp bề mặt, đó là nguồn gốc của hầu hết sự nhầm lẫn. Sự khác biệt trong một khối:

set

dict

lưu trữ

các mục duy nhất

các khóa duy nhất, mỗi khóa có một giá trị

literal có giá trị

{1, 2, 3}

{"a": 1, "b": 2}

literal rỗng

set()

{}

kiểm tra thành phần

x in s

k in d (chỉ khóa)

lấy một giá trị

không có

d[k]

thêm một mục

s.add(x)

d[k] = v

lặp qua

trả ra các mục

trả ra các khóa (dùng d.items() cho các cặp)

Sự bất đối xứng giữa literal có giá trị và literal rỗng là điểm đáng lưu ý:

  • Dấu ngoặc nhọn với các mục bên trong -- {1, 2, 3} -- là literal tập hợp; dấu ngoặc nhọn với các cặp khóa-giá trị -- {"a": 1} -- là literal dict. Trình phân tích cú pháp phân biệt chúng dựa trên nội dung bên trong.

  • Dấu ngoặc nhọn không có gì bên trong -- {} -- là dict rỗng, không phải tập hợp rỗng. Dict ra đời trước; literal rỗng thuộc về chúng. Tập hợp rỗng không có literal ngoặc nhọn nào cả và phải viết là set().

Một mẫu phổ biến khi chỉ đọc các khóa của dict là chuyển sang dùng set -- điều này làm rõ ý định và loại bỏ các giá trị không dùng đến khỏi bộ nhớ.

2.10.3. Thêm và xóa

s = {1, 2, 3}
s.add(4)
s.discard(99)            # silent: 99 not in s
s.remove(2)
print(s)

Output:

{1, 3, 4}

2.10.4. Tư cách thành viên

Toán tử in kiểm tra tư cách thành viên. Trên một tập hợp, thời gian thực thi gần như hằng số bất kể kích thước -- đây là lý do chính để chọn tập hợp thay vì list khi bạn chỉ cần hỏi "giá trị này có trong đó không":

if "red" in colours:
    print("colour is allowed")

Một list với cùng nội dung sẽ quét từ đầu mỗi lần, điều này ổn với mười mục nhưng chậm với mười nghìn.

2.10.5. Các phép toán tập hợp

Hai tập hợp có thể được kết hợp với các phép toán toán học thông thường. Mỗi phép toán có cả dạng toán tử lẫn dạng phương thức:

  • a | b hoặc a.union(b) -- tất cả những gì có trong cả hai tập hợp.

  • a & b hoặc a.intersection(b) -- chỉ những gì xuất hiện trong cả hai.

  • a - b hoặc a.difference(b) -- trong a nhưng không trong b.

  • a ^ b hoặc a.symmetric_difference(b) -- trong một tập hợp nhưng không trong cả hai.

a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a | b)
print(a & b)
print(a - b)
print(a ^ b)

Output:

{1, 2, 3, 4, 5, 6}
{3, 4}
{1, 2}
{1, 2, 5, 6}

Dạng toán tử chỉ đọc; dạng phương thức chấp nhận bất kỳ iterable nào ở bên phải, không chỉ tập hợp khác (a.union([5, 6])). Chọn cái nào đọc rõ hơn trong ngữ cảnh.

2.10.6. Những gì có thể nằm trong tập hợp

Các phần tử tập hợp phải có khả năng băm (hash) -- ràng buộc giống như khóa dict. int, float, str, bool, bytes, và tuple (khi nội dung của nó đều có thể băm) đều dùng được. listdict thì không; cố thêm một trong số chúng sẽ gây ra TypeError.

2.10.7. frozenset

Một set thông thường có thể thay đổi: mỗi lần gọi add / remove / discard đều thay đổi đối tượng tại chỗ. Tính có thể thay đổi này loại trừ khả năng băm, vì vậy một set không thể được dùng làm khóa dict hoặc làm phần tử của tập hợp khác.

frozenset là đối tác bất biến. Nó có cùng các phép tra cứu và toán tử (in, |, &, -, ^) như set, nhưng không có add / remove và không có phương thức nào thay đổi nội dung. Vì không có gì có thể thay đổi nội dung của nó, hash của frozenset được xác định rõ ràng -- vì vậy nó có thể băm được:

primary = frozenset({"red", "green", "blue"})
secondary = frozenset({"yellow", "purple", "orange"})

palettes = {
    primary: "RGB",
    secondary: "mixed",
}

print(palettes[primary])

Output:

RGB

Tạo frozenset từ bất kỳ iterable nào -- frozenset() cho trường hợp rỗng, frozenset(some_set) để chụp nhanh bất biến của một tập hợp hiện có:

snapshot = frozenset(s)         # immutable copy of s
s.add("new")                    # snapshot does not change

Hai lý do phổ biến để dùng nó:

  • Dùng làm khóa dict hoặc phần tử set. Bất cứ khi nào một giá trị đơn không thể nắm bắt những gì bạn cần, một frozenset các giá trị có thể -- "tập hợp các tính năng được trình điều khiển này hỗ trợ", "tập hợp các chân (pin) mà hồ sơ này sử dụng".

  • Khóa một hằng số. Một frozenset cấp module của các tên được phép không thể bị vô tình thay đổi bởi người gọi; một set thông thường có thể. Ưu tiên frozenset cho bất cứ thứ gì được cho là chỉ đọc sau khi khởi tạo.