Полное руководство по множествам в Python
Класс set является одной из ключевых структур данных в Python. Это неупорядоченная коллекция элементов без дубликатов. В определенной степени он представляет собой математическое множество, и многие из часто используемых математических операций для множеств существуют в Python. Часто операции для множеств выполняются гораздо быстрее, чем альтернативные операции со списками, поэтому для написания эффективного кода множества необходимы. В этой статье я расскажу об особенностях класса множеств. Давайте приступим к этому.
Инициализация
В Python существует два способа создания объекта множества: использование set(iterable)
или помещение элементов, разделенных запятой, внутрь фигурных скобок { ... }
. Исключением является случай, когда фигурные скобки пусты, т.е. {}
, тогда будет создан словарь, а не пустое множество. Для пустого множества используйте set()
. Обратите внимание, что порядок элементов не имеет значения, а дубликаты будут удалены.
a = { "a", "b", "c", "d", "e", "f", "f" }
# any iterable to the set constructor
b = set(["a", "b", "c", "d", "e", "f"])
c = set(("a", "b", "c", "d", "e", "e", "f", "f"))
# order does not matter
d = set(["d", "e", "f", "a", "b", "c"])
# destructuring list
e = { *["a", "b", "c", "d", "e", "f"] }
assert a == b == c == d == e
# different types can be stored in the same set
f = set(["a", True, 123])
g = { "a", True, 123, True, 123 }
assert f == g
# set() is a set, {} is a dictionary
assert set() != {}
Какие элементы могут быть включены в множество? Ответ: только объекты типа immutable* . К ним относятся такие типы, как float, int, string, bool и т.д. В отличие от изменяемых типов, таких как списки, словари и сами множества. Если вы хотите узнать больше о различных типах переменных в Python, я рекомендую прочитать эту статью . Таким образом, следующие действия приведут к ошибке:
{ ["a", "b", "c"], True }
# => TypeError: unhashable type: 'list'
Но что делать в тех случаях, когда вам нужно хранить, например, уникальные последовательности в наборе? Я расскажу об этом подробнее в последнем разделе этой статьи.
*Замечание о неизменности
Изменяемость является ограничением только для встроенных типов. Практически, объект должен быть hashable только для того, чтобы быть вставленным в набор или быть ключом в словаре. По умолчанию пользовательские классы имеют хэш, основанный на их id, и равенство определяется по их id. Это означает, что два объекта, которые равны по своим атрибутам, не равны при проверке на равенство, если только они не являются одним и тем же объектом или если не определен пользовательский оператор __eq__
.
Если определен пользовательский оператор __eq__
, то они больше не хэшируются , если только не определен пользовательский оператор . Здесь важно, что если два объекта равны, то их хэши также должны быть равны. В противном случае возникнут проблемы при добавлении объекта в словарь или набор, так как при проверке существования в ключах словарей и наборах проверяется и хэш-значение, и равенство __hash__
Единственный случай, когда имеет смысл иметь изменяемый объект в наборе или в качестве ключа словаря, - это если у него нет оператора равенства, основанного на его изменяемых атрибутах. Если у вас есть оператор равенства и соответствующая хэш-функция, основанная на атрибутах объекта, то если вы сначала добавите объект в набор, а затем измените его, хэш-значение, используемое для сохранения объекта в наборе, будет отличаться от текущего хэш-значения. Это плохая практика
Добавление элементов
Существует несколько способов добавления элементов к множеству. Чтобы мутировать множество, вы добавляете отдельные элементы с помощью .add()
и итерации с помощью .update()
или эквивалентно |=
:
a = set()
# adding a string element
a.add("hello")
# The following does NOT do the same thing.
# Update expects an iterable and so
# the string is seen as an iterable of characters
# that are all added
a.update("hello")
assert a == { 'hello', 'h', 'e', 'l', 'o' }
# here two strings are added since they are in a list
a.update(["hi", "world"])
assert a == { 'hello', 'h', 'e', 'l', 'o', "hi", "world" }
Под словом mutate я подразумеваю изменение исходного объекта. Вы также можете добавлять элементы, которые не будут изменять исходный набор. Это можно сделать с помощью union()
или эквивалентно с помощью |
:
a = { "a", "b" , "c" }
b = { "a", "c", "d" }
assert a | b == a.union(b) == { "a", "b", "c", "d" }
# original objects are unchanged
assert a == { "a", "b" , "c" } and b == { "a", "c", "d" }
Четкая разница в поведении между использованием .update()
и .union()
может быть показана на следующем примере:
def add_to_set1(a, b):
a.update(b)
return a
def add_to_set2(a, b):
a = a.union(b)
return a
a = { "a", "b" , "c" }
b = { "a", "c", "d" }
# the original object has been modified
# and will be equal to the returned object
assert a == add_to_set1(a, b)
a = { "a", "b" , "c" }
b = { "a", "c", "d" }
# the original object has NOT been modified
# and will not be equal to the returned object
assert a != add_to_set2(a, b)
Наконец, вы также можете объединить два набора с помощью деструктуризации:
a = { "a", "b" , "c" }
b = { "a", "c", "d" }
assert { *a, *b } == { "a", "b", "c", "d" }
Это будет работать как union()
, но я бы рекомендовал union()
вместо него.
Обратите внимание, в приведенных выше примерах я использовал .update()
, но вы также можете использовать |=
. Это означает, что a |= b
( .update()
) - это НЕ то же самое, что a = a | b
(.union()
), поскольку первое мутирует объект в a
, а второе присваивает новое значение a
.
Удаление элементов
Как и для добавления элементов, существуют эквивалентные операции для удаления элементов. Ниже я покажу вам соответствующий оператор удаления для ранее показанных операторов добавления:
.add()
это.remove()
.update()
- это.difference_update()
или-=
.union()
это.difference()
или-
Примеры:
a = { "a", "b" , "c" }
a.remove("b")
assert a == { "a", "c" }
a = { "a", "b" , "c" }
# just like .update() it expects an iterable
# thus, here "a" and "b" is removed as opposed
# to the entire string "ab"
a.difference_update("ab")
assert a == { "c" }
a = { "a", "b" , "c" }
a.difference_update(["ab"])
# "ab" doesn't exist in set, nothing was removed
assert a == { "a", "b", "c" }
# - or equivalently difference
# does not modify original object
a = { "a", "b" , "c" }
b = a - { "b", "c" }
assert a != b and b == { "a" }
Опять же, помните о разнице между a -= b
(мутирует) и a = a — b
(не мутирует).
Есть также несколько других полезных методов удаления:
.clear()
удалит все элементы в списке.- В то время как
.remove()
удаляет элемент только если он существует (иначе выдает ошибку),.discard()
просто ничего не делает, если элемент не существует. .pop()
удалит и вернет случайный элемент из множества.
Другие модифицирующие операции
Одной из возможностей множеств в Python является множество встроенных операций, которые существуют для них. Вы уже видели добавление и удаление элементов, но вы также можете выполнять следующие операции:
Пересечение
Пересечение двух множеств - это множество элементов, которые входят в оба множества. Операторами для него являются:
- Не мутирующие:
.intersection()
или&
, т.е.a.intersection(b)
илиa & b
- Мутирующие:
.intersection_update
или&=
Пример:
a = { "a", "b", "c" }
b = { "b", "c", "d" }
assert a & b == { "b", "c" }
Симметричная разность или дизъюнктивное объединение
Противоположность пересечения, т.е. все элементы, которые входят только в одно из множеств, но не в оба, называется симметричной разностью или дизъюнктивным объединением. Операторами для него являются:
- Немутирующий:
.symmmetric_difference()
или^
, т. е.a.symmmetric_difference(b)
илиa ^ b
- Мутирование:
.symmetric_difference_update()
или^=
a = { "a", "b", "c" }
b = { "b", "c", "d" }
assert a ^ b == { "a", "d" }
Методы сравнения
Я показал вам, как изменять множества, но то, для чего в основном используются множества - это изучение того, какие элементы находятся внутри них, а какие нет быстро, что в противном случае было бы медленнее со списками. Давайте посмотрим, какие операции могут это сделать.
Проверить существование в множестве
Это, скорее всего, та операция, которую вы чаще всего будете выполнять с множествами. Для этого используется оператор in
для проверки существования элемента или not in
для проверки несуществования элемента. В отличие от поиска элемента в списке, временная сложность постоянна, O(1). То есть, даже по мере добавления все новых и новых элементов операции будут выполняться так же быстро.
a = { "a", "b", "c" }
assert "a" in a
assert "d" not in a
Проверить, является ли одно множество подмножеством другого
Множество является подмножеством другого множества, если все элементы первого множества существуют во втором множестве. Например, (A, B, C) является подмножеством (A, B, C, D). В Python это можно проверить с помощью .issubset
или <=
. Чтобы проверить, является ли одно множество правильным подмножеством другого, то есть является ли оно подмножеством и не равны ли они, можно использовать <
. Но обратите внимание, что вы также можете использовать >=, >
.
a = { "a", "b", "c" }
b = { "a", "b" }
assert a.issubset(b) == (a <= b) == False
assert b.issubset(a) == (b <= a) == True
# it is a subset of itself but not a proper subset of itself
assert a.issubset(a) == (a <= a) and not a < a
# changing direction
assert a >= b and a > b
Проверить, нет ли у множества общих элементов
Если множества не имеют общих элементов, они называются disjoint, и в Python это можно вычислить с помощью .isdisjoint()
.
a = { "a", "b", "c" }
b = { "a", "b" }
c = { "d" }
# without isdisjoint()
assert len(a & c) == 0 and len(a & b) != 0
# with it
assert a.isdisjoint(c) and not a.isdisjoint(b)
Устанавливает понимание
Как и в случае со списками и словарями, вы можете использовать понимание. Это делается путем добавления выражения понимания внутри фигурных скобок и возврата одного изменяемого элемента в каждом цикле: { <element> for ... in ... }
. Примеры:
# turning a list into sets while adding 1 to each element
assert { i+1 for i in [1, 2, 3, 4] } == { 2, 3, 4, 5 }
# only even numbers
a = { i for i in range(10) if i % 2 == 0 }
a.update({ -3, 100 })
# Turning a set into a list and adding 1
# NOTE: do not assume the order of insertion will be
# preserved when looping over sets
print([i+1 for i in a])
# => [1, 3, 5, 101, 7, 9, -2]
Хранение более сложных типов
Представьте, что вы ходите от узла к узлу графа в отдельных итерациях. Например, допустим, вы прошли граф два раза и достигли путей:
A -> B -> D
D -> C -> E -> B
После этого вы захотите быстро посмотреть, прошли ли вы определенный путь, и из-за скорости поиска множество является естественным выбором. Как же это сделать, если списки нельзя вставлять, потому что они изменяемы? К счастью, в этих обстоятельствах можно использовать кортеж class, который по сути является неизменяемой версией списка. Рассмотрим пример.
Сначала я создам граф со словарем. Каждый ключ будет представлять узел, а значения будут перечислять все возможные направления из узла.
# you can go from key to values
graph = {
"A": ["B", "D", "F"],
"B": ["C", "F"],
"C": ["D", "E"],
"D": "A",
"E": "F",
"F": "B",
}
Это создаст следующий график:
Если вам интересно, как я создал график, то это было сделано с помощью graphviz
и следующего кода:
from graphviz import Digraph
dot = Digraph()
for k in graph.keys():
dot.node(k, k)
edges = []
for k, v in graph.items():
edges += [f"{k}{to}" for to in v]
dot.edges(edges)
dot.render(view=True)
Теперь я буду выполнять так называемые случайные прогулки длиной 1-10, а затем хранить полученные пути в наборе в виде кортежей. Давайте посмотрим, сколько уникальных путей мы сможем сгенерировать за 100 прогулок:
import random
def perform_random_walk(graph, n_steps):
node = random.sample(list(graph), 1)[0]
path = [node]
for _ in range(n_steps):
node = random.sample(graph[node], 1)[0]
path.append(node)
return tuple(path)
paths = set()
lengths = list(range(1, 10+1))
for _ in range(100):
paths.add(perform_random_walk(graph, random.choice(lengths)))
len(paths)
# => 83
Из 100 случайных прогулок 83 были разными.
Теперь, что если нам не важен порядок узлов, а нужно просто хранить, какие узлы были посещены? Тогда имеет смысл использовать множество, но, опять же, множества являются изменяемыми. Вместо этого мы можем использовать класс frozenset , неизменяемое множество. Давайте добавим новый цикл, чтобы сделать это:
paths = set()
lengths = list(range(1, 10+1))
for _ in range(100):
path = perform_random_walk(graph, random.choice(lengths))
paths.add(frozenset(path))
len(paths)
# => 21
Summary
Наборы полезны благодаря скорости выполнения некоторых операций и могут значительно упростить ваш код. Кроме того, в Python существует множество лаконичных и полезных методов для упрощения кода. Спасибо за прочтение.
Вернуться на верх