Работа с последовательностями в Python [2.5]
В этом уроке разберём, как Python перебирает элементы коллекций: итерируемые объекты, итераторы, генераторы, а также компактный синтаксис: list/dict comprehensions и generator expressions. В конце — базовые алгоритмы поиска и сортировки на последовательностях.
Главная мысль: цикл for — это “вежливый интерфейс” над протоколом итератора. Если понять iter(), next() и StopIteration, многие вещи в Python станут логичными.
Содержание
- 1. Итераторы и итерируемые объекты
- 2. Генераторы и ленивые вычисления
- 3. Списковые включения (list comprehensions)
- 4. Генераторы структур: dict и generator expressions
- 5. Алгоритмы на последовательностях: поиск и сортировка
- 6. Мини‑тесты по теме + разбор ответов
- 7. Чек‑лист самопроверки (обязательный)
1. Итераторы и итерируемые объекты
Что такое iterable и iterator
Вы уже знаете структуры данных, которые содержат много элементов: списки, кортежи, словари, множества, строки. Такие объекты можно “перебирать” — они итерируемые (англ. iterable).
- Iterable — объект, по которому можно начать перебор (например, список).
- Iterator — объект “текущего состояния перебора”, который умеет отдавать следующий элемент.
Один список можно перебирать много раз, а вот один конкретный итератор обычно “одноразовый”: как только он закончился, повторный перебор не начнётся автоматически.
Протокол итератора: __iter__() и __next__()
В Python итерация построена на двух методах:
__iter__()— возвращает итератор (объект, который будет отдавать элементы).__next__()— возвращает следующий элемент; когда элементы закончились — вызывает исключениеStopIteration.
Как работает for “под капотом”
Когда вы пишете цикл:
array = [1, 2, 3]
for a in array:
pass
Python делает примерно следующее (упрощённо):
array = [1, 2, 3]
it = array.__iter__() # или iter(array)
while True:
try:
a = it.__next__() # или next(it)
except StopIteration:
break
# тело цикла for:
pass
for останавливается именно потому, что в конце итератор вызывает StopIteration.iter() и next(): “человеческие” функции вместо магических методов
Вы можете работать с итераторами напрямую через встроенные функции iter() и next().
array = (1, 2, 3)
it = iter(array) # создаём итератор
print(next(it)) # 1
print(next(it)) # 2
StopIteration: что происходит на конце последовательности
Если вызвать next() больше раз, чем элементов в итераторе, будет исключение StopIteration.
it = iter([1, 2, 3])
print(next(it)) # 1
print(next(it)) # 2
print(next(it)) # 3
print(next(it)) # StopIteration
next() есть второй аргумент — значение по умолчанию. Тогда исключение не будет выброшено.it = iter([1, 2, 3])
print(next(it, None)) # 1
print(next(it, None)) # 2
print(next(it, None)) # 3
print(next(it, None)) # None (вместо StopIteration)
Типичные ошибки с итераторами (с разбором)
Ошибка 1. Путать iterable и iterator
Список — iterable. Итератор списка — iterator. Если вы уже получили итератор и “проели” его, повторить перебор не получится.
nums = [1, 2, 3]
it = iter(nums)
print(list(it)) # [1, 2, 3]
print(list(it)) # [] итератор уже закончился
it = iter(nums), либо сразу сохранить результат, если он нужен несколько раз.Ошибка 2. Вызывать next() на iterable без iter()
next() работает с итератором, а не со списком напрямую. (Список сам по себе не обязан иметь __next__().)
nums = [1, 2, 3]
# next(nums) # TypeError: 'list' object is not an iterator
it = iter(nums)
print(next(it)) # 1
Ошибка 3. Менять список во время обхода
Если удалять/вставлять элементы в список во время перебора, можно пропустить элементы или получить неожиданный результат.
arr = [1, 2, 3, 4, 5]
for x in arr:
if x % 2 == 0:
arr.remove(x)
print(arr)
# часто неожиданно: [1, 3, 5] (или иной эффект на других кейсах)
arr = [1, 2, 3, 4, 5]
arr2 = [x for x in arr if x % 2 != 0]
print(arr2) # [1, 3, 5]
2. Генераторы и ленивые вычисления
Зачем нужны генераторы
Генератор — это удобный способ создавать итераторы “на лету”. Он особенно полезен для больших последовательностей: значения вычисляются по мере необходимости, а не сохраняются сразу целиком в памяти. Это называют ленивыми вычислениями.
yield vs return: в чём разница
В обычной функции return завершает выполнение и возвращает значение. В генераторе yield “выдаёт” значение и приостанавливает функцию, чтобы продолжить с того же места при следующем next().
return— “вернул и закончился”.yield— “отдал порцию и запомнил место”.
Пример: генератор факториалов
def factorial(N):
a = 1
for i in range(1, N + 1):
a *= i
yield a
my_gen = factorial(4)
print(next(my_gen)) # 1
print(next(my_gen)) # 2
print(next(my_gen)) # 6
print(next(my_gen)) # 24
Каждое значение вычисляется только в момент вызова next().
Почему range — не список
range(start, end, step) ведёт себя как “последовательность чисел”, но обычно не хранит все числа в памяти сразу. Он выдаёт значения по мере перебора, то есть работает лениво (как итератор/генераторная последовательность).
r = range(1, 10, 2)
for x in r:
print(x)
# 1
# 3
# 5
# 7
# 9
Типичные ошибки с генераторами (с разбором)
Ошибка 1. Ожидать, что генератор можно “пройти” два раза
Генератор (как и итератор) одноразовый: после полного прохода он “заканчивается”.
gen = (x**2 for x in range(3))
print(list(gen)) # [0, 1, 4]
print(list(gen)) # [] генератор исчерпан
Ошибка 2. “Случайно съесть” генератор через list()
Любое преобразование в list() полностью перебирает генератор и забирает все элементы. После этого генератор пуст.
Ошибка 3. Использовать return внутри генератора как “ещё один yield”
return внутри генератора завершает генератор. Это нормально, но важно понимать смысл: “значений больше не будет”.
3. Списковые включения (list comprehensions)
Базовый синтаксис
Списковое включение — это компактный способ создать новый список на основе существующего итерируемого объекта.
numbers = [1, 2, 3, 4, 5]
squared_numbers = [x**2 for x in numbers]
print(squared_numbers)
# [1, 4, 9, 16, 25]
new_list = [expression for item in iterable if condition]Часть
if condition — необязательная.Условие if внутри включения (как filter)
Можно оставить только элементы, подходящие под условие — так же, как делает filter().
numbers = [1, 2, 3, 4, 5]
squared_even = [x**2 for x in numbers if x % 2 == 0]
print(squared_even)
# [4, 16]
Вложенные включения (как двойной цикл)
Если нужно создать комбинации “каждый с каждым”, можно вложить два цикла в одно включение.
list1 = [1, 2, 3]
list2 = [3, 4, 5]
new_list = [(x, y) for x in list1 for y in list2 if x + y == 6]
print(new_list)
# [(1, 5), (2, 4)]
Это эквивалентно вложенным циклам for + условие if.
Сравнение: for+append, map(), comprehension
Одна и та же задача “квадраты чисел” тремя способами:
| Подход | Пример | Когда удобен |
|---|---|---|
| Классический цикл | for + append |
Когда логика сложнее, чем 1 выражение, или есть побочные действия |
| Функциональный стиль | list(map(lambda x: ..., seq)) |
Когда функцию удобно передать как объект (или уже есть готовая функция) |
| Списковое включение | [expr for x in seq if cond] |
Когда нужно быстро и компактно создать список (часто читается лучше) |
Типичные ошибки списковых включений (и когда лучше не использовать)
Ошибка 1. Использовать включение ради “действия”, а не результата
Списковое включение должно создавать список. Если вы используете его ради print или других side effects — получится мусор (список из None) и плохая читаемость.
# Плохо: список не нужен, а побочные эффекты есть
res = [print(x) for x in [1, 2, 3]]
print(res)
# 1
# 2
# 3
# [None, None, None]
for, если цель — действие.Ошибка 2. Слишком сложные вложенные включения (падает читаемость)
Если включение содержит несколько for, несколько условий и сложное выражение — часто выгоднее развернуть в обычный код, чтобы было проще поддерживать.
4. Генераторы структур: dict и generator expressions
Dictionary comprehension (генератор словарей)
Словари тоже можно создавать компактно. Формула: {key_expression: value_expression for element in iterable if condition}.
my_dict = {x: x**2 for x in range(5)}
print(my_dict)
# {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
Словарь из двух списков через zip()
Частый практический приём: есть список ключей и список значений — “склеиваем” их через zip и строим словарь.
keys = ['a', 'b', 'c']
values = [1, 2, 3]
new_dict = {k: v for k, v in zip(keys, values)}
print(new_dict)
# {'a': 1, 'b': 2, 'c': 3}
Generator expression: “генераторное включение”
Если вместо списка вам нужен ленивый поток значений, используйте круглые скобки:
my_generator = (x**2 for x in range(5))
for value in my_generator:
print(value)
# 0
# 1
# 4
# 9
# 16
[ ... ]— создаёт список сразу (в памяти).{ ... }— создаёт словарь или множество (в зависимости от синтаксиса).( ... )— создаёт генератор (лениво).
Типичные ошибки dict/gen включений
Ошибка 1. Дубликаты ключей в dict comprehension
Если выражение ключа даёт одинаковые ключи, более поздние значения перезапишут ранние. Это не ошибка Python — это свойство словаря.
data = [1, 2, 3, 4]
d = {x % 2: x for x in data}
print(d)
# {1: 3, 0: 4} (1 перезаписался, 0 перезаписался)
Ошибка 2. Не понимать, что generator expression одноразовый
Генераторные выражения — это итераторы. После перебора они заканчиваются (как и любые генераторы).
5. Алгоритмы на последовательностях: поиск и сортировка
Поиск: линейный и бинарный
Линейный поиск
Линейный поиск — это последовательный перебор всех элементов, пока не найдём нужный. Время работы растёт линейно с размером массива: чем больше элементов, тем дольше (в среднем).
def linear_search(arr, x):
for i, a in enumerate(arr):
if a == x:
return i
return None
Минус: на больших списках может быть медленно.
Бинарный поиск
Бинарный поиск работает только на отсортированном массиве. Он каждый раз сравнивает с серединой и отбрасывает половину элементов.
def binary_search(arr, x):
low = 0
high = len(arr) - 1
while low <= high:
mid = (high + low) // 2
if arr[mid] < x:
low = mid + 1
elif arr[mid] > x:
high = mid - 1
else:
return mid
return None
Сортировки: пузырёк, выбор, вставки
Сортировка пузырьком (bubble sort)
Сравнивает соседние элементы и меняет местами, если порядок неправильный. Повторяет проходы, пока всё не отсортируется.
def bubble_sort(arr):
n = len(arr)
for i in range(n):
for j in range(0, n - i - 1):
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
return arr
Сортировка выбором (selection sort)
На каждом шаге ищет минимальный элемент в неотсортированной части и ставит его в “следующую” позицию отсортированной части.
def selection_sort(arr):
n = len(arr)
for i in range(n):
min_idx = i
for j in range(i + 1, n):
if arr[j] < arr[min_idx]:
min_idx = j
arr[i], arr[min_idx] = arr[min_idx], arr[i]
return arr
Сортировка вставками (insertion sort)
Постепенно строит отсортированную часть слева, вставляя каждый новый элемент на правильное место.
def insertion_sort(arr):
n = len(arr)
for i in range(1, n):
key = arr[i]
j = i - 1
while j >= 0 and key < arr[j]:
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = key
return arr
“Разделяй и властвуй”: quick sort и merge sort
Быстрая сортировка (quick sort)
Выбирает опорный элемент (pivot), разбивает массив на элементы меньше/больше и рекурсивно сортирует части.
def quick_sort(arr):
if len(arr) <= 1:
return arr
pivot = arr[0]
less = [x for x in arr[1:] if x <= pivot]
greater = [x for x in arr[1:] if x > pivot]
return quick_sort(less) + [pivot] + quick_sort(greater)
less/greater, то есть расходует память. Это нормально для обучения, но в реальных задачах обычно используют встроенную сортировку Python.Сортировка слиянием (merge sort)
Рекурсивно делит массив пополам до одноэлементных списков, а затем аккуратно “сливает” их обратно в правильном порядке.
def merge_sort(arr):
if len(arr) > 1:
mid = len(arr) // 2
L = arr[:mid]
R = arr[mid:]
merge_sort(L)
merge_sort(R)
i = j = k = 0
while i < len(L) and j < len(R):
if L[i] < R[j]:
arr[k] = L[i]
i += 1
else:
arr[k] = R[j]
j += 1
k += 1
while i < len(L):
arr[k] = L[i]
i += 1
k += 1
while j < len(R):
arr[k] = R[j]
j += 1
k += 1
return arr
L и R. Это делает алгоритм понятным, но расходует дополнительную память.Таблица: скорость/память/когда применять
| Алгоритм | Идея | Оценка по времени (в среднем) | Доп. память | Комментарий |
|---|---|---|---|---|
| Линейный поиск | Перебор | O(n) | O(1) | Работает всегда, но медленно на больших данных |
| Бинарный поиск | Половинное деление | O(log n) | O(1) | Только для отсортированных массивов |
| Bubble sort | Соседние обмены | O(n²) | O(1) | Простой, но почти всегда слишком медленный |
| Selection sort | Выбор минимума | O(n²) | O(1) | Тоже медленный, но понятный |
| Insertion sort | Вставка в отсортированную часть | O(n²) | O(1) | Хорош на почти отсортированных данных и маленьких массивах |
| Quick sort | Разделяй и властвуй | O(n log n) | зависит от реализации | Быстрый на практике, но чувствителен к выбору pivot |
| Merge sort | Разделяй и властвуй + слияние | O(n log n) | O(n) | Стабильный, предсказуемый, но требует память |
Как сортирует Python (TimSort) — практический вывод
В реальном коде почти всегда используйте встроенные инструменты: sorted(iterable) (возвращает новый список) и list.sort() (сортирует список на месте). Внутри Python применяет гибридный алгоритм (TimSort), который обычно очень эффективен на “живых” данных.
sorted()/sort().Типичные ошибки в алгоритмах (с разбором)
Ошибка 1. Запуск бинарного поиска на неотсортированном массиве
Бинарный поиск предполагает порядок. Если данных порядок не соблюдают — логика “отбрасывания половины” ломается.
Ошибка 2. Путать “сортирует на месте” и “возвращает новый список”
list.sort() возвращает None, потому что сортирует список на месте. А sorted() возвращает новый список.
arr = [3, 1, 2]
x = arr.sort()
print(x) # None
print(arr) # [1, 2, 3]
arr2 = sorted([3, 1, 2])
print(arr2) # [1, 2, 3]
6. Мини‑тесты по теме + разбор
Вопрос 1
Какой метод нужно вызвать у объекта, чтобы получить итератор?
- __iter__()
- __next__()
- __getitem__()
- __setitem__()
__iter__() возвращает итератор. Аналог — iter(obj).Вопрос 2
Какой метод нужно вызывать у итератора, чтобы получить следующий элемент?
- __iter__()
- __next__()
- __getitem__()
- __setitem__()
__next__() (или next(it)) выдаёт следующий элемент.Вопрос 3
Что происходит, когда итератор достигает конца последовательности?
- Итератор начинает обход заново
- Итератор возвращает пустой объект
- Итератор перестаёт работать
- Возникает исключение StopIteration
StopIteration — сигнал “элементов больше нет”. Цикл for ловит его и завершает обход.Вопрос 4
Какой оператор используют для создания генератора?
- for
- while
- yield
- return
yield.Вопрос 5
Что такое списковое включение?
- Функция, которая добавляет элементы в список
- Способ создания нового списка на основе существующего
- Конструкция, которая добавляет элементы без циклов и условий
- Метод, который удаляет элементы из списка
Вопрос 6
Результат кода:
new_list = [y + x for x in ['a','b','c'] for y in ['d','e','f']]
print(new_list)
Ответ: ['da', 'ea', 'fa', 'db', 'eb', 'fb', 'dc', 'ec', 'fc']
x='a', а y пробегает d,e,f, затем x='b' и снова y.Вопрос 7
Какая будет сумма элементов списка?
lst = [x for x in range(10) if x % 2 == 0 or x % 3 == 0]
print(lst)
# ?
Правильный список: [0, 2, 3, 4, 6, 8, 9]
Правильная сумма: 0 + 2 + 3 + 4 + 6 + 8 + 9 = 32
Вопрос 8
Что такое линейный поиск?
- Сравнение среднего элемента
- Случайный выбор элементов
- Деление массива пополам
- Последовательное сравнение каждого элемента с искомым
Вопрос 9
Какой алгоритм поиска можно использовать только для отсортированных массивов?
- Линейный поиск
- Бинарный поиск
- Оба алгоритма
- Ни один
Вопрос 10
Какая цель у алгоритмов сортировки?
- Искать элемент
- Находить максимум
- Упорядочивать элементы
- Вычислять среднее
Вопрос 11
Какие сортировки используют принцип “разделяй и властвуй”?
- Пузырёк
- Выбором
- Слиянием
- Вставками
- Быстрая сортировка
Вопрос 12
Какая сортировка использует дополнительную память для промежуточных списков?
- Пузырьком
- Выбором
- Слиянием
- Быстрая сортировка
7. Чек‑лист самопроверки знаний
Отметьте пункты, которые вы действительно понимаете и можете воспроизвести без подсказок.
| ✓ | Навык | Проверка (что я могу сделать) |
|---|---|---|
| Отличаю iterable от iterator | Могу объяснить, почему список можно перебирать много раз, а итератор — обычно нет | |
| Понимаю протокол итератора | Могу объяснить роль __iter__(), __next__() и StopIteration |
|
| Умею пользоваться iter() и next() | Могу вручную получить 2–3 элемента из коллекции через it = iter(obj) и next(it) |
|
| Знаю, что такое генератор | Могу написать генератор с yield и получить значения через next() |
|
| Понимаю ленивые вычисления | Могу объяснить, почему range и генераторы экономят память |
|
| Умею писать list comprehensions | Могу написать [x**2 for x in nums if x % 2 == 0] |
|
| Умею писать dict comprehension | Могу создать словарь вида {x: f(x) for x in ...} |
|
| Понимаю generator expression | Могу создать (x**2 for x in range(10)) и объяснить, почему он одноразовый |
|
| Знаю линейный и бинарный поиск | Могу объяснить, почему бинарный поиск работает только на отсортированном массиве | |
| Понимаю базовые сортировки | Могу словами описать “пузырёк”, “выбор”, “вставки” и их отличия | |
| Понимаю “разделяй и властвуй” | Могу объяснить идею quick sort / merge sort на примере “делим на части, потом собираем” | |
| Умею правильно сортировать в Python | Могу объяснить разницу sorted() и list.sort() |