Примеры идут от простого к сложному: сначала базовый list comprehension и его варианты, затем сравнение с циклом и с map/filter, затем стек и очередь — сначала на обычном списке, затем на collections.deque с измерением реальной разницы в скорости. Весь вывод в комментариях получен запуском кода, а не набран по памяти.
Пример 1. Базовый list comprehension и его цикл-эквивалент
List comprehension строит новый список из итерируемого объекта в одной строке. Исходная коллекция не меняется, если в выражении нет побочных эффектов.
numbers = [1, 4, 6, 7, 9]
squares_loop = []
for n in numbers:
squares_loop.append(n ** 2)
squares_lc = [n ** 2 for n in numbers]
print(squares_loop) # [1, 16, 36, 49, 81]
print(squares_lc) # [1, 16, 36, 49, 81]
print(numbers) # [1, 4, 6, 7, 9] — исходный список не тронут
Что происходит: обе записи делают одно и то же — берут каждый n из numbers и кладут n ** 2 в новый список. Comprehension — не новая возможность языка, а более компактная запись того же цикла с append.
Пример 2. Фильтр if — только часть элементов
if в конце comprehension — это фильтр: элемент попадёт в результат, только если условие истинно. Место фильтра — строго после for.
even_numbers = [number for number in range(10) if number % 2 == 0]
print(even_numbers) # [0, 2, 4, 6, 8]
words = ["cat", "elephant", "dog", "bird", "lion", "ant"]
long_words_reversed = [word[::-1] for word in words if len(word) > 3]
print(long_words_reversed) # ['tnahpele', 'drib', 'noil']
Что происходит: для каждого слова длиннее трёх символов выражение word[::-1] переворачивает строку срезом; слова короче трёх символов ("cat", "dog", "ant") в результат не попадают вовсе — фильтр отбросил их до вычисления выражения.
Пример 3. Условное выражение if...else — оно стоит перед for
Если нужно не отфильтровать, а выбрать одно из двух значений для каждого элемента, тернарный if...else пишется в начале выражения, до for. Это самое частое место путаницы в теме.
numbers = [2, 7, 5, 4, 1, 1, 7, 8]
modified = [number if number % 2 == 0 else -1 for number in numbers]
print(modified) # [2, -1, -1, 4, -1, -1, -1, 8]
try:
eval("[n if n % 2 == 0 for n in range(5)]")
except SyntaxError as error:
print(f"SyntaxError: {error}")
# SyntaxError: expected 'else' after 'if' expression (<string>, line 1)
Почему так: в результате остались все восемь элементов — длина списка не изменилась, в отличие от фильтра из примера 2. Вторая попытка показывает типичную ошибку: if без else после for Python принимает как незаконченное тройное выражение и требует else, а не трактует его как фильтр.
Пример 4. Вложенный comprehension: разворот и обработка матрицы
Вложенный comprehension читается как обычные вложенные циклы — слева направо: во внешнем цикле сначала идёт строка, потом элемент строки.
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flattened = [value for row in matrix for value in row]
row_sums = [sum(row) for row in matrix]
doubled_flat = [value * 2 for row in matrix for value in row]
print(flattened) # [1, 2, 3, 4, 5, 6, 7, 8, 9]
print(row_sums) # [6, 15, 24]
print(doubled_flat) # [2, 4, 6, 8, 10, 12, 14, 16, 18]
Что происходит: flattened — это for row in matrix for value in row, то есть «для каждой строки, для каждого значения в строке» — тот же порядок, что у двух вложенных for. row_sums — comprehension верхнего уровня, где элемент — не число, а сама строка, к которой применяется sum().
Пример 5. List comprehension против map/filter
Тот же результат, что comprehension с фильтром из примера 2, можно получить связкой встроенных функций высшего порядка. Comprehension в Python обычно читается яснее — подробный разбор map/filter/lambda в уроке 42.
nums = [1, 2, 3, 4, 5]
lc_result = [x ** 2 for x in nums if x % 2 == 0]
mf_result = list(map(lambda x: x ** 2, filter(lambda x: x % 2 == 0, nums)))
print(lc_result) # [4, 16]
print(mf_result) # [4, 16]
print(lc_result == mf_result) # True
Что происходит: оба варианта дают одинаковый список, но map/filter читаются изнутри наружу (сначала filter, потом map), а comprehension — слева направо, в порядке «что вычисляем → откуда → при каком условии». Это не значит, что map/filter не нужны: они естественны, когда функция уже готова (map(str, nums)), без обёртки в lambda.
map/filter не входит в материалы этого урока — это дополнение курса. Подробности — в разделе List Comprehensions официальной документации.
Пример 6. zip(): параллельный обход, разная длина, разовый итератор
zip() сопоставляет элементы нескольких последовательностей по позиции. Результат — итератор кортежей: он останавливается на самой короткой последовательности и его нельзя пройти дважды.
names = ["Alice", "Bob", "Charlie"]
ages = [25, 30, 35]
print(list(zip(names, ages)))
# [('Alice', 25), ('Bob', 30), ('Charlie', 35)]
short = [10, 20, 30]
letters = ["x", "y"]
print(list(zip(short, letters)))
# [(10, 'x'), (20, 'y')] — третий элемент short потерян, ошибки нет
zipped = zip(names, ages)
print(list(zipped)) # [('Alice', 25), ('Bob', 30), ('Charlie', 35)]
print(list(zipped)) # [] — итератор уже исчерпан
Что происходит: для списков разной длины zip тихо обрезает результат по короткому — значение 30 из short нигде не появилось, и Python не предупредил об этом. Второй вызов list(zipped) вернул пустой список: как и map/filter, zip — одноразовый итератор.
Пример 7. Стек (LIFO) на списке: append/pop
Стек — структура LIFO («последним пришёл — первым ушёл»). На обычном списке она реализуется двумя методами: append() кладёт элемент наверх, pop() без аргумента снимает и возвращает верхний.
stack = []
stack.append(1)
stack.append(2)
stack.append(3)
print(stack) # [1, 2, 3]
top = stack.pop()
print("Сняли:", top) # Сняли: 3
print("Стек:", stack) # Стек: [1, 2]
print("Верхний:", stack[-1]) # Верхний: 2
def check_parentheses(s):
balance_stack = []
for char in s:
if char == "(":
balance_stack.append(char)
elif char == ")":
if not balance_stack:
return False
balance_stack.pop()
return not balance_stack
print(check_parentheses("(())")) # True
print(check_parentheses("(()")) # False
print(check_parentheses("())(")) # False
Что происходит: append/pop у списка работают с концом списка за O(1) — ни один элемент не сдвигается. Проверка скобок — классическая задача на стек: открывающая скобка кладётся на стек, закрывающая снимает последнюю открытую; если снимать нечего или стек не пуст в конце — баланс нарушен.
Пример 8. Очередь (FIFO) на списке — и почему это медленно
Очередь — структура FIFO («первым пришёл — первым ушёл»). На списке она тоже реализуема, но pop(0) удаляет с начала, и после каждого удаления все оставшиеся элементы сдвигаются на одну позицию влево — это O(n), а не O(1).
import time
queue = []
queue.append(1)
queue.append(2)
queue.append(3)
print(queue) # [1, 2, 3]
first = queue.pop(0)
print("Обслужили:", first) # Обслужили: 1
print("Очередь:", queue) # Очередь: [2, 3]
print("Первый:", queue[0]) # Первый: 2
n = 20000
lst = list(range(n))
start = time.perf_counter()
while lst:
lst.pop(0)
list_time = time.perf_counter() - start
print(f"pop(0) для {n} элементов: {list_time:.4f} c")
# pop(0) для 20000 элементов: 0.0388 c
Что происходит: функционально всё работает верно, но на 20 000 элементах опустошение списка через pop(0) заняло заметное время именно из-за постоянных сдвигов — это и есть цена O(n)-операции, повторённой n раз.
Пример 9. Очередь на collections.deque: popleft() и реальная разница в скорости
deque («double-ended queue») из collections — двусторонняя очередь: добавление и удаление с обоих концов работают за O(1). Для очереди это правильный инструмент, а не список.
from collections import deque
queue2 = deque(["print", "scan", "send"])
print(queue2.popleft()) # print
dq = deque(range(n))
start = time.perf_counter()
while dq:
dq.popleft()
deque_time = time.perf_counter() - start
print(f"popleft() для {n} элементов: {deque_time:.4f} c")
print(f"deque быстрее в {list_time / deque_time:.1f} раз")
# popleft() для 20000 элементов: 0.0017 c
# deque быстрее в 23.4 раз
Что происходит: deque хранит элементы блоками в двусвязном списке, поэтому удаление с начала не требует сдвига остальных — на том же объёме данных popleft() оказался на порядок быстрее pop(0). Точное число раз зависит от машины и версии Python — важен порядок величины, а не конкретная цифра.
deque.append()/deque.popleft() против O(n) для list.pop(0) сверьте по collections.deque и TimeComplexity.
Пример 10. Устойчивая сортировка: sorted(key=...)
Сортировка в Python устойчива: элементы с одинаковым значением ключа сохраняют исходный взаимный порядок. Это не побочный эффект, а гарантия языка.
words = ["apple", "dog", "bat", "cat", "banana"]
print(sorted(words, key=len))
# ['dog', 'bat', 'cat', 'apple', 'banana']
# dog/bat/cat — длина 3, и они остались в исходном порядке друг относительно друга
more_words = ["orange", "mango", "apple", "banana", "kiwi", "cherry"]
for w in sorted(more_words, key=len):
print(f"{len(w)}: {w}")
# 4: kiwi
# 5: mango
# 5: apple
# 6: orange
# 6: banana
# 6: cherry
data = [("a", 2), ("b", 1), ("c", 2), ("d", 1)]
print(sorted(data, key=lambda pair: pair[1]))
# [('b', 1), ('d', 1), ('a', 2), ('c', 2)]
Почему так: среди слов длиной 5 порядок остался mango, apple — так же, как в исходном списке, хотя сортировка шла только по len(w). То же с парами: у ключей 1 порядок остался "b", "d", у ключей 2 — "a", "c". Устойчивость — то, что позволяет сортировать по нескольким критериям несколькими последовательными вызовами sorted().
Пример 11. Стек, очередь и comprehension вместе: кольцевой буфер событий
На практике эти инструменты комбинируются. deque(maxlen=N) — готовый кольцевой буфер: при заполнении новый элемент вытесняет самый старый, вручную ничего удалять не нужно. Comprehension здесь же отбирает важные события из полной истории.
events = ["login", "click", "logout", "click", "error"]
recent = deque(maxlen=3)
for e in events:
recent.append(e)
print(list(recent))
# ['login']
# ['login', 'click']
# ['login', 'click', 'logout']
# ['click', 'logout', 'click']
# ['logout', 'click', 'error']
urgent = [e for e in events if e in ("error", "logout")]
print(urgent) # ['logout', 'error']
Что происходит: как только в recent оказывается четвёртый элемент, deque сам выбрасывает самый старый — буфер держит ровно последние 3 события без ручного pop(0) или проверки длины. urgent при этом прошёл по полному списку событий (не по буферу) и отобрал только два важных — независимая задача, решённая обычным comprehension с фильтром из примера 2.
maxlen у deque не упоминался в материалах урока — проверьте поведение по collections.deque.
Что дальше
Разберите файлы репозитория — там те же идеи в исходном виде из лекции, затем переходите к заданиям. За методами списка, которые пригодятся для стека, — в урок 26; за остальными структурами модуля collections (Counter, defaultdict, OrderedDict) — в урок 40. Если что-то из примеров не запустилось — сначала загляните в типичные ошибки.