Перейти к основному содержимому

Коллекции - списки, кортежи, словари, множества

Разработчику Архитектору
Сначала — теория (раздел 3 "Данные")
список.append(x) # динамический массив, в конец
элемент := список[i] # O(1) по индексу

словарь[ключ] := значение # хеш-таблица, в среднем O(1)
значение := словарь[ключ]

множество.add(x) # уникальные элементы

Play ITЗагрузка интерактивного демо…

Play ITЗагрузка интерактивного демо…


Коллекции

Последовательности

Типы данных не всегда единичны, и порой их нужно перечислять в форме коллекции, собирая несколько элементов.

Последовательности — это упорядоченные коллекции элементов, доступные по индексу. К ним относятся строки (str), списки (list), кортежи (tuple), а также объекты range, bytes и другие. Все последовательности поддерживают общие операции — индексирование, срезы, конкатенацию, повторение и проверку на вхождение.

Базовые операции:

  • Индексирование: seq[i] — получение элемента по индексу (от 0).
  • Срезы: seq[start:stop:step] — извлечение подпоследовательности.
  • Длина: len(seq) — количество элементов.
  • Принадлежность: x in seq — возвращает True, если x содержится в последовательности.
  • Конкатенация: seq1 + seq2 — объединение (если типы совместимы).
  • Повторение: seq * n — повторение последовательности n раз.
s = "hello"
print(s[1]) # 'e'
print(s[1:4]) # 'ell'
print('h' in s) # True
print(s * 2) # 'hellohello'

lst = [1, 2, 3]
print(lst + [4, 5]) # [1, 2, 3, 4, 5]

Разбор:

  • s[1] берет символ по индексу.
  • s[1:4] делает срез: старт включается, стоп не включается.
  • 'h' in s проверяет вхождение подстроки.
  • s * 2 повторяет строку два раза.
  • lst + [4, 5] создает новый список, исходный lst остается без изменений.

Циклы for естественно работают с последовательностями:

for item in [10, 20, 30]:
print(item)

Особенность строк — неизменяемость.

При попытке модификации через индекс возникнет TypeError. Для списков и кортежей это ограничение действует только для кортежей (они тоже неизменяемы). Но, собственно, давайте по порядку.

Коллекции — это типы данных, предназначенные для хранения упорядоченных или неупорядоченных множеств элементов. В языке Python коллекции реализованы как встроенные классы (встроенные типы), обеспечивающие различные семантики доступа, модификации и организации данных.

Основные встроенные коллекции включают:

  • list — список;
  • tuple — кортеж;
  • set — множество;
  • dict — словарь.

Каждая из этих структур отличается по четырём частым критериям выбора:

  • Индексация — доступ к элементу по целочисленной позиции: lst[0], t[1].
  • Упорядоченность — при обходе элементы идут в предсказуемом порядке (как при вставке или по правилу сортировки).
  • Изменяемость — можно ли после создания менять состав или значения.
  • Дубликаты — допускаются ли повторяющиеся элементы (у dict уникальны только ключи).

Сводка свойств встроенных коллекций

ТипИндексация по iУпорядоченИзменяемДубликатыЛитерал
listдададада[1, 2, 3]
tupleдаданетда(1, 2, 3)
setнетнет*данет{1, 2, 3}
dictпо ключу**да (с 3.7+)***даключи уникальны{"a": 1}

* у set порядок обхода в CPython может совпадать с порядком вставки, но на него не опираются в логике программы — тип считается неупорядоченным.
** у dict доступ d["имя"], а не d[0]; целочисленный индекс позиции не используется.
*** порядок вставки ключей гарантирован с Python 3.7.

Кратко по назначению:

  • list — упорядоченный изменяемый набор с позициями 0, 1, 2…; дубликаты разрешены; удобен для очередей задач, логов, результатов запросов.
  • tuple — как список по доступу t[i], но после создания не меняется; подходит для координат, записей "полей", ключей dict, возврата нескольких значений из функции.
  • set — только уникальные элементы; позиционного индекса нет; быстрые проверки x in s и операции |, &, -.
  • dict — пары "ключ → значение"; ключи хешируемые и уникальные; значения могут повторяться.
