Вы уже умеете сортировать список одной строкой: sorted(numbers) или numbers.sort() справятся с чем угодно, от пяти чисел до миллиона. Тогда зачем разбираться, как устроена сортировка внутри? Во-первых, это частый вопрос на технических собеседованиях. Во-вторых, это учит смотреть на любой код с вопросом "а что будет, если данных станет намного больше". В этом уроке вы научитесь сравнивать алгоритмы по скорости и разберёте первый алгоритм сортировки — пузырьковый.
Как сравнивать скорость алгоритмов
Представьте, что вы ищете имя в бумажном телефонном справочнике. Если вы точно знаете номер страницы — открываете сразу нужный разворот, и не важно, сколько всего страниц: десять или тысяча, действие одно и то же. А если вы листаете справочник подряд, страницу за страницей, пока не наткнётесь на нужную фамилию — в справочнике толщиной в тысячу страниц вы потратите в сто раз больше времени, чем в справочнике на десять страниц. Именно такую закономерность важно измерять — не секунды на конкретном компьютере, а то, как действий становится больше при росте данных.
В программировании такую оценку называют временной сложностью алгоритма и записывают в нотации Big O (читается "О большое"). Big O показывает не точное число операций, а то, как оно растёт вместе с размером входных данных n. Разберём три частых случая на коротких примерах.
numbers = [4, 8, 15, 16, 23, 42]
first = numbers[0]
numbers[0]— обращение по индексу. Python сразу знает, где лежит нулевой элемент, и берёт его за один шаг — независимо от того, шесть элементов в списке или шесть миллионов.
Это и есть O(1), константная сложность: число действий не зависит от размера данных вообще.
total = 0
for number in numbers:
total = total + number
for number in numbers:— цикл проходит по каждому элементу списка ровно один раз.total = total + number— на каждом шаге выполняется одно и то же простое действие.
Если элементов n, тело цикла выполнится n раз — это O(n), линейная сложность: список вдвое длиннее — работы вдвое больше.
for a in numbers:
for b in numbers:
pair = (a, b)
for a in numbers:— внешний цикл проходит по всем n элементам.for b in numbers:— и для каждого значения a снова проходит по всем n элементам — это вложенный цикл.
Тело внутреннего цикла выполнится n × n = n² раз — это O(n²), квадратичная сложность: список длиннее в 10 раз — действий больше в 100 раз.
| n (размер списка) | O(n) | O(n²) |
|---|---|---|
| 10 | 10 действий | 100 действий |
| 1 000 | 1 000 действий | 1 000 000 действий |
| 1 000 000 | 1 000 000 действий | 10¹² действий |
Важно. Big O описывает не секунды на вашем ноутбуке, а закономерность роста — сравнивать алгоритмы по-настоящему имеет смысл на больших данных, там разница становится решающей.
Идея пузырьковой сортировки
Представьте аквариум с камешками разного размера на дне. Если взболтать воду, со дна начнут подниматься пузырьки: крупные всплывают быстрее, мелкие медленнее, но в итоге все пузырьки оказываются наверху, а камни остаются внизу. Пузырьковая сортировка устроена похоже: большие числа постепенно "всплывают" к концу списка — за счёт одного простого действия, повторённого много раз: сравнить двух соседей и, если они стоят не по порядку, поменять местами.
В документации и учебниках по программированию этот алгоритм называют Bubble Sort, или пузырьковой сортировкой: название пришло из аналогии с пузырьками. Главная идея: пройти по списку слева направо, сравнивая пары соседних элементов, и менять их местами, если левый больше правого. После одного такого прохода самый большой из непросмотренных элементов гарантированно окажется в конце.
Разберём на списке [5, 3, 8, 1, 2], сравнивая соседей слева направо:
| Сравниваем | Решение | Список после шага |
|---|---|---|
| 5 и 3 | 5 > 3 — меняем местами | [3, 5, 8, 1, 2] |
| 5 и 8 | 5 < 8 — оставляем как есть | [3, 5, 8, 1, 2] |
| 8 и 1 | 8 > 1 — меняем местами | [3, 5, 1, 8, 2] |
| 8 и 2 | 8 > 2 — меняем местами | [3, 5, 1, 2, 8] |
После первого прохода число 8 — самое большое — заняло место в конце, и его больше не нужно трогать. Следующий проход так же "поднимет" следующее по величине число, сравнивая на один элемент меньше. После нескольких проходов список станет полностью отсортированным: [1, 2, 3, 5, 8]. Закономерность: для n элементов хватает максимум n - 1 прохода, потому что после каждого прохода хотя бы одно число встаёт на окончательное место.
Переводим идею в код
Чтобы повторить проходы, а внутри каждого прохода сравнить всех соседей, нужны два вложенных цикла: внешний считает проходы, внутренний сравнивает пары.
def bubble_sort(arr):
n = len(arr)
for i in range(n - 1):
for j in range(n - 1 - i):
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
return arr
numbers = [5, 3, 8, 1, 2]
print(bubble_sort(numbers))
Разберём построчно:
def bubble_sort(arr):— функция принимает списокarr, который нужно отсортировать по возрастанию.n = len(arr)— запоминаем длину списка один раз, чтобы не считать её заново на каждом шаге.for i in range(n - 1):— внешний цикл считает проходы. Их нужно n - 1: как только n - 1 чисел встали на места, последнее оказывается на месте само собой.for j in range(n - 1 - i):— внутренний цикл сравнивает соседей: элемент j и элемент j + 1. Верхняя граница уменьшается на i с каждым проходом, потому что i последних элементов уже "всплыли".if arr[j] > arr[j + 1]:— если левый сосед больше правого, пара стоит не по порядку.arr[j], arr[j + 1] = arr[j + 1], arr[j]— обмен без временной переменной: справа Python собирает пару текущих значений(arr[j + 1], arr[j]), а затем раскладывает её по именам слева в том же порядке. Такarr[j]получает старое значениеarr[j + 1], аarr[j + 1]— старое значениеarr[j]: два значения меняются местами одной строкой.return arr— после всех проходов список отсортирован, функция возвращает его.print(bubble_sort(numbers))— выведет[1, 2, 3, 5, 8].
Оптимизация: выйти раньше, если список уже отсортирован
Список [1, 2, 3, 4, 5] уже отсортирован, но функция из прошлого раздела всё равно честно выполнит все n - 1 проходов, ни разу не поменяв элементы местами — это лишняя работа. Хочется, чтобы алгоритм умел заметить: "за целый проход ни одного обмена не было — список уже готов".
Для этого заводят флаг обменов — переменную, которая в начале каждого прохода сбрасывается в False, а становится True, как только произошёл хотя бы один обмен. Если после внутреннего цикла флаг остался False, значит, обменов не было, и можно завершить сортировку досрочно.
def bubble_sort_optimized(arr):
n = len(arr)
for i in range(n - 1):
swapped = False
for j in range(n - 1 - i):
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
swapped = True
if not swapped:
break
return arr
print(bubble_sort_optimized([1, 2, 3, 4, 5]))
print(bubble_sort_optimized([5, 3, 8, 1, 2]))
swapped = False— перед началом очередного прохода считаем, что обменов ещё не было.arr[j], arr[j + 1] = arr[j + 1], arr[j]и следомswapped = True— как только реально произошёл хотя бы один обмен, отмечаем это флагом.if not swapped:— проверка после внутреннего цикла: если за весь проход флаг так и осталсяFalse, обменов не было ни разу.break— прерывает внешний цикл: дальнейшие проходы точно ничего не изменят, список уже отсортирован.- Первый вызов
bubble_sort_optimized([1, 2, 3, 4, 5])сделает один проход, не найдёт ни одного обмена и сразу выйдет — напечатает[1, 2, 3, 4, 5]. - Второй вызов отработает как обычно и напечатает
[1, 2, 3, 5, 8], потому что обмены там будут происходить до последнего прохода.
Это меняет сложность в лучшем случае: для уже отсортированного списка алгоритм с флагом делает один проход — O(n) вместо O(n²). В среднем и худшем случаях сложность остаётся O(n²): элементы всё равно приходится "протаскивать" через значительную часть списка.
Сложность по памяти: почему это in-place сортировка
Кроме времени, алгоритмы оценивают ещё по памяти: сколько дополнительного места им нужно сверх исходных данных. Аналогия: чтобы переставить книги на одной полке по алфавиту, вторая полка не нужна — книги просто меняются местами прямо на своей полке.
Функции bubble_sort и bubble_sort_optimized переставляют элементы прямо внутри переданного списка arr, не создавая новый список. Такой подход называют сортировкой на месте, или in-place: дополнительно расходуется лишь пара переменных для индексов и флага — это не зависит от размера списка, то есть O(1) по памяти. Это то же различие, что между .sort() (сортирует на месте, ничего не возвращает) и sorted() (создаёт и возвращает новый список, оставляя исходный без изменений) — второму нужна память под копию, то есть O(n).
Частые ошибки
Сравнивать arr[j] с arr[j + 1], когда j доходит до последнего индекса. Если внутренний цикл написать как range(n) вместо range(n - 1 - i), на последнем шаге arr[j + 1] выйдет за границы списка, и программа упадёт с IndexError.
Не уменьшать границу внутреннего цикла. Если вместо range(n - 1 - i) всегда писать range(n - 1), ошибки не будет, но алгоритм заново сравнит уже "всплывшие" элементы — лишняя работа на каждом проходе.
Сбрасывать флаг swapped не в том месте. Если поставить swapped = False один раз перед внешним циклом, а не внутри него на каждом проходе, флаг не станет True заново после первого же обмена, и оптимизация не сработает.
Забыть return arr. Список меняется прямо внутри функции, и кажется, что возвращать нечего — но без return вызов bubble_sort(numbers) вернёт None, и если результат сразу печатать или сравнивать, вместо списка вы увидите None.
Что важно запомнить
- Временная сложность (Big O) показывает, как число операций растёт вместе с размером данных n, а не сколько секунд займёт работа на конкретном компьютере.
- O(1) — действий столько же независимо от n (обращение по индексу). O(n) — один проход по всем элементам. O(n²) — вложенный цикл по всем элементам, самая медленная из трёх при больших n.
- Пузырьковая сортировка много раз проходит по списку, сравнивает пары соседних элементов и меняет их местами, если левый больше правого — за один проход самый большой из оставшихся элементов "всплывает" в конец.
- Обмен
arr[j], arr[j + 1] = arr[j + 1], arr[j]меняет два значения местами за одну строку, без временной переменной. - Флаг обменов (
swapped) позволяет выйти из сортировки досрочно, если за проход не было ни одного обмена — это ускоряет лучший случай до O(n). - Сложность по времени: O(n²) в среднем и худшем случае, O(n) в лучшем случае (только с флагом обменов).
- Сложность по памяти: O(1) — сортировка in-place, элементы переставляются прямо в исходном списке, без создания копии.
- Пузырьковая сортировка учебная: она наглядная и простая, но на больших списках заметно медленнее алгоритмов, которые разберём дальше в этом модуле.
Проверьте себя
8 вопросов
Базовая пузырьковая сортировка
Реализуйте функцию bubble_sort(arr), которая сортирует список целых чисел по возрастанию с помощью пузырьковой сортировки.
Используйте два вложенных цикла:
- внешний цикл — n - 1 проходов;
- внутренний цикл — до n - 1 - i, где i — номер текущего прохода (последние i элементов уже на месте).
Меняйте соседние элементы местами через a, b = b, a. Например, bubble_sort([5, 3, 8, 1, 2]) должна вернуть [1, 2, 3, 5, 8].