lst = [10, 20, 10] # индекс, порядок, изменения, дубликаты
tpl = (10, 20, 10) # индекс и порядок; tpl[0] = 1 → TypeError
uniq = {10, 20, 10} # {10, 20}
phonebook = {"Аня": "+7…", "Борис": "+7…"} # phonebook["Аня"], не phonebook[0]

Выбор конкретной коллекции определяется требованиями к производительности, безопасности данных и логике алгоритма. Теория структур без привязки к Python — в разделе "Структуры данных"; обзор алгоритмов и сложности — в Алгоритмы и структуры данных.


Сводка операций по типам

list (изменяемый упорядоченный список):

ДействиеСинтаксис
Добавить в конецappend(value)
Вставить по индексуinsert(index, value)
Прочитатьlst[index]
Заменитьlst[index] = value
Удалить по индексуpop(index) или del lst[index]
Удалить по значениюremove(value)

set (уникальные элементы):

ДействиеСинтаксис
Добавитьadd(value)
Удалитьremove(value) или discard(value)
Проверитьvalue in s

dict (словарь):

ДействиеСинтаксис
Добавить или заменитьd[key] = value
Прочитатьd[key] или d.get(key)
Удалитьdel d[key] или pop(key)

tuple — только чтение t[index]; изменение после создания недоступно.

collections.deque (очередь с быстрыми операциями с обоих концов):

ДействиеМетод
В конецappend(value)
В началоappendleft(value)
Снять с концаpop()
Снять с началаpopleft()

Списки (list).

Список — это упорядоченная, изменяемая коллекция элементов произвольных типов. Элементы списка хранятся последовательно и доступны по целочисленным индексам, начиная с нуля. Списки реализованы как динамические массивы с автоматическим управлением памятью.

Создание:

lst = [1, 2, 3]
fruits = ["яблоко", "банан", "вишня"]
print(fruits[1]) # банан — индекс с нуля
fruits[1] = "смородина"

Из другой последовательности (например, кортежа) — через конструктор list():

fruits = list(("яблоко", "банан", "вишня"))

Перебор в цикле for:

for item in fruits:
print(item)

Шаблон:

<список> = [<элемент>, <элемент>, ..., <элемент>]
  • <список> — имя переменной типа list.
  • Элементы могут быть любого типа и не обязаны быть однородными.

Добавление элемента в список:

<список>.append(<значение>)
  • Добавляет <значение> в конец списка.
  • Метод изменяет исходный список.

Расширение списка итерируемым объектом:

<список>.extend(<итерируемый_объект>)
  • Добавляет все элементы из <итерируемый_объект> (например, другого списка) в конец текущего списка.

Вставка элемента по индексу:

<список>.insert(<индекс>, <значение>)
  • Вставляет <значение> в позицию <индекс>, сдвигая остальные элементы вправо.

Удаление элемента по значению:

<список>.remove(<значение>)
  • Удаляет первое вхождение <значение> из списка.
  • Если значение отсутствует — вызывается исключение ValueError.

Удаление и получение элемента по индексу:

<элемент> = <список>.pop([<индекс>])
  • Удаляет и возвращает элемент по указанному <индекс>. По умолчанию — последний элемент (-1).
items = ["яблоко", "банан", "вишня"]
items.remove("банан") # по значению
last = items.pop() # снимает "вишня" с конца
del items[0] # удалить по индексу
items.clear() # оставить пустой список []
# del items # удалить имя items из программы

Разбор:

  • remove("банан") удаляет первое совпадение по значению.
  • pop() удаляет и возвращает элемент, поэтому результат сохранен в last.
  • del items[0] удаляет элемент по индексу.
  • clear() очищает содержимое списка, но сам объект списка остается существовать.

Сортировка списка:

<список>.sort(key=<функция>, reverse=<булево_значение>)
  • Сортирует список на месте.
  • key — функция для получения ключа сравнения.
  • reverse=True — сортировка по убыванию.

Разворот списка

Упорядоченный список можно пройти с конца к началу пятью распространёнными способами. Главное различие — меняется ли исходный объект или создаётся новая последовательность.

СпособКодИсходный listЧто возвращается
Метод reverse()lst.reverse()меняетсяNone
Срезlst[::-1]без измененийновый list
reversed()list(reversed(lst))без измененийновый list
Цикл с insert(0, …)см. нижебез измененийновый list
Цикл с append по индексамсм. нижебез измененийновый list

1. Метод reverse() — на месте

my_list = [1, 2, 3, 4, 5]
my_list.reverse()
print(my_list) # [5, 4, 3, 2, 1]

list.reverse() переворачивает элементы в том же объекте и возвращает None. Переменная my_list по-прежнему ссылается на изменённый список.

2. Срез [::-1] — новый список

my_list = [1, 2, 3, 4, 5]
reversed_list = my_list[::-1]
print(reversed_list) # [5, 4, 3, 2, 1]
print(my_list) # [1, 2, 3, 4, 5]

Синтаксис lst[start:stop:step] с step = -1 идёт от последнего индекса к нулевому. Исходный список остаётся прежним. Тот же приём для строк — в однострочных приёмах.

3. Встроенная reversed()

my_list = [1, 2, 3, 4, 5]
reversed_list = list(reversed(my_list))

reversed() возвращает итератор, а не список. Для материализации нужен list(...). Исходная коллекция не меняется. В цикле for x in reversed(lst) копию всего списка создавать не обязательно.

4. Цикл с insert(0, …)

my_list = [1, 2, 3, 4, 5]
r_list = []
for i in my_list:
r_list.insert(0, i)
print(r_list) # [5, 4, 3, 2, 1]

Каждый insert(0, i) сдвигает уже вставленные элементы, поэтому на длинных списках сложность растёт квадратично. Способ полезен на учебных задачах, в продакшене его редко выбирают.

5. Цикл с append по индексам с конца

my_list = [1, 2, 3, 4, 5]
r_list = []
for i in range(len(my_list) - 1, -1, -1):
r_list.append(my_list[i])
print(r_list) # [5, 4, 3, 2, 1]

Линейная работа по длине списка: индексы идут от len - 1 до 0 с шагом -1. Читается нагляднее, чем вариант с insert, но уступает срезу и reversed() по лаконичности.

Что выбрать

Разворот на местеlst.reverse(). Копия в обратном порядке при сохранении исходного порядка — lst[::-1] или list(reversed(lst)). Срез и reversed() — привычные идиомы; циклы помогают понять индексы и оценку сложности.


Индексация и срезы.

Доступ к элементам осуществляется по индексу:

lst[0] # первый элемент
lst[-1] # последний элемент

Срезы позволяют извлекать подпоследовательности:

lst[1:3] # элементы с индексами 1 и 2
lst[::2] # каждый второй элемент
lst[::-1] # весь список в обратном порядке (новый list)

Срезы возвращают новый объект list. Они поддерживают шаг, отрицательные индексы и обратный порядок. Подробнее про все способы разворота — выше.

Списки имеют следующие методы:

  • append(x) - Добавляет элемент x в конец списка.
  • extend(iterable) - Добавляет все элементы итерируемого объекта в конец.
  • Вставляет элемент x на позицию i.
insert(i, x)
  • remove(x) - Удаляет первое вхождение элемента x.
  • pop([i]) - Удаляет и возвращает элемент по индексу (по умолчанию — последний).
  • clear() - Удаляет все элементы, список остаётся [].
  • count(x) - Возвращает количество вхождений элемента x.
  • sort(key=None, reverse=False) - Сортирует список in-place.
  • reverse() - Переворачивает список in-place.
  • copy() - Создаёт поверхностную копию списка.
numbers = [5, 2, 8, 1]
numbers.sort()
print(numbers) # [1, 2, 5, 8]

numbers.reverse()
print(numbers) # [8, 5, 2, 1]

Разбор:

  • sort() сортирует список на месте и меняет порядок элементов в текущем объекте.
  • reverse() также работает на месте и инвертирует порядок (см. разворот списка).
  • чтобы сохранить исходный порядок, используйте sorted(numbers) и срез numbers[::-1] или list(reversed(numbers)).

Методы sort и reverse модифицируют исходный объект и возвращают None.

Списки гетерогенны (могут быть разных типов), изменяемы (допускается добавление, удаление, замена элементов), и представляют собой массив указателей на объекты. При расширении происходит перераспределение памяти с запасом.

Бинарные списки: array.array используются для эффективного хранения числовых данных большого объёма. Этот тип представляет собой массив примитивных значений (например, целых или вещественных чисел) фиксированного размера.


import array

arr = array.array('i', [1, 2, 3]) # массив 32-битных целых

Такие списки занимают меньше памяти по сравнению с обычным списком, быстрее при последовательном доступе и поддерживают те же операции, что и списки (индексация, срезы), но только для однотипных данных. Но - не поддерживают гетерогенные данные.

Если же требуется получить и использовать именно индекс, а не элемент, тогда используется функция enumerate():

my_list = ["яблоко", "банан", "апельсин", "груша"]

for index, item in enumerate(my_list):
print(f"Индекс: {index}, Элемент: {item}")

Кортежи (tuple)

Кортеж — это неизменяемый список. Всё.

# Список (можно менять)
my_list = [1, 2, 3]
my_list[0] = 999 # работает

# Кортеж (нельзя менять)
my_tuple = (1, 2, 3)
my_tuple[0] = 999 # Ошибка!

Это даёт нам:

  • защиту от случайных изменений;
  • возможность использовать как ключ;
  • возможность возвращать несколько значений из функции;
  • производительность (он занимает меньше памяти).
# Кортеж — всё, что не меняется
coordinates = (55.752, 37.615) # координаты Красной площади
rgb_color = (255, 0, 0) # красный цвет
menu_position = ('File', 'Open') # путь в меню

# Список — то, что меняется
users = ['alex', 'john', 'anna'] # будут добавляться/удаляться
cart = [1, 2, 3] # товары в корзине (пополняется)

Если тебе нужно менять коллекцию — бери список (list). Если не нужно менять и просто читать — бери кортеж (tuple). А если не уверен — бери список, это проще. Кортежи — это оптимизация и защита от дурака (от себя же).

Кортеж — это упорядоченная, неизменяемая коллекция элементов произвольных типов. После создания кортеж нельзя модифицировать: нельзя добавлять, удалять или заменять элементы. Кортежи неизменяемые (чтобы гарантировать целостность данных), хэшируемые (сам кортеж может использовать в качестве ключа в словаре или элементе множества), производительны и применяются часто для представления составных данных, например, координат (x, y) или возврат нескольких значений из функции.

Пример:

t = (1, 2, 3)
t = 1, 2, 3 # скобки опциональны
coords = ("помидор", "огурец", "лук")
print(coords[1]) # огурец

Из последовательности — конструктор tuple() (часто с двойными скобками внутри, чтобы не спутать с группировкой выражений):

coords = tuple(("помидор", "огурец", "лук"))

После создания элемент нельзя заменить — будет TypeError:

coords = ("помидор", "огурец", "лук")
# coords[1] = "морковка" # TypeError —'tuple' object does not support item assignment

Удалить можно только весь кортеж через del, отдельные элементы не снимаются.

Кортежи поддерживают ограниченный набор методов:

  • count(x) — количество вхождений элемента x.
  • index(x[, start[, end]]) — индекс первого вхождения x.

Поскольку кортежи неизменяемы, методы модификации отсутствуют.

Создание кортежа:

<кортеж> = (<элемент>, <элемент>, ..., <элемент>)

или

<кортеж> = <элемент>, <элемент>, ..., <элемент>

Получение количества вхождений значения в кортеж:

<число> = <кортеж>.count(<значение>)
  • Возвращает количество элементов, равных <значение>.

Получение индекса первого вхождения значения:

<индекс> = <кортеж>.index(<значение>[, <начало>[, <конец>]])
  • Возвращает индекс первого совпадения <значение> в пределах [начало:конец].

Множества (set)

Множество — это неупорядоченная, изменяемая коллекция уникальных элементов. Элементы множества должны быть хэшируемыми (т.е. иметь метод __hash__ и быть сравнимыми).

Создание:

s = {1, 2, 3}
s = set([1, 2, 3]) # из итерируемого
# Список с дубликатами
items = ["тигр", "лев", "панда", "тигр", "заяц", "лев"]

# Превращаем в множество — дубликаты исчезнут!
unique_items = set(items)
print(unique_items) # {'тигр', 'лев', 'панда', 'заяц'}

Разбор:

  • set(items) создает множество из итерируемого объекта.
  • множество хранит только уникальные значения, повторы удаляются автоматически.
  • порядок элементов у set не предназначен для бизнес-логики.

Пустое множество создаётся только через set(), так как {} интерпретируется как пустой словарь.

Множества автоматически устраняют дубликаты:

{1, 1, 2} # → {1, 2}

Множества бывают изменяемыми и неизменяемыми.

Методы изменяемых множеств (set):

  • add(x) — добавить элемент.
  • remove(x) — удалить элемент; ошибка, если нет.
  • discard(x) — удалить, если есть; ошибки не возникает.
  • pop() — удалить и вернуть произвольный элемент.
  • clear() — очистить множество.

frozenset — неизменяемая версия множества. Поддерживает все операции чтения и теоретико-множественные операции, но не позволяет модифицировать содержимое. Полезен как ключ в словаре или элемент другого множества.

# set - изменяемый
mutable_set = {1, 2, 3}
mutable_set.add(4) # Работает
mutable_set.remove(2) # Работает

# frozenset - неизменяемый
frozen = frozenset({1, 2, 3})
# frozen.add(4) # Ошибка — AttributeError
# frozen.remove(2) # Ошибка — AttributeError
# frozenset хэшируемый
hash(frozenset({1, 2, 3})) # Работает

# set не хэшируемый
# hash({1, 2, 3}) # Ошибка — TypeError
# frozenset можно использовать как ключ
d = {frozenset({1, 2}): "value"} # Работает

# set нельзя использовать как ключ
# d = {{1, 2} — "value"} # Ошибка: TypeError

Объединение: | или .union()

first = {"тигр", "панда", "заяц"}
second = {"лев", "заяц", "медведь"}

all_animals = first | second
print(all_animals) # Все звери без повторов

Создание множества:

<множество> = {<элемент>, <элемент>, ..., <элемент>}

или

<множество> = set(<итерируемый_объект>)
  • Все элементы должны быть хэшируемыми.
  • Автоматически удаляются дубликаты.

Добавление элемента в множество:

<множество>.add(<значение>)
  • Добавляет <значение> в множество, если оно ещё не присутствует.

Удаление элемента из множества (с ошибкой при отсутствии):

<множество>.remove(<значение>)
  • Удаляет <значение>. Если его нет — возникает исключение KeyError.

Удаление элемента из множества (без ошибки):

<множество>.discard(<значение>)
  • Удаляет <значение>, если оно есть. Не вызывает исключение при отсутствии.

Объединение двух множеств:

<новое_множество> = <множество_1> | <множество_2>

или

<новое_множество> = <множество_1>.union(<множество_2>)
  • Возвращает новое множество, содержащее все уникальные элементы из обоих исходных.

Преобразование:

# Преобразование set в frozenset
frozen = frozenset({1, 2, 3})

# Преобразование frozenset в set
mutable = set(frozenset({1, 2, 3}))

Play ITЗагрузка интерактивного демо…


Словари (dict)

Словарь — это изменяемая коллекция пар "ключ-значение", где ключи уникальны и хэшируемы, а значения могут быть любыми объектами. Доступ к значениям осуществляется по ключу. Словари реализованы как хеш-таблицы с открытой адресацией.

Создание:

d = {'a': 1, 'b': 2}
d = dict(a=1, b=2)

Ключи должны быть хэшируемыми (числа, строки, кортежи из хэшируемых объектов и т.д.). Значения же произвольные объекты, включая изменяемые. Словари сохраняют порядок вставки с Python 3.7+ (в 3.6 — как особенность CPython, с 3.7 — как гарантия языка).

Методы словаря

Девять методов, которые чаще всего нужны в повседневном коде (плюс copy() для копии):

МетодНазначение
get(key[, default])Значение по ключу; если ключа нет — default (по умолчанию None), без KeyError
keys()Представление ключей (итерируемое)
values()Представление значений
items()Пары (ключ, значение)
update(other)Добавить или перезаписать пары из другого словаря или итерируемого
setdefault(key, default)Вернуть значение; если ключа нет — записать default и вернуть его
pop(key[, default])Удалить ключ и вернуть значение; при отсутствии ключа — default или KeyError
popitem()Удалить и вернуть последнюю вставленную пару (ключ, значение)
clear()Удалить все пары
copy()Поверхностная копия

keys(), values() и items() возвращают представления (views), а не списки. Для вывода в консоль удобно обернуть в list(...).

Один словарь — по шагам, как на шпаргалке:

Код ITЗагрузка примера кода…

Безопасное чтение

Запись d[key] при отсутствии ключа вызывает KeyError (см. исключения). Метод get() подходит, когда отсутствие ключа — нормальный сценарий — конфиг, кэш, счётчики (freq.get(token, 0) + 1 ниже в практических примерах).

Пример — классический словарь-переводчик:

translator = {
"cat": "кошка",
"dog": "собака",
"house": "дом",
"sun": "солнце",
"book": "книга"
}

word = input("Введите слово на английском: ")
translation = translator.get(word, "Не знаю такого слова")
print(f"Перевод: {translation}")

Разбор:

  • словарь translator хранит пары английское_слово -> перевод.
  • input(...) получает ключ для поиска в словаре.
  • get(word, "Не знаю такого слова") возвращает перевод или значение по умолчанию, если ключ отсутствует.
  • финальная f-строка печатает уже готовый результат поиска.

Создание словаря:

<словарь> = {<ключ>: <значение>, <ключ>: <значение>, ..., <ключ>: <значение>}

или

<словарь> = dict(<ключ>=<значение>, ...)
  • Ключи должны быть хэшируемыми; значения — произвольными.

Получение значения по ключу (с безопасным доступом):

<значение> = <словарь>.get(<ключ>[, <значение_по_умолчанию>])
  • Возвращает значение, связанное с <ключ>, или <значение_по_умолчанию>, если ключ отсутствует.

Установка значения по умолчанию при отсутствии ключа:

<значение> = <словарь>.setdefault(<ключ>, <значение_по_умолчанию>)
  • Если <ключ> отсутствует, добавляет пару <ключ>: <значение_по_умолчанию> и возвращает это значение.
  • Если ключ есть — возвращает существующее значение.

Обновление словаря парами из другого словаря:

<словарь>.update(<другой_словарь>)
  • Добавляет или заменяет пары "ключ-значение" из <другой_словарь> в текущий словарь.

Удаление по ключу и снятие последней пары:

<значение> = <словарь>.pop(<ключ>[, <значение_по_умолчанию>])
<пара> = <словарь>.popitem()
<словарь>.clear()
  • pop возвращает значение удалённого ключа; popitem — кортеж (ключ, значение) последней вставки; clear очищает весь словарь.

Итерация по ключам, значениям и парам:

for <ключ> in <словарь>.keys():
...

for <значение> in <словарь>.values():
...

for <ключ>, <значение> in <словарь>.items():
...
  • .keys(), .values(), .items() возвращают представления (views), а не списки.

Практические сценарии

Подсчёт частот

text = ["api", "db", "api", "cache", "api", "db"]
freq = {}
for token in text:
freq[token] = freq.get(token, 0) + 1

print(freq) # {'api': 3, 'db': 2, 'cache': 1}

Разбор:

  • цикл for token in text проходит по каждому элементу списка.
  • freq.get(token, 0) берет текущее значение счетчика или 0, если ключ встречается впервые.
  • выражение + 1 увеличивает счетчик, после чего обновленное значение записывается в freq[token].

Удаление дублей с сохранением порядка

items = ["a", "b", "a", "c", "b"]
unique = list(dict.fromkeys(items))
print(unique) # ['a', 'b', 'c']

Очередь задач

from collections import deque

queue = deque(["task-1", "task-2"])
queue.append("task-3")
current = queue.popleft()
print(current) # task-1

Типичные ошибки при работе с коллекциями

  1. Использование изменяемого объекта как ключа словаря.
  2. Ожидание стабильного порядка у set в бизнес-логике.
  3. Неявное копирование вложенных структур вместо copy.deepcopy.
  4. Выбор list для операции "проверить наличие" в больших наборах, где нужен set.

Для корректной копии вложенных структур:


import copy

config = {"limits": [1, 2, 3]}
clone = copy.deepcopy(config)

Связанные материалы


Как выбирать коллекцию без лишних сомнений

Полезная инженерная эвристика:

  • Нужен порядок и изменения — берите list.
  • Нужен порядок без изменений — берите tuple.
  • Нужен быстрый доступ по ключу — берите dict.
  • Нужна уникальность и операции пересечения/объединения — берите set.

Если структура выбрана верно, код становится быстрее и проще, чем при попытке "решить всё списком".

В задачах ЕГЭ и олимпиад те же типы постоянно встречаются в коде — list для массива чисел, set для "уже видели", dict/Counter для частот — готовые примеры с разбором — Lab / 1122. Почему x in list в цикле даёт O(n²), а x in setO(n)Lab / 1128, ловушки Python